Compare commits
13 Commits
pr/livekit
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| da1db16e5f | |||
| 39eea11244 | |||
| 31abff05f3 | |||
| 26274c3f31 | |||
| 166707b164 | |||
| 7f11e33ca5 | |||
| 878157efc0 | |||
| d9bc14650d | |||
| dc8602cacf | |||
| 17b6bf9b2d | |||
| 9f2063d088 | |||
| 8745a433dc | |||
| f54fff5fb5 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -25,3 +25,7 @@ plans/
|
|||||||
|
|
||||||
# Host-local notes
|
# Host-local notes
|
||||||
local/
|
local/
|
||||||
|
|
||||||
|
# Ignore actual sounds in sounds/widgets/
|
||||||
|
sounds/widgets/*.ogg
|
||||||
|
sounds/widgets/**/*.ogg
|
||||||
@@ -10,6 +10,7 @@ RUN apk add --no-cache php83 php83-fpm php83-curl php83-ctype
|
|||||||
RUN echo 'clear_env = no' >> /etc/php83/php-fpm.d/www.conf
|
RUN echo 'clear_env = no' >> /etc/php83/php-fpm.d/www.conf
|
||||||
COPY --from=build /app/dist /usr/share/nginx/html
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
COPY deploy/php/media_proxy.php /usr/share/nginx/html/media_proxy.php
|
COPY deploy/php/media_proxy.php /usr/share/nginx/html/media_proxy.php
|
||||||
|
COPY deploy/php/sounds_list.php /usr/share/nginx/html/sounds_list.php
|
||||||
COPY client/nginx.conf /etc/nginx/conf.d/default.conf
|
COPY client/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
CMD ["sh", "-c", "php-fpm83 && exec nginx -g 'daemon off;'"]
|
CMD ["sh", "-c", "find /usr/share/nginx/html/sounds/widgets -type d -exec chmod 755 {} \\; 2>/dev/null; find /usr/share/nginx/html/sounds/widgets -type f -exec chmod 644 {} \\; 2>/dev/null; php-fpm83 && exec nginx -g 'daemon off;'"]
|
||||||
|
|||||||
@@ -95,6 +95,27 @@
|
|||||||
<button id="closeSettingsButton">Close</button>
|
<button id="closeSettingsButton">Close</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="mobileControls" class="mobile-controls" data-expanded="false" aria-label="Mobile game controls">
|
||||||
|
<button id="mobileControlsToggle" type="button" class="mobile-toggle-btn"
|
||||||
|
aria-expanded="false" aria-controls="mobileControlsBody">☰ Controls</button>
|
||||||
|
<div id="mobileControlsBody" class="mobile-controls-body">
|
||||||
|
<div class="dpad" role="group" aria-label="Movement">
|
||||||
|
<button id="dpadUp" type="button" class="dpad-btn dpad-up" aria-label="Move up">▲</button>
|
||||||
|
<button id="dpadLeft" type="button" class="dpad-btn dpad-left" aria-label="Move left">◀</button>
|
||||||
|
<div class="dpad-center" aria-hidden="true"></div>
|
||||||
|
<button id="dpadRight" type="button" class="dpad-btn dpad-right" aria-label="Move right">▶</button>
|
||||||
|
<button id="dpadDown" type="button" class="dpad-btn dpad-down" aria-label="Move down">▼</button>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-actions" role="group" aria-label="Actions">
|
||||||
|
<button id="mobileBtnChat" type="button" class="mobile-action-btn">Chat</button>
|
||||||
|
<button id="mobileBtnUse" type="button" class="mobile-action-btn">Use</button>
|
||||||
|
<button id="mobileBtnLocateUser" type="button" class="mobile-action-btn">Find User</button>
|
||||||
|
<button id="mobileBtnLocateItem" type="button" class="mobile-action-btn">Find Item</button>
|
||||||
|
<button id="mobileBtnCommands" type="button" class="mobile-action-btn mobile-action-btn--wide">Commands</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<script src="%BASE_URL%version.js"></script>
|
<script src="%BASE_URL%version.js"></script>
|
||||||
<script type="module" src="/src/main.ts"></script>
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
|||||||
BIN
client/public/sounds/card_draw.ogg
Normal file
BIN
client/public/sounds/card_draw.ogg
Normal file
Binary file not shown.
BIN
client/public/sounds/card_shuffle.ogg
Normal file
BIN
client/public/sounds/card_shuffle.ogg
Normal file
Binary file not shown.
0
client/public/sounds/widgets/.gitkeep
Normal file
0
client/public/sounds/widgets/.gitkeep
Normal file
@@ -48,6 +48,9 @@ export class AudioEngine {
|
|||||||
private readonly sampleLoaders = new Map<string, Promise<AudioBuffer>>();
|
private readonly sampleLoaders = new Map<string, Promise<AudioBuffer>>();
|
||||||
private readonly activeSpatialSamples = new Set<ActiveSpatialSampleRuntime>();
|
private readonly activeSpatialSamples = new Set<ActiveSpatialSampleRuntime>();
|
||||||
|
|
||||||
|
private previewSourceNode: AudioBufferSourceNode | null = null;
|
||||||
|
private previewGainNode: GainNode | null = null;
|
||||||
|
|
||||||
private outboundSource: MediaStreamAudioSourceNode | null = null;
|
private outboundSource: MediaStreamAudioSourceNode | null = null;
|
||||||
private outboundInputGain: GainNode | null = null;
|
private outboundInputGain: GainNode | null = null;
|
||||||
private outboundInputGainValue = 1;
|
private outboundInputGainValue = 1;
|
||||||
@@ -491,6 +494,47 @@ export class AudioEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
stopPreviewSample(): void {
|
||||||
|
if (this.previewSourceNode) {
|
||||||
|
try { this.previewSourceNode.stop(); } catch { /* already ended */ }
|
||||||
|
try { this.previewSourceNode.disconnect(); } catch { /* ignore */ }
|
||||||
|
this.previewSourceNode = null;
|
||||||
|
}
|
||||||
|
if (this.previewGainNode) {
|
||||||
|
try { this.previewGainNode.disconnect(); } catch { /* ignore */ }
|
||||||
|
this.previewGainNode = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async playPreviewSample(url: string, gain = 1): Promise<void> {
|
||||||
|
this.stopPreviewSample();
|
||||||
|
await this.ensureContext();
|
||||||
|
const { audioCtx, sfxGainNode } = this;
|
||||||
|
if (!audioCtx || !sfxGainNode) return;
|
||||||
|
if (gain <= 0) return;
|
||||||
|
try {
|
||||||
|
const buffer = await this.getSampleBuffer(url);
|
||||||
|
this.stopPreviewSample();
|
||||||
|
const source = audioCtx.createBufferSource();
|
||||||
|
source.buffer = buffer;
|
||||||
|
const gainNode = audioCtx.createGain();
|
||||||
|
gainNode.gain.setValueAtTime(0, audioCtx.currentTime);
|
||||||
|
gainNode.gain.setTargetAtTime(gain, audioCtx.currentTime, ONE_SHOT_ATTACK_SECONDS);
|
||||||
|
source.connect(gainNode).connect(sfxGainNode);
|
||||||
|
this.previewSourceNode = source;
|
||||||
|
this.previewGainNode = gainNode;
|
||||||
|
source.onended = () => {
|
||||||
|
if (this.previewSourceNode === source) {
|
||||||
|
this.previewSourceNode = null;
|
||||||
|
this.previewGainNode = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
source.start();
|
||||||
|
} catch {
|
||||||
|
// Ignore decode/load errors.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Starts a looping sample and returns a stop callback for explicit teardown. */
|
/** Starts a looping sample and returns a stop callback for explicit teardown. */
|
||||||
async startLoopingSample(url: string, gain = 1): Promise<(() => void) | null> {
|
async startLoopingSample(url: string, gain = 1): Promise<(() => void) | null> {
|
||||||
await this.ensureContext();
|
await this.ensureContext();
|
||||||
|
|||||||
91
client/src/input/mobileController.ts
Normal file
91
client/src/input/mobileController.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import type { ModeInput } from './commandTypes';
|
||||||
|
|
||||||
|
type MobileControllerDeps = {
|
||||||
|
dom: {
|
||||||
|
canvas: HTMLCanvasElement;
|
||||||
|
mobileControls: HTMLDivElement;
|
||||||
|
toggleButton: HTMLButtonElement;
|
||||||
|
dpadUp: HTMLButtonElement;
|
||||||
|
dpadDown: HTMLButtonElement;
|
||||||
|
dpadLeft: HTMLButtonElement;
|
||||||
|
dpadRight: HTMLButtonElement;
|
||||||
|
btnChat: HTMLButtonElement;
|
||||||
|
btnUse: HTMLButtonElement;
|
||||||
|
btnLocateUser: HTMLButtonElement;
|
||||||
|
btnLocateItem: HTMLButtonElement;
|
||||||
|
btnCommandPalette: HTMLButtonElement;
|
||||||
|
};
|
||||||
|
state: {
|
||||||
|
running: boolean;
|
||||||
|
keysPressed: Record<string, boolean>;
|
||||||
|
};
|
||||||
|
handleModeInput: (input: ModeInput) => void;
|
||||||
|
openCommandPalette: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wires touch handlers for the on-screen mobile controls panel.
|
||||||
|
* Movement uses hold-to-walk by writing directly into keysPressed (same as the
|
||||||
|
* keyboard controller). Action buttons dispatch through handleModeInput so they
|
||||||
|
* travel the same code path as their keyboard equivalents.
|
||||||
|
*/
|
||||||
|
export function setupMobileControls(deps: MobileControllerDeps): void {
|
||||||
|
const { dom, state } = deps;
|
||||||
|
|
||||||
|
// ── Toggle ───────────────────────────────────────────────────────────────
|
||||||
|
dom.toggleButton.addEventListener('click', () => {
|
||||||
|
const expanded = dom.mobileControls.dataset['expanded'] === 'true';
|
||||||
|
const next = String(!expanded);
|
||||||
|
dom.mobileControls.dataset['expanded'] = next;
|
||||||
|
dom.toggleButton.setAttribute('aria-expanded', next);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── D-pad movement ───────────────────────────────────────────────────────
|
||||||
|
const dpadMap: Array<[HTMLButtonElement, string]> = [
|
||||||
|
[dom.dpadUp, 'ArrowUp'],
|
||||||
|
[dom.dpadDown, 'ArrowDown'],
|
||||||
|
[dom.dpadLeft, 'ArrowLeft'],
|
||||||
|
[dom.dpadRight, 'ArrowRight'],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [btn, arrowCode] of dpadMap) {
|
||||||
|
btn.addEventListener('touchstart', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!state.running) return;
|
||||||
|
state.keysPressed[arrowCode] = true;
|
||||||
|
}, { passive: false });
|
||||||
|
|
||||||
|
btn.addEventListener('touchend', () => {
|
||||||
|
state.keysPressed[arrowCode] = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
btn.addEventListener('touchcancel', () => {
|
||||||
|
state.keysPressed[arrowCode] = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Action buttons ───────────────────────────────────────────────────────
|
||||||
|
type ActionDef = [HTMLButtonElement, string, string];
|
||||||
|
const actionMap: ActionDef[] = [
|
||||||
|
[dom.btnChat, 'Slash', '/'],
|
||||||
|
[dom.btnUse, 'Enter', 'Enter'],
|
||||||
|
[dom.btnLocateUser, 'KeyL', 'l'],
|
||||||
|
[dom.btnLocateItem, 'KeyI', 'i'],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [btn, code, key] of actionMap) {
|
||||||
|
btn.addEventListener('touchstart', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!state.running) return;
|
||||||
|
dom.canvas.focus();
|
||||||
|
deps.handleModeInput({ code, key, ctrlKey: false, shiftKey: false });
|
||||||
|
}, { passive: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
dom.btnCommandPalette.addEventListener('touchstart', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!state.running) return;
|
||||||
|
dom.canvas.focus();
|
||||||
|
deps.openCommandPalette();
|
||||||
|
}, { passive: false });
|
||||||
|
}
|
||||||
466
client/src/items/cardTableController.ts
Normal file
466
client/src/items/cardTableController.ts
Normal file
@@ -0,0 +1,466 @@
|
|||||||
|
import { handleListControlKey } from '../input/listController';
|
||||||
|
import { handleYesNoMenuInput, YES_NO_OPTIONS } from '../input/yesNoMenu';
|
||||||
|
import type { OutgoingMessage } from '../network/protocol';
|
||||||
|
import type { GameMode, WorldItem } from '../state/gameState';
|
||||||
|
|
||||||
|
const CARD_ACTIONS = ['Discard', 'Return to draw pile', 'Cancel'] as const;
|
||||||
|
|
||||||
|
const RANK_NAMES: Record<string, string> = {
|
||||||
|
A: 'Ace',
|
||||||
|
'2': 'Two',
|
||||||
|
'3': 'Three',
|
||||||
|
'4': 'Four',
|
||||||
|
'5': 'Five',
|
||||||
|
'6': 'Six',
|
||||||
|
'7': 'Seven',
|
||||||
|
'8': 'Eight',
|
||||||
|
'9': 'Nine',
|
||||||
|
'10': 'Ten',
|
||||||
|
J: 'Jack',
|
||||||
|
Q: 'Queen',
|
||||||
|
K: 'King',
|
||||||
|
};
|
||||||
|
|
||||||
|
const SUIT_NAMES: Record<string, string> = {
|
||||||
|
S: 'Spades',
|
||||||
|
H: 'Hearts',
|
||||||
|
D: 'Diamonds',
|
||||||
|
C: 'Clubs',
|
||||||
|
};
|
||||||
|
|
||||||
|
function cardName(code: string): string {
|
||||||
|
if (code === 'JO1' || code === 'JO2') return 'Joker';
|
||||||
|
const suit = code.slice(-1);
|
||||||
|
const rank = code.slice(0, -1);
|
||||||
|
return `${RANK_NAMES[rank] ?? rank} of ${SUIT_NAMES[suit] ?? suit}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
type CardTableControllerDeps = {
|
||||||
|
state: {
|
||||||
|
mode: GameMode;
|
||||||
|
items: Map<string, WorldItem>;
|
||||||
|
player: { nickname: string };
|
||||||
|
cardTableItemId: string | null;
|
||||||
|
cardTableMenuIndex: number;
|
||||||
|
cardTableHandIndex: number;
|
||||||
|
cardTableCardActionIndex: number;
|
||||||
|
cardTableDiscardIndex: number;
|
||||||
|
cardTableConfirmIndex: number;
|
||||||
|
};
|
||||||
|
signalingSend: (message: OutgoingMessage) => void;
|
||||||
|
updateStatus: (message: string) => void;
|
||||||
|
sfxUiBlip: () => void;
|
||||||
|
sfxUiCancel: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getDrawPile(item: WorldItem): string[] {
|
||||||
|
const raw = item.params['draw_pile'];
|
||||||
|
if (!Array.isArray(raw)) return [];
|
||||||
|
return raw.filter((c): c is string => typeof c === 'string');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDiscardPile(item: WorldItem): string[] {
|
||||||
|
const raw = item.params['discard_pile'];
|
||||||
|
if (!Array.isArray(raw)) return [];
|
||||||
|
return raw.filter((c): c is string => typeof c === 'string');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHand(item: WorldItem, nickname: string): string[] {
|
||||||
|
const hands = item.params['hands'];
|
||||||
|
if (!hands || typeof hands !== 'object' || Array.isArray(hands)) return [];
|
||||||
|
const hand = (hands as Record<string, unknown>)[nickname];
|
||||||
|
if (!Array.isArray(hand)) return [];
|
||||||
|
return hand.filter((c): c is string => typeof c === 'string');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMainMenuEntries(item: WorldItem, nickname: string): string[] {
|
||||||
|
const drawPile = getDrawPile(item);
|
||||||
|
const discardPile = getDiscardPile(item);
|
||||||
|
const hand = getHand(item, nickname);
|
||||||
|
return [
|
||||||
|
drawPile.length > 0 ? `Draw a card (${drawPile.length} in pile)` : 'Draw a card (pile empty)',
|
||||||
|
discardPile.length > 0 ? `Draw from discard (${discardPile.length})` : 'Draw from discard (none)',
|
||||||
|
hand.length > 0 ? `View hand (${hand.length} cards)` : 'View hand (empty)',
|
||||||
|
'Shuffle and reset',
|
||||||
|
'Close',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCardTableController(deps: CardTableControllerDeps): {
|
||||||
|
beginCardTableMenu: (item: WorldItem) => void;
|
||||||
|
handleCardTableMenuModeInput: (code: string, key: string) => void;
|
||||||
|
handleCardTableHandModeInput: (code: string, key: string) => void;
|
||||||
|
handleCardTableCardActionModeInput: (code: string, key: string) => void;
|
||||||
|
handleCardTableDiscardModeInput: (code: string, key: string) => void;
|
||||||
|
handleCardTableConfirmResetModeInput: (code: string, key: string) => void;
|
||||||
|
refreshCardTableStatus: () => void;
|
||||||
|
} {
|
||||||
|
function getActiveItem(): WorldItem | null {
|
||||||
|
return deps.state.cardTableItemId ? (deps.state.items.get(deps.state.cardTableItemId) ?? null) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function exitMenu(): void {
|
||||||
|
deps.state.mode = 'normal';
|
||||||
|
deps.state.cardTableItemId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function beginCardTableMenu(item: WorldItem): void {
|
||||||
|
deps.state.cardTableItemId = item.id;
|
||||||
|
deps.state.cardTableMenuIndex = 0;
|
||||||
|
deps.state.mode = 'cardTableMenu';
|
||||||
|
const entries = buildMainMenuEntries(item, deps.state.player.nickname);
|
||||||
|
deps.updateStatus(`${item.title}. ${entries[0]}.`);
|
||||||
|
deps.sfxUiBlip();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCardTableMenuModeInput(code: string, key: string): void {
|
||||||
|
const item = getActiveItem();
|
||||||
|
if (!item) {
|
||||||
|
exitMenu();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nickname = deps.state.player.nickname;
|
||||||
|
const entries = buildMainMenuEntries(item, nickname);
|
||||||
|
const control = handleListControlKey(code, key, entries, deps.state.cardTableMenuIndex, (e) => e);
|
||||||
|
|
||||||
|
if (control.type === 'move') {
|
||||||
|
deps.state.cardTableMenuIndex = control.index;
|
||||||
|
deps.updateStatus(entries[control.index]);
|
||||||
|
deps.sfxUiBlip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (control.type === 'select') {
|
||||||
|
const idx = deps.state.cardTableMenuIndex;
|
||||||
|
|
||||||
|
if (idx === 0) {
|
||||||
|
// Draw a card
|
||||||
|
const drawPile = getDrawPile(item);
|
||||||
|
if (drawPile.length === 0) {
|
||||||
|
deps.updateStatus('Draw pile is empty.');
|
||||||
|
deps.sfxUiCancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
deps.signalingSend({ type: 'item_interact', itemId: item.id, action: 'draw' });
|
||||||
|
deps.updateStatus('Drawing a card.');
|
||||||
|
deps.sfxUiBlip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (idx === 1) {
|
||||||
|
// Draw from discard
|
||||||
|
const discardPile = getDiscardPile(item);
|
||||||
|
if (discardPile.length === 0) {
|
||||||
|
deps.updateStatus('Discard pile is empty.');
|
||||||
|
deps.sfxUiCancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
deps.state.cardTableDiscardIndex = 0;
|
||||||
|
deps.state.mode = 'cardTableDiscard';
|
||||||
|
deps.updateStatus(`Discard pile. ${cardName(discardPile[0])}.`);
|
||||||
|
deps.sfxUiBlip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (idx === 2) {
|
||||||
|
// View hand
|
||||||
|
const hand = getHand(item, nickname);
|
||||||
|
if (hand.length === 0) {
|
||||||
|
deps.updateStatus('Your hand is empty.');
|
||||||
|
deps.sfxUiCancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
deps.state.cardTableHandIndex = 0;
|
||||||
|
deps.state.mode = 'cardTableHand';
|
||||||
|
deps.updateStatus(`Your hand. ${cardName(hand[0])}.`);
|
||||||
|
deps.sfxUiBlip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (idx === 3) {
|
||||||
|
// Shuffle and reset — confirm first
|
||||||
|
deps.state.cardTableConfirmIndex = 0;
|
||||||
|
deps.state.mode = 'cardTableConfirmReset';
|
||||||
|
deps.updateStatus(`Shuffle and reset ${item.title}? ${YES_NO_OPTIONS[0].label}.`);
|
||||||
|
deps.sfxUiBlip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close
|
||||||
|
exitMenu();
|
||||||
|
deps.updateStatus('Closed.');
|
||||||
|
deps.sfxUiCancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (control.type === 'cancel') {
|
||||||
|
exitMenu();
|
||||||
|
deps.updateStatus('Closed.');
|
||||||
|
deps.sfxUiCancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCardTableHandModeInput(code: string, key: string): void {
|
||||||
|
const item = getActiveItem();
|
||||||
|
if (!item) {
|
||||||
|
exitMenu();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nickname = deps.state.player.nickname;
|
||||||
|
const hand = getHand(item, nickname);
|
||||||
|
const entries = [...hand.map(cardName), 'Back'];
|
||||||
|
const control = handleListControlKey(code, key, entries, deps.state.cardTableHandIndex, (e) => e);
|
||||||
|
|
||||||
|
if (control.type === 'move') {
|
||||||
|
deps.state.cardTableHandIndex = control.index;
|
||||||
|
deps.updateStatus(entries[control.index]);
|
||||||
|
deps.sfxUiBlip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (control.type === 'select') {
|
||||||
|
if (deps.state.cardTableHandIndex === hand.length) {
|
||||||
|
// Back
|
||||||
|
deps.state.mode = 'cardTableMenu';
|
||||||
|
const menuEntries = buildMainMenuEntries(item, nickname);
|
||||||
|
deps.updateStatus(menuEntries[deps.state.cardTableMenuIndex] ?? menuEntries[0]);
|
||||||
|
deps.sfxUiCancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Select a card → card action submenu
|
||||||
|
deps.state.cardTableCardActionIndex = 0;
|
||||||
|
deps.state.mode = 'cardTableCardAction';
|
||||||
|
const selectedCard = hand[deps.state.cardTableHandIndex];
|
||||||
|
deps.updateStatus(`${cardName(selectedCard)}. ${CARD_ACTIONS[0]}.`);
|
||||||
|
deps.sfxUiBlip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (control.type === 'cancel') {
|
||||||
|
deps.state.mode = 'cardTableMenu';
|
||||||
|
const menuEntries = buildMainMenuEntries(item, nickname);
|
||||||
|
deps.updateStatus(menuEntries[deps.state.cardTableMenuIndex] ?? menuEntries[0]);
|
||||||
|
deps.sfxUiCancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCardTableCardActionModeInput(code: string, key: string): void {
|
||||||
|
const item = getActiveItem();
|
||||||
|
if (!item) {
|
||||||
|
exitMenu();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nickname = deps.state.player.nickname;
|
||||||
|
const hand = getHand(item, nickname);
|
||||||
|
const cardIndex = deps.state.cardTableHandIndex;
|
||||||
|
|
||||||
|
if (cardIndex >= hand.length) {
|
||||||
|
// Card no longer exists, go back to hand
|
||||||
|
deps.state.cardTableHandIndex = Math.max(0, hand.length - 1);
|
||||||
|
deps.state.mode = 'cardTableHand';
|
||||||
|
const entries = [...hand.map(cardName), 'Back'];
|
||||||
|
deps.updateStatus(entries[deps.state.cardTableHandIndex] ?? 'Back');
|
||||||
|
deps.sfxUiCancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const control = handleListControlKey(code, key, CARD_ACTIONS, deps.state.cardTableCardActionIndex, (e) => e);
|
||||||
|
|
||||||
|
if (control.type === 'move') {
|
||||||
|
deps.state.cardTableCardActionIndex = control.index;
|
||||||
|
deps.updateStatus(CARD_ACTIONS[control.index]);
|
||||||
|
deps.sfxUiBlip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (control.type === 'select') {
|
||||||
|
const actionIdx = deps.state.cardTableCardActionIndex;
|
||||||
|
if (actionIdx === 0) {
|
||||||
|
// Discard
|
||||||
|
deps.signalingSend({
|
||||||
|
type: 'item_interact',
|
||||||
|
itemId: item.id,
|
||||||
|
action: 'discard',
|
||||||
|
params: { card_index: cardIndex },
|
||||||
|
});
|
||||||
|
deps.state.mode = 'cardTableHand';
|
||||||
|
deps.updateStatus('Discarding card.');
|
||||||
|
deps.sfxUiBlip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actionIdx === 1) {
|
||||||
|
// Return to draw pile
|
||||||
|
deps.signalingSend({
|
||||||
|
type: 'item_interact',
|
||||||
|
itemId: item.id,
|
||||||
|
action: 'return_to_pile',
|
||||||
|
params: { card_index: cardIndex },
|
||||||
|
});
|
||||||
|
deps.state.mode = 'cardTableHand';
|
||||||
|
deps.updateStatus('Returning card to draw pile.');
|
||||||
|
deps.sfxUiBlip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel
|
||||||
|
deps.state.mode = 'cardTableHand';
|
||||||
|
const entries = [...hand.map(cardName), 'Back'];
|
||||||
|
deps.updateStatus(entries[cardIndex] ?? 'Back');
|
||||||
|
deps.sfxUiCancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (control.type === 'cancel') {
|
||||||
|
deps.state.mode = 'cardTableHand';
|
||||||
|
const entries = [...hand.map(cardName), 'Back'];
|
||||||
|
deps.updateStatus(entries[cardIndex] ?? 'Back');
|
||||||
|
deps.sfxUiCancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCardTableDiscardModeInput(code: string, key: string): void {
|
||||||
|
const item = getActiveItem();
|
||||||
|
if (!item) {
|
||||||
|
exitMenu();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nickname = deps.state.player.nickname;
|
||||||
|
const discardPile = getDiscardPile(item);
|
||||||
|
const entries = [...discardPile.map(cardName), 'Back'];
|
||||||
|
const control = handleListControlKey(code, key, entries, deps.state.cardTableDiscardIndex, (e) => e);
|
||||||
|
|
||||||
|
if (control.type === 'move') {
|
||||||
|
deps.state.cardTableDiscardIndex = control.index;
|
||||||
|
deps.updateStatus(entries[control.index]);
|
||||||
|
deps.sfxUiBlip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (control.type === 'select') {
|
||||||
|
if (deps.state.cardTableDiscardIndex === discardPile.length) {
|
||||||
|
// Back
|
||||||
|
deps.state.mode = 'cardTableMenu';
|
||||||
|
const menuEntries = buildMainMenuEntries(item, nickname);
|
||||||
|
deps.updateStatus(menuEntries[deps.state.cardTableMenuIndex] ?? menuEntries[0]);
|
||||||
|
deps.sfxUiCancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Take card from discard into hand
|
||||||
|
const cardIdx = deps.state.cardTableDiscardIndex;
|
||||||
|
deps.signalingSend({
|
||||||
|
type: 'item_interact',
|
||||||
|
itemId: item.id,
|
||||||
|
action: 'draw_from_discard',
|
||||||
|
params: { card_index: cardIdx },
|
||||||
|
});
|
||||||
|
deps.state.mode = 'cardTableMenu';
|
||||||
|
deps.state.cardTableMenuIndex = 0;
|
||||||
|
deps.updateStatus('Taking card from discard pile.');
|
||||||
|
deps.sfxUiBlip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (control.type === 'cancel') {
|
||||||
|
deps.state.mode = 'cardTableMenu';
|
||||||
|
const menuEntries = buildMainMenuEntries(item, nickname);
|
||||||
|
deps.updateStatus(menuEntries[deps.state.cardTableMenuIndex] ?? menuEntries[0]);
|
||||||
|
deps.sfxUiCancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCardTableConfirmResetModeInput(code: string, key: string): void {
|
||||||
|
const item = getActiveItem();
|
||||||
|
if (!item) {
|
||||||
|
exitMenu();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const control = handleYesNoMenuInput(code, key, deps.state.cardTableConfirmIndex);
|
||||||
|
|
||||||
|
if (control.type === 'move') {
|
||||||
|
deps.state.cardTableConfirmIndex = control.index;
|
||||||
|
deps.updateStatus(YES_NO_OPTIONS[control.index].label);
|
||||||
|
deps.sfxUiBlip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (control.type === 'select') {
|
||||||
|
if (YES_NO_OPTIONS[deps.state.cardTableConfirmIndex].id === 'yes') {
|
||||||
|
deps.signalingSend({ type: 'item_secondary_use', itemId: item.id });
|
||||||
|
exitMenu();
|
||||||
|
deps.updateStatus('Shuffling and resetting card table.');
|
||||||
|
deps.sfxUiBlip();
|
||||||
|
} else {
|
||||||
|
deps.state.mode = 'cardTableMenu';
|
||||||
|
const menuEntries = buildMainMenuEntries(item, deps.state.player.nickname);
|
||||||
|
deps.updateStatus(menuEntries[deps.state.cardTableMenuIndex] ?? menuEntries[0]);
|
||||||
|
deps.sfxUiCancel();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (control.type === 'cancel') {
|
||||||
|
deps.state.mode = 'cardTableMenu';
|
||||||
|
const menuEntries = buildMainMenuEntries(item, deps.state.player.nickname);
|
||||||
|
deps.updateStatus(menuEntries[deps.state.cardTableMenuIndex] ?? menuEntries[0]);
|
||||||
|
deps.sfxUiCancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshCardTableStatus(): void {
|
||||||
|
const item = getActiveItem();
|
||||||
|
if (!item) return;
|
||||||
|
|
||||||
|
const nickname = deps.state.player.nickname;
|
||||||
|
const mode = deps.state.mode;
|
||||||
|
|
||||||
|
if (mode === 'cardTableMenu') {
|
||||||
|
const entries = buildMainMenuEntries(item, nickname);
|
||||||
|
deps.state.cardTableMenuIndex = Math.min(deps.state.cardTableMenuIndex, entries.length - 1);
|
||||||
|
deps.updateStatus(`Updated. ${entries[deps.state.cardTableMenuIndex]}.`);
|
||||||
|
} else if (mode === 'cardTableHand') {
|
||||||
|
const hand = getHand(item, nickname);
|
||||||
|
const entries = [...hand.map(cardName), 'Back'];
|
||||||
|
deps.state.cardTableHandIndex = Math.min(deps.state.cardTableHandIndex, entries.length - 1);
|
||||||
|
deps.updateStatus(`Updated. ${entries[deps.state.cardTableHandIndex]}.`);
|
||||||
|
} else if (mode === 'cardTableCardAction') {
|
||||||
|
const hand = getHand(item, nickname);
|
||||||
|
if (deps.state.cardTableHandIndex >= hand.length) {
|
||||||
|
deps.state.cardTableHandIndex = Math.max(0, hand.length - 1);
|
||||||
|
if (hand.length === 0) {
|
||||||
|
deps.state.mode = 'cardTableMenu';
|
||||||
|
const menuEntries = buildMainMenuEntries(item, nickname);
|
||||||
|
deps.updateStatus(`Updated. ${menuEntries[deps.state.cardTableMenuIndex] ?? menuEntries[0]}.`);
|
||||||
|
} else {
|
||||||
|
deps.state.mode = 'cardTableHand';
|
||||||
|
const entries = [...hand.map(cardName), 'Back'];
|
||||||
|
deps.updateStatus(`Updated. ${entries[deps.state.cardTableHandIndex]}.`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const card = hand[deps.state.cardTableHandIndex];
|
||||||
|
deps.updateStatus(`Updated. ${cardName(card)}. ${CARD_ACTIONS[deps.state.cardTableCardActionIndex]}.`);
|
||||||
|
}
|
||||||
|
} else if (mode === 'cardTableDiscard') {
|
||||||
|
const discardPile = getDiscardPile(item);
|
||||||
|
const entries = [...discardPile.map(cardName), 'Back'];
|
||||||
|
deps.state.cardTableDiscardIndex = Math.min(deps.state.cardTableDiscardIndex, entries.length - 1);
|
||||||
|
deps.updateStatus(`Updated. ${entries[deps.state.cardTableDiscardIndex]}.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
beginCardTableMenu,
|
||||||
|
handleCardTableMenuModeInput,
|
||||||
|
handleCardTableHandModeInput,
|
||||||
|
handleCardTableCardActionModeInput,
|
||||||
|
handleCardTableDiscardModeInput,
|
||||||
|
handleCardTableConfirmResetModeInput,
|
||||||
|
refreshCardTableStatus,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -47,6 +47,9 @@ type EditorDeps = {
|
|||||||
updateStatus: (message: string) => void;
|
updateStatus: (message: string) => void;
|
||||||
sfxUiBlip: () => void;
|
sfxUiBlip: () => void;
|
||||||
sfxUiCancel: () => void;
|
sfxUiCancel: () => void;
|
||||||
|
openSoundPropertyPicker?: (item: WorldItem, key: string) => void;
|
||||||
|
previewSound?: (soundPath: string) => void;
|
||||||
|
stopPreviewSound?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -187,9 +190,13 @@ export function createItemPropertyEditor(deps: EditorDeps): {
|
|||||||
deps.openItemPropertyOptionSelect(item, selectedKey);
|
deps.openItemPropertyOptionSelect(item, selectedKey);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (metadata?.valueType === 'sound' && deps.openSoundPropertyPicker) {
|
||||||
|
deps.openSoundPropertyPicker(item, selectedKey);
|
||||||
|
return;
|
||||||
|
}
|
||||||
deps.state.mode = 'itemPropertyEdit';
|
deps.state.mode = 'itemPropertyEdit';
|
||||||
deps.state.editingPropertyKey = selectedKey;
|
deps.state.editingPropertyKey = selectedKey;
|
||||||
const selectedMetadata = deps.getItemPropertyMetadata(item.type, selectedKey);
|
const selectedMetadata = metadata;
|
||||||
deps.state.nicknameInput =
|
deps.state.nicknameInput =
|
||||||
selectedKey === 'title'
|
selectedKey === 'title'
|
||||||
? item.title
|
? item.title
|
||||||
@@ -369,6 +376,13 @@ export function createItemPropertyEditor(deps: EditorDeps): {
|
|||||||
const nextIndex = (deps.state.itemPropertyOptionIndex + delta + length * 1000) % length;
|
const nextIndex = (deps.state.itemPropertyOptionIndex + delta + length * 1000) % length;
|
||||||
deps.state.itemPropertyOptionIndex = nextIndex;
|
deps.state.itemPropertyOptionIndex = nextIndex;
|
||||||
deps.updateStatus(deps.state.itemPropertyOptionValues[nextIndex]);
|
deps.updateStatus(deps.state.itemPropertyOptionValues[nextIndex]);
|
||||||
|
const pageItem = deps.state.items.get(itemId!);
|
||||||
|
if (pageItem) {
|
||||||
|
const pageMeta = deps.getItemPropertyMetadata(pageItem.type, propertyKey);
|
||||||
|
if (pageMeta?.valueType === 'sound') {
|
||||||
|
deps.previewSound?.(deps.state.itemPropertyOptionValues[nextIndex]);
|
||||||
|
}
|
||||||
|
}
|
||||||
deps.sfxUiBlip();
|
deps.sfxUiBlip();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -383,11 +397,19 @@ export function createItemPropertyEditor(deps: EditorDeps): {
|
|||||||
if (control.type === 'move') {
|
if (control.type === 'move') {
|
||||||
deps.state.itemPropertyOptionIndex = control.index;
|
deps.state.itemPropertyOptionIndex = control.index;
|
||||||
deps.updateStatus(deps.state.itemPropertyOptionValues[deps.state.itemPropertyOptionIndex]);
|
deps.updateStatus(deps.state.itemPropertyOptionValues[deps.state.itemPropertyOptionIndex]);
|
||||||
|
const moveItem = deps.state.items.get(itemId!);
|
||||||
|
if (moveItem) {
|
||||||
|
const moveMeta = deps.getItemPropertyMetadata(moveItem.type, propertyKey);
|
||||||
|
if (moveMeta?.valueType === 'sound') {
|
||||||
|
deps.previewSound?.(deps.state.itemPropertyOptionValues[deps.state.itemPropertyOptionIndex]);
|
||||||
|
}
|
||||||
|
}
|
||||||
deps.sfxUiBlip();
|
deps.sfxUiBlip();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (control.type === 'select') {
|
if (control.type === 'select') {
|
||||||
|
deps.stopPreviewSound?.();
|
||||||
const selectedValue = deps.state.itemPropertyOptionValues[deps.state.itemPropertyOptionIndex];
|
const selectedValue = deps.state.itemPropertyOptionValues[deps.state.itemPropertyOptionIndex];
|
||||||
deps.signalingSend({ type: 'item_update', itemId, params: { [propertyKey]: selectedValue } });
|
deps.signalingSend({ type: 'item_update', itemId, params: { [propertyKey]: selectedValue } });
|
||||||
const item = deps.state.items.get(itemId);
|
const item = deps.state.items.get(itemId);
|
||||||
@@ -402,6 +424,7 @@ export function createItemPropertyEditor(deps: EditorDeps): {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (control.type === 'cancel') {
|
if (control.type === 'cancel') {
|
||||||
|
deps.stopPreviewSound?.();
|
||||||
deps.state.mode = 'itemProperties';
|
deps.state.mode = 'itemProperties';
|
||||||
deps.state.editingPropertyKey = null;
|
deps.state.editingPropertyKey = null;
|
||||||
deps.state.itemPropertyOptionValues = [];
|
deps.state.itemPropertyOptionValues = [];
|
||||||
|
|||||||
265
client/src/items/whiteboardController.ts
Normal file
265
client/src/items/whiteboardController.ts
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
import { handleListControlKey } from '../input/listController';
|
||||||
|
import type { OutgoingMessage } from '../network/protocol';
|
||||||
|
import type { GameMode, WorldItem } from '../state/gameState';
|
||||||
|
|
||||||
|
const LINE_ACTIONS = ['Edit', 'Delete'] as const;
|
||||||
|
|
||||||
|
type WhiteboardControllerDeps = {
|
||||||
|
state: {
|
||||||
|
mode: GameMode;
|
||||||
|
nicknameInput: string;
|
||||||
|
cursorPos: number;
|
||||||
|
items: Map<string, WorldItem>;
|
||||||
|
whiteboardItemId: string | null;
|
||||||
|
whiteboardLineIndex: number;
|
||||||
|
whiteboardLineActionIndex: number;
|
||||||
|
whiteboardEditingLineIndex: number | null;
|
||||||
|
};
|
||||||
|
signalingSend: (message: OutgoingMessage) => void;
|
||||||
|
updateStatus: (message: string) => void;
|
||||||
|
sfxUiBlip: () => void;
|
||||||
|
sfxUiCancel: () => void;
|
||||||
|
applyTextInputEdit: (code: string, key: string, maxLength: number, ctrlKey?: boolean) => void;
|
||||||
|
setReplaceTextOnNextType: (value: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getLines(item: WorldItem): string[] {
|
||||||
|
const raw = item.params['lines'];
|
||||||
|
if (!Array.isArray(raw)) return [];
|
||||||
|
return raw.filter((l): l is string => typeof l === 'string');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createWhiteboardController(deps: WhiteboardControllerDeps): {
|
||||||
|
beginWhiteboardLines: (item: WorldItem) => void;
|
||||||
|
handleWhiteboardLinesModeInput: (code: string, key: string) => void;
|
||||||
|
handleWhiteboardLineActionsModeInput: (code: string, key: string) => void;
|
||||||
|
handleWhiteboardLineEditModeInput: (code: string, key: string, ctrlKey: boolean) => void;
|
||||||
|
refreshWhiteboardStatus: () => void;
|
||||||
|
} {
|
||||||
|
function beginWhiteboardLines(item: WorldItem): void {
|
||||||
|
deps.state.whiteboardItemId = item.id;
|
||||||
|
deps.state.whiteboardLineIndex = 0;
|
||||||
|
deps.state.whiteboardLineActionIndex = 0;
|
||||||
|
deps.state.whiteboardEditingLineIndex = null;
|
||||||
|
deps.state.mode = 'whiteboardLines';
|
||||||
|
|
||||||
|
const lines = getLines(item);
|
||||||
|
const n = lines.length;
|
||||||
|
const countText = `${n} line${n !== 1 ? 's' : ''}`;
|
||||||
|
const firstEntry = n > 0 ? lines[0] : 'Add line';
|
||||||
|
deps.updateStatus(`${item.title}. ${countText}. ${firstEntry}.`);
|
||||||
|
deps.sfxUiBlip();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWhiteboardLinesModeInput(code: string, key: string): void {
|
||||||
|
const item = deps.state.whiteboardItemId ? deps.state.items.get(deps.state.whiteboardItemId) : null;
|
||||||
|
if (!item) {
|
||||||
|
deps.state.mode = 'normal';
|
||||||
|
deps.state.whiteboardItemId = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = getLines(item);
|
||||||
|
const entries = [...lines, 'Add line'];
|
||||||
|
const control = handleListControlKey(code, key, entries, deps.state.whiteboardLineIndex, (e) => e);
|
||||||
|
|
||||||
|
if (control.type === 'move') {
|
||||||
|
deps.state.whiteboardLineIndex = control.index;
|
||||||
|
deps.updateStatus(entries[control.index]);
|
||||||
|
deps.sfxUiBlip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (control.type === 'select') {
|
||||||
|
if (deps.state.whiteboardLineIndex === lines.length) {
|
||||||
|
// "Add line" selected
|
||||||
|
deps.state.whiteboardEditingLineIndex = null;
|
||||||
|
deps.state.nicknameInput = '';
|
||||||
|
deps.state.cursorPos = 0;
|
||||||
|
deps.setReplaceTextOnNextType(false);
|
||||||
|
deps.state.mode = 'whiteboardLineEdit';
|
||||||
|
deps.updateStatus('Add line. Type and press Enter.');
|
||||||
|
deps.sfxUiBlip();
|
||||||
|
} else {
|
||||||
|
// Select a line → actions submenu
|
||||||
|
deps.state.whiteboardLineActionIndex = 0;
|
||||||
|
deps.state.mode = 'whiteboardLineActions';
|
||||||
|
deps.updateStatus(`${lines[deps.state.whiteboardLineIndex]}. Edit or Delete.`);
|
||||||
|
deps.sfxUiBlip();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (control.type === 'cancel') {
|
||||||
|
deps.state.mode = 'normal';
|
||||||
|
deps.state.whiteboardItemId = null;
|
||||||
|
deps.updateStatus('Cancelled.');
|
||||||
|
deps.sfxUiCancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWhiteboardLineActionsModeInput(code: string, key: string): void {
|
||||||
|
const item = deps.state.whiteboardItemId ? deps.state.items.get(deps.state.whiteboardItemId) : null;
|
||||||
|
if (!item) {
|
||||||
|
deps.state.mode = 'normal';
|
||||||
|
deps.state.whiteboardItemId = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = getLines(item);
|
||||||
|
const lineIndex = deps.state.whiteboardLineIndex;
|
||||||
|
|
||||||
|
if (lineIndex >= lines.length) {
|
||||||
|
deps.state.whiteboardLineIndex = Math.max(0, lines.length);
|
||||||
|
deps.state.mode = 'whiteboardLines';
|
||||||
|
const n = lines.length;
|
||||||
|
const countText = `${n} line${n !== 1 ? 's' : ''}`;
|
||||||
|
const firstEntry = n > 0 ? lines[0] : 'Add line';
|
||||||
|
deps.updateStatus(`${item.title}. ${countText}. ${firstEntry}.`);
|
||||||
|
deps.sfxUiCancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const control = handleListControlKey(code, key, LINE_ACTIONS, deps.state.whiteboardLineActionIndex, (e) => e);
|
||||||
|
|
||||||
|
if (control.type === 'move') {
|
||||||
|
deps.state.whiteboardLineActionIndex = control.index;
|
||||||
|
deps.updateStatus(LINE_ACTIONS[control.index]);
|
||||||
|
deps.sfxUiBlip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (control.type === 'select') {
|
||||||
|
if (deps.state.whiteboardLineActionIndex === 0) {
|
||||||
|
// Edit
|
||||||
|
deps.state.whiteboardEditingLineIndex = lineIndex;
|
||||||
|
deps.state.nicknameInput = lines[lineIndex];
|
||||||
|
deps.state.cursorPos = lines[lineIndex].length;
|
||||||
|
deps.setReplaceTextOnNextType(true);
|
||||||
|
deps.state.mode = 'whiteboardLineEdit';
|
||||||
|
deps.updateStatus(`Edit line. ${lines[lineIndex]}`);
|
||||||
|
deps.sfxUiBlip();
|
||||||
|
} else {
|
||||||
|
// Delete
|
||||||
|
deps.signalingSend({
|
||||||
|
type: 'item_interact',
|
||||||
|
itemId: item.id,
|
||||||
|
action: 'delete_line',
|
||||||
|
params: { line_index: lineIndex },
|
||||||
|
});
|
||||||
|
deps.state.whiteboardLineIndex = Math.min(deps.state.whiteboardLineIndex, Math.max(0, lines.length - 1));
|
||||||
|
deps.state.mode = 'whiteboardLines';
|
||||||
|
deps.updateStatus('Line deleted.');
|
||||||
|
deps.sfxUiBlip();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (control.type === 'cancel') {
|
||||||
|
deps.state.mode = 'whiteboardLines';
|
||||||
|
deps.updateStatus(lines[lineIndex]);
|
||||||
|
deps.sfxUiCancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWhiteboardLineEditModeInput(code: string, key: string, ctrlKey: boolean): void {
|
||||||
|
const item = deps.state.whiteboardItemId ? deps.state.items.get(deps.state.whiteboardItemId) : null;
|
||||||
|
if (!item) {
|
||||||
|
deps.state.mode = 'normal';
|
||||||
|
deps.state.whiteboardItemId = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code === 'Enter') {
|
||||||
|
const text = deps.state.nicknameInput.trim();
|
||||||
|
if (!text) {
|
||||||
|
deps.updateStatus('Cannot add empty line.');
|
||||||
|
deps.sfxUiCancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const editIndex = deps.state.whiteboardEditingLineIndex;
|
||||||
|
if (editIndex !== null) {
|
||||||
|
deps.signalingSend({
|
||||||
|
type: 'item_interact',
|
||||||
|
itemId: item.id,
|
||||||
|
action: 'edit_line',
|
||||||
|
params: { line_index: editIndex, text },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
deps.signalingSend({
|
||||||
|
type: 'item_interact',
|
||||||
|
itemId: item.id,
|
||||||
|
action: 'add_line',
|
||||||
|
params: { text },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
deps.state.nicknameInput = '';
|
||||||
|
deps.state.cursorPos = 0;
|
||||||
|
deps.setReplaceTextOnNextType(false);
|
||||||
|
deps.state.whiteboardEditingLineIndex = null;
|
||||||
|
deps.state.mode = 'whiteboardLines';
|
||||||
|
deps.updateStatus(editIndex !== null ? 'Line updated.' : 'Line added.');
|
||||||
|
deps.sfxUiBlip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code === 'Escape') {
|
||||||
|
const wasEditing = deps.state.whiteboardEditingLineIndex !== null;
|
||||||
|
deps.state.nicknameInput = '';
|
||||||
|
deps.state.cursorPos = 0;
|
||||||
|
deps.setReplaceTextOnNextType(false);
|
||||||
|
deps.state.whiteboardEditingLineIndex = null;
|
||||||
|
|
||||||
|
if (wasEditing) {
|
||||||
|
deps.state.mode = 'whiteboardLineActions';
|
||||||
|
const lines = getLines(item);
|
||||||
|
const line = lines[deps.state.whiteboardLineIndex] ?? '';
|
||||||
|
deps.updateStatus(`${line}. Edit or Delete.`);
|
||||||
|
deps.sfxUiCancel();
|
||||||
|
} else {
|
||||||
|
deps.state.mode = 'whiteboardLines';
|
||||||
|
deps.updateStatus('Cancelled.');
|
||||||
|
deps.sfxUiCancel();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
deps.applyTextInputEdit(code, key, 200, ctrlKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshWhiteboardStatus(): void {
|
||||||
|
const item = deps.state.whiteboardItemId ? deps.state.items.get(deps.state.whiteboardItemId) : null;
|
||||||
|
if (!item) return;
|
||||||
|
|
||||||
|
const lines = getLines(item);
|
||||||
|
const n = lines.length;
|
||||||
|
|
||||||
|
// Clamp index if lines shrank
|
||||||
|
deps.state.whiteboardLineIndex = Math.min(deps.state.whiteboardLineIndex, n);
|
||||||
|
|
||||||
|
if (deps.state.mode === 'whiteboardLines') {
|
||||||
|
const entries = [...lines, 'Add line'];
|
||||||
|
const current = entries[deps.state.whiteboardLineIndex] ?? 'Add line';
|
||||||
|
deps.updateStatus(`Updated. ${current}.`);
|
||||||
|
} else if (deps.state.mode === 'whiteboardLineActions') {
|
||||||
|
if (deps.state.whiteboardLineIndex < lines.length) {
|
||||||
|
deps.updateStatus(`Updated. ${lines[deps.state.whiteboardLineIndex]}. Edit or Delete.`);
|
||||||
|
} else {
|
||||||
|
deps.state.mode = 'whiteboardLines';
|
||||||
|
deps.state.whiteboardLineIndex = Math.max(0, n);
|
||||||
|
const countText = `${n} line${n !== 1 ? 's' : ''}`;
|
||||||
|
deps.updateStatus(`Updated. ${countText}.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
beginWhiteboardLines,
|
||||||
|
handleWhiteboardLinesModeInput,
|
||||||
|
handleWhiteboardLineActionsModeInput,
|
||||||
|
handleWhiteboardLineEditModeInput,
|
||||||
|
refreshWhiteboardStatus,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ import { dispatchModeInput } from './input/modeDispatcher';
|
|||||||
import { handleListControlKey } from './input/listController';
|
import { handleListControlKey } from './input/listController';
|
||||||
import { createAdminController, type AdminMenuAction } from './input/adminController';
|
import { createAdminController, type AdminMenuAction } from './input/adminController';
|
||||||
import { setupKeyboardInputHandlers } from './input/keyboardController';
|
import { setupKeyboardInputHandlers } from './input/keyboardController';
|
||||||
|
import { setupMobileControls } from './input/mobileController';
|
||||||
import { handleYesNoMenuInput, YES_NO_OPTIONS } from './input/yesNoMenu';
|
import { handleYesNoMenuInput, YES_NO_OPTIONS } from './input/yesNoMenu';
|
||||||
import { getEditSessionAction } from './input/editSession';
|
import { getEditSessionAction } from './input/editSession';
|
||||||
import { formatSteppedNumber, snapNumberToStep } from './input/numeric';
|
import { formatSteppedNumber, snapNumberToStep } from './input/numeric';
|
||||||
@@ -63,6 +64,8 @@ import {
|
|||||||
itemTypeLabel,
|
itemTypeLabel,
|
||||||
} from './items/itemRegistry';
|
} from './items/itemRegistry';
|
||||||
import { createItemInteractionController } from './items/itemInteractionController';
|
import { createItemInteractionController } from './items/itemInteractionController';
|
||||||
|
import { createWhiteboardController } from './items/whiteboardController';
|
||||||
|
import { createCardTableController } from './items/cardTableController';
|
||||||
import { createItemPropertyEditor } from './items/itemPropertyEditor';
|
import { createItemPropertyEditor } from './items/itemPropertyEditor';
|
||||||
import { createItemPropertyPresentation } from './items/itemPropertyPresentation';
|
import { createItemPropertyPresentation } from './items/itemPropertyPresentation';
|
||||||
import { ItemBehaviorRegistry } from './items/types/behaviorRegistry';
|
import { ItemBehaviorRegistry } from './items/types/behaviorRegistry';
|
||||||
@@ -460,6 +463,24 @@ const itemInteractionController = createItemInteractionController({
|
|||||||
useItem: (item) => useItem(item),
|
useItem: (item) => useItem(item),
|
||||||
secondaryUseItem: (item) => secondaryUseItem(item),
|
secondaryUseItem: (item) => secondaryUseItem(item),
|
||||||
});
|
});
|
||||||
|
const whiteboardController = createWhiteboardController({
|
||||||
|
state,
|
||||||
|
signalingSend: (message) => signaling.send(message),
|
||||||
|
updateStatus,
|
||||||
|
sfxUiBlip: () => audio.sfxUiBlip(),
|
||||||
|
sfxUiCancel: () => audio.sfxUiCancel(),
|
||||||
|
applyTextInputEdit,
|
||||||
|
setReplaceTextOnNextType: (value) => {
|
||||||
|
replaceTextOnNextType = value;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const cardTableController = createCardTableController({
|
||||||
|
state,
|
||||||
|
signalingSend: (message) => signaling.send(message),
|
||||||
|
updateStatus,
|
||||||
|
sfxUiBlip: () => audio.sfxUiBlip(),
|
||||||
|
sfxUiCancel: () => audio.sfxUiCancel(),
|
||||||
|
});
|
||||||
|
|
||||||
/** Toggles updates panel visibility and syncs associated ARIA state. */
|
/** Toggles updates panel visibility and syncs associated ARIA state. */
|
||||||
function setUpdatesExpanded(expanded: boolean): void {
|
function setUpdatesExpanded(expanded: boolean): void {
|
||||||
@@ -969,6 +990,14 @@ function recomputeActiveItemPropertyKeys(itemId: string): void {
|
|||||||
|
|
||||||
/** Sends an item-use request for the selected item. */
|
/** Sends an item-use request for the selected item. */
|
||||||
function useItem(item: WorldItem): void {
|
function useItem(item: WorldItem): void {
|
||||||
|
if (item.type === 'whiteboard') {
|
||||||
|
whiteboardController.beginWhiteboardLines(item);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (item.type === 'card_table') {
|
||||||
|
cardTableController.beginCardTableMenu(item);
|
||||||
|
return;
|
||||||
|
}
|
||||||
signaling.send({ type: 'item_use', itemId: item.id });
|
signaling.send({ type: 'item_use', itemId: item.id });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -993,6 +1022,70 @@ function openItemPropertyOptionSelect(item: WorldItem, key: string): void {
|
|||||||
audio.sfxUiBlip();
|
audio.sfxUiBlip();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Fetches the list of widget sounds from the server. Returns [] on error. */
|
||||||
|
async function fetchWidgetSounds(): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(withBase('sounds_list.php'));
|
||||||
|
if (!response.ok) return [];
|
||||||
|
const names = (await response.json()) as string[];
|
||||||
|
if (!Array.isArray(names)) return [];
|
||||||
|
return names.map((name: string) => `sounds/widgets/${name}`);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let previewDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
/** Plays a sound preview with debounce, for use while navigating sound picker. */
|
||||||
|
function previewSound(soundPath: string): void {
|
||||||
|
audio.stopPreviewSample();
|
||||||
|
if (previewDebounceTimer !== null) {
|
||||||
|
clearTimeout(previewDebounceTimer);
|
||||||
|
}
|
||||||
|
previewDebounceTimer = setTimeout(() => {
|
||||||
|
previewDebounceTimer = null;
|
||||||
|
if (soundPath) {
|
||||||
|
void audio.playPreviewSample(soundPath, 0.7);
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stops any in-progress preview sound and clears the debounce timer. */
|
||||||
|
function stopPreviewSound(): void {
|
||||||
|
if (previewDebounceTimer !== null) {
|
||||||
|
clearTimeout(previewDebounceTimer);
|
||||||
|
previewDebounceTimer = null;
|
||||||
|
}
|
||||||
|
audio.stopPreviewSample();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Opens the sound picker for a sound-typed item property, falling back to text edit if no sounds are found. */
|
||||||
|
async function openSoundPropertyPicker(item: WorldItem, key: string): Promise<void> {
|
||||||
|
updateStatus('Loading sounds...');
|
||||||
|
const sounds = await fetchWidgetSounds();
|
||||||
|
if (sounds.length === 0) {
|
||||||
|
state.mode = 'itemPropertyEdit';
|
||||||
|
state.editingPropertyKey = key;
|
||||||
|
const currentValue = String(item.params[key] ?? '');
|
||||||
|
state.nicknameInput = currentValue;
|
||||||
|
state.cursorPos = currentValue.length;
|
||||||
|
replaceTextOnNextType = true;
|
||||||
|
updateStatus(`Edit ${itemPropertyLabel(key)}: ${currentValue}`);
|
||||||
|
audio.sfxUiBlip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const options = ['', ...sounds];
|
||||||
|
state.mode = 'itemPropertyOptionSelect';
|
||||||
|
state.editingPropertyKey = key;
|
||||||
|
state.itemPropertyOptionValues = options;
|
||||||
|
const currentValue = String(item.params[key] ?? '').trim();
|
||||||
|
const currentIndex = options.indexOf(currentValue);
|
||||||
|
state.itemPropertyOptionIndex = currentIndex >= 0 ? currentIndex : 0;
|
||||||
|
updateStatus(`Select ${itemPropertyLabel(key)}: ${options[state.itemPropertyOptionIndex] || 'none'}`);
|
||||||
|
audio.sfxUiBlip();
|
||||||
|
}
|
||||||
|
|
||||||
/** Returns the active text-input max length for the current UI mode, if applicable. */
|
/** Returns the active text-input max length for the current UI mode, if applicable. */
|
||||||
function textInputMaxLengthForMode(mode: typeof state.mode): number | null {
|
function textInputMaxLengthForMode(mode: typeof state.mode): number | null {
|
||||||
if (mode === 'nickname') return NICKNAME_MAX_LENGTH;
|
if (mode === 'nickname') return NICKNAME_MAX_LENGTH;
|
||||||
@@ -1000,6 +1093,7 @@ function textInputMaxLengthForMode(mode: typeof state.mode): number | null {
|
|||||||
if (mode === 'itemPropertyEdit') return 500;
|
if (mode === 'itemPropertyEdit') return 500;
|
||||||
if (mode === 'micGainEdit') return 8;
|
if (mode === 'micGainEdit') return 8;
|
||||||
if (mode === 'adminRoleNameEdit') return 32;
|
if (mode === 'adminRoleNameEdit') return 32;
|
||||||
|
if (mode === 'whiteboardLineEdit') return 200;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1024,7 +1118,8 @@ function isTextEditingMode(mode: typeof state.mode): boolean {
|
|||||||
mode === 'chat' ||
|
mode === 'chat' ||
|
||||||
mode === 'itemPropertyEdit' ||
|
mode === 'itemPropertyEdit' ||
|
||||||
mode === 'micGainEdit' ||
|
mode === 'micGainEdit' ||
|
||||||
mode === 'adminRoleNameEdit'
|
mode === 'adminRoleNameEdit' ||
|
||||||
|
mode === 'whiteboardLineEdit'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1585,6 +1680,8 @@ const onAppMessage = createOnMessageHandler({
|
|||||||
connectToLiveKit: (url, token) => {
|
connectToLiveKit: (url, token) => {
|
||||||
void connectLiveKit(url, token);
|
void connectLiveKit(url, token);
|
||||||
},
|
},
|
||||||
|
refreshWhiteboardStatus: () => whiteboardController.refreshWhiteboardStatus(),
|
||||||
|
refreshCardTableStatus: () => cardTableController.refreshCardTableStatus(),
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Handles signaling packets with heartbeat/restart metadata before app-level dispatch. */
|
/** Handles signaling packets with heartbeat/restart metadata before app-level dispatch. */
|
||||||
@@ -2553,6 +2650,9 @@ const itemPropertyEditor = createItemPropertyEditor({
|
|||||||
updateStatus,
|
updateStatus,
|
||||||
sfxUiBlip: () => audio.sfxUiBlip(),
|
sfxUiBlip: () => audio.sfxUiBlip(),
|
||||||
sfxUiCancel: () => audio.sfxUiCancel(),
|
sfxUiCancel: () => audio.sfxUiCancel(),
|
||||||
|
openSoundPropertyPicker: (item, key) => { void openSoundPropertyPicker(item, key); },
|
||||||
|
previewSound,
|
||||||
|
stopPreviewSound,
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Handles nickname edit mode submission/cancel and text editing keys. */
|
/** Handles nickname edit mode submission/cancel and text editing keys. */
|
||||||
@@ -2626,6 +2726,22 @@ function handleModeInput(input: ModeInput): void {
|
|||||||
itemPropertyEditor.handleItemPropertyEditModeInput(currentCode, currentKey, currentCtrlKey),
|
itemPropertyEditor.handleItemPropertyEditModeInput(currentCode, currentKey, currentCtrlKey),
|
||||||
itemPropertyOptionSelect: ({ code: currentCode, key: currentKey }) =>
|
itemPropertyOptionSelect: ({ code: currentCode, key: currentKey }) =>
|
||||||
itemPropertyEditor.handleItemPropertyOptionSelectModeInput(currentCode, currentKey),
|
itemPropertyEditor.handleItemPropertyOptionSelectModeInput(currentCode, currentKey),
|
||||||
|
whiteboardLines: ({ code: currentCode, key: currentKey }) =>
|
||||||
|
whiteboardController.handleWhiteboardLinesModeInput(currentCode, currentKey),
|
||||||
|
whiteboardLineActions: ({ code: currentCode, key: currentKey }) =>
|
||||||
|
whiteboardController.handleWhiteboardLineActionsModeInput(currentCode, currentKey),
|
||||||
|
whiteboardLineEdit: ({ code: currentCode, key: currentKey, ctrlKey: currentCtrlKey }) =>
|
||||||
|
whiteboardController.handleWhiteboardLineEditModeInput(currentCode, currentKey, currentCtrlKey),
|
||||||
|
cardTableMenu: ({ code: currentCode, key: currentKey }) =>
|
||||||
|
cardTableController.handleCardTableMenuModeInput(currentCode, currentKey),
|
||||||
|
cardTableHand: ({ code: currentCode, key: currentKey }) =>
|
||||||
|
cardTableController.handleCardTableHandModeInput(currentCode, currentKey),
|
||||||
|
cardTableCardAction: ({ code: currentCode, key: currentKey }) =>
|
||||||
|
cardTableController.handleCardTableCardActionModeInput(currentCode, currentKey),
|
||||||
|
cardTableDiscard: ({ code: currentCode, key: currentKey }) =>
|
||||||
|
cardTableController.handleCardTableDiscardModeInput(currentCode, currentKey),
|
||||||
|
cardTableConfirmReset: ({ code: currentCode, key: currentKey }) =>
|
||||||
|
cardTableController.handleCardTableConfirmResetModeInput(currentCode, currentKey),
|
||||||
},
|
},
|
||||||
onNormalMode: handleNormalModeInput,
|
onNormalMode: handleNormalModeInput,
|
||||||
});
|
});
|
||||||
@@ -2679,6 +2795,25 @@ setupKeyboardInputHandlers({
|
|||||||
replaceTextOnNextType = value;
|
replaceTextOnNextType = value;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
setupMobileControls({
|
||||||
|
dom: {
|
||||||
|
canvas: dom.canvas,
|
||||||
|
mobileControls: requiredById('mobileControls') as HTMLDivElement,
|
||||||
|
toggleButton: requiredById('mobileControlsToggle') as HTMLButtonElement,
|
||||||
|
dpadUp: requiredById('dpadUp') as HTMLButtonElement,
|
||||||
|
dpadDown: requiredById('dpadDown') as HTMLButtonElement,
|
||||||
|
dpadLeft: requiredById('dpadLeft') as HTMLButtonElement,
|
||||||
|
dpadRight: requiredById('dpadRight') as HTMLButtonElement,
|
||||||
|
btnChat: requiredById('mobileBtnChat') as HTMLButtonElement,
|
||||||
|
btnUse: requiredById('mobileBtnUse') as HTMLButtonElement,
|
||||||
|
btnLocateUser: requiredById('mobileBtnLocateUser') as HTMLButtonElement,
|
||||||
|
btnLocateItem: requiredById('mobileBtnLocateItem') as HTMLButtonElement,
|
||||||
|
btnCommandPalette: requiredById('mobileBtnCommands') as HTMLButtonElement,
|
||||||
|
},
|
||||||
|
state,
|
||||||
|
handleModeInput,
|
||||||
|
openCommandPalette,
|
||||||
|
});
|
||||||
setupDomUiHandlers({
|
setupDomUiHandlers({
|
||||||
dom,
|
dom,
|
||||||
updateConnectAvailability,
|
updateConnectAvailability,
|
||||||
|
|||||||
@@ -22,7 +22,11 @@ type MessageHandlerDeps = {
|
|||||||
itemPropertyKeys: string[];
|
itemPropertyKeys: string[];
|
||||||
itemPropertyIndex: number;
|
itemPropertyIndex: number;
|
||||||
carriedItemId: string | null;
|
carriedItemId: string | null;
|
||||||
|
whiteboardItemId: string | null;
|
||||||
|
cardTableItemId: string | null;
|
||||||
};
|
};
|
||||||
|
refreshWhiteboardStatus: () => void;
|
||||||
|
refreshCardTableStatus: () => void;
|
||||||
dom: {
|
dom: {
|
||||||
connectButton: HTMLElement;
|
connectButton: HTMLElement;
|
||||||
disconnectButton: HTMLElement;
|
disconnectButton: HTMLElement;
|
||||||
@@ -258,6 +262,21 @@ export function createOnMessageHandler(deps: MessageHandlerDeps): (message: Inco
|
|||||||
deps.updateStatus(`${deps.itemPropertyLabel(key)}: ${deps.getItemPropertyValue(message.item, key)}`);
|
deps.updateStatus(`${deps.itemPropertyLabel(key)}: ${deps.getItemPropertyValue(message.item, key)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
deps.state.whiteboardItemId === message.item.id &&
|
||||||
|
(deps.state.mode === 'whiteboardLines' || deps.state.mode === 'whiteboardLineActions')
|
||||||
|
) {
|
||||||
|
deps.refreshWhiteboardStatus();
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
deps.state.cardTableItemId === message.item.id &&
|
||||||
|
(deps.state.mode === 'cardTableMenu' ||
|
||||||
|
deps.state.mode === 'cardTableHand' ||
|
||||||
|
deps.state.mode === 'cardTableCardAction' ||
|
||||||
|
deps.state.mode === 'cardTableDiscard')
|
||||||
|
) {
|
||||||
|
deps.refreshCardTableStatus();
|
||||||
|
}
|
||||||
await deps.refreshAudioSubscriptions(true);
|
await deps.refreshAudioSubscriptions(true);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -241,7 +241,7 @@ export const itemRemoveSchema = z.object({
|
|||||||
export const itemActionResultSchema = z.object({
|
export const itemActionResultSchema = z.object({
|
||||||
type: z.literal('item_action_result'),
|
type: z.literal('item_action_result'),
|
||||||
ok: z.boolean(),
|
ok: z.boolean(),
|
||||||
action: z.enum(['add', 'pickup', 'drop', 'delete', 'transfer', 'use', 'secondary_use', 'update']),
|
action: z.enum(['add', 'pickup', 'drop', 'delete', 'transfer', 'use', 'secondary_use', 'update', 'interact']),
|
||||||
message: z.string(),
|
message: z.string(),
|
||||||
itemId: z.string().optional(),
|
itemId: z.string().optional(),
|
||||||
});
|
});
|
||||||
@@ -415,6 +415,7 @@ export type OutgoingMessage =
|
|||||||
| { type: 'item_transfer'; itemId: string; targetUserId: string }
|
| { type: 'item_transfer'; itemId: string; targetUserId: string }
|
||||||
| { type: 'item_use'; itemId: string }
|
| { type: 'item_use'; itemId: string }
|
||||||
| { type: 'item_secondary_use'; itemId: string }
|
| { type: 'item_secondary_use'; itemId: string }
|
||||||
|
| { type: 'item_interact'; itemId: string; action: string; params?: Record<string, unknown> }
|
||||||
| { type: 'item_piano_note'; itemId: string; keyId: string; midi: number; on: boolean }
|
| { type: 'item_piano_note'; itemId: string; keyId: string; midi: number; on: boolean }
|
||||||
| { type: 'item_piano_recording'; itemId: string; action: 'toggle_record' | 'playback' | 'stop_playback' | 'stop_record' }
|
| { type: 'item_piano_recording'; itemId: string; action: 'toggle_record' | 'playback' | 'stop_playback' | 'stop_record' }
|
||||||
| {
|
| {
|
||||||
|
|||||||
@@ -51,7 +51,15 @@ export type GameMode =
|
|||||||
| 'adminUserRoleSelect'
|
| 'adminUserRoleSelect'
|
||||||
| 'adminUserDeleteConfirm'
|
| 'adminUserDeleteConfirm'
|
||||||
| 'adminRoleNameEdit'
|
| 'adminRoleNameEdit'
|
||||||
| 'pianoUse';
|
| 'pianoUse'
|
||||||
|
| 'whiteboardLines'
|
||||||
|
| 'whiteboardLineActions'
|
||||||
|
| 'whiteboardLineEdit'
|
||||||
|
| 'cardTableMenu'
|
||||||
|
| 'cardTableHand'
|
||||||
|
| 'cardTableCardAction'
|
||||||
|
| 'cardTableDiscard'
|
||||||
|
| 'cardTableConfirmReset';
|
||||||
|
|
||||||
export type Player = {
|
export type Player = {
|
||||||
id: string | null;
|
id: string | null;
|
||||||
@@ -96,6 +104,16 @@ export type GameState = {
|
|||||||
peers: Map<string, PeerState>;
|
peers: Map<string, PeerState>;
|
||||||
items: Map<string, WorldItem>;
|
items: Map<string, WorldItem>;
|
||||||
carriedItemId: string | null;
|
carriedItemId: string | null;
|
||||||
|
whiteboardItemId: string | null;
|
||||||
|
whiteboardLineIndex: number;
|
||||||
|
whiteboardLineActionIndex: number;
|
||||||
|
whiteboardEditingLineIndex: number | null;
|
||||||
|
cardTableItemId: string | null;
|
||||||
|
cardTableMenuIndex: number;
|
||||||
|
cardTableHandIndex: number;
|
||||||
|
cardTableCardActionIndex: number;
|
||||||
|
cardTableDiscardIndex: number;
|
||||||
|
cardTableConfirmIndex: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createInitialState(): GameState {
|
export function createInitialState(): GameState {
|
||||||
@@ -132,6 +150,16 @@ export function createInitialState(): GameState {
|
|||||||
peers: new Map(),
|
peers: new Map(),
|
||||||
items: new Map(),
|
items: new Map(),
|
||||||
carriedItemId: null,
|
carriedItemId: null,
|
||||||
|
whiteboardItemId: null,
|
||||||
|
whiteboardLineIndex: 0,
|
||||||
|
whiteboardLineActionIndex: 0,
|
||||||
|
whiteboardEditingLineIndex: null,
|
||||||
|
cardTableItemId: null,
|
||||||
|
cardTableMenuIndex: 0,
|
||||||
|
cardTableHandIndex: 0,
|
||||||
|
cardTableCardActionIndex: 0,
|
||||||
|
cardTableDiscardIndex: 0,
|
||||||
|
cardTableConfirmIndex: 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -234,3 +234,124 @@ canvas {
|
|||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Mobile controls panel ─────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/* Hide on pointer:fine (mouse) devices; show on touch screens */
|
||||||
|
@media (hover: hover) and (pointer: fine) {
|
||||||
|
.mobile-controls {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Extra bottom padding so the fixed panel doesn't cover footer content */
|
||||||
|
@media not all and (hover: hover) and (pointer: fine) {
|
||||||
|
.app {
|
||||||
|
padding-bottom: 3rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-controls {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: rgb(15 23 42 / 95%);
|
||||||
|
border-top: 1px solid #334155;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-toggle-btn {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #e5e7eb;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 1rem;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-controls-body {
|
||||||
|
display: none;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.75rem 1rem 1rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-controls[data-expanded="true"] .mobile-controls-body {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* D-pad: 3×3 cross layout */
|
||||||
|
.dpad {
|
||||||
|
display: grid;
|
||||||
|
grid-template-areas:
|
||||||
|
". up ."
|
||||||
|
"left mid right"
|
||||||
|
". down .";
|
||||||
|
grid-template-columns: repeat(3, 52px);
|
||||||
|
grid-template-rows: repeat(3, 52px);
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dpad-btn {
|
||||||
|
background: #1e293b;
|
||||||
|
border: 1px solid #475569;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #e5e7eb;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
touch-action: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dpad-btn:active {
|
||||||
|
background: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dpad-up { grid-area: up; }
|
||||||
|
.dpad-left { grid-area: left; }
|
||||||
|
.dpad-center { grid-area: mid; }
|
||||||
|
.dpad-right { grid-area: right; }
|
||||||
|
.dpad-down { grid-area: down; }
|
||||||
|
|
||||||
|
/* Action buttons row */
|
||||||
|
.mobile-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-action-btn {
|
||||||
|
min-height: 48px;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
background: #1e293b;
|
||||||
|
border: 1px solid #475569;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #e5e7eb;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
touch-action: manipulation;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-action-btn:active {
|
||||||
|
background: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-action-btn--wide {
|
||||||
|
flex: 1 0 100%;
|
||||||
|
}
|
||||||
|
|||||||
41
deploy/php/sounds_list.php
Normal file
41
deploy/php/sounds_list.php
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Chat Grid sounds list endpoint.
|
||||||
|
*
|
||||||
|
* Returns a JSON array of filenames found in sounds/widgets/ relative to
|
||||||
|
* the document root. Filters to .ogg, .mp3, and .wav files.
|
||||||
|
* Returns [] if the directory does not exist or contains no audio files.
|
||||||
|
*/
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
header('Cache-Control: no-store');
|
||||||
|
|
||||||
|
$dir = rtrim($_SERVER['DOCUMENT_ROOT'] ?? '', '/') . '/sounds/widgets';
|
||||||
|
|
||||||
|
if (!is_dir($dir)) {
|
||||||
|
echo '[]';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$files = scandir($dir);
|
||||||
|
if ($files === false) {
|
||||||
|
echo '[]';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$allowed = ['ogg', 'mp3', 'wav'];
|
||||||
|
$results = [];
|
||||||
|
|
||||||
|
foreach ($files as $file) {
|
||||||
|
if ($file === '.' || $file === '..') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$ext = strtolower(pathinfo($file, PATHINFO_EXTENSION));
|
||||||
|
if (in_array($ext, $allowed, true)) {
|
||||||
|
$results[] = $file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sort($results);
|
||||||
|
echo json_encode($results, JSON_UNESCAPED_SLASHES);
|
||||||
@@ -35,6 +35,8 @@ services:
|
|||||||
- CHGRID_MEDIA_PROXY_SESSION_CHECK_URL=http://server:4474/auth/session/check
|
- CHGRID_MEDIA_PROXY_SESSION_CHECK_URL=http://server:4474/auth/session/check
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:4474:80"
|
- "127.0.0.1:4474:80"
|
||||||
|
volumes:
|
||||||
|
- ./sounds/widgets:/usr/share/nginx/html/sounds/widgets
|
||||||
depends_on:
|
depends_on:
|
||||||
- server
|
- server
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ ITEM_TYPE_HANDLERS: dict[ItemType, ItemTypeHandler] = {
|
|||||||
validate_update=module.validate_update,
|
validate_update=module.validate_update,
|
||||||
use=module.use_item,
|
use=module.use_item,
|
||||||
secondary_use=getattr(module, "secondary_use_item", None),
|
secondary_use=getattr(module, "secondary_use_item", None),
|
||||||
|
interact=getattr(module, "interact_item", None),
|
||||||
)
|
)
|
||||||
for item_type, module in ITEM_MODULES.items()
|
for item_type, module in ITEM_MODULES.items()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,3 +26,4 @@ class ItemTypeHandler:
|
|||||||
validate_update: Callable[[WorldItem, dict], dict]
|
validate_update: Callable[[WorldItem, dict], dict]
|
||||||
use: Callable[[WorldItem, str, Callable[[dict], str]], ItemUseResult]
|
use: Callable[[WorldItem, str, Callable[[dict], str]], ItemUseResult]
|
||||||
secondary_use: Callable[[WorldItem, str, Callable[[dict], str]], ItemUseResult] | None = None
|
secondary_use: Callable[[WorldItem, str, Callable[[dict], str]], ItemUseResult] | None = None
|
||||||
|
interact: Callable[[WorldItem, str, dict | None, str], ItemUseResult] | None = None
|
||||||
|
|||||||
1
server/app/items/types/card_deck/__init__.py
Normal file
1
server/app/items/types/card_deck/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Card deck item type plugin package."""
|
||||||
96
server/app/items/types/card_deck/actions.py
Normal file
96
server/app/items/types/card_deck/actions.py
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
"""Card deck item use actions."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import random
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
from ....item_types import ItemUseResult
|
||||||
|
from ....models import WorldItem
|
||||||
|
|
||||||
|
RANK_NAMES: dict[str, str] = {
|
||||||
|
"A": "Ace",
|
||||||
|
"2": "Two",
|
||||||
|
"3": "Three",
|
||||||
|
"4": "Four",
|
||||||
|
"5": "Five",
|
||||||
|
"6": "Six",
|
||||||
|
"7": "Seven",
|
||||||
|
"8": "Eight",
|
||||||
|
"9": "Nine",
|
||||||
|
"10": "Ten",
|
||||||
|
"J": "Jack",
|
||||||
|
"Q": "Queen",
|
||||||
|
"K": "King",
|
||||||
|
}
|
||||||
|
SUIT_NAMES: dict[str, str] = {
|
||||||
|
"S": "Spades",
|
||||||
|
"H": "Hearts",
|
||||||
|
"D": "Diamonds",
|
||||||
|
"C": "Clubs",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _card_name(code: str) -> str:
|
||||||
|
"""Return the display name for a card code, e.g. '10H' → 'Ten of Hearts'."""
|
||||||
|
if code in ("JO1", "JO2"):
|
||||||
|
return "Joker"
|
||||||
|
suit = code[-1]
|
||||||
|
rank = code[:-1]
|
||||||
|
return f"{RANK_NAMES[rank]} of {SUIT_NAMES[suit]}"
|
||||||
|
|
||||||
|
|
||||||
|
def _build_deck(include_jokers: bool) -> list[str]:
|
||||||
|
"""Return a sorted list of 52 (or 54) card codes."""
|
||||||
|
ranks = ["A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"]
|
||||||
|
suits = ["S", "H", "D", "C"]
|
||||||
|
deck = [f"{r}{s}" for s in suits for r in ranks]
|
||||||
|
if include_jokers:
|
||||||
|
deck += ["JO1", "JO2"]
|
||||||
|
return deck
|
||||||
|
|
||||||
|
|
||||||
|
def use_item(item: WorldItem, nickname: str, _clock_formatter: Callable[[dict], str]) -> ItemUseResult:
|
||||||
|
"""Draw one or more cards from the deck."""
|
||||||
|
try:
|
||||||
|
draw_count = max(1, min(10, int(item.params.get("draw_count", 1))))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
draw_count = 1
|
||||||
|
|
||||||
|
deck = item.params.get("deck", [])
|
||||||
|
if not isinstance(deck, list):
|
||||||
|
deck = []
|
||||||
|
|
||||||
|
if not deck:
|
||||||
|
return ItemUseResult(
|
||||||
|
self_message=f"{item.title} is empty. Shift+Use to shuffle.",
|
||||||
|
others_message="",
|
||||||
|
)
|
||||||
|
|
||||||
|
count = min(draw_count, len(deck))
|
||||||
|
drawn = deck[:count]
|
||||||
|
remaining = deck[count:]
|
||||||
|
|
||||||
|
card_names = ", ".join(_card_name(c) for c in drawn)
|
||||||
|
cards_left = len(remaining)
|
||||||
|
left_text = f"{cards_left} card{'s' if cards_left != 1 else ''} left"
|
||||||
|
|
||||||
|
return ItemUseResult(
|
||||||
|
self_message=f"You draw from {item.title}: {card_names}. ({left_text})",
|
||||||
|
others_message=f"{nickname} draws {count} card{'s' if count != 1 else ''} from {item.title}. ({left_text})",
|
||||||
|
updated_params={"deck": remaining, "useSound": "sounds/card_draw.ogg"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def secondary_use_item(item: WorldItem, nickname: str, _clock_formatter: Callable[[dict], str]) -> ItemUseResult:
|
||||||
|
"""Shuffle the deck."""
|
||||||
|
include_jokers = bool(item.params.get("include_jokers", False))
|
||||||
|
deck = _build_deck(include_jokers)
|
||||||
|
random.shuffle(deck)
|
||||||
|
total = len(deck)
|
||||||
|
|
||||||
|
return ItemUseResult(
|
||||||
|
self_message=f"You shuffle {item.title}. {total} cards ready.",
|
||||||
|
others_message=f"{nickname} shuffles {item.title}.",
|
||||||
|
updated_params={"deck": deck, "useSound": "sounds/card_shuffle.ogg"},
|
||||||
|
)
|
||||||
43
server/app/items/types/card_deck/definition.py
Normal file
43
server/app/items/types/card_deck/definition.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
"""Card deck item static metadata and defaults."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
LABEL = "card deck"
|
||||||
|
TOOLTIP = "A standard 52-card deck. Use to draw cards, Shift+Use to shuffle."
|
||||||
|
EDITABLE_PROPERTIES: tuple[str, ...] = ("title", "draw_count", "include_jokers")
|
||||||
|
CAPABILITIES: tuple[str, ...] = ("editable", "carryable", "deletable", "usable")
|
||||||
|
USE_SOUND = None
|
||||||
|
EMIT_SOUND: str | None = None
|
||||||
|
EMIT_RANGE = 15
|
||||||
|
DIRECTIONAL = False
|
||||||
|
USE_COOLDOWN_MS = 500
|
||||||
|
DEFAULT_TITLE = "Card Deck"
|
||||||
|
PARAM_KEYS: tuple[str, ...] = ("deck", "draw_count", "include_jokers", "useSound")
|
||||||
|
|
||||||
|
_RANKS = ["A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"]
|
||||||
|
_SUITS = ["S", "H", "D", "C"]
|
||||||
|
_FULL_DECK: list[str] = [f"{r}{s}" for s in _SUITS for r in _RANKS]
|
||||||
|
|
||||||
|
DEFAULT_PARAMS: dict = {
|
||||||
|
"deck": list(_FULL_DECK),
|
||||||
|
"draw_count": 1,
|
||||||
|
"include_jokers": False,
|
||||||
|
"useSound": "sounds/card_draw.ogg",
|
||||||
|
}
|
||||||
|
|
||||||
|
PROPERTY_METADATA: dict[str, dict[str, object]] = {
|
||||||
|
"title": {
|
||||||
|
"valueType": "text",
|
||||||
|
"tooltip": "Display name spoken and shown for this item.",
|
||||||
|
"maxLength": 80,
|
||||||
|
},
|
||||||
|
"draw_count": {
|
||||||
|
"valueType": "number",
|
||||||
|
"tooltip": "How many cards to draw per use.",
|
||||||
|
"range": {"min": 1, "max": 10, "step": 1},
|
||||||
|
},
|
||||||
|
"include_jokers": {
|
||||||
|
"valueType": "boolean",
|
||||||
|
"tooltip": "Include two Jokers when shuffled.",
|
||||||
|
},
|
||||||
|
}
|
||||||
17
server/app/items/types/card_deck/plugin.py
Normal file
17
server/app/items/types/card_deck/plugin.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
"""Plugin registration for card deck item type."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from ..plugin_helpers import build_item_module
|
||||||
|
from . import actions, definition, validator
|
||||||
|
|
||||||
|
ITEM_TYPE_PLUGIN = {
|
||||||
|
"type": "card_deck",
|
||||||
|
"order": 25,
|
||||||
|
"module": build_item_module(
|
||||||
|
definition,
|
||||||
|
validate_update=validator.validate_update,
|
||||||
|
use_item=actions.use_item,
|
||||||
|
secondary_use_item=actions.secondary_use_item,
|
||||||
|
),
|
||||||
|
}
|
||||||
53
server/app/items/types/card_deck/validator.py
Normal file
53
server/app/items/types/card_deck/validator.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
"""Card deck item validation/normalization."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from ....models import WorldItem
|
||||||
|
from ...helpers import keep_only_known_params, parse_bool_like
|
||||||
|
from .definition import PARAM_KEYS
|
||||||
|
|
||||||
|
_VALID_RANKS = frozenset(["A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"])
|
||||||
|
_VALID_SUITS = frozenset(["S", "H", "D", "C"])
|
||||||
|
_VALID_JOKERS = frozenset(["JO1", "JO2"])
|
||||||
|
_ALLOWED_SOUNDS = frozenset(["sounds/card_draw.ogg", "sounds/card_shuffle.ogg", ""])
|
||||||
|
|
||||||
|
|
||||||
|
def _is_valid_card(code: object) -> bool:
|
||||||
|
if not isinstance(code, str):
|
||||||
|
return False
|
||||||
|
if code in _VALID_JOKERS:
|
||||||
|
return True
|
||||||
|
if len(code) < 2:
|
||||||
|
return False
|
||||||
|
suit = code[-1]
|
||||||
|
rank = code[:-1]
|
||||||
|
return rank in _VALID_RANKS and suit in _VALID_SUITS
|
||||||
|
|
||||||
|
|
||||||
|
def validate_update(_item: WorldItem, next_params: dict) -> dict:
|
||||||
|
"""Validate and normalize card deck params."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
draw_count = int(next_params.get("draw_count", 1))
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise ValueError("draw_count must be a number.") from exc
|
||||||
|
if not (1 <= draw_count <= 10):
|
||||||
|
raise ValueError("draw_count must be between 1 and 10.")
|
||||||
|
next_params["draw_count"] = draw_count
|
||||||
|
|
||||||
|
deck = next_params.get("deck", [])
|
||||||
|
if not isinstance(deck, list):
|
||||||
|
raise ValueError("deck must be a list.")
|
||||||
|
for card in deck:
|
||||||
|
if not _is_valid_card(card):
|
||||||
|
raise ValueError(f"Invalid card code: {card!r}")
|
||||||
|
next_params["deck"] = deck
|
||||||
|
|
||||||
|
next_params["include_jokers"] = parse_bool_like(next_params.get("include_jokers", False), default=False)
|
||||||
|
|
||||||
|
use_sound = str(next_params.get("useSound", "")).strip()
|
||||||
|
if use_sound not in _ALLOWED_SOUNDS:
|
||||||
|
use_sound = "sounds/card_draw.ogg"
|
||||||
|
next_params["useSound"] = use_sound
|
||||||
|
|
||||||
|
return keep_only_known_params(next_params, PARAM_KEYS)
|
||||||
1
server/app/items/types/card_table/__init__.py
Normal file
1
server/app/items/types/card_table/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Card table item type plugin package."""
|
||||||
165
server/app/items/types/card_table/actions.py
Normal file
165
server/app/items/types/card_table/actions.py
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
"""Card table item use actions."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import random
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
from ....item_types import ItemUseResult
|
||||||
|
from ....models import WorldItem
|
||||||
|
|
||||||
|
_VALID_RANKS = {"A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"}
|
||||||
|
_VALID_SUITS = {"S", "H", "D", "C"}
|
||||||
|
_RANK_NAMES = {
|
||||||
|
"A": "Ace", "2": "Two", "3": "Three", "4": "Four", "5": "Five",
|
||||||
|
"6": "Six", "7": "Seven", "8": "Eight", "9": "Nine", "10": "Ten",
|
||||||
|
"J": "Jack", "Q": "Queen", "K": "King",
|
||||||
|
}
|
||||||
|
_SUIT_NAMES = {"S": "Spades", "H": "Hearts", "D": "Diamonds", "C": "Clubs"}
|
||||||
|
|
||||||
|
_CARD_TABLE_ACTIONS = frozenset(["draw", "draw_from_discard", "discard", "return_to_pile"])
|
||||||
|
|
||||||
|
|
||||||
|
def _card_name(code: str) -> str:
|
||||||
|
"""Human-readable card name."""
|
||||||
|
if code in ("JO1", "JO2"):
|
||||||
|
return "Joker"
|
||||||
|
suit = code[-1]
|
||||||
|
rank = code[:-1]
|
||||||
|
return f"{_RANK_NAMES.get(rank, rank)} of {_SUIT_NAMES.get(suit, suit)}"
|
||||||
|
|
||||||
|
|
||||||
|
def _build_deck(include_jokers: bool) -> list[str]:
|
||||||
|
"""Return a sorted list of 52 (or 54) card codes."""
|
||||||
|
ranks = ["A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"]
|
||||||
|
suits = ["S", "H", "D", "C"]
|
||||||
|
deck = [f"{r}{s}" for s in suits for r in ranks]
|
||||||
|
if include_jokers:
|
||||||
|
deck += ["JO1", "JO2"]
|
||||||
|
return deck
|
||||||
|
|
||||||
|
|
||||||
|
def use_item(item: WorldItem, nickname: str, _clock_formatter: Callable[[dict], str]) -> ItemUseResult:
|
||||||
|
"""Return status message; client opens menu from existing state."""
|
||||||
|
draw_pile = item.params.get("draw_pile", [])
|
||||||
|
discard_pile = item.params.get("discard_pile", [])
|
||||||
|
hands = item.params.get("hands", {})
|
||||||
|
if not isinstance(draw_pile, list):
|
||||||
|
draw_pile = []
|
||||||
|
if not isinstance(discard_pile, list):
|
||||||
|
discard_pile = []
|
||||||
|
if not isinstance(hands, dict):
|
||||||
|
hands = {}
|
||||||
|
hand = hands.get(nickname, [])
|
||||||
|
if not isinstance(hand, list):
|
||||||
|
hand = []
|
||||||
|
draw_count = len(draw_pile)
|
||||||
|
discard_count = len(discard_pile)
|
||||||
|
hand_count = len(hand)
|
||||||
|
|
||||||
|
return ItemUseResult(
|
||||||
|
self_message=(
|
||||||
|
f"{item.title}: {draw_count} in draw pile, "
|
||||||
|
f"{discard_count} in discard, {hand_count} in your hand."
|
||||||
|
),
|
||||||
|
others_message="",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def secondary_use_item(item: WorldItem, nickname: str, _clock_formatter: Callable[[dict], str]) -> ItemUseResult:
|
||||||
|
"""Shuffle and reset the card table."""
|
||||||
|
include_jokers = bool(item.params.get("include_jokers", False))
|
||||||
|
deck = _build_deck(include_jokers)
|
||||||
|
random.shuffle(deck)
|
||||||
|
total = len(deck)
|
||||||
|
|
||||||
|
return ItemUseResult(
|
||||||
|
self_message=f"You reset {item.title}. {total} cards shuffled into draw pile.",
|
||||||
|
others_message=f"{nickname} resets {item.title}.",
|
||||||
|
updated_params={
|
||||||
|
"draw_pile": deck,
|
||||||
|
"discard_pile": [],
|
||||||
|
"hands": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def interact_item(
|
||||||
|
item: WorldItem,
|
||||||
|
action: str,
|
||||||
|
params: dict | None,
|
||||||
|
nickname: str,
|
||||||
|
) -> ItemUseResult:
|
||||||
|
"""Handle a card table interact action on behalf of any user."""
|
||||||
|
if action not in _CARD_TABLE_ACTIONS:
|
||||||
|
raise ValueError(f"Unknown card table action: {action!r}")
|
||||||
|
|
||||||
|
draw_pile = list(item.params.get("draw_pile", []))
|
||||||
|
discard_pile = list(item.params.get("discard_pile", []))
|
||||||
|
hands_raw = item.params.get("hands", {})
|
||||||
|
hands: dict[str, list[str]] = dict(hands_raw) if isinstance(hands_raw, dict) else {}
|
||||||
|
|
||||||
|
if action == "draw":
|
||||||
|
if not draw_pile:
|
||||||
|
raise ValueError("Draw pile is empty.")
|
||||||
|
card = draw_pile.pop(0)
|
||||||
|
hand = list(hands.get(nickname, []))
|
||||||
|
hand.append(card)
|
||||||
|
hands[nickname] = hand
|
||||||
|
return ItemUseResult(
|
||||||
|
self_message=f"You drew {_card_name(card)}. {len(draw_pile)} remaining in draw pile.",
|
||||||
|
others_message=f"{nickname} draws a card.",
|
||||||
|
updated_params={"draw_pile": draw_pile, "hands": hands},
|
||||||
|
)
|
||||||
|
|
||||||
|
if action == "draw_from_discard":
|
||||||
|
if not discard_pile:
|
||||||
|
raise ValueError("Discard pile is empty.")
|
||||||
|
if not params or "card_index" not in params:
|
||||||
|
raise ValueError("draw_from_discard requires params.card_index.")
|
||||||
|
card_index = params["card_index"]
|
||||||
|
if not isinstance(card_index, int) or card_index < 0 or card_index >= len(discard_pile):
|
||||||
|
raise ValueError("Invalid card_index.")
|
||||||
|
card = discard_pile.pop(card_index)
|
||||||
|
hand = list(hands.get(nickname, []))
|
||||||
|
hand.append(card)
|
||||||
|
hands[nickname] = hand
|
||||||
|
return ItemUseResult(
|
||||||
|
self_message=f"You took {_card_name(card)} from the discard pile.",
|
||||||
|
others_message=f"{nickname} takes a card from the discard pile.",
|
||||||
|
updated_params={"discard_pile": discard_pile, "hands": hands},
|
||||||
|
)
|
||||||
|
|
||||||
|
if action == "discard":
|
||||||
|
if not params or "card_index" not in params:
|
||||||
|
raise ValueError("discard requires params.card_index.")
|
||||||
|
hand = list(hands.get(nickname, []))
|
||||||
|
card_index = params["card_index"]
|
||||||
|
if not isinstance(card_index, int) or card_index < 0 or card_index >= len(hand):
|
||||||
|
raise ValueError("Invalid card_index.")
|
||||||
|
card = hand.pop(card_index)
|
||||||
|
discard_pile.insert(0, card)
|
||||||
|
hands[nickname] = hand
|
||||||
|
return ItemUseResult(
|
||||||
|
self_message=f"You discarded {_card_name(card)}.",
|
||||||
|
others_message=f"{nickname} discards a card.",
|
||||||
|
updated_params={"discard_pile": discard_pile, "hands": hands},
|
||||||
|
)
|
||||||
|
|
||||||
|
if action == "return_to_pile":
|
||||||
|
if not params or "card_index" not in params:
|
||||||
|
raise ValueError("return_to_pile requires params.card_index.")
|
||||||
|
hand = list(hands.get(nickname, []))
|
||||||
|
card_index = params["card_index"]
|
||||||
|
if not isinstance(card_index, int) or card_index < 0 or card_index >= len(hand):
|
||||||
|
raise ValueError("Invalid card_index.")
|
||||||
|
card = hand.pop(card_index)
|
||||||
|
draw_pile.append(card)
|
||||||
|
hands[nickname] = hand
|
||||||
|
return ItemUseResult(
|
||||||
|
self_message=f"You returned {_card_name(card)} to the draw pile.",
|
||||||
|
others_message=f"{nickname} returns a card to the draw pile.",
|
||||||
|
updated_params={"draw_pile": draw_pile, "hands": hands},
|
||||||
|
)
|
||||||
|
|
||||||
|
raise ValueError(f"Unhandled action: {action!r}") # unreachable guard
|
||||||
39
server/app/items/types/card_table/definition.py
Normal file
39
server/app/items/types/card_table/definition.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
"""Card table item static metadata and defaults."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
TYPE = "card_table"
|
||||||
|
LABEL = "Card Table"
|
||||||
|
TOOLTIP = "A shared card table with draw pile, discard pile, and per-player hands."
|
||||||
|
EDITABLE_PROPERTIES: tuple[str, ...] = ("title", "include_jokers")
|
||||||
|
CAPABILITIES: tuple[str, ...] = ("editable", "carryable", "deletable", "usable")
|
||||||
|
USE_SOUND = None
|
||||||
|
EMIT_SOUND: str | None = None
|
||||||
|
EMIT_RANGE = 15
|
||||||
|
DIRECTIONAL = False
|
||||||
|
USE_COOLDOWN_MS = 500
|
||||||
|
DEFAULT_TITLE = "Card Table"
|
||||||
|
PARAM_KEYS: tuple[str, ...] = ("draw_pile", "discard_pile", "hands", "include_jokers")
|
||||||
|
|
||||||
|
_RANKS = ["A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"]
|
||||||
|
_SUITS = ["S", "H", "D", "C"]
|
||||||
|
_FULL_DECK: list[str] = [f"{r}{s}" for s in _SUITS for r in _RANKS]
|
||||||
|
|
||||||
|
DEFAULT_PARAMS: dict = {
|
||||||
|
"draw_pile": list(_FULL_DECK),
|
||||||
|
"discard_pile": [],
|
||||||
|
"hands": {},
|
||||||
|
"include_jokers": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
PROPERTY_METADATA: dict[str, dict[str, object]] = {
|
||||||
|
"title": {
|
||||||
|
"valueType": "text",
|
||||||
|
"tooltip": "Display name spoken and shown for this item.",
|
||||||
|
"maxLength": 80,
|
||||||
|
},
|
||||||
|
"include_jokers": {
|
||||||
|
"valueType": "boolean",
|
||||||
|
"tooltip": "Include two Jokers when shuffled and reset.",
|
||||||
|
},
|
||||||
|
}
|
||||||
18
server/app/items/types/card_table/plugin.py
Normal file
18
server/app/items/types/card_table/plugin.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
"""Plugin registration for card table item type."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from ..plugin_helpers import build_item_module
|
||||||
|
from . import actions, definition, validator
|
||||||
|
|
||||||
|
ITEM_TYPE_PLUGIN = {
|
||||||
|
"type": "card_table",
|
||||||
|
"order": 26,
|
||||||
|
"module": build_item_module(
|
||||||
|
definition,
|
||||||
|
validate_update=validator.validate_update,
|
||||||
|
use_item=actions.use_item,
|
||||||
|
secondary_use_item=actions.secondary_use_item,
|
||||||
|
interact_item=actions.interact_item,
|
||||||
|
),
|
||||||
|
}
|
||||||
64
server/app/items/types/card_table/validator.py
Normal file
64
server/app/items/types/card_table/validator.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
"""Card table item validation/normalization."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from ....models import WorldItem
|
||||||
|
from ...helpers import keep_only_known_params, parse_bool_like
|
||||||
|
from .definition import PARAM_KEYS
|
||||||
|
|
||||||
|
_VALID_RANKS = frozenset(["A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"])
|
||||||
|
_VALID_SUITS = frozenset(["S", "H", "D", "C"])
|
||||||
|
_VALID_JOKERS = frozenset(["JO1", "JO2"])
|
||||||
|
|
||||||
|
|
||||||
|
def _is_valid_card(code: object) -> bool:
|
||||||
|
if not isinstance(code, str):
|
||||||
|
return False
|
||||||
|
if code in _VALID_JOKERS:
|
||||||
|
return True
|
||||||
|
if len(code) < 2:
|
||||||
|
return False
|
||||||
|
suit = code[-1]
|
||||||
|
rank = code[:-1]
|
||||||
|
return rank in _VALID_RANKS and suit in _VALID_SUITS
|
||||||
|
|
||||||
|
|
||||||
|
def validate_update(_item: WorldItem, next_params: dict) -> dict:
|
||||||
|
"""Validate and normalize card table params."""
|
||||||
|
|
||||||
|
draw_pile = next_params.get("draw_pile", [])
|
||||||
|
if not isinstance(draw_pile, list):
|
||||||
|
raise ValueError("draw_pile must be a list.")
|
||||||
|
for card in draw_pile:
|
||||||
|
if not _is_valid_card(card):
|
||||||
|
raise ValueError(f"Invalid card code in draw_pile: {card!r}")
|
||||||
|
next_params["draw_pile"] = draw_pile
|
||||||
|
|
||||||
|
discard_pile = next_params.get("discard_pile", [])
|
||||||
|
if not isinstance(discard_pile, list):
|
||||||
|
raise ValueError("discard_pile must be a list.")
|
||||||
|
for card in discard_pile:
|
||||||
|
if not _is_valid_card(card):
|
||||||
|
raise ValueError(f"Invalid card code in discard_pile: {card!r}")
|
||||||
|
next_params["discard_pile"] = discard_pile
|
||||||
|
|
||||||
|
hands = next_params.get("hands", {})
|
||||||
|
if not isinstance(hands, dict):
|
||||||
|
raise ValueError("hands must be a dict.")
|
||||||
|
if len(hands) > 20:
|
||||||
|
raise ValueError("Too many hands (max 20).")
|
||||||
|
for player, hand in hands.items():
|
||||||
|
if not isinstance(player, str):
|
||||||
|
raise ValueError("Hand keys must be strings.")
|
||||||
|
if not isinstance(hand, list):
|
||||||
|
raise ValueError(f"Hand for {player!r} must be a list.")
|
||||||
|
if len(hand) > 60:
|
||||||
|
raise ValueError(f"Too many cards in hand for {player!r} (max 60).")
|
||||||
|
for card in hand:
|
||||||
|
if not _is_valid_card(card):
|
||||||
|
raise ValueError(f"Invalid card code in hand for {player!r}: {card!r}")
|
||||||
|
next_params["hands"] = hands
|
||||||
|
|
||||||
|
next_params["include_jokers"] = parse_bool_like(next_params.get("include_jokers", False), default=False)
|
||||||
|
|
||||||
|
return keep_only_known_params(next_params, PARAM_KEYS)
|
||||||
@@ -6,7 +6,14 @@ from types import SimpleNamespace
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
def build_item_module(definition: Any, *, validate_update: Any, use_item: Any, secondary_use_item: Any = None) -> Any:
|
def build_item_module(
|
||||||
|
definition: Any,
|
||||||
|
*,
|
||||||
|
validate_update: Any,
|
||||||
|
use_item: Any,
|
||||||
|
secondary_use_item: Any = None,
|
||||||
|
interact_item: Any = None,
|
||||||
|
) -> Any:
|
||||||
"""Compose a plugin module-like object from split definition/validator/actions files."""
|
"""Compose a plugin module-like object from split definition/validator/actions files."""
|
||||||
|
|
||||||
exports: dict[str, Any] = {
|
exports: dict[str, Any] = {
|
||||||
@@ -18,4 +25,6 @@ def build_item_module(definition: Any, *, validate_update: Any, use_item: Any, s
|
|||||||
exports["use_item"] = use_item
|
exports["use_item"] = use_item
|
||||||
if secondary_use_item is not None:
|
if secondary_use_item is not None:
|
||||||
exports["secondary_use_item"] = secondary_use_item
|
exports["secondary_use_item"] = secondary_use_item
|
||||||
|
if interact_item is not None:
|
||||||
|
exports["interact_item"] = interact_item
|
||||||
return SimpleNamespace(**exports)
|
return SimpleNamespace(**exports)
|
||||||
|
|||||||
1
server/app/items/types/whiteboard/__init__.py
Normal file
1
server/app/items/types/whiteboard/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Whiteboard item type package."""
|
||||||
89
server/app/items/types/whiteboard/actions.py
Normal file
89
server/app/items/types/whiteboard/actions.py
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
"""Whiteboard item use actions."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
from ....item_types import ItemUseResult
|
||||||
|
from ....models import WorldItem
|
||||||
|
_WHITEBOARD_ACTIONS = frozenset(["add_line", "edit_line", "delete_line"])
|
||||||
|
_MAX_LINES = 20
|
||||||
|
_MAX_LINE_LENGTH = 200
|
||||||
|
|
||||||
|
|
||||||
|
def use_item(item: WorldItem, nickname: str, _clock_formatter: Callable[[dict], str]) -> ItemUseResult:
|
||||||
|
"""Report whiteboard contents to the user who used it."""
|
||||||
|
|
||||||
|
lines = item.params.get("lines", [])
|
||||||
|
if not isinstance(lines, list):
|
||||||
|
lines = []
|
||||||
|
n = len(lines)
|
||||||
|
line_text = f"{n} line{'s' if n != 1 else ''}"
|
||||||
|
|
||||||
|
return ItemUseResult(
|
||||||
|
self_message=f"You open {item.title}. {line_text}.",
|
||||||
|
others_message=f"{nickname} opens {item.title}.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def interact_item(
|
||||||
|
item: WorldItem,
|
||||||
|
action: str,
|
||||||
|
params: dict | None,
|
||||||
|
nickname: str,
|
||||||
|
) -> ItemUseResult:
|
||||||
|
"""Handle a whiteboard interact action on behalf of any user."""
|
||||||
|
if action not in _WHITEBOARD_ACTIONS:
|
||||||
|
raise ValueError(f"Unknown whiteboard action: {action!r}")
|
||||||
|
|
||||||
|
lines = list(item.params.get("lines", []))
|
||||||
|
|
||||||
|
if action == "add_line":
|
||||||
|
if not params or not isinstance(params.get("text"), str):
|
||||||
|
raise ValueError("add_line requires params.text.")
|
||||||
|
text = params["text"].strip()
|
||||||
|
if not text:
|
||||||
|
raise ValueError("Line text cannot be empty.")
|
||||||
|
if len(text) > _MAX_LINE_LENGTH:
|
||||||
|
raise ValueError(f"Line text is too long (max {_MAX_LINE_LENGTH} characters).")
|
||||||
|
if len(lines) >= _MAX_LINES:
|
||||||
|
raise ValueError(f"Whiteboard is full (max {_MAX_LINES} lines).")
|
||||||
|
lines.append(text)
|
||||||
|
return ItemUseResult(
|
||||||
|
self_message=f"Line added to {item.title}.",
|
||||||
|
others_message=f"{nickname} adds a line to {item.title}.",
|
||||||
|
updated_params={"lines": lines},
|
||||||
|
)
|
||||||
|
|
||||||
|
if action == "edit_line":
|
||||||
|
if not params or "line_index" not in params or not isinstance(params.get("text"), str):
|
||||||
|
raise ValueError("edit_line requires params.line_index and params.text.")
|
||||||
|
line_index = params["line_index"]
|
||||||
|
if not isinstance(line_index, int) or line_index < 0 or line_index >= len(lines):
|
||||||
|
raise ValueError("Invalid line_index.")
|
||||||
|
text = params["text"].strip()
|
||||||
|
if not text:
|
||||||
|
raise ValueError("Line text cannot be empty.")
|
||||||
|
if len(text) > _MAX_LINE_LENGTH:
|
||||||
|
raise ValueError(f"Line text is too long (max {_MAX_LINE_LENGTH} characters).")
|
||||||
|
lines[line_index] = text
|
||||||
|
return ItemUseResult(
|
||||||
|
self_message=f"Line updated on {item.title}.",
|
||||||
|
others_message=f"{nickname} updates a line on {item.title}.",
|
||||||
|
updated_params={"lines": lines},
|
||||||
|
)
|
||||||
|
|
||||||
|
if action == "delete_line":
|
||||||
|
if not params or "line_index" not in params:
|
||||||
|
raise ValueError("delete_line requires params.line_index.")
|
||||||
|
line_index = params["line_index"]
|
||||||
|
if not isinstance(line_index, int) or line_index < 0 or line_index >= len(lines):
|
||||||
|
raise ValueError("Invalid line_index.")
|
||||||
|
lines.pop(line_index)
|
||||||
|
return ItemUseResult(
|
||||||
|
self_message=f"Line deleted from {item.title}.",
|
||||||
|
others_message=f"{nickname} deletes a line from {item.title}.",
|
||||||
|
updated_params={"lines": lines},
|
||||||
|
)
|
||||||
|
|
||||||
|
raise ValueError(f"Unhandled action: {action!r}") # unreachable guard
|
||||||
19
server/app/items/types/whiteboard/definition.py
Normal file
19
server/app/items/types/whiteboard/definition.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
"""Whiteboard item static metadata and defaults."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
LABEL = "whiteboard"
|
||||||
|
TOOLTIP = "A shared text board. Use to read and edit lines."
|
||||||
|
EDITABLE_PROPERTIES: tuple[str, ...] = ("title",)
|
||||||
|
CAPABILITIES: tuple[str, ...] = ("editable", "carryable", "deletable", "usable")
|
||||||
|
USE_SOUND: str | None = None
|
||||||
|
EMIT_SOUND: str | None = None
|
||||||
|
USE_COOLDOWN_MS = 500
|
||||||
|
EMIT_RANGE = 15
|
||||||
|
DIRECTIONAL = False
|
||||||
|
DEFAULT_TITLE = "whiteboard"
|
||||||
|
DEFAULT_PARAMS: dict = {"lines": []}
|
||||||
|
PARAM_KEYS: tuple[str, ...] = ("lines",)
|
||||||
|
PROPERTY_METADATA: dict[str, dict[str, object]] = {
|
||||||
|
"title": {"valueType": "text", "tooltip": "Display name.", "maxLength": 80},
|
||||||
|
}
|
||||||
17
server/app/items/types/whiteboard/plugin.py
Normal file
17
server/app/items/types/whiteboard/plugin.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
"""Plugin registration for whiteboard item type."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from ..plugin_helpers import build_item_module
|
||||||
|
from . import actions, definition, validator
|
||||||
|
|
||||||
|
ITEM_TYPE_PLUGIN = {
|
||||||
|
"type": "whiteboard",
|
||||||
|
"order": 70,
|
||||||
|
"module": build_item_module(
|
||||||
|
definition,
|
||||||
|
validate_update=validator.validate_update,
|
||||||
|
use_item=actions.use_item,
|
||||||
|
interact_item=actions.interact_item,
|
||||||
|
),
|
||||||
|
}
|
||||||
32
server/app/items/types/whiteboard/validator.py
Normal file
32
server/app/items/types/whiteboard/validator.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
"""Whiteboard item validation/normalization."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from ....models import WorldItem
|
||||||
|
from ...helpers import keep_only_known_params
|
||||||
|
from .definition import PARAM_KEYS
|
||||||
|
|
||||||
|
_MAX_LINES = 20
|
||||||
|
_MAX_LINE_LENGTH = 200
|
||||||
|
|
||||||
|
|
||||||
|
def validate_update(_item: WorldItem, next_params: dict) -> dict:
|
||||||
|
"""Validate and normalize whiteboard params."""
|
||||||
|
|
||||||
|
lines = next_params.get("lines", [])
|
||||||
|
if not isinstance(lines, list):
|
||||||
|
raise ValueError("lines must be a list.")
|
||||||
|
if len(lines) > _MAX_LINES:
|
||||||
|
raise ValueError(f"A whiteboard can have at most {_MAX_LINES} lines.")
|
||||||
|
|
||||||
|
cleaned: list[str] = []
|
||||||
|
for line in lines:
|
||||||
|
if not isinstance(line, str):
|
||||||
|
raise ValueError("Each line must be a string.")
|
||||||
|
stripped = line.strip()
|
||||||
|
if len(stripped) > _MAX_LINE_LENGTH:
|
||||||
|
raise ValueError(f"Each line must be at most {_MAX_LINE_LENGTH} characters.")
|
||||||
|
cleaned.append(stripped)
|
||||||
|
|
||||||
|
next_params["lines"] = cleaned
|
||||||
|
return keep_only_known_params(next_params, PARAM_KEYS)
|
||||||
@@ -176,6 +176,13 @@ class ItemUpdatePacket(BasePacket):
|
|||||||
params: dict | None = None
|
params: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ItemInteractPacket(BasePacket):
|
||||||
|
type: Literal["item_interact"]
|
||||||
|
itemId: str
|
||||||
|
action: str = Field(min_length=1, max_length=64)
|
||||||
|
params: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
ClientPacket = (
|
ClientPacket = (
|
||||||
UpdatePositionPacket
|
UpdatePositionPacket
|
||||||
| TeleportCompletePacket
|
| TeleportCompletePacket
|
||||||
@@ -204,6 +211,7 @@ ClientPacket = (
|
|||||||
| ItemTransferTargetsPacket
|
| ItemTransferTargetsPacket
|
||||||
| ItemUsePacket
|
| ItemUsePacket
|
||||||
| ItemSecondaryUsePacket
|
| ItemSecondaryUsePacket
|
||||||
|
| ItemInteractPacket
|
||||||
| ItemPianoNotePacket
|
| ItemPianoNotePacket
|
||||||
| ItemPianoRecordingPacket
|
| ItemPianoRecordingPacket
|
||||||
| ItemUpdatePacket
|
| ItemUpdatePacket
|
||||||
@@ -366,7 +374,7 @@ class ItemRemovePacket(BasePacket):
|
|||||||
class ItemActionResultPacket(BasePacket):
|
class ItemActionResultPacket(BasePacket):
|
||||||
type: Literal["item_action_result"]
|
type: Literal["item_action_result"]
|
||||||
ok: bool
|
ok: bool
|
||||||
action: Literal["add", "pickup", "drop", "delete", "transfer", "use", "secondary_use", "update"]
|
action: Literal["add", "pickup", "drop", "delete", "transfer", "use", "secondary_use", "update", "interact"]
|
||||||
message: str
|
message: str
|
||||||
itemId: str | None = None
|
itemId: str | None = None
|
||||||
|
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ from .models import (
|
|||||||
ItemClockAnnouncePacket,
|
ItemClockAnnouncePacket,
|
||||||
ItemDeletePacket,
|
ItemDeletePacket,
|
||||||
ItemDropPacket,
|
ItemDropPacket,
|
||||||
|
ItemInteractPacket,
|
||||||
ItemPianoNoteBroadcastPacket,
|
ItemPianoNoteBroadcastPacket,
|
||||||
ItemPianoNotePacket,
|
ItemPianoNotePacket,
|
||||||
ItemPianoRecordingPacket,
|
ItemPianoRecordingPacket,
|
||||||
@@ -2955,9 +2956,69 @@ class SignalingServer:
|
|||||||
BroadcastChatMessagePacket(type="chat_message", message=secondary_result.others_message, system=True),
|
BroadcastChatMessagePacket(type="chat_message", message=secondary_result.others_message, system=True),
|
||||||
exclude=client.websocket,
|
exclude=client.websocket,
|
||||||
)
|
)
|
||||||
|
use_sound = self._resolve_item_use_sound(item)
|
||||||
|
if use_sound:
|
||||||
|
sound_x, sound_y = self._get_item_sound_source_position(item)
|
||||||
|
sound_range = self._get_item_emit_range(item)
|
||||||
|
await self._broadcast(
|
||||||
|
ItemUseSoundPacket(
|
||||||
|
type="item_use_sound",
|
||||||
|
itemId=item.id,
|
||||||
|
sound=use_sound,
|
||||||
|
x=sound_x,
|
||||||
|
y=sound_y,
|
||||||
|
range=sound_range,
|
||||||
|
)
|
||||||
|
)
|
||||||
await self._send_item_result(client, True, "secondary_use", secondary_result.self_message, item.id)
|
await self._send_item_result(client, True, "secondary_use", secondary_result.self_message, item.id)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if isinstance(packet, ItemInteractPacket):
|
||||||
|
if not self._client_has_permission(client, "item.use"):
|
||||||
|
await self._send_item_result(client, False, "interact", "Not authorized to use items.")
|
||||||
|
return
|
||||||
|
item = self.items.get(packet.itemId)
|
||||||
|
if not item:
|
||||||
|
await self._send_item_result(client, False, "interact", "Item not found.")
|
||||||
|
return
|
||||||
|
if item.carrierId not in (None, client.id):
|
||||||
|
await self._send_item_result(client, False, "interact", "Item is not available.", item.id)
|
||||||
|
return
|
||||||
|
if item.carrierId is None and (item.x != client.x or item.y != client.y):
|
||||||
|
await self._send_item_result(client, False, "interact", "Item is not on your square.", item.id)
|
||||||
|
return
|
||||||
|
handler = get_item_type_handler(item.type)
|
||||||
|
if handler.interact is None:
|
||||||
|
await self._send_item_result(
|
||||||
|
client, False, "interact", f"{item.title} does not support interact actions.", item.id
|
||||||
|
)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
interact_result = handler.interact(item, packet.action, packet.params, client.nickname)
|
||||||
|
except ValueError as exc:
|
||||||
|
await self._send_item_result(client, False, "interact", str(exc), item.id)
|
||||||
|
return
|
||||||
|
if interact_result.updated_params is not None:
|
||||||
|
try:
|
||||||
|
item.params = handler.validate_update(item, {**item.params, **interact_result.updated_params})
|
||||||
|
except ValueError as exc:
|
||||||
|
await self._send_item_result(client, False, "interact", str(exc), item.id)
|
||||||
|
return
|
||||||
|
item.updatedAt = self.item_service.now_ms()
|
||||||
|
actor_id, actor_name = self._item_updated_actor(client)
|
||||||
|
item.updatedBy = actor_id
|
||||||
|
item.updatedByName = actor_name
|
||||||
|
item.version += 1
|
||||||
|
self._request_state_save()
|
||||||
|
await self._broadcast_item(item)
|
||||||
|
if interact_result.others_message.strip():
|
||||||
|
await self._broadcast(
|
||||||
|
BroadcastChatMessagePacket(type="chat_message", message=interact_result.others_message, system=True),
|
||||||
|
exclude=client.websocket,
|
||||||
|
)
|
||||||
|
await self._send_item_result(client, True, "interact", interact_result.self_message, item.id)
|
||||||
|
return
|
||||||
|
|
||||||
if isinstance(packet, ItemPianoNotePacket):
|
if isinstance(packet, ItemPianoNotePacket):
|
||||||
if not self._client_has_permission(client, "item.use"):
|
if not self._client_has_permission(client, "item.use"):
|
||||||
return
|
return
|
||||||
|
|||||||
0
sounds/widgets/.gitkeep
Normal file
0
sounds/widgets/.gitkeep
Normal file
Reference in New Issue
Block a user