add a dialog for prompts with action menu
This commit is contained in:
parent
fc61cae3fc
commit
6431609d33
|
@ -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
|
||||
if (event.client === clientId) {
|
||||
// only notify the active player
|
||||
setActiveTurn(event.client === clientId);
|
||||
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 <ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<DetailDialog renderEntity={renderEntity} />
|
||||
<PromptDialog sendInput={sendInput} skipPrompt={skipPrompt} />
|
||||
<Container maxWidth='xl'>
|
||||
<Stack direction="column">
|
||||
<Statusbar setName={setName} />
|
||||
<Stack direction="row" spacing={2}>
|
||||
{innerLayout(
|
||||
<Stack direction="column" spacing={2} sx={{ minWidth: leftWidth }} className="scroll-history">
|
||||
<PlayerPanel sendInput={sendInput} />
|
||||
<PlayerPanel />
|
||||
<WorldPanel setPlayer={setPlayer} />
|
||||
</Stack>,
|
||||
<Stack direction="column" sx={{ minWidth: rightWidth }} className="scroll-history">
|
||||
|
|
|
@ -127,6 +127,14 @@ export function WorldDetails(props: WorldDetailsProps) {
|
|||
<Typography variant='body2'>
|
||||
Theme: {world.theme}
|
||||
</Typography>
|
||||
<Typography variant='body2'>
|
||||
Order:
|
||||
</Typography>
|
||||
<ol>
|
||||
{world.order.map((name) => (
|
||||
<li key={name}>{name}</li>
|
||||
))}
|
||||
</ol>
|
||||
<div id="graph" />
|
||||
</DialogContent>
|
||||
</Fragment>;
|
||||
|
|
|
@ -53,7 +53,43 @@ export interface World {
|
|||
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
|
||||
export interface GameEvent {
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface PromptEvent {
|
||||
type: 'prompt';
|
||||
prompt: string;
|
||||
actions: Array<Action>;
|
||||
character: Character;
|
||||
room: Room;
|
||||
}
|
||||
|
|
|
@ -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<string>('');
|
||||
const { character, promptEvent } = state;
|
||||
|
||||
if (doesExist(character)) {
|
||||
return <Card style={{ minHeight: '6vh', overflow: 'auto' }}>
|
||||
<CardContent>
|
||||
<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="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>
|
||||
</CardContent>
|
||||
</Card>;
|
||||
|
|
|
@ -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>;
|
||||
}
|
|
@ -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<Character>;
|
||||
promptEvent: Maybe<PromptEvent>;
|
||||
|
||||
// setters
|
||||
setActiveTurn: (activeTurn: boolean) => void;
|
||||
setPlayerCharacter: (character: Maybe<Character>) => void;
|
||||
setPromptEvent: (promptEvent: Maybe<PromptEvent>) => void;
|
||||
|
||||
// misc helpers
|
||||
isActive: () => boolean;
|
||||
isPlaying: () => boolean;
|
||||
}
|
||||
|
||||
|
@ -117,10 +118,15 @@ export function createWorldStore(): StateCreator<WorldState> {
|
|||
|
||||
export function createPlayerStore(): StateCreator<PlayerState> {
|
||||
return (set) => ({
|
||||
activeTurn: false,
|
||||
playerCharacter: undefined,
|
||||
setActiveTurn: (activeTurn: boolean) => set({ activeTurn }),
|
||||
promptEvent: undefined,
|
||||
setPlayerCharacter: (character: Maybe<Character>) => set({ playerCharacter: character }),
|
||||
setPromptEvent(promptEvent) {
|
||||
set({ promptEvent });
|
||||
},
|
||||
isActive() {
|
||||
return doesExist(this.playerCharacter) && doesExist(this.promptEvent);
|
||||
},
|
||||
isPlaying() {
|
||||
return doesExist(this.playerCharacter);
|
||||
},
|
||||
|
|
|
@ -147,7 +147,7 @@ export function WorldPanel(props: BaseEntityItemProps) {
|
|||
</Typography>
|
||||
<Divider />
|
||||
<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} />)}
|
||||
</SimpleTreeView>
|
||||
</Stack>
|
||||
|
|
Loading…
Reference in New Issue