diff --git a/client/public/sounds/spin.ogg b/client/public/sounds/spin.ogg new file mode 100644 index 0000000..887141a Binary files /dev/null and b/client/public/sounds/spin.ogg differ diff --git a/client/public/sounds/step-1.ogg b/client/public/sounds/step-1.ogg new file mode 100644 index 0000000..acf3575 Binary files /dev/null and b/client/public/sounds/step-1.ogg differ diff --git a/client/public/sounds/step-10.ogg b/client/public/sounds/step-10.ogg new file mode 100644 index 0000000..e45a845 Binary files /dev/null and b/client/public/sounds/step-10.ogg differ diff --git a/client/public/sounds/step-11.ogg b/client/public/sounds/step-11.ogg new file mode 100644 index 0000000..2b343ba Binary files /dev/null and b/client/public/sounds/step-11.ogg differ diff --git a/client/public/sounds/step-2.ogg b/client/public/sounds/step-2.ogg new file mode 100644 index 0000000..f9fd5c7 Binary files /dev/null and b/client/public/sounds/step-2.ogg differ diff --git a/client/public/sounds/step-3.ogg b/client/public/sounds/step-3.ogg new file mode 100644 index 0000000..ec158c7 Binary files /dev/null and b/client/public/sounds/step-3.ogg differ diff --git a/client/public/sounds/step-4.ogg b/client/public/sounds/step-4.ogg new file mode 100644 index 0000000..796fffa Binary files /dev/null and b/client/public/sounds/step-4.ogg differ diff --git a/client/public/sounds/step-5.ogg b/client/public/sounds/step-5.ogg new file mode 100644 index 0000000..f531099 Binary files /dev/null and b/client/public/sounds/step-5.ogg differ diff --git a/client/public/sounds/step-6.ogg b/client/public/sounds/step-6.ogg new file mode 100644 index 0000000..95c4cd4 Binary files /dev/null and b/client/public/sounds/step-6.ogg differ diff --git a/client/public/sounds/step-7.ogg b/client/public/sounds/step-7.ogg new file mode 100644 index 0000000..dcec170 Binary files /dev/null and b/client/public/sounds/step-7.ogg differ diff --git a/client/public/sounds/step-8.ogg b/client/public/sounds/step-8.ogg new file mode 100644 index 0000000..a997e11 Binary files /dev/null and b/client/public/sounds/step-8.ogg differ diff --git a/client/public/sounds/step-9.ogg b/client/public/sounds/step-9.ogg new file mode 100644 index 0000000..b045928 Binary files /dev/null and b/client/public/sounds/step-9.ogg differ diff --git a/client/public/sounds/wall.ogg b/client/public/sounds/wall.ogg new file mode 100644 index 0000000..b70e6e5 Binary files /dev/null and b/client/public/sounds/wall.ogg differ diff --git a/client/public/version.js b/client/public/version.js index c449489..0ed4090 100644 --- a/client/public/version.js +++ b/client/public/version.js @@ -1,3 +1,3 @@ // Maintainer-controlled web client version. // Format: YYYY.MM.DD Rn (example: 2026.02.20 R2) -window.CHGRID_WEB_VERSION = "2026.02.20 R69"; +window.CHGRID_WEB_VERSION = "2026.02.21 R70"; diff --git a/client/src/audio/audioEngine.ts b/client/src/audio/audioEngine.ts index 07ded1d..ff647a1 100644 --- a/client/src/audio/audioEngine.ts +++ b/client/src/audio/audioEngine.ts @@ -226,15 +226,6 @@ export class AudioEngine { } } - sfxMove(player: { x: number; y: number }): void { - void player; - this.playSound({ freq: 165, duration: 0.05, type: 'triangle', gain: 0.13 }); - } - - sfxPeerMove(peer: { x: number; y: number }): void { - this.playSound({ freq: 330, duration: 0.05, type: 'triangle', gain: 0.12, sourcePosition: peer }); - } - sfxLocate(peer: { x: number; y: number }): void { this.playSound({ freq: 880, duration: 0.2, type: 'sine', gain: 0.5, sourcePosition: peer }); } diff --git a/client/src/main.ts b/client/src/main.ts index 391c9e7..7c82e35 100644 --- a/client/src/main.ts +++ b/client/src/main.ts @@ -83,10 +83,11 @@ const APP_VERSION = String(window.CHGRID_WEB_VERSION ?? '').trim(); dom.appVersion.textContent = APP_VERSION ? `Another AI experiment with Jage. Version ${APP_VERSION}` : 'Another AI experiment with Jage. Version unknown'; -const ITEM_TYPE_SEQUENCE: ItemType[] = ['radio_station', 'dice']; +const ITEM_TYPE_SEQUENCE: ItemType[] = ['radio_station', 'dice', 'wheel']; const ITEM_TYPE_GLOBAL_PROPERTIES: Record> = { radio_station: { useCooldownMs: 1000 }, dice: { useCooldownMs: 1000 }, + wheel: { useCooldownMs: 4000 }, }; const EDITABLE_ITEM_PROPERTY_KEYS = new Set([ 'title', @@ -95,6 +96,7 @@ const EDITABLE_ITEM_PROPERTY_KEYS = new Set([ 'volume', 'effect', 'effectValue', + 'spaces', 'sides', 'number', ]); @@ -111,6 +113,8 @@ const SYSTEM_SOUND_URLS = { logout: withBase('sounds/logout.ogg'), notify: withBase('sounds/notify.ogg'), } as const; +const FOOTSTEP_SOUND_URLS = Array.from({ length: 11 }, (_, index) => withBase(`sounds/step-${index + 1}.ogg`)); +const WALL_SOUND_URL = withBase('sounds/wall.ogg'); const state = createInitialState(); const renderer = new CanvasRenderer(dom.canvas); @@ -312,7 +316,8 @@ function getPeerNamesAtPosition(x: number, y: number): string[] { } function itemTypeLabel(type: ItemType): string { - return type === 'radio_station' ? 'radio' : type; + if (type === 'radio_station') return 'radio'; + return type; } function itemLabel(item: WorldItem): string { @@ -348,6 +353,8 @@ function getEditableItemPropertyKeys(item: WorldItem): string[] { keys.push('streamUrl', 'enabled', 'volume', 'effect', 'effectValue'); } else if (item.type === 'dice') { keys.push('sides', 'number'); + } else if (item.type === 'wheel') { + keys.push('spaces'); } return keys; } @@ -758,6 +765,10 @@ function persistPlayerPosition(): void { } } +function randomFootstepUrl(): string { + return FOOTSTEP_SOUND_URLS[Math.floor(Math.random() * FOOTSTEP_SOUND_URLS.length)]; +} + function gameLoop(): void { if (!state.running) return; handleMovement(); @@ -784,13 +795,17 @@ 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) return; + if (nextX < 0 || nextY < 0 || nextX >= GRID_SIZE || nextY >= GRID_SIZE) { + state.player.lastMoveTime = now; + void audio.playSample(WALL_SOUND_URL, 1); + return; + } state.player.x = nextX; state.player.y = nextY; persistPlayerPosition(); state.player.lastMoveTime = now; - audio.sfxMove(state.player); + void audio.playSample(randomFootstepUrl(), 1); signaling.send({ type: 'update_position', x: nextX, y: nextY }); const namesOnTile = getPeerNamesAtPosition(nextX, nextY); @@ -1034,7 +1049,7 @@ async function onMessage(message: IncomingMessage): Promise { } peerManager.setPeerPosition(message.id, message.x, message.y); if (peer) { - audio.sfxPeerMove({ x: peer.x - state.player.x, y: peer.y - state.player.y }); + void audio.playSpatialSample(randomFootstepUrl(), { x: peer.x - state.player.x, y: peer.y - state.player.y }, 1); } break; } @@ -1773,6 +1788,27 @@ function handleItemPropertyEditModeInput(code: string, key: string): void { return; } signaling.send({ type: 'item_update', itemId, params: { effectValue: clampEffectLevel(parsed) } }); + } else if (propertyKey === 'spaces') { + const spaces = value + .split(',') + .map((token) => token.trim()) + .filter((token) => token.length > 0); + if (spaces.length === 0) { + updateStatus('spaces must include at least one comma-delimited value.'); + audio.sfxUiCancel(); + return; + } + if (spaces.length > 100) { + updateStatus('spaces supports up to 100 values.'); + audio.sfxUiCancel(); + return; + } + if (spaces.some((token) => token.length > 80)) { + updateStatus('each space must be 80 chars or less.'); + audio.sfxUiCancel(); + return; + } + signaling.send({ type: 'item_update', itemId, params: { spaces: spaces.join(', ') } }); } else if (propertyKey === 'sides' || propertyKey === 'number') { const parsed = Number(value); if (!Number.isInteger(parsed) || parsed < 1 || parsed > 100) { diff --git a/client/src/network/protocol.ts b/client/src/network/protocol.ts index 7563507..22eaea7 100644 --- a/client/src/network/protocol.ts +++ b/client/src/network/protocol.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; export const itemSchema = z.object({ id: z.string(), - type: z.enum(['radio_station', 'dice']), + type: z.enum(['radio_station', 'dice', 'wheel']), title: z.string(), x: z.number().int(), y: z.number().int(), @@ -129,7 +129,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' } + | { type: 'item_add'; itemType: 'radio_station' | 'dice' | 'wheel' } | { type: 'item_pickup'; itemId: string } | { type: 'item_drop'; itemId: string; x: number; y: number } | { type: 'item_delete'; itemId: string } diff --git a/client/src/render/canvasRenderer.ts b/client/src/render/canvasRenderer.ts index 99dc2ed..88c9c99 100644 --- a/client/src/render/canvasRenderer.ts +++ b/client/src/render/canvasRenderer.ts @@ -76,11 +76,11 @@ export class CanvasRenderer { private drawItem(item: WorldItem): void { const drawX = item.x * this.squarePixelSize; const drawY = this.canvas.height - (item.y * this.squarePixelSize) - this.squarePixelSize; - this.ctx.fillStyle = item.type === 'radio_station' ? '#fbbf24' : '#60a5fa'; + this.ctx.fillStyle = item.type === 'radio_station' ? '#fbbf24' : item.type === 'wheel' ? '#f97316' : '#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' : 'D', drawX + this.squarePixelSize / 2, drawY + 13); + this.ctx.fillText(item.type === 'radio_station' ? 'R' : item.type === 'wheel' ? 'W' : 'D', drawX + this.squarePixelSize / 2, drawY + 13); } } diff --git a/client/src/state/gameState.ts b/client/src/state/gameState.ts index 5a4cf6e..e6ea903 100644 --- a/client/src/state/gameState.ts +++ b/client/src/state/gameState.ts @@ -1,8 +1,8 @@ export const GRID_SIZE = 40; export const HEARING_RADIUS = 15; -export const MOVE_COOLDOWN_MS = 100; +export const MOVE_COOLDOWN_MS = 200; -export type ItemType = 'radio_station' | 'dice'; +export type ItemType = 'radio_station' | 'dice' | 'wheel'; export type WorldItem = { id: string; diff --git a/server/app/item_catalog.py b/server/app/item_catalog.py index 2ed28c8..1849aaa 100644 --- a/server/app/item_catalog.py +++ b/server/app/item_catalog.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import dataclass from typing import Literal -ItemType = Literal["radio_station", "dice"] +ItemType = Literal["radio_station", "dice", "wheel"] @dataclass(frozen=True) @@ -28,6 +28,13 @@ ITEM_DEFINITIONS: dict[ItemType, ItemDefinition] = { use_sound="sounds/roll.ogg", default_params={"sides": 6, "number": 2}, ), + "wheel": ItemDefinition( + default_title="wheel", + capabilities=("editable", "carryable", "deletable", "usable"), + use_sound="sounds/spin.ogg", + default_params={"spaces": "yes, no"}, + use_cooldown_ms=4000, + ), } diff --git a/server/app/item_service.py b/server/app/item_service.py index a4c3b62..bac0377 100644 --- a/server/app/item_service.py +++ b/server/app/item_service.py @@ -25,7 +25,7 @@ class ItemService: def now_ms() -> int: return int(time.time() * 1000) - def default_item(self, client: ClientConnection, item_type: Literal["radio_station", "dice"]) -> WorldItem: + def default_item(self, client: ClientConnection, item_type: Literal["radio_station", "dice", "wheel"]) -> WorldItem: item_def = get_item_definition(item_type) now = self.now_ms() return WorldItem( diff --git a/server/app/models.py b/server/app/models.py index b42cf0f..0affb97 100644 --- a/server/app/models.py +++ b/server/app/models.py @@ -40,7 +40,7 @@ class PingPacket(BasePacket): class ItemAddPacket(BasePacket): type: Literal["item_add"] - itemType: Literal["radio_station", "dice"] + itemType: Literal["radio_station", "dice", "wheel"] class ItemPickupPacket(BasePacket): @@ -152,7 +152,7 @@ class NicknameResultPacket(BasePacket): class WorldItem(BaseModel): id: str - type: Literal["radio_station", "dice"] + type: Literal["radio_station", "dice", "wheel"] title: str x: int y: int @@ -169,7 +169,7 @@ class WorldItem(BaseModel): class PersistedWorldItem(BaseModel): model_config = ConfigDict(extra="ignore") id: str - type: Literal["radio_station", "dice"] + type: Literal["radio_station", "dice", "wheel"] title: str x: int y: int diff --git a/server/app/server.py b/server/app/server.py index 23aff5d..a6aaca6 100644 --- a/server/app/server.py +++ b/server/app/server.py @@ -388,7 +388,7 @@ class SignalingServer: if item.carrierId is None and (item.x != client.x or item.y != client.y): await self._send_item_result(client, False, "use", "Item is not on your square.", item.id) return - if item.type != "dice": + if item.type not in {"dice", "wheel"}: await self._send_item_result(client, False, "use", "This item cannot be used yet.", item.id) return now_ms = self.item_service.now_ms() @@ -405,18 +405,39 @@ class SignalingServer: ) return self.item_last_use_ms[item.id] = now_ms - try: - sides = max(1, min(100, int(item.params.get("sides", 6)))) - number = max(1, min(100, int(item.params.get("number", 2)))) - except (TypeError, ValueError): - sides = 6 - number = 2 - rolls = [random.randint(1, sides) for _ in range(number)] - total = sum(rolls) - others_message = ( - f"{client.nickname} rolled {item.title}: {', '.join(str(value) for value in rolls)} (total {total})." - ) - self_message = f"You rolled {item.title}: {', '.join(str(value) for value in rolls)} (total {total})." + if item.type == "dice": + try: + sides = max(1, min(100, int(item.params.get("sides", 6)))) + number = max(1, min(100, int(item.params.get("number", 2)))) + except (TypeError, ValueError): + sides = 6 + number = 2 + rolls = [random.randint(1, sides) for _ in range(number)] + total = sum(rolls) + others_message = ( + f"{client.nickname} rolled {item.title}: {', '.join(str(value) for value in rolls)} (total {total})." + ) + self_message = f"You rolled {item.title}: {', '.join(str(value) for value in rolls)} (total {total})." + else: + spaces_raw = item.params.get("spaces", "") + if isinstance(spaces_raw, str): + spaces = [token.strip() for token in spaces_raw.split(",") if token.strip()] + elif isinstance(spaces_raw, list): + spaces = [str(token).strip() for token in spaces_raw if str(token).strip()] + else: + spaces = [] + if not spaces: + await self._send_item_result( + client, + False, + "use", + "wheel spaces must contain at least one comma-delimited value.", + item.id, + ) + return + landed = random.choice(spaces) + others_message = f"{client.nickname} spins {item.title} and it lands on {landed}." + self_message = others_message await self._broadcast( BroadcastChatMessagePacket(type="chat_message", message=others_message, system=True), exclude=client.websocket, @@ -467,6 +488,30 @@ class SignalingServer: return next_params["sides"] = sides next_params["number"] = number + if item.type == "wheel": + spaces_raw = next_params.get("spaces", "") + if not isinstance(spaces_raw, str): + await self._send_item_result( + client, False, "update", "spaces must be a comma-delimited string.", item.id + ) + return + spaces = [token.strip() for token in spaces_raw.split(",") if token.strip()] + if not spaces: + await self._send_item_result( + client, + False, + "update", + "spaces must include at least one value, separated by commas.", + item.id, + ) + return + if len(spaces) > 100: + await self._send_item_result(client, False, "update", "spaces supports up to 100 values.", item.id) + return + if any(len(token) > 80 for token in spaces): + await self._send_item_result(client, False, "update", "each space must be 80 chars or less.", item.id) + return + next_params["spaces"] = ", ".join(spaces) if item.type == "radio_station": stream_url = str(next_params.get("streamUrl", "")).strip() previous_stream_url = str(item.params.get("streamUrl", "")).strip()