Add wheel item type and random step/wall movement audio
This commit is contained in:
BIN
client/public/sounds/spin.ogg
Normal file
BIN
client/public/sounds/spin.ogg
Normal file
Binary file not shown.
BIN
client/public/sounds/step-1.ogg
Normal file
BIN
client/public/sounds/step-1.ogg
Normal file
Binary file not shown.
BIN
client/public/sounds/step-10.ogg
Normal file
BIN
client/public/sounds/step-10.ogg
Normal file
Binary file not shown.
BIN
client/public/sounds/step-11.ogg
Normal file
BIN
client/public/sounds/step-11.ogg
Normal file
Binary file not shown.
BIN
client/public/sounds/step-2.ogg
Normal file
BIN
client/public/sounds/step-2.ogg
Normal file
Binary file not shown.
BIN
client/public/sounds/step-3.ogg
Normal file
BIN
client/public/sounds/step-3.ogg
Normal file
Binary file not shown.
BIN
client/public/sounds/step-4.ogg
Normal file
BIN
client/public/sounds/step-4.ogg
Normal file
Binary file not shown.
BIN
client/public/sounds/step-5.ogg
Normal file
BIN
client/public/sounds/step-5.ogg
Normal file
Binary file not shown.
BIN
client/public/sounds/step-6.ogg
Normal file
BIN
client/public/sounds/step-6.ogg
Normal file
Binary file not shown.
BIN
client/public/sounds/step-7.ogg
Normal file
BIN
client/public/sounds/step-7.ogg
Normal file
Binary file not shown.
BIN
client/public/sounds/step-8.ogg
Normal file
BIN
client/public/sounds/step-8.ogg
Normal file
Binary file not shown.
BIN
client/public/sounds/step-9.ogg
Normal file
BIN
client/public/sounds/step-9.ogg
Normal file
Binary file not shown.
BIN
client/public/sounds/wall.ogg
Normal file
BIN
client/public/sounds/wall.ogg
Normal file
Binary file not shown.
@@ -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";
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,6 +405,7 @@ class SignalingServer:
|
||||
)
|
||||
return
|
||||
self.item_last_use_ms[item.id] = now_ms
|
||||
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))))
|
||||
@@ -417,6 +418,26 @@ class SignalingServer:
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user