Add wheel item type and random step/wall movement audio

This commit is contained in:
Jage9
2026-02-21 00:55:19 -05:00
parent 9b518d79dc
commit 9ee915e42c
23 changed files with 118 additions and 39 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -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";

View File

@@ -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 });
}

View File

@@ -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<ItemType, Record<string, string | number | boolean>> = {
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<void> {
}
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) {

View File

@@ -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 }

View File

@@ -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);
}
}

View File

@@ -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;

View File

@@ -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,
),
}

View File

@@ -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(

View File

@@ -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

View File

@@ -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()