Compare commits
4 Commits
main
...
pr/livekit
| Author | SHA1 | Date | |
|---|---|---|---|
| 49a9ca473d | |||
| f3efec5c97 | |||
| ceb693778c | |||
| ae301db3bb |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -25,7 +25,3 @@ plans/
|
|||||||
|
|
||||||
# Host-local notes
|
# Host-local notes
|
||||||
local/
|
local/
|
||||||
|
|
||||||
# Ignore actual sounds in sounds/widgets/
|
|
||||||
sounds/widgets/*.ogg
|
|
||||||
sounds/widgets/**/*.ogg
|
|
||||||
@@ -10,7 +10,6 @@ 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", "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;'"]
|
CMD ["sh", "-c", "php-fpm83 && exec nginx -g 'daemon off;'"]
|
||||||
|
|||||||
@@ -95,27 +95,6 @@
|
|||||||
<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>
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -48,9 +48,6 @@ 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;
|
||||||
@@ -494,47 +491,6 @@ 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();
|
||||||
|
|||||||
@@ -1,91 +0,0 @@
|
|||||||
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 });
|
|
||||||
}
|
|
||||||
@@ -1,466 +0,0 @@
|
|||||||
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,9 +47,6 @@ 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;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -190,13 +187,9 @@ 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 = metadata;
|
const selectedMetadata = deps.getItemPropertyMetadata(item.type, selectedKey);
|
||||||
deps.state.nicknameInput =
|
deps.state.nicknameInput =
|
||||||
selectedKey === 'title'
|
selectedKey === 'title'
|
||||||
? item.title
|
? item.title
|
||||||
@@ -376,13 +369,6 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -397,19 +383,11 @@ 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);
|
||||||
@@ -424,7 +402,6 @@ 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 = [];
|
||||||
|
|||||||
@@ -1,265 +0,0 @@
|
|||||||
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,7 +31,6 @@ 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';
|
||||||
@@ -64,8 +63,6 @@ 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';
|
||||||
@@ -463,24 +460,6 @@ 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 {
|
||||||
@@ -990,14 +969,6 @@ 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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1022,70 +993,6 @@ 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;
|
||||||
@@ -1093,7 +1000,6 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1118,8 +1024,7 @@ 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'
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1680,8 +1585,6 @@ 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. */
|
||||||
@@ -2650,9 +2553,6 @@ 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. */
|
||||||
@@ -2726,22 +2626,6 @@ 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,
|
||||||
});
|
});
|
||||||
@@ -2795,25 +2679,6 @@ 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,11 +22,7 @@ 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;
|
||||||
@@ -262,21 +258,6 @@ 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', 'interact']),
|
action: z.enum(['add', 'pickup', 'drop', 'delete', 'transfer', 'use', 'secondary_use', 'update']),
|
||||||
message: z.string(),
|
message: z.string(),
|
||||||
itemId: z.string().optional(),
|
itemId: z.string().optional(),
|
||||||
});
|
});
|
||||||
@@ -415,7 +415,6 @@ 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,15 +51,7 @@ 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;
|
||||||
@@ -104,16 +96,6 @@ 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 {
|
||||||
@@ -150,16 +132,6 @@ 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,124 +234,3 @@ 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%;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
<?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,8 +35,6 @@ 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,7 +11,6 @@ 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,4 +26,3 @@ 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 +0,0 @@
|
|||||||
"""Card deck item type plugin package."""
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
"""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"},
|
|
||||||
)
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
"""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.",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
"""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,
|
|
||||||
),
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
"""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 +0,0 @@
|
|||||||
"""Card table item type plugin package."""
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
"""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.",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
"""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,
|
|
||||||
),
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
"""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,14 +6,7 @@ from types import SimpleNamespace
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
def build_item_module(
|
def build_item_module(definition: Any, *, validate_update: Any, use_item: Any, secondary_use_item: Any = None) -> Any:
|
||||||
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] = {
|
||||||
@@ -25,6 +18,4 @@ def build_item_module(
|
|||||||
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 +0,0 @@
|
|||||||
"""Whiteboard item type package."""
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
"""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},
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
"""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,
|
|
||||||
),
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
"""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,13 +176,6 @@ 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
|
||||||
@@ -211,7 +204,6 @@ ClientPacket = (
|
|||||||
| ItemTransferTargetsPacket
|
| ItemTransferTargetsPacket
|
||||||
| ItemUsePacket
|
| ItemUsePacket
|
||||||
| ItemSecondaryUsePacket
|
| ItemSecondaryUsePacket
|
||||||
| ItemInteractPacket
|
|
||||||
| ItemPianoNotePacket
|
| ItemPianoNotePacket
|
||||||
| ItemPianoRecordingPacket
|
| ItemPianoRecordingPacket
|
||||||
| ItemUpdatePacket
|
| ItemUpdatePacket
|
||||||
@@ -374,7 +366,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", "interact"]
|
action: Literal["add", "pickup", "drop", "delete", "transfer", "use", "secondary_use", "update"]
|
||||||
message: str
|
message: str
|
||||||
itemId: str | None = None
|
itemId: str | None = None
|
||||||
|
|
||||||
|
|||||||
@@ -80,7 +80,6 @@ from .models import (
|
|||||||
ItemClockAnnouncePacket,
|
ItemClockAnnouncePacket,
|
||||||
ItemDeletePacket,
|
ItemDeletePacket,
|
||||||
ItemDropPacket,
|
ItemDropPacket,
|
||||||
ItemInteractPacket,
|
|
||||||
ItemPianoNoteBroadcastPacket,
|
ItemPianoNoteBroadcastPacket,
|
||||||
ItemPianoNotePacket,
|
ItemPianoNotePacket,
|
||||||
ItemPianoRecordingPacket,
|
ItemPianoRecordingPacket,
|
||||||
@@ -2956,69 +2955,9 @@ 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
|
||||||
|
|||||||
Reference in New Issue
Block a user