Add wheel item type and random step/wall movement audio
This commit is contained in:
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.
@@ -1,3 +1,3 @@
|
|||||||
// 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.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 {
|
sfxLocate(peer: { x: number; y: number }): void {
|
||||||
this.playSound({ freq: 880, duration: 0.2, type: 'sine', gain: 0.5, sourcePosition: peer });
|
this.playSound({ freq: 880, duration: 0.2, type: 'sine', gain: 0.5, sourcePosition: peer });
|
||||||
}
|
}
|
||||||
|
|||||||
+41
-5
@@ -83,10 +83,11 @@ const APP_VERSION = String(window.CHGRID_WEB_VERSION ?? '').trim();
|
|||||||
dom.appVersion.textContent = APP_VERSION
|
dom.appVersion.textContent = APP_VERSION
|
||||||
? `Another AI experiment with Jage. Version ${APP_VERSION}`
|
? `Another AI experiment with Jage. Version ${APP_VERSION}`
|
||||||
: 'Another AI experiment with Jage. Version unknown';
|
: '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>> = {
|
const ITEM_TYPE_GLOBAL_PROPERTIES: Record<ItemType, Record<string, string | number | boolean>> = {
|
||||||
radio_station: { useCooldownMs: 1000 },
|
radio_station: { useCooldownMs: 1000 },
|
||||||
dice: { useCooldownMs: 1000 },
|
dice: { useCooldownMs: 1000 },
|
||||||
|
wheel: { useCooldownMs: 4000 },
|
||||||
};
|
};
|
||||||
const EDITABLE_ITEM_PROPERTY_KEYS = new Set([
|
const EDITABLE_ITEM_PROPERTY_KEYS = new Set([
|
||||||
'title',
|
'title',
|
||||||
@@ -95,6 +96,7 @@ const EDITABLE_ITEM_PROPERTY_KEYS = new Set([
|
|||||||
'volume',
|
'volume',
|
||||||
'effect',
|
'effect',
|
||||||
'effectValue',
|
'effectValue',
|
||||||
|
'spaces',
|
||||||
'sides',
|
'sides',
|
||||||
'number',
|
'number',
|
||||||
]);
|
]);
|
||||||
@@ -111,6 +113,8 @@ const SYSTEM_SOUND_URLS = {
|
|||||||
logout: withBase('sounds/logout.ogg'),
|
logout: withBase('sounds/logout.ogg'),
|
||||||
notify: withBase('sounds/notify.ogg'),
|
notify: withBase('sounds/notify.ogg'),
|
||||||
} as const;
|
} 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 state = createInitialState();
|
||||||
const renderer = new CanvasRenderer(dom.canvas);
|
const renderer = new CanvasRenderer(dom.canvas);
|
||||||
@@ -312,7 +316,8 @@ function getPeerNamesAtPosition(x: number, y: number): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function itemTypeLabel(type: ItemType): 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 {
|
function itemLabel(item: WorldItem): string {
|
||||||
@@ -348,6 +353,8 @@ function getEditableItemPropertyKeys(item: WorldItem): string[] {
|
|||||||
keys.push('streamUrl', 'enabled', 'volume', 'effect', 'effectValue');
|
keys.push('streamUrl', 'enabled', 'volume', 'effect', 'effectValue');
|
||||||
} else if (item.type === 'dice') {
|
} else if (item.type === 'dice') {
|
||||||
keys.push('sides', 'number');
|
keys.push('sides', 'number');
|
||||||
|
} else if (item.type === 'wheel') {
|
||||||
|
keys.push('spaces');
|
||||||
}
|
}
|
||||||
return keys;
|
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 {
|
function gameLoop(): void {
|
||||||
if (!state.running) return;
|
if (!state.running) return;
|
||||||
handleMovement();
|
handleMovement();
|
||||||
@@ -784,13 +795,17 @@ function handleMovement(): void {
|
|||||||
|
|
||||||
const nextX = state.player.x + dx;
|
const nextX = state.player.x + dx;
|
||||||
const nextY = state.player.y + dy;
|
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.x = nextX;
|
||||||
state.player.y = nextY;
|
state.player.y = nextY;
|
||||||
persistPlayerPosition();
|
persistPlayerPosition();
|
||||||
state.player.lastMoveTime = now;
|
state.player.lastMoveTime = now;
|
||||||
audio.sfxMove(state.player);
|
void audio.playSample(randomFootstepUrl(), 1);
|
||||||
signaling.send({ type: 'update_position', x: nextX, y: nextY });
|
signaling.send({ type: 'update_position', x: nextX, y: nextY });
|
||||||
|
|
||||||
const namesOnTile = getPeerNamesAtPosition(nextX, 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);
|
peerManager.setPeerPosition(message.id, message.x, message.y);
|
||||||
if (peer) {
|
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;
|
break;
|
||||||
}
|
}
|
||||||
@@ -1773,6 +1788,27 @@ function handleItemPropertyEditModeInput(code: string, key: string): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
signaling.send({ type: 'item_update', itemId, params: { effectValue: clampEffectLevel(parsed) } });
|
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') {
|
} else if (propertyKey === 'sides' || propertyKey === 'number') {
|
||||||
const parsed = Number(value);
|
const parsed = Number(value);
|
||||||
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 100) {
|
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 100) {
|
||||||
|
|||||||
@@ -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']),
|
type: z.enum(['radio_station', 'dice', 'wheel']),
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
x: z.number().int(),
|
x: z.number().int(),
|
||||||
y: z.number().int(),
|
y: z.number().int(),
|
||||||
@@ -129,7 +129,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' }
|
| { type: 'item_add'; itemType: 'radio_station' | 'dice' | 'wheel' }
|
||||||
| { 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 }
|
||||||
|
|||||||
@@ -76,11 +76,11 @@ export class CanvasRenderer {
|
|||||||
private drawItem(item: WorldItem): void {
|
private drawItem(item: WorldItem): void {
|
||||||
const drawX = item.x * this.squarePixelSize;
|
const drawX = item.x * this.squarePixelSize;
|
||||||
const drawY = this.canvas.height - (item.y * this.squarePixelSize) - 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.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(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 GRID_SIZE = 40;
|
||||||
export const HEARING_RADIUS = 15;
|
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 = {
|
export type WorldItem = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
ItemType = Literal["radio_station", "dice"]
|
ItemType = Literal["radio_station", "dice", "wheel"]
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -28,6 +28,13 @@ ITEM_DEFINITIONS: dict[ItemType, ItemDefinition] = {
|
|||||||
use_sound="sounds/roll.ogg",
|
use_sound="sounds/roll.ogg",
|
||||||
default_params={"sides": 6, "number": 2},
|
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:
|
def now_ms() -> int:
|
||||||
return int(time.time() * 1000)
|
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)
|
item_def = get_item_definition(item_type)
|
||||||
now = self.now_ms()
|
now = self.now_ms()
|
||||||
return WorldItem(
|
return WorldItem(
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ class PingPacket(BasePacket):
|
|||||||
|
|
||||||
class ItemAddPacket(BasePacket):
|
class ItemAddPacket(BasePacket):
|
||||||
type: Literal["item_add"]
|
type: Literal["item_add"]
|
||||||
itemType: Literal["radio_station", "dice"]
|
itemType: Literal["radio_station", "dice", "wheel"]
|
||||||
|
|
||||||
|
|
||||||
class ItemPickupPacket(BasePacket):
|
class ItemPickupPacket(BasePacket):
|
||||||
@@ -152,7 +152,7 @@ class NicknameResultPacket(BasePacket):
|
|||||||
|
|
||||||
class WorldItem(BaseModel):
|
class WorldItem(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
type: Literal["radio_station", "dice"]
|
type: Literal["radio_station", "dice", "wheel"]
|
||||||
title: str
|
title: str
|
||||||
x: int
|
x: int
|
||||||
y: int
|
y: int
|
||||||
@@ -169,7 +169,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"]
|
type: Literal["radio_station", "dice", "wheel"]
|
||||||
title: str
|
title: str
|
||||||
x: int
|
x: int
|
||||||
y: int
|
y: int
|
||||||
|
|||||||
+46
-1
@@ -388,7 +388,7 @@ class SignalingServer:
|
|||||||
if item.carrierId is None and (item.x != client.x or item.y != client.y):
|
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)
|
await self._send_item_result(client, False, "use", "Item is not on your square.", item.id)
|
||||||
return
|
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)
|
await self._send_item_result(client, False, "use", "This item cannot be used yet.", item.id)
|
||||||
return
|
return
|
||||||
now_ms = self.item_service.now_ms()
|
now_ms = self.item_service.now_ms()
|
||||||
@@ -405,6 +405,7 @@ class SignalingServer:
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
self.item_last_use_ms[item.id] = now_ms
|
self.item_last_use_ms[item.id] = now_ms
|
||||||
|
if item.type == "dice":
|
||||||
try:
|
try:
|
||||||
sides = max(1, min(100, int(item.params.get("sides", 6))))
|
sides = max(1, min(100, int(item.params.get("sides", 6))))
|
||||||
number = max(1, min(100, int(item.params.get("number", 2))))
|
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})."
|
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})."
|
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(
|
await self._broadcast(
|
||||||
BroadcastChatMessagePacket(type="chat_message", message=others_message, system=True),
|
BroadcastChatMessagePacket(type="chat_message", message=others_message, system=True),
|
||||||
exclude=client.websocket,
|
exclude=client.websocket,
|
||||||
@@ -467,6 +488,30 @@ class SignalingServer:
|
|||||||
return
|
return
|
||||||
next_params["sides"] = sides
|
next_params["sides"] = sides
|
||||||
next_params["number"] = number
|
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":
|
if item.type == "radio_station":
|
||||||
stream_url = str(next_params.get("streamUrl", "")).strip()
|
stream_url = str(next_params.get("streamUrl", "")).strip()
|
||||||
previous_stream_url = str(item.params.get("streamUrl", "")).strip()
|
previous_stream_url = str(item.params.get("streamUrl", "")).strip()
|
||||||
|
|||||||
Reference in New Issue
Block a user