Add piano item type with realtime play mode and remote notes

This commit is contained in:
Jage9
2026-02-22 23:42:17 -05:00
parent 81c6af6399
commit 1319c044dd
23 changed files with 1014 additions and 23 deletions

View File

@@ -5,10 +5,10 @@ from __future__ import annotations
from dataclasses import dataclass
from typing import Literal, cast
from .items import clock, radio
from .items import clock, piano, radio
from .items.registry import ITEM_MODULES, ITEM_TYPE_ORDER
ItemType = Literal["radio_station", "dice", "wheel", "clock", "widget"]
ItemType = Literal["radio_station", "dice", "wheel", "clock", "widget", "piano"]
ITEM_TYPE_SEQUENCE: tuple[ItemType, ...] = cast(tuple[ItemType, ...], ITEM_TYPE_ORDER)
ITEM_TYPE_LABELS: dict[ItemType, str] = {item_type: ITEM_MODULES[item_type].LABEL for item_type in ITEM_TYPE_SEQUENCE}
ITEM_TYPE_EDITABLE_PROPERTIES: dict[ItemType, tuple[str, ...]] = {
@@ -19,6 +19,7 @@ CLOCK_DEFAULT_TIME_ZONE = clock.DEFAULT_TIME_ZONE
CLOCK_TIME_ZONE_OPTIONS = clock.TIME_ZONE_OPTIONS
RADIO_EFFECT_OPTIONS = radio.EFFECT_OPTIONS
RADIO_CHANNEL_OPTIONS = radio.CHANNEL_OPTIONS
PIANO_INSTRUMENT_OPTIONS = piano.INSTRUMENT_OPTIONS
@dataclass(frozen=True)
@@ -79,6 +80,7 @@ ITEM_PROPERTY_OPTIONS: dict[str, tuple[str, ...]] = {
"emitEffect": RADIO_EFFECT_OPTIONS,
"mediaChannel": RADIO_CHANNEL_OPTIONS,
"timeZone": CLOCK_TIME_ZONE_OPTIONS,
"instrument": PIANO_INSTRUMENT_OPTIONS,
}
ITEM_TYPE_TOOLTIPS: dict[ItemType, str] = {

View File

@@ -33,7 +33,7 @@ class ItemService:
return int(time.time() * 1000)
def default_item(self, client: ClientConnection, item_type: Literal["radio_station", "dice", "wheel", "clock", "widget"]) -> WorldItem:
def default_item(self, client: ClientConnection, item_type: Literal["radio_station", "dice", "wheel", "clock", "widget", "piano"]) -> WorldItem:
"""Create a new server-authoritative item at the caller's position."""
item_def = get_item_definition(item_type)

100
server/app/items/piano.py Normal file
View File

@@ -0,0 +1,100 @@
"""Piano item schema metadata and behavior."""
from __future__ import annotations
from typing import Callable
from ..item_types import ItemUseResult
from ..models import WorldItem
LABEL = "piano"
TOOLTIP = "Playable keyboard instrument with multiple synth voices."
EDITABLE_PROPERTIES: tuple[str, ...] = ("title", "instrument", "attack", "decay", "emitRange")
CAPABILITIES: tuple[str, ...] = ("editable", "carryable", "deletable", "usable")
USE_SOUND: str | None = None
EMIT_SOUND: str | None = None
USE_COOLDOWN_MS = 1000
EMIT_RANGE = 15
DIRECTIONAL = False
DEFAULT_TITLE = "piano"
DEFAULT_PARAMS: dict = {
"instrument": "piano",
"attack": 15,
"decay": 45,
"emitRange": 15,
}
INSTRUMENT_OPTIONS: tuple[str, ...] = (
"piano",
"electric_piano",
"guitar",
"organ",
"bass",
"violin",
"synth_lead",
"drum_kit",
)
PROPERTY_METADATA: dict[str, dict[str, object]] = {
"title": {"valueType": "text", "tooltip": "Display name spoken and shown for this item.", "maxLength": 80},
"instrument": {"valueType": "list", "tooltip": "Instrument voice used when playing this piano."},
"attack": {
"valueType": "number",
"tooltip": "How quickly notes ramp in. Lower is sharper; higher is softer.",
"range": {"min": 0, "max": 100, "step": 1},
},
"decay": {
"valueType": "number",
"tooltip": "How long notes ring out after the initial hit.",
"range": {"min": 0, "max": 100, "step": 1},
},
"emitRange": {
"valueType": "number",
"tooltip": "Maximum distance in squares where this piano can be heard.",
"range": {"min": 5, "max": 20, "step": 1},
},
}
def validate_update(_item: WorldItem, next_params: dict) -> dict:
"""Validate and normalize piano params."""
instrument = str(next_params.get("instrument", "piano")).strip().lower()
if instrument not in INSTRUMENT_OPTIONS:
raise ValueError(f"instrument must be one of: {', '.join(INSTRUMENT_OPTIONS)}.")
next_params["instrument"] = instrument
try:
attack = int(next_params.get("attack", 15))
except (TypeError, ValueError) as exc:
raise ValueError("attack must be an integer between 0 and 100.") from exc
if not (0 <= attack <= 100):
raise ValueError("attack must be between 0 and 100.")
next_params["attack"] = attack
try:
decay = int(next_params.get("decay", 45))
except (TypeError, ValueError) as exc:
raise ValueError("decay must be an integer between 0 and 100.") from exc
if not (0 <= decay <= 100):
raise ValueError("decay must be between 0 and 100.")
next_params["decay"] = decay
try:
emit_range = int(next_params.get("emitRange", 15))
except (TypeError, ValueError) as exc:
raise ValueError("emitRange must be an integer between 5 and 20.") from exc
if not (5 <= emit_range <= 20):
raise ValueError("emitRange must be between 5 and 20.")
next_params["emitRange"] = emit_range
return next_params
def use_item(item: WorldItem, nickname: str, _clock_formatter: Callable[[dict], str]) -> ItemUseResult:
"""Enter piano play mode for the user who used the item."""
return ItemUseResult(
self_message=f"You begin playing {item.title}.",
others_message=f"{nickname} begins playing {item.title}.",
)

View File

@@ -7,7 +7,7 @@ from typing import Callable, Protocol
from ..item_types import ItemUseResult
from ..models import WorldItem
from . import clock, dice, radio, wheel, widget
from . import clock, dice, piano, radio, wheel, widget
class ItemModule(Protocol):
@@ -29,11 +29,12 @@ class ItemModule(Protocol):
use_item: Callable[[WorldItem, str, Callable[[dict], str]], ItemUseResult]
ITEM_TYPE_ORDER: tuple[str, ...] = ("clock", "dice", "radio_station", "wheel", "widget")
ITEM_TYPE_ORDER: tuple[str, ...] = ("clock", "dice", "piano", "radio_station", "wheel", "widget")
ITEM_MODULES: dict[str, ItemModule] = {
"clock": clock,
"dice": dice,
"piano": piano,
"radio_station": radio,
"wheel": wheel,
"widget": widget,

View File

@@ -42,7 +42,7 @@ class PingPacket(BasePacket):
class ItemAddPacket(BasePacket):
type: Literal["item_add"]
itemType: Literal["radio_station", "dice", "wheel", "clock", "widget"]
itemType: Literal["radio_station", "dice", "wheel", "clock", "widget", "piano"]
class ItemPickupPacket(BasePacket):
@@ -67,6 +67,14 @@ class ItemUsePacket(BasePacket):
itemId: str
class ItemPianoNotePacket(BasePacket):
type: Literal["item_piano_note"]
itemId: str
keyId: str = Field(min_length=1, max_length=32)
midi: int = Field(ge=0, le=127)
on: bool
class ItemUpdatePacket(BasePacket):
type: Literal["item_update"]
itemId: str
@@ -85,6 +93,7 @@ ClientPacket = (
| ItemDropPacket
| ItemDeletePacket
| ItemUsePacket
| ItemPianoNotePacket
| ItemUpdatePacket
)
@@ -157,7 +166,7 @@ class NicknameResultPacket(BasePacket):
class WorldItem(BaseModel):
id: str
type: Literal["radio_station", "dice", "wheel", "clock", "widget"]
type: Literal["radio_station", "dice", "wheel", "clock", "widget", "piano"]
title: str
x: int
y: int
@@ -175,7 +184,7 @@ class WorldItem(BaseModel):
class PersistedWorldItem(BaseModel):
model_config = ConfigDict(extra="ignore")
id: str
type: Literal["radio_station", "dice", "wheel", "clock", "widget"]
type: Literal["radio_station", "dice", "wheel", "clock", "widget", "piano"]
title: str
x: int
y: int
@@ -211,3 +220,18 @@ class ItemUseSoundPacket(BasePacket):
sound: str
x: int
y: int
class ItemPianoNoteBroadcastPacket(BasePacket):
type: Literal["item_piano_note"]
itemId: str
senderId: str
keyId: str
midi: int
on: bool
instrument: str
attack: int
decay: int
x: int
y: int
emitRange: int

View File

@@ -46,6 +46,8 @@ from .models import (
ItemAddPacket,
ItemDeletePacket,
ItemDropPacket,
ItemPianoNoteBroadcastPacket,
ItemPianoNotePacket,
ItemPickupPacket,
ItemRemovePacket,
ItemUpdatePacket,
@@ -656,6 +658,39 @@ class SignalingServer:
)
return
if isinstance(packet, ItemPianoNotePacket):
item = self.items.get(packet.itemId)
if not item or item.type != "piano":
return
if item.carrierId not in (None, client.id):
return
if item.carrierId is None and (item.x != client.x or item.y != client.y):
return
instrument = str(item.params.get("instrument", "piano")).strip().lower()
attack = int(item.params.get("attack", 15)) if isinstance(item.params.get("attack", 15), (int, float)) else 15
decay = int(item.params.get("decay", 45)) if isinstance(item.params.get("decay", 45), (int, float)) else 45
emit_range = int(item.params.get("emitRange", 15)) if isinstance(item.params.get("emitRange", 15), (int, float)) else 15
source_x = client.x if item.carrierId == client.id else item.x
source_y = client.y if item.carrierId == client.id else item.y
await self._broadcast(
ItemPianoNoteBroadcastPacket(
type="item_piano_note",
itemId=item.id,
senderId=client.id,
keyId=packet.keyId,
midi=packet.midi,
on=packet.on,
instrument=instrument,
attack=max(0, min(100, attack)),
decay=max(0, min(100, decay)),
x=source_x,
y=source_y,
emitRange=max(5, min(20, emit_range)),
),
exclude=client.websocket,
)
return
if isinstance(packet, ItemUpdatePacket):
item = self.items.get(packet.itemId)
if not item: