Open-source interactive fiction engine, powered by React, Redux and TypeScript. _ https://discoteque.pub/
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

284 lines
8.0 KiB

import React from 'react';
import * as R from 'ramda';
import { useSelector, useDispatch } from 'react-redux';
import * as styles from './styles';
import { stateSelector as engineStateSelector, gameStateSelector, setState, initState, resetState } from '../../engine/lib/store';
import { IActor, ILine, ILineOption, ILocation, EngineState, Chrono, EngineConfig } from '../../engine/types';
import { toast } from 'react-toastify';
import SkillTree from '../../components/SkillTree';
import Image from './Image';
import Dropdown from '../Dropdown';
import Menu from '../Menu';
type TextLogProps = {
lines: string[];
}
type OptionsProps = {
options: ILineOption<any>[];
next: (opt?: ILineOption<any>) => void;
}
type RendererProps = {
backlog: ILine<any, any>[];
config: EngineConfig<any, any>;
next: (opt?: ILineOption<any>) => void;
location?: ILocation<any, any> | null;
time: Chrono | null;
}
export class TextLog extends React.Component<TextLogProps> {
private linesList: HTMLDivElement | null = null;
private scrollDown = () => {
if (this.linesList) this.linesList.scrollTo(0, this.linesList.scrollHeight)
};
componentDidMount() {
this.scrollDown();
}
componentDidUpdate() {
this.scrollDown();
}
render() {
const { lines } = this.props;
return (
<div className={styles.textContainer} ref={(e) => this.linesList = e }>
<ul className={styles.textList}>
{lines.map((line, index) => <li key={index}>{line}</li>)}
</ul>
</div>
)
}
}
const getDifficultyText = (difficulty: number) => {
if (difficulty === 0) {
return 'PASSED';
}
if (difficulty < 5) {
return 'Very Easy';
}
if (difficulty < 10) {
return 'Easy';
}
if (difficulty < 15) {
return 'Average';
}
if (difficulty < 20) {
return 'Tough';
}
if (difficulty < 25) {
return 'Challenging';
}
if (difficulty < 30) {
return 'Formidable';
}
if (difficulty < 40) {
return 'Heroic';
}
if (difficulty === 40) {
return 'Impossible';
}
}
const formatOptionSkill = (skill: string, difficulty: number) => {
const capitalizedSkill = skill.charAt(0).toUpperCase() + skill.slice(1);
const difficultyText = getDifficultyText(difficulty);
return `[${capitalizedSkill} ${!!difficulty ? difficulty : ''} - ${difficultyText}]`;
}
export const Options = ({ options, next }: OptionsProps) => {
const engineState = useSelector(engineStateSelector) as EngineState;
return (
<div className={styles.optionsContainer}>
{!!(options && options.length) && (
<div className={styles.optionsList}>
{options.map(option => (
<div
key={option.value}
onClick={() => next(option)}
>
{option.skill ? `${formatOptionSkill(option.skill.name, option.skill.difficulty)} ${option.text}` : option.text}
</div>
))}
</div>
)}
{!!(!options || !options.length) && (
<a className={styles.optionsNext} onClick={() => next()}>
{engineState.ui.isOver ? 'THE END' : 'Click to Advance...' }
</a>
)}
</div>
);
}
type SaveFile<GS = object> = {
engineState: EngineState;
gameState: GS;
};
const saveGame = <GS, >(engineState: EngineState, gameState: GS): void => {
const { nodeMap, backlog, ...cleanEngineState } = engineState;
const saveGame: Record<string, SaveFile<GS>> = {
'Save1': { engineState: {
...cleanEngineState,
backlog: R.takeLast(1000, backlog),
} as EngineState, gameState },
};
localStorage.setItem('save', JSON.stringify(saveGame));
toast.success('Saved Successfully!');
}
const loadGame = (slotName: string = 'Save1'): SaveFile | null => {
const localSaveStr = localStorage.getItem('save');
if (localSaveStr) {
const saves: Record<string, SaveFile> = JSON.parse(localSaveStr);
return R.propOr(null, slotName, saves);
}
return null;
}
const deleteSave = (slotName: string = 'Save1') => {
const localSaveStr = localStorage.getItem('save');
if (localSaveStr && localSaveStr !== '{}') {
const saves: Record<string, SaveFile> = JSON.parse(localSaveStr);
const newSaves =
Object.keys(saves)
.filter(key => key !== slotName)
.reduce((acc, key) => ({ ...acc, [key]: saves[key] }), {});
localStorage.setItem('save', JSON.stringify(newSaves));
} else {
toast.error('No Saves to delete :^(');
}
}
type ToolbarProps = {
location?: ILocation<any> | null;
time: Chrono | null;
};
const formatTime = (time: number): string => {
const hours = Math.floor(time / 60);
const hoursFormatted = hours > 9 ? hours : `0${hours}`;
const minutes = time % 60;
const minutesFormatted = minutes > 9 ? minutes : `0${minutes}`;
return `${hoursFormatted}:${minutesFormatted}`;
}
const Toolbar: React.FC<ToolbarProps> = ({ location, time }) => {
const dispatch = useDispatch();
const engineState = useSelector(engineStateSelector) as EngineState;
const gameState = useSelector(gameStateSelector) as any;
const doSave = () => {
saveGame(engineState, gameState);
return true;
}
const doLoad = () => {
const newStates = loadGame();
if (newStates) {
dispatch(initState(newStates));
toast.success('Loaded successfully!');
return true;
}
toast.error('Something went wrong during loading :^(');
return false;
}
const openSkillMenu = () => {
dispatch(setState({ ui: { skillsOpen: true } }));
}
const exitToMenu = () => {
window.location.reload();
}
return (
<div className={styles.toolbarContainer}>
<span className={styles.toolbarTools}>
<Dropdown label="Options">
{(toggle) => (
<div className={styles.toolbarButtons}>
<span onClick={openSkillMenu}>Skills</span>
<span className={styles.toolbarSpacer} />
<span onClick={() => doSave() && toggle()}>Save Game</span>
<span onClick={() => doLoad() && toggle()}>Load Game</span>
<span onClick={() => { deleteSave(); toggle(); }}>Delete Save</span>
<span onClick={() => { exitToMenu(); toggle(); }}>Exit</span>
</div>
)}
</Dropdown>
</span>
<span className={styles.toolbarStatus}>{location?.name || 'Nowhere'}</span>
{ time && (
<span className={styles.toolbarTime}>
{time.date ? `${time.date} - ` : ''}
{formatTime(time.time)}
</span>
)}
</div>
)
}
const formatName = (actor: IActor<any> | null) => {
if (actor) {
return `[${actor.name || 'NONAME'}]: `;
}
return ``;
}
const lineToStr = (line: ILine<any>): string => `${formatName(line.actor)}${line.text}`;
export const Renderer: React.FC<RendererProps> = ({ backlog = [], ...props }) => {
const dispatch = useDispatch();
const engineState = useSelector(engineStateSelector) as EngineState;
const next = (opt?: ILineOption<any>) => {
if (!engineState.ui.isOver) {
props.next(opt)
}
}
const startGame = () => {
dispatch(resetState(props.config.startNode));
dispatch(setState({ ui: { menuOpen: false, skillsOpen: true } }));
}
const doLoad = () => {
const newStates = loadGame();
if (newStates) {
dispatch(initState(newStates));
toast.success('Loaded successfully!');
return;
}
toast.error('Something went wrong during loading :^(');
return;
}
const line = R.last(backlog);
const hasOptions = line?.options?.length && line.options;
const image = line?.actor?.image;
const showGame = !engineState.ui.skillsOpen && !engineState.ui.menuOpen;
return (
<div
className={styles.rendererContainer}
>
{engineState.ui.menuOpen && (<Menu startGame={startGame} loadGame={doLoad} />)}
{engineState.ui.skillsOpen && (
<SkillTree skillDescriptions={props.config.skillDescriptoins} />
)}
{showGame && (<>
<Toolbar location={props.location} time={props.time} />
<Image src={image} />
<TextLog lines={backlog.map(lineToStr)} />
<Options options={hasOptions || []} next={next} />
</>)}
</div>
);
};