diff --git a/client/src/app.tsx b/client/src/app.tsx index 4a11ae9..9a611b2 100644 --- a/client/src/app.tsx +++ b/client/src/app.tsx @@ -18,9 +18,10 @@ import { PlayerPanel } from './player.js'; import { Statusbar } from './status.js'; import { StoreState, store } from './store.js'; import { WorldPanel } from './world.js'; +import { DetailDialog } from './details.js'; +import { PromptDialog } from './prompt.js'; import 'allotment/dist/style.css'; -import { DetailDialog } from './details.js'; import './main.css'; const useWebSocket = (useWebSocketModule as any).default; @@ -63,13 +64,18 @@ export function App(props: AppProps) { } function sendInput(input: string) { - const { playerCharacter: character, setActiveTurn } = store.getState(); + const { playerCharacter: character, setPromptEvent } = store.getState(); if (doesExist(character)) { sendMessage(JSON.stringify({ type: 'input', input })); - setActiveTurn(false); + setPromptEvent(undefined); } } + function skipPrompt() { + const { setPromptEvent } = store.getState(); + setPromptEvent(undefined); + } + function setName(name: string) { const { setClientName } = store.getState(); sendMessage(JSON.stringify({ type: 'player', name })); @@ -83,7 +89,7 @@ export function App(props: AppProps) { }); useEffect(() => { - const { setClientId, setActiveTurn, setPlayers, appendEvent, setTurn, setWorld, world, clientId, setPlayerCharacter: setCharacter } = store.getState(); + const { setClientId, setPromptEvent, setPlayers, appendEvent, setTurn, setWorld, world, clientId, setPlayerCharacter: setCharacter } = store.getState(); if (doesExist(lastMessage)) { const event = JSON.parse(lastMessage.data); @@ -95,8 +101,13 @@ export function App(props: AppProps) { return; case 'prompt': // prompts are broadcast to all players - // only notify the active player - setActiveTurn(event.client === clientId); + if (event.client === clientId) { + // only notify the active player + setPromptEvent(event); + } else { + // if it has moved on to another player, clear the prompt + setPromptEvent(undefined); + } break; case 'player': if (doesExist(world) && event.client === clientId) { @@ -155,13 +166,14 @@ export function App(props: AppProps) { return + {innerLayout( - + , diff --git a/client/src/details.tsx b/client/src/details.tsx index 50b10b3..349055c 100644 --- a/client/src/details.tsx +++ b/client/src/details.tsx @@ -127,6 +127,14 @@ export function WorldDetails(props: WorldDetailsProps) { Theme: {world.theme} + + Order: + +
    + {world.order.map((name) => ( +
  1. {name}
  2. + ))} +
; diff --git a/client/src/models.ts b/client/src/models.ts index 4d40236..5b9314c 100644 --- a/client/src/models.ts +++ b/client/src/models.ts @@ -53,7 +53,43 @@ export interface World { turn: number; } +// + +export interface StringParameter { + type: 'string'; + default?: string; + enum?: Array; +} + +export interface NumberParameter { + type: 'number'; + default?: string; + enum?: Array; +} + +export type Parameter = NumberParameter | StringParameter; + +export interface Action { + type: 'function'; + function: { + name: string; + description: string; + parameters: { + type: 'object'; + properties: Record; + }; + }; +} + // TODO: copy event types from server export interface GameEvent { type: string; } + +export interface PromptEvent { + type: 'prompt'; + prompt: string; + actions: Array; + character: Character; + room: Room; +} diff --git a/client/src/player.tsx b/client/src/player.tsx index ce7ba93..945acae 100644 --- a/client/src/player.tsx +++ b/client/src/player.tsx @@ -1,52 +1,27 @@ import { doesExist } from '@apextoaster/js-utils'; -import { Alert, Button, Card, CardContent, Stack, TextField, Typography } from '@mui/material'; -import React, { useState } from 'react'; +import { Alert, Card, CardContent, Stack, Typography } from '@mui/material'; +import React from 'react'; import { useStore } from 'zustand'; import { store, StoreState } from './store'; -export interface PlayerPanelProps { - sendInput: (input: string) => void; -} - export function playerStateSelector(s: StoreState) { return { character: s.playerCharacter, - activeTurn: s.activeTurn, + promptEvent: s.promptEvent, }; } -export function PlayerPanel(props: PlayerPanelProps) { +export function PlayerPanel() { const state = useStore(store, playerStateSelector); - const { character, activeTurn } = state; - const { sendInput } = props; - const [input, setInput] = useState(''); + const { character, promptEvent } = state; if (doesExist(character)) { return - {activeTurn && It's your turn!} + {doesExist(promptEvent) && It's your turn!} Playing as: {character.name} {character.backstory} - - setInput(event.target.value)} - onKeyDown={(event) => { - if (event.key === 'Enter') { - sendInput(input); - setInput(''); - } - }} - /> - - ; diff --git a/client/src/prompt.tsx b/client/src/prompt.tsx new file mode 100644 index 0000000..8bf3c00 --- /dev/null +++ b/client/src/prompt.tsx @@ -0,0 +1,245 @@ +import { Maybe, doesExist, mustDefault, mustExist } from '@apextoaster/js-utils'; +import { Alert, Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, Divider, MenuItem, Stack, Switch, TextField } from '@mui/material'; +import React from 'react'; +import { useStore } from 'zustand'; +import { World, NumberParameter, StringParameter, Parameter, Action } from './models'; +import { StoreState, store } from './store'; + +// region parameter components +export interface BooleanParameterProps { + name: string; + + setParameter: (value: boolean) => void; +} + +export function BooleanParameter(props: BooleanParameterProps) { + return ; +} + +export function EnumParameterItem(props: NumberParameterProps | StringParameterProps) { + const { name, parameter, setParameter } = props; + const enumValues = mustExist(parameter.enum); + const defaultValue = mustDefault(parameter.default, enumValues[0]); + + return setParameter(event.target.value)} + > + {enumValues.map((value) => {value})} + ; +} + +export interface NumberParameterProps { + name: string; + parameter: NumberParameter; + + setParameter: (value: number) => void; +} + +export function NumberParameterItem(props: NumberParameterProps) { + const { name, parameter, setParameter } = props; + + if (doesExist(parameter.enum)) { + return ; + } + + return setParameter(parseFloat(event.target.value))} + />; +} + +export interface StringParameterProps { + name: string; + parameter: StringParameter; + + setParameter: (value: string) => void; +} + +export function StringParameterItem(props: StringParameterProps) { + const { name, parameter, setParameter } = props; + + if (doesExist(parameter.enum)) { + return ; + } + + return setParameter(event.target.value)} + />; +} + +export interface UnknownParameterProps { + name: string; +} + +export function UnknownParameter(props: UnknownParameterProps) { + const { name } = props; + + return Unknown parameter type: {name}; +} +// endregion + +export const SIGNIFICANT_PARAMETERS = ['character', 'direction', 'item', 'room', 'target']; + +export function listCharacters(world: World) { + return world.rooms.flatMap((room) => room.characters); +} + +export function listItems(world: World) { + return world.rooms.flatMap((room) => room.items); +} + +export function listPortals(world: World) { + return world.rooms.flatMap((room) => room.portals); +} + +export function enumerateSignificantParameterValues(name: string, world: World) { + switch (name) { + case 'character': + return listCharacters(world).map((character) => character.name); + case 'direction': + return listPortals(world).map((portal) => portal.name); + case 'item': + return listItems(world).map((item) => item.name); + case 'room': + return world.rooms.map((room) => room.name); + case 'target': + { + const characters = listCharacters(world); + const items = listItems(world); + + return [ + ...characters.map((character) => character.name), + ...items.map((item) => item.name), + ]; + } + default: + return []; + } +} + +export function convertSignificantParameter(name: string, parameter: Parameter, world: Maybe): Parameter { + if (doesExist(world) && SIGNIFICANT_PARAMETERS.includes(name)) { + return { + ...parameter, + enum: enumerateSignificantParameterValues(name, world), + }; + } + + return parameter; +} + +export function selectWorld(state: StoreState) { + return { + world: state.world, + }; +} + +export function formatAction(action: string, parameters: Record) { + return `~${action}:${Object.entries(parameters).map(([name, value]) => `${name}=${value}`).join(',')}`; +} + +export interface PromptActionProps { + action: Action; + + setAction: (action: string) => void; +} + +export function PromptAction(props: PromptActionProps) { + const { action, setAction } = props; + const { world } = useStore(store, selectWorld); + + const [parameterValues, setParameterValues] = React.useState>({}); + + // create an input for each parameter + const inputs = Object.entries(action.function.parameters.properties).map(([name, parameter]) => { + const convertedParameter = convertSignificantParameter(name, parameter, world); + + switch (convertedParameter.type) { + case 'string': + return { + setParameterValues((old) => ({ ...old, [name]: value })); + }} />; + case 'number': + return { + setParameterValues((old) => ({ ...old, [name]: value })); + }} />; + default: + return ; + } + }); + + return + + + {inputs} + + ; +} + +export function selectPromptEvent(state: StoreState) { + return { + promptEvent: state.promptEvent, + }; +} + +export interface PromptDialogProps { + sendInput: (input: string) => void; + skipPrompt: () => void; +} + +export function PromptDialog(props: PromptDialogProps) { + const { sendInput, skipPrompt } = props; + const { promptEvent } = useStore(store, selectPromptEvent); + + const [input, setInput] = React.useState(''); + + // eslint-disable-next-line no-restricted-syntax + if (!doesExist(promptEvent)) { + return ; + } + + return + It's your turn, {promptEvent.character.name}! + + + {promptEvent.prompt} + + Select parameters first, then click the label to act. + {promptEvent.actions.map((action: Action) => )} + + + setInput(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + sendInput(input); + setInput(''); + } + }} + /> + + + + + + + + + ; +} diff --git a/client/src/store.ts b/client/src/store.ts index c3f4972..2f4defd 100644 --- a/client/src/store.ts +++ b/client/src/store.ts @@ -4,7 +4,7 @@ import { createStore, StateCreator } from 'zustand'; import { doesExist, Maybe } from '@apextoaster/js-utils'; import { PaletteMode } from '@mui/material'; import { ReadyState } from 'react-use-websocket'; -import { Character, GameEvent, Item, Portal, Room, World } from './models'; +import { Character, GameEvent, Item, Portal, PromptEvent, Room, World } from './models'; export type LayoutMode = 'horizontal' | 'vertical'; @@ -45,14 +45,15 @@ export interface WorldState { } export interface PlayerState { - activeTurn: boolean; playerCharacter: Maybe; + promptEvent: Maybe; // setters - setActiveTurn: (activeTurn: boolean) => void; setPlayerCharacter: (character: Maybe) => void; + setPromptEvent: (promptEvent: Maybe) => void; // misc helpers + isActive: () => boolean; isPlaying: () => boolean; } @@ -117,10 +118,15 @@ export function createWorldStore(): StateCreator { export function createPlayerStore(): StateCreator { return (set) => ({ - activeTurn: false, playerCharacter: undefined, - setActiveTurn: (activeTurn: boolean) => set({ activeTurn }), + promptEvent: undefined, setPlayerCharacter: (character: Maybe) => set({ playerCharacter: character }), + setPromptEvent(promptEvent) { + set({ promptEvent }); + }, + isActive() { + return doesExist(this.playerCharacter) && doesExist(this.promptEvent); + }, isPlaying() { return doesExist(this.playerCharacter); }, diff --git a/client/src/world.tsx b/client/src/world.tsx index c34e0b7..87e5afb 100644 --- a/client/src/world.tsx +++ b/client/src/world.tsx @@ -147,7 +147,7 @@ export function WorldPanel(props: BaseEntityItemProps) { - setDetailEntity(world)} /> + setDetailEntity(world)} /> {world.rooms.map((room) => )}