1
0
Fork 0

add a dialog for prompts with action menu

This commit is contained in:
Sean Sube 2024-06-02 15:07:35 -05:00
parent fc61cae3fc
commit 6431609d33
Signed by: ssube
GPG Key ID: 3EED7B957D362AF1
7 changed files with 326 additions and 44 deletions

View File

@ -18,9 +18,10 @@ import { PlayerPanel } from './player.js';
import { Statusbar } from './status.js'; import { Statusbar } from './status.js';
import { StoreState, store } from './store.js'; import { StoreState, store } from './store.js';
import { WorldPanel } from './world.js'; import { WorldPanel } from './world.js';
import { DetailDialog } from './details.js';
import { PromptDialog } from './prompt.js';
import 'allotment/dist/style.css'; import 'allotment/dist/style.css';
import { DetailDialog } from './details.js';
import './main.css'; import './main.css';
const useWebSocket = (useWebSocketModule as any).default; const useWebSocket = (useWebSocketModule as any).default;
@ -63,13 +64,18 @@ export function App(props: AppProps) {
} }
function sendInput(input: string) { function sendInput(input: string) {
const { playerCharacter: character, setActiveTurn } = store.getState(); const { playerCharacter: character, setPromptEvent } = store.getState();
if (doesExist(character)) { if (doesExist(character)) {
sendMessage(JSON.stringify({ type: 'input', input })); sendMessage(JSON.stringify({ type: 'input', input }));
setActiveTurn(false); setPromptEvent(undefined);
} }
} }
function skipPrompt() {
const { setPromptEvent } = store.getState();
setPromptEvent(undefined);
}
function setName(name: string) { function setName(name: string) {
const { setClientName } = store.getState(); const { setClientName } = store.getState();
sendMessage(JSON.stringify({ type: 'player', name })); sendMessage(JSON.stringify({ type: 'player', name }));
@ -83,7 +89,7 @@ export function App(props: AppProps) {
}); });
useEffect(() => { 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)) { if (doesExist(lastMessage)) {
const event = JSON.parse(lastMessage.data); const event = JSON.parse(lastMessage.data);
@ -95,8 +101,13 @@ export function App(props: AppProps) {
return; return;
case 'prompt': case 'prompt':
// prompts are broadcast to all players // prompts are broadcast to all players
// only notify the active player if (event.client === clientId) {
setActiveTurn(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; break;
case 'player': case 'player':
if (doesExist(world) && event.client === clientId) { if (doesExist(world) && event.client === clientId) {
@ -155,13 +166,14 @@ export function App(props: AppProps) {
return <ThemeProvider theme={theme}> return <ThemeProvider theme={theme}>
<CssBaseline /> <CssBaseline />
<DetailDialog renderEntity={renderEntity} /> <DetailDialog renderEntity={renderEntity} />
<PromptDialog sendInput={sendInput} skipPrompt={skipPrompt} />
<Container maxWidth='xl'> <Container maxWidth='xl'>
<Stack direction="column"> <Stack direction="column">
<Statusbar setName={setName} /> <Statusbar setName={setName} />
<Stack direction="row" spacing={2}> <Stack direction="row" spacing={2}>
{innerLayout( {innerLayout(
<Stack direction="column" spacing={2} sx={{ minWidth: leftWidth }} className="scroll-history"> <Stack direction="column" spacing={2} sx={{ minWidth: leftWidth }} className="scroll-history">
<PlayerPanel sendInput={sendInput} /> <PlayerPanel />
<WorldPanel setPlayer={setPlayer} /> <WorldPanel setPlayer={setPlayer} />
</Stack>, </Stack>,
<Stack direction="column" sx={{ minWidth: rightWidth }} className="scroll-history"> <Stack direction="column" sx={{ minWidth: rightWidth }} className="scroll-history">

View File

@ -127,6 +127,14 @@ export function WorldDetails(props: WorldDetailsProps) {
<Typography variant='body2'> <Typography variant='body2'>
Theme: {world.theme} Theme: {world.theme}
</Typography> </Typography>
<Typography variant='body2'>
Order:
</Typography>
<ol>
{world.order.map((name) => (
<li key={name}>{name}</li>
))}
</ol>
<div id="graph" /> <div id="graph" />
</DialogContent> </DialogContent>
</Fragment>; </Fragment>;

View File

@ -53,7 +53,43 @@ export interface World {
turn: number; turn: number;
} }
//
export interface StringParameter {
type: 'string';
default?: string;
enum?: Array<string>;
}
export interface NumberParameter {
type: 'number';
default?: string;
enum?: Array<string>;
}
export type Parameter = NumberParameter | StringParameter;
export interface Action {
type: 'function';
function: {
name: string;
description: string;
parameters: {
type: 'object';
properties: Record<string, Parameter>;
};
};
}
// TODO: copy event types from server // TODO: copy event types from server
export interface GameEvent { export interface GameEvent {
type: string; type: string;
} }
export interface PromptEvent {
type: 'prompt';
prompt: string;
actions: Array<Action>;
character: Character;
room: Room;
}

View File

@ -1,52 +1,27 @@
import { doesExist } from '@apextoaster/js-utils'; import { doesExist } from '@apextoaster/js-utils';
import { Alert, Button, Card, CardContent, Stack, TextField, Typography } from '@mui/material'; import { Alert, Card, CardContent, Stack, Typography } from '@mui/material';
import React, { useState } from 'react'; import React from 'react';
import { useStore } from 'zustand'; import { useStore } from 'zustand';
import { store, StoreState } from './store'; import { store, StoreState } from './store';
export interface PlayerPanelProps {
sendInput: (input: string) => void;
}
export function playerStateSelector(s: StoreState) { export function playerStateSelector(s: StoreState) {
return { return {
character: s.playerCharacter, character: s.playerCharacter,
activeTurn: s.activeTurn, promptEvent: s.promptEvent,
}; };
} }
export function PlayerPanel(props: PlayerPanelProps) { export function PlayerPanel() {
const state = useStore(store, playerStateSelector); const state = useStore(store, playerStateSelector);
const { character, activeTurn } = state; const { character, promptEvent } = state;
const { sendInput } = props;
const [input, setInput] = useState<string>('');
if (doesExist(character)) { if (doesExist(character)) {
return <Card style={{ minHeight: '6vh', overflow: 'auto' }}> return <Card style={{ minHeight: '6vh', overflow: 'auto' }}>
<CardContent> <CardContent>
<Stack direction="column" spacing={2}> <Stack direction="column" spacing={2}>
{activeTurn && <Alert severity="warning">It's your turn!</Alert>} {doesExist(promptEvent) && <Alert severity="warning">It's your turn!</Alert>}
<Typography variant="h6">Playing as: {character.name}</Typography> <Typography variant="h6">Playing as: {character.name}</Typography>
<Typography variant="body1">{character.backstory}</Typography> <Typography variant="body1">{character.backstory}</Typography>
<Stack direction="row" spacing={2}>
<TextField
fullWidth
label="Input"
variant="outlined"
value={input}
onChange={(event) => setInput(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
sendInput(input);
setInput('');
}
}}
/>
<Button variant="contained" onClick={() => {
sendInput(input);
setInput('');
}}>Send</Button>
</Stack>
</Stack> </Stack>
</CardContent> </CardContent>
</Card>; </Card>;

245
client/src/prompt.tsx Normal file
View File

@ -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 <Switch />;
}
export function EnumParameterItem(props: NumberParameterProps | StringParameterProps) {
const { name, parameter, setParameter } = props;
const enumValues = mustExist(parameter.enum);
const defaultValue = mustDefault(parameter.default, enumValues[0]);
return <TextField
select
label={name}
variant="outlined"
defaultValue={defaultValue}
onChange={(event) => setParameter(event.target.value)}
>
{enumValues.map((value) => <MenuItem value={value}>{value}</MenuItem>)}
</TextField>;
}
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 <EnumParameterItem name={name} parameter={parameter} setParameter={setParameter} />;
}
return <TextField
label={name}
variant="outlined"
defaultValue={parameter.default}
onChange={(event) => 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 <EnumParameterItem name={name} parameter={parameter} setParameter={setParameter} />;
}
return <TextField
label={name}
variant="outlined"
defaultValue={parameter.default}
onChange={(event) => setParameter(event.target.value)}
/>;
}
export interface UnknownParameterProps {
name: string;
}
export function UnknownParameter(props: UnknownParameterProps) {
const { name } = props;
return <Alert severity="warning">Unknown parameter type: {name}</Alert>;
}
// 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<World>): 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<string, number | string>) {
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<Record<string, number | string>>({});
// 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 <StringParameterItem name={name} parameter={convertedParameter as StringParameter} setParameter={(value) => {
setParameterValues((old) => ({ ...old, [name]: value }));
}} />;
case 'number':
return <NumberParameterItem name={name} parameter={convertedParameter as NumberParameter} setParameter={(value) => {
setParameterValues((old) => ({ ...old, [name]: value }));
}} />;
default:
return <UnknownParameter name={name} />;
}
});
return <Stack direction='column' spacing={2}>
<Button onClick={() => setAction(formatAction(action.function.name, parameterValues))}>{action.function.description}</Button>
<Stack direction='row' spacing={2}>
{inputs}
</Stack>
</Stack>;
}
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<string>('');
// eslint-disable-next-line no-restricted-syntax
if (!doesExist(promptEvent)) {
return <Dialog open={false} />;
}
return <Dialog open={true}>
<DialogTitle>It's your turn, {promptEvent.character.name}!</DialogTitle>
<DialogContent>
<Stack direction="column" spacing={2}>
<DialogContentText>{promptEvent.prompt}</DialogContentText>
<Divider />
<DialogContentText>Select parameters first, then click the label to act.</DialogContentText>
{promptEvent.actions.map((action: Action) => <PromptAction action={action} setAction={setInput} />)}
<Divider />
<Stack direction="row" spacing={2}>
<TextField
fullWidth
label="Input"
variant="outlined"
value={input}
onChange={(event) => setInput(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
sendInput(input);
setInput('');
}
}}
/>
<Button variant="contained" onClick={() => {
sendInput(input);
setInput('');
}}>Enqueue</Button>
</Stack>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={skipPrompt}>Skip</Button>
<Button onClick={skipPrompt}>Leave</Button>
</DialogActions>
</Dialog>;
}

View File

@ -4,7 +4,7 @@ import { createStore, StateCreator } from 'zustand';
import { doesExist, Maybe } from '@apextoaster/js-utils'; import { doesExist, Maybe } from '@apextoaster/js-utils';
import { PaletteMode } from '@mui/material'; import { PaletteMode } from '@mui/material';
import { ReadyState } from 'react-use-websocket'; 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'; export type LayoutMode = 'horizontal' | 'vertical';
@ -45,14 +45,15 @@ export interface WorldState {
} }
export interface PlayerState { export interface PlayerState {
activeTurn: boolean;
playerCharacter: Maybe<Character>; playerCharacter: Maybe<Character>;
promptEvent: Maybe<PromptEvent>;
// setters // setters
setActiveTurn: (activeTurn: boolean) => void;
setPlayerCharacter: (character: Maybe<Character>) => void; setPlayerCharacter: (character: Maybe<Character>) => void;
setPromptEvent: (promptEvent: Maybe<PromptEvent>) => void;
// misc helpers // misc helpers
isActive: () => boolean;
isPlaying: () => boolean; isPlaying: () => boolean;
} }
@ -117,10 +118,15 @@ export function createWorldStore(): StateCreator<WorldState> {
export function createPlayerStore(): StateCreator<PlayerState> { export function createPlayerStore(): StateCreator<PlayerState> {
return (set) => ({ return (set) => ({
activeTurn: false,
playerCharacter: undefined, playerCharacter: undefined,
setActiveTurn: (activeTurn: boolean) => set({ activeTurn }), promptEvent: undefined,
setPlayerCharacter: (character: Maybe<Character>) => set({ playerCharacter: character }), setPlayerCharacter: (character: Maybe<Character>) => set({ playerCharacter: character }),
setPromptEvent(promptEvent) {
set({ promptEvent });
},
isActive() {
return doesExist(this.playerCharacter) && doesExist(this.promptEvent);
},
isPlaying() { isPlaying() {
return doesExist(this.playerCharacter); return doesExist(this.playerCharacter);
}, },

View File

@ -147,7 +147,7 @@ export function WorldPanel(props: BaseEntityItemProps) {
</Typography> </Typography>
<Divider /> <Divider />
<SimpleTreeView> <SimpleTreeView>
<TreeItem itemId="world-graph" label="Graph" onClick={() => setDetailEntity(world)} /> <TreeItem itemId="world-graph" label="Details" onClick={() => setDetailEntity(world)} />
{world.rooms.map((room) => <RoomItem key={room.name} room={room} setPlayer={setPlayer} />)} {world.rooms.map((room) => <RoomItem key={room.name} room={room} setPlayer={setPlayer} />)}
</SimpleTreeView> </SimpleTreeView>
</Stack> </Stack>