Browse Source

feat: Rework engine and renderer - stage 1

tags/v2.0.0
Dale 4 months ago
parent
commit
4228e7e8f6
Signed by: Deiru GPG Key ID: AA250C0277B927E1
43 changed files with 1253 additions and 1793 deletions
  1. +11
    -0
      .eslintrc.json
  2. +8
    -1
      package.json
  3. +1
    -2
      shell.nix
  4. BIN
     
  5. BIN
     
  6. BIN
     
  7. BIN
     
  8. +0
    -17
      src/assets/style-vars.ts
  9. +0
    -77
      src/components/App.tsx
  10. +0
    -32
      src/components/Dropdown.styles.ts
  11. +0
    -47
      src/components/Dropdown.tsx
  12. +0
    -58
      src/components/Menu.styles.ts
  13. +0
    -28
      src/components/Menu.tsx
  14. +0
    -27
      src/components/Playback.styles.ts
  15. +0
    -55
      src/components/Playback.tsx
  16. +0
    -34
      src/components/Renderer/Image.styles.ts
  17. +0
    -20
      src/components/Renderer/Image.tsx
  18. +0
    -296
      src/components/Renderer/index.tsx
  19. +0
    -265
      src/components/Renderer/styles.ts
  20. +0
    -158
      src/components/SkillTree.styles.ts
  21. +0
    -157
      src/components/SkillTree.tsx
  22. +0
    -4
      src/declarations.d.ts
  23. +159
    -153
      src/engine/Engine.ts
  24. +0
    -7
      src/engine/errors.ts
  25. +0
    -31
      src/engine/index.ts
  26. +0
    -215
      src/engine/lib/store.ts
  27. +74
    -0
      src/engine/store/actions.ts
  28. +47
    -0
      src/engine/store/reducer.ts
  29. +6
    -0
      src/engine/store/selectors.ts
  30. +44
    -0
      src/engine/store/types.ts
  31. +70
    -83
      src/engine/types.ts
  32. +0
    -12
      src/index.tsx
  33. +35
    -0
      src/playback/components/Renderer.tsx
  34. +26
    -0
      src/playback/index.tsx
  35. +15
    -0
      src/playback/theme/index.ts
  36. +5
    -0
      src/playback/theme/types.ts
  37. +7
    -0
      src/react.shim.d.ts
  38. +25
    -0
      src/test-game/game.ts
  39. +0
    -0
      src/test-game/index.html
  40. +19
    -0
      src/test-game/index.tsx
  41. +1
    -1
      tsconfig.json
  42. +54
    -0
      webpack.config.js
  43. +646
    -13
      yarn.lock

+ 11
- 0
.eslintrc.json View File

@@ -0,0 +1,11 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"plugins": [
"@typescript-eslint"
],
"extends": [ "airbnb-typescript" ],
"parserOptions": {
"project": "./tsconfig.json"
}
}

+ 8
- 1
package.json View File

@@ -19,10 +19,11 @@
},
"scripts": {
"build": "tsc",
"dev": "tsc --watch -p ."
"dev": "./node_modules/.bin/webpack-dev-server --config ./webpack.config.js"
},
"dependencies": {
"@emotion/core": "^10.0.35",
"@emotion/styled": "^10.0.27",
"classnames": "^2.2.6",
"debounce": "^1.2.0",
"emotion": "^10.0.27",
@@ -33,6 +34,7 @@
"react-redux": "^7.2.1",
"react-toastify": "^6.0.8",
"redux": "^4.0.5",
"redux-actions": "^2.6.5",
"styled-components": "^5.2.0"
},
"devDependencies": {
@@ -43,8 +45,13 @@
"@types/react-click-outside-hook": "^1.0.0",
"@types/react-dom": "^16.9.8",
"@types/react-redux": "^7.1.9",
"@types/redux-actions": "^2.6.1",
"@types/styled-components": "^5.1.3",
"@typescript-eslint/eslint-plugin": "^4.4.0",
"@typescript-eslint/parser": "^4.4.0",
"css-loader": "^4.2.2",
"eslint": "^7.11.0",
"eslint-config-airbnb-typescript": "^11.0.0",
"file-loader": "^6.1.0",
"html-webpack-plugin": "^4.4.1",
"style-loader": "^1.2.1",


+ 1
- 2
shell.nix View File

@@ -6,10 +6,9 @@ let
nodePkg = pkgs.nodejs-12_x;
yarnPkg = pkgs.yarn.override { nodejs = nodePkg; };
tslPkg = pkgs.nodePackages.typescript-language-server.override { nodejs = nodePkg; };
src = ./.;
in mkShell {
shellHook = ''
export PATH=$PATH:${src}/node_modules/.bin
export PATH=$PATH:$PWD/node_modules/.bin
'';
buildInputs = [
yarnPkg nodePkg tslPkg


BIN
View File


BIN
View File


BIN
View File


BIN
View File


+ 0
- 17
src/assets/style-vars.ts View File

@@ -1,17 +0,0 @@
export const MainBgColor = 'rgb(243, 223, 193)';
export const MainBgColorDark = 'rgb(144, 87, 18)';
export const MainTextColor = 'rgb(26, 28, 26)';
export const MainErrorColor = 'rgb(232, 91, 30)';
export const MainSuccessColor = 'rgb(123, 189, 70)';

export const colors = {
MainBgColor,
MainBgColorDark,
MainTextColor,
MainErrorColor,
MainSuccessColor,
};

export default {
colors,
};

+ 0
- 77
src/components/App.tsx View File

@@ -1,77 +0,0 @@
import React, { useMemo } from 'react';
import { Global, css } from '@emotion/core';
import styled from 'styled-components';

import { colors } from '../assets/style-vars'

import Playback from './Playback';
import { Renderer } from './Renderer';

import createEngine from '../engine';
import { Engine } from '../engine/Engine';
import { Provider } from 'react-redux';
import { ToastContainer } from 'react-toastify';
import { EngineConfig } from '../engine/types';

type AppProps<GS = object, MT = undefined, ST extends string = string> = {
config: EngineConfig<GS, MT, ST>
}

const StyledToastContainer = styled(ToastContainer)`
.Toastify__toast {
background: ${colors.MainBgColor};
color: ${colors.MainTextColor};
}

.Toastify__progress-bar--default {
background: ${colors.MainBgColorDark};
}

.Toastify__progress-bar--success {
background: ${colors.MainSuccessColor};
}

.Toastify__progress-bar--error {
background: ${colors.MainErrorColor};
}

.Toastify__close-button {
color: ${colors.MainTextColor};
}
`;

export default <GS, MT, ST extends string, >({ config }: AppProps<GS, MT, ST>) => {
const engine: Engine<GS, MT, ST> = useMemo(
() => createEngine<GS, MT, ST>(config, config.reducer),
[config],
)

return (
<>
<Global styles={css`
html, body, #root {
width: 100%;
height: 100%;
}
html, body {
margin: 0;
padding: 0;
}
body {
font-size: 1.5rem;
color: ${colors.MainTextColor};
background-color: ${colors.MainBgColor};
font-weight: 400;
}
`} />
<Provider store={engine.store}>
<Playback engine={engine} engineConfig={config}>
{(engine, config, backlog, location, time, next) => {
return (<Renderer engine={engine} backlog={backlog} next={next} location={location} time={time} config={config} />)
}}
</Playback>
</Provider>
<StyledToastContainer />
</>
);
}

+ 0
- 32
src/components/Dropdown.styles.ts View File

@@ -1,32 +0,0 @@
import { css, cx } from 'emotion';
import { colors } from '../assets/style-vars';

export const dropdownContainer = css`
label: dropdown-contianer;
position: relative;
`;

export const dropdownContent = css`
label: dropdown-content;
display: flex;
top: calc(100% + 5px);
position: absolute;
border: 2px solid ${colors.MainBgColorDark};
background-color: ${colors.MainBgColor};
box-sizing: border-box;
transition: all 0.3s ease-in;
padding: 5px;
opacity: 0;
z-index: 1;
`

export const activateContainer = css`
label: activate-contianer;
cursor: pointer;
`;

export const dropdownContentOpen = css`
label: dropdown-content;
opacity: 1;
transition: all 0.3s ease-in;
`;

+ 0
- 47
src/components/Dropdown.tsx View File

@@ -1,47 +0,0 @@
import React, { FC, useState } from 'react';

import cn from 'classnames';
import * as styles from './Dropdown.styles';
import { useClickOutside } from 'react-click-outside-hook';

type ActivateFn = (toggleDropdown: () => void, label?: string, isOpen?: boolean) => React.ReactNode;
type ChildrenFn = (toggleDropdown: () => void) => React.ReactNode;

type Props = {
label?: string;
activate?: ActivateFn;
children: ChildrenFn;
}

const defaultActivate: ActivateFn = (toggleDropdown, label = 'Dropdown', isOpen = false) => (
<span onClick={toggleDropdown}>{label}&nbsp;{isOpen ? '▲' : '▼'}</span>
);

const Dropdown: FC<Props> = ({ label = 'Dropdown', activate = defaultActivate, children }) => {
const [isOpen, setIsOpen] = useState(false);
const [ref, hasClickedOutside] = useClickOutside()

const toggle = () => setIsOpen(!isOpen);

if (hasClickedOutside && isOpen) {
setIsOpen(false);
}

return (
<div className={styles.dropdownContainer} ref={ref}>
<span className={styles.activateContainer}>
{activate(toggle, label, isOpen)}
</span>
<div className={cn(
styles.dropdownContent,
{
[styles.dropdownContentOpen]: isOpen,
},
)}>
{isOpen && children(toggle)}
</div>
</div>
)
}

export default Dropdown;

+ 0
- 58
src/components/Menu.styles.ts View File

@@ -1,58 +0,0 @@
import { css } from 'emotion';
import { colors } from '../assets/style-vars';

const menuItem = css`
font-weight: bold;
position: relative;
padding: 0.75em 15px 0.80em 15px;
border: none;
display: block;
width: 100%;
text-align: left;
font-family: inherit;
background-color: transparent;
&.start {
font-size: 1.5em;
}
&.load {
font-size: 1.17em;
}
&:hover {
color: ${colors.MainBgColor};
cursor: pointer;
}
&::after {
content: '';
height: 100%;
position: absolute;
background-color: ${colors.MainBgColorDark};
width: 0;
z-index: -1;
transition: width 0.1s ease-out;
top: 0;
left: 0;
}
&:hover::after {
width: 100%;
transition: width 0.3s ease-in;
}
`;

const menuTitle = css`
padding: 15px;
border-bottom: 2px dashed ${colors.MainBgColorDark};
& > h1 {
margin: 0;
}
& > h1 > small {
font-size: 1.4rem;
display: inline-block;
}
`;

export default {
menuItem,
menuTitle,
start: 'start',
load: 'load',
}

+ 0
- 28
src/components/Menu.tsx View File

@@ -1,28 +0,0 @@
import React, { FC } from 'react';

import cn from 'classnames';
import styles from './Menu.styles';
import { MenuConfig } from '../engine/types';

type Props = {
startGame: () => void;
loadGame: () => void;
menuConfig: MenuConfig;
}

const Menu: FC<Props> = ({ startGame, loadGame, menuConfig }) => (
<>
<div className={styles.menuTitle}>
<h1>
{menuConfig.title || 'Game'} <br />
{menuConfig.description && (
<small>{menuConfig.description}</small>
)}
</h1>
</div>
<button className={cn(styles.menuItem, styles.start)} onClick={startGame}>Start Game</button>
<button className={cn(styles.menuItem, styles.load)} onClick={loadGame}>Load Game</button>
</>
);

export default Menu;

+ 0
- 27
src/components/Playback.styles.ts View File

@@ -1,27 +0,0 @@
import { css } from 'emotion';
import { colors } from '../assets/style-vars';

const playbackContainer = css`
display: flex;
width: 100%;
height: 100%;
justify-content: center;
align-items: center;
flex-direction: column;
`;

const playbackRestart = css`
color: ${colors.MainBgColorDark};
cursor: pointer;
@media (min-width: 799px) {
margin-bottom: 50px;
}
&:hover {
text-decoration: underline;
}
`;

export default {
playbackContainer,
playbackRestart,
}

+ 0
- 55
src/components/Playback.tsx View File

@@ -1,55 +0,0 @@
import React from 'react';
import * as R from 'ramda';

import { Engine } from '../engine/Engine';

import styles from '../components/Playback.styles';
import { ILine, ILineOption, Chrono, ILocation, EngineConfig } from '../engine/types';
import { useSelector, useDispatch } from 'react-redux';
import { setState, gameStateSelector, stateSelector } from '../engine/lib/store';

type PlaybackRenderFn<GS = any, MT = undefined, ST extends string = string> = (
engine: Engine<GS, MT, ST>,
engineConfig: EngineConfig<GS, MT, ST>,
backlog: ILine<GS, MT>[],
location: ILocation<GS, MT> | null,
time: Chrono | null,
next: (opt?: ILineOption<GS>) => void,
) => React.ReactNode;

interface PlaybackProps<GS = any, MT = undefined, ST extends string = string> {
engine: Engine<GS, MT, ST>;
children: PlaybackRenderFn<GS, MT, ST>;
engineConfig: EngineConfig<GS, MT, ST>;
}

const restart = () => location.reload();

const Playback = <GS, MT, ST extends string, >({ engine, children, engineConfig }: PlaybackProps<GS, MT, ST>) => {
const dispatch = useDispatch();
// We have to subscribe to engine state as well or else it doesn't update
const engineState = useSelector(stateSelector) as any;
const state = useSelector(gameStateSelector) as any;

const next = (opt?: ILineOption<GS>) => {
if (!state.isOver) {
const nextLine = engine.next(state, opt)
if (nextLine) {
const backlog = engine.backlog;
dispatch(setState({ backlog: R.flatten([...backlog, ...nextLine].filter(R.identity)) }))
}
}
};

return state ? (
<div className={styles.playbackContainer}>
{children(
engine, engineConfig, engineState.backlog, engine.currentLocation as any,
engine.currentTime, next,
)}
{engineState.ui.isOver && <span onClick={restart} className={styles.playbackRestart}>Restart</span>}
</div>
) : <div>Something went wrong :^\(</div>;
}

export default Playback

+ 0
- 34
src/components/Renderer/Image.styles.ts View File

@@ -1,34 +0,0 @@
import { css, cx } from 'emotion';

export const imageContainer = css`
height: 10rem;
position: absolute;
box-sizing: border-box;
border-top-left-radius: 6px;
border-bottom-left-radius: 6px;
@media(max-width: 1099px) {
display: none;
}
& > div {
width: 100%;
height: 100%;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
}
`;

export const imageContainerHidden = css`
border: 0;
transition: all 0.2s ease-in;
width: 0;
left: -4px;
`;

export const imageContainerVisible = css`
transition: all 0.2s ease-out;
border: 4px solid black;
width: 8rem;
top: 1rem;
left: -8rem;
`;

+ 0
- 20
src/components/Renderer/Image.tsx View File

@@ -1,20 +0,0 @@
import React, { FC } from 'react';

import * as styles from './Image.styles';

import cn from 'classnames';

type Props = {
src: string | null | undefined;
}

const Image: FC<Props> = ({ src }) => (
<div className={cn([styles.imageContainer, {
[styles.imageContainerVisible]: !!src,
[styles.imageContainerHidden]: !src,
}])}>
{!!src && <div className={''} style={{ backgroundImage: `url('${src}')` }} />}
</div>
);

export default Image;

+ 0
- 296
src/components/Renderer/index.tsx View File

@@ -1,296 +0,0 @@
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, initEngineState, initState } from '../../engine/lib/store';
import { IActor, ILine, ILineOption, ILocation, EngineState, Chrono, EngineConfig, ToolbarOption } 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';
import { Engine } from 'discoteque/lib/engine/Engine';

type TextLogProps = {
lines: string[];
}

type OptionsProps = {
options: ILineOption<any>[];
next: (opt?: ILineOption<any>) => void;
}

type RendererProps = {
engine: Engine<any, any, any>;
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;
extraOptions: ToolbarOption[];
};

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, extraOptions }) => {
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 className={styles.toolbarButton} onClick={openSkillMenu}>Skills</span>
{extraOptions.map(option => <span className={styles.toolbarButton} onClick={option.onClick}>{option.label}</span>)}
<hr />
<span className={styles.toolbarButton} onClick={() => doSave() && toggle()}>Save Game</span>
<span className={styles.toolbarButton} onClick={() => doLoad() && toggle()}>Load Game</span>
<span className={styles.toolbarButton} onClick={() => { deleteSave(); toggle(); }}>Delete Save</span>
<span className={styles.toolbarButton} 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 = [], engine, config, ...props }) => {
const dispatch = useDispatch();
const engineState = useSelector(engineStateSelector) as EngineState;
const gameState = useSelector(gameStateSelector) as any;

const next = (opt?: ILineOption<any>) => {
if (!engineState.ui.isOver) {
props.next(opt)
}
}

const startGame = () => {
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
&& !engineState.ui.showCustom;

const extraOptions = config.customOptions
? config.customOptions(engine, config, gameState, dispatch)
: [];

return (
<div className={styles.rendererContainer}
>
{engineState.ui.menuOpen && (<Menu startGame={startGame} loadGame={doLoad} menuConfig={config.menu} />)}
{engineState.ui.skillsOpen && (
<SkillTree skillDescriptions={config.skillDescriptoins} />
)}
{config.customRenderer && engineState.ui.showCustom && config.customRenderer(
engine, config, gameState, dispatch,
)}
{showGame && (<>
<Toolbar location={props.location} time={props.time} extraOptions={extraOptions} />
<Image src={image} />
<TextLog lines={backlog.map(lineToStr)} />
<Options options={hasOptions || []} next={next} />
</>)}
</div>
);
};

+ 0
- 265
src/components/Renderer/styles.ts View File

@@ -1,265 +0,0 @@
import { css, keyframes } from 'emotion';
import { colors } from '../../assets/style-vars';

export const rendererContainer = css`
label: renderer-container;
width: 100%;
max-width: 800px;
max-height: 600px;
height: 100%;
display: flex;
flex-direction: column;
border-radius: 6px;
position: relative;
border: 4px solid ${colors.MainTextColor};
box-sizing: border-box;
@media(max-width: 799px) {
max-height: unset;
border-radius: 0;
}
`;

export const textContainer = css`
label: text-container;
display: flex;
height: 100%;
overflow-y: auto;
`;

const lineSlide = keyframes`
from {
width: 100%;
}
to {
width: 0;
}
`;

export const textList = css`
label: text-list;
list-style: none;
margin: 0;
padding-left: 0;
margin: 15px;
& > li {
position: relative;
&::before {
content: '>';
display: inline-block;
width: 40px;
text-align: center;
}
&::after {
content: '';
position: absolute;
bottom: 0;
right: 0;
height: 100%;
width: 100%;
background-color: ${colors.MainBgColor};
animation: ${lineSlide} 0.6s normal forwards linear;
animation-iteration-count: 1;
}
&:last-of-type {
margin-bottom: 15px;
}
}
`

export const optionsContainer = css`
label: options-container;
display: flex;
height: 200px;
overflow-y: auto;
align-items: center;
border-top: 2px dashed ${colors.MainBgColorDark};
padding: 0;
`

export const optionsList = css`
label: options-list;
margin: 0;
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
& > div {
width: 100%;
cursor: pointer;
position: relative;
transition: color 0.4s linear;
box-sizing: border-box;
border-bottom: 2px solid ${colors.MainBgColorDark};
padding: 5px 15px 5px 15px;
@media(max-width: 799px) {
padding-top: 15px;
padding-bottom: 15px;
}
&:last-of-type {
border-bottom: 0;
}
&:hover {
color: ${colors.MainBgColor};
}
&::before {
content: '';
background-color: ${colors.MainBgColorDark};
position: absolute;
top: 0;
left: 0;
display: inline-block;
z-index: -1;
width: 0;
height: 100%;
transition: width 0.4s linear;
}
&:hover::before {
width: 100%;
}
}
`;

export const optionsNext = css`
label: options-next;
height: 100%;
width: 100%;
display: flex;
color: ${colors.MainBgColorDark};
font-weight: bold;
letter-spacing: 10px;
text-transform: uppercase;
font-size: 2rem;
justify-content: center;
align-items: center;
cursor: pointer;
user-select: none;
transition: border 0.3s ease-in;
position: relative;
overflow: hidden;
text-align: center;
&::after {
content: "";
background: ${colors.MainBgColorDark};
display: block;
position: absolute;
padding-top: 300%;
padding-left: 350%;
margin-left: -20px!important;
margin-top: -120%;
opacity: 0;
transition: all 1s;
}
&:focus {
outline: none;
}
&:active::after {
padding: 0;
margin: 0;
opacity: 1;
transition: 0s
}
`;

export const toolbarContainer = css`
label: toolbar-contianer;
width: 100%;
border-bottom: 2px dashed ${colors.MainBgColorDark};
padding: 15px;
position: relative;
box-sizing: border-box;
justify-content: space-between;
display: flex;
min-height: 30px;
@media(max-width: 799px) {
min-height: initial;
flex-direction: column;
padding: 0px;
}
`;


export const toolbarButtons = css`
label: toolbar-buttons;
display: flex;
flex-direction: column;
& > hr {
display: block;
height: 35px;
background-color: ${colors.MainBgColorDark};
border: 2px dashed ${colors.MainBgColorDark};
box-sizing: border-box;
magrin: 10px 0 10px 0;
}
`;

export const toolbarButton = css`
label: toolbar-button;
display: inline-flex;
width: 100%;
transition: all 0.2s linear;
cursor: pointer;
border-bottom: 1px solid ${colors.MainBgColorDark};
&:last-of-type {
border-bottom: 0;
}
&:hover {
background-color: ${colors.MainBgColorDark};
color: ${colors.MainBgColor};
}
`

export const toolbarTools = css`
label: toolbar-tools;
align-self: flex-start;
border-right: 1px solid ${colors.MainBgColorDark};
margin-right: 5px;
padding-right: 2px;
height: 100%;
display: inline-flex;
align-items: center;
@media(max-width: 799px) {
box-sizing: border-box;
padding: 5px 15px 5px 15px;
width: 100%;
border-right: 0;
border-bottom: 1px solid ${colors.MainBgColorDark};
}
`;



export const toolbarStatus = css`
label: toolbar-status;
align-self: flex-end;
text-align: left;
border-right: 1px solid ${colors.MainBgColorDark};
margin-right: 5px;
padding-right: 2px;
height: 100%;
display: inline-flex;
align-items: center;
flex: 2;
@media(max-width: 799px) {
box-sizing: border-box;
padding: 5px 15px 5px 15px;
width: 100%;
border-right: 0;
border-bottom: 1px solid ${colors.MainBgColorDark};
align-self: initial;
}
`;

export const toolbarTime = css`
label: toolbar-time;
text-align: right;
height: 100%;
display: inline-flex;
align-items: center;
@media(max-width: 799px) {
box-sizing: border-box;
padding: 5px 15px 5px 15px;
width: 100%;
border-right: 0;
align-self: initial;
}
`;

+ 0
- 158
src/components/SkillTree.styles.ts View File

@@ -1,158 +0,0 @@
import { css } from 'emotion';
import { colors } from '../assets/style-vars';

export const skillList = css`
list-style: none;
padding: 0;
margin: 0;
height: 100%;
overflow-y: auto;
`;

export const skillTree = css`
display: flex;
flex-direction: column;
height: 100%;
`;

export const skillItem = css`
width: 100%;
display: flex;
justify-content: space-between;
font-size: 2.5rem;
padding: 10px 15px 10px 15px;
border-bottom: 1px solid black;
margin-bottom: 10px;
box-sizing: border-box;
flex-direction: column;
&:last-of-type {
margin-bottom: 0;
border-bottom: 0;
}
`;

export const skillDesc = css`
box-sizing: border-box;
font-size: 1.4rem;
transition: all 0.4s linear;
padding: 10px 0 0 0;
`;

export const skillDescClosed = css`
height: 0;
opacity: 0;
transition: all 0.4s linear;
`;

export const skillDescOpen = css`
height: 100%;
opacity: 1;
transition: all 0.4s linear;
`;

export const skillRow = css`
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
`;

export const skillButton = css`
font-size: 2.5rem;
width: 3.5rem;
background: transparent;
border: 1px solid black;
border-radius: 6px;
cursor: pointer;
color: ${colors.MainBgColorDark};
transition: all 0.3s ease-in;
&:hover {
background-color: ${colors.MainBgColorDark};
color: ${colors.MainBgColor};
transition: all 0.3s ease-out;
}
&.disabled {
cursor: default;
color: black;
border: none;
opacity: 0;
}
&:focus {
outline: none;
}
@media(max-width: 799px) {
font-size: 2rem;
width: 2.5rem;
}
`;

export const skillValue = css`
margin-left: 10px;
margin-right: 10px;
width: 2rem;
text-align: center;
display: inline-block;
color: ${colors.MainBgColorDark};
@media(max-width: 799px) {
font-size: 2rem;
margin: 0;
}
`;

export const skillHeader = css`
display: flex;
justify-content: space-between;
border-bottom: 2px dashed ${colors.MainBgColorDark};
padding: 15px;
margin-bottom: 0.67rem;
& > h1 {
margin: 0;
& > small {
font-size: 1.3rem;
display: inline-block;
}
}
& > div {
display: flex;
align-items: center;
font-size: 1.8rem;
}
}
`;

export const skillName = css`
text-transform: capitalize;
@media(max-width: 799px) {
font-size: 1.8rem;
}
`;

export const skillNameClick = css`
cursor: pointer;
color: ${colors.MainBgColorDark};
text-decoration: underline;
`;

export const skillConfirm = css`
font-size: 2.5rem;
letter-spacing: 10px;
background: transparent;
font-family: inherit;
font-weight: bold;
cursor: pointer;
border: none;
border-top: 2px dashed ${colors.MainBgColorDark};
width: 100%;
text-align: center;
color: $(colors.MainBgColorDark);
margin-top: auto;
padding: 15px;
transition: all 0.3s ease-in;
&:hover {
background-color: ${colors.MainBgColorDark};
color: ${colors.MainBgColor};
transition: all 0.3s ease-out;
}
`;

export const disabled = 'disabled';

+ 0
- 157
src/components/SkillTree.tsx View File

@@ -1,157 +0,0 @@
import React, { FC, useState, useMemo } from 'react';
import * as R from 'ramda';

import * as styles from './SkillTree.styles';
import { useDispatch, useSelector } from 'react-redux';
import { stateSelector, setSkill, setSkillpoints, setState, lockSkills, unlockSkills } from '../engine/lib/store';
import { EngineState } from '../engine/types';

import cn from 'classnames';

type Props = {
embeded?: boolean;
skillDescriptions?: Record<string, string>;
}

type minSkills<ST extends string> = [
Record<ST, number>,
(skills: Record<ST, number>) => void,
];

export const SkillTree: FC<Props> = <GS, MT, ST extends string>({
skillDescriptions = {}, embeded = false
}: Props) => {
const dispatch = useDispatch();
const engineState = useSelector(stateSelector) as unknown as EngineState<GS, MT, ST>;
const protectSkills = useSelector(R.pathOr(false, ['engine', 'protectSkills']));
const pointsLeft = engineState.skillPoints;
const skills: ST[] = Object.keys(engineState.skills) as ST[];

const [ minSkills, setMinSkills ]: minSkills<ST> = useState(
useMemo(() => {
if (protectSkills) {
return engineState.skills;
}
return {} as any;
}, [protectSkills])
);

const isNoSpend: boolean = useMemo(() => {
const result = R.all(
(skillName: ST) => {
return engineState.skills[skillName] === minSkills[skillName];
},
Object.keys(minSkills) as ST[],
);
return result && !pointsLeft;
}, [minSkills, pointsLeft, engineState]);

const [skillOpen, setSkillOpen] = useState<ST | null>(null);

const blockDecrement = (skill: ST) => {
const minSkill = R.propOr(0, skill, minSkills) as number;
return engineState.skills[skill] <= minSkill;
}

const incrementSkill = (skill: ST) => () => {
const oldValue = engineState.skills[skill];
const newValue = oldValue + 1;
if (pointsLeft) {
dispatch(setSkillpoints(pointsLeft - 1))
dispatch(setSkill(skill, newValue));
}
}

const decrementSkill = (skill: ST) => () => {
const oldValue = engineState.skills[skill];
const newValue = oldValue - 1;
const minSkill = R.propOr(0, skill, minSkills) as number;
if (newValue >= 1 && (newValue >= minSkill)) {
dispatch(setSkillpoints(pointsLeft + 1))
dispatch(setSkill(skill, newValue));
}
}

const exit = () => {
dispatch(setState({ ui: { skillsOpen: false } }))
}

const confirmSkills = () => {
const confirm = pointsLeft ? window.confirm('You have unspent skill poinst. Really confirm?') : true;
if (confirm) {
setMinSkills(engineState.skills);
if (!embeded) {
exit();
}
}
}

const toggleSkillOpen = (name: ST) => () => {
if (skillOpen === name) {
setSkillOpen(null);
} else {
setSkillOpen(name);
}
}

const getDescription = (name: ST): string | undefined => {
return skillDescriptions[name];
}

return (
<div className={styles.skillTree}>
<div className={styles.skillHeader}>
<h1>Skills <small>Allocate skill points</small></h1>
<div>Points left: {pointsLeft}</div>
</div>
<ul className={styles.skillList}>
{skills.map(skill => (
<li key={skill} className={styles.skillItem}>
<div className={styles.skillRow}>
<a
className={cn(styles.skillName, { [styles.skillNameClick]: !!getDescription(skill) })}
onClick={toggleSkillOpen(skill)}
>
{skill}
{!!getDescription(skill) && ' (?)'}
</a>
<div>
<button onClick={decrementSkill(skill)} className={cn(
styles.skillButton, {
[styles.disabled]: blockDecrement(skill) || (engineState.skills[skill] === 1),
}
)}>-</button>
<span className={styles.skillValue}>{engineState.skills[skill]}</span>
<button onClick={incrementSkill(skill)} className={cn(
styles.skillButton, {
[styles.disabled]: !pointsLeft,
}
)}>+</button>
</div>
</div>
{!!skillDescriptions[skill] && (
<div className={cn(styles.skillDesc, {
[styles.skillDescClosed]: skillOpen !== skill,
[styles.skillDescOpen]: skillOpen === skill,
})}>
{getDescription(skill)}
</div>
)}
</li>
))}
</ul>
{isNoSpend && !embeded && (
<button className={styles.skillConfirm} onClick={exit}>
EXIT
</button>
)}
{!isNoSpend && (
<button className={styles.skillConfirm} onClick={confirmSkills}>
DECIDE
</button>
)}
</div>
);
}

export default SkillTree;

+ 0
- 4
src/declarations.d.ts View File

@@ -1,4 +0,0 @@
declare module "*.css" {
const cssModules: Record<string, string>;
export default cssModules;
}

+ 159
- 153
src/engine/Engine.ts View File

@@ -1,160 +1,166 @@
import * as R from 'ramda';
import { ILineOption, EngineState, ILineRaw, ILine, INode, LineFn, IActor, ILocation, Chrono, EngineConfig } from './types';
import makeStore, * as actions from './lib/store';
import { Reducer, CombinedState, Store } from 'redux';

type EngineStore<GS = object, MT = undefined, ST extends string = string> = Store<CombinedState<{
engine: EngineState<GS, MT, ST>,
import { createStore, combineReducers, Store, CombinedState, AnyAction } from "redux";

import {
EngineConfig, LineRaw, Line,
Node, Actor, LineObject, Location,
EngineState, Option, isLineFn, LineFunction,
} from "@/engine/types";
import reducer from "@/engine/store/reducer";
import { pathOr, propOr, reduce } from "ramda";
import { changeNode, changeLine, changeLocation, expandBacklog } from "./store/actions";

type StoreType<GS extends object = object, ST extends string = string> =
Store<CombinedState<{
game: GS,
}>>;

const isLocation = <GS = object, MT = undefined>(node?: INode<GS, MT> | null): node is ILocation<GS, MT> =>
!!node && node.kind === 'location';

export class Engine<GS = object, MT = undefined, ST extends string = string> {
store: EngineStore<GS, MT, ST>;
config: EngineConfig<GS, MT, ST>;
public constructor(state: EngineState<GS, MT, ST>, gameReducer: Reducer, config: EngineConfig<GS, MT, ST>) {
this.config = config;
this.store = makeStore(gameReducer) as EngineStore<GS, MT, ST>;
this.store.dispatch(actions.initEngineState(state))
const firstLine = this.currentLine || this.next(this.gameState);
if (this.currentNode && this.currentNode?.kind === 'location') {
this.switchLocation(this.currentNode.id);
}
this.store.dispatch(actions.setState({ backlog: [...R.flatten(firstLine as unknown as ILine<GS, MT>[][])] }));
}
private get state(): EngineState<GS, MT, ST> {
return this.store.getState().engine;
}
public get gameState(): GS {
return this.store.getState().game;
}
public get currentLine(): ILine<GS, MT> | null {
const gameState = this.gameState;
const line = R.pathOr<ILineRaw<GS, MT> | LineFn<GS, MT> | null>(null, [this.state.node, 'lines', this.state.line], this.state.nodeMap);
if (line && typeof line === 'function') {
return this.augmentLine(line(this.state, gameState, this.store.dispatch, this.config))
}
return this.augmentLine(line);
}
public get currentNode(): INode<GS, MT> | null {
return R.propOr(null, this.state.node, this.state.nodeMap);
engine: EngineState<GS, ST>,
}>>
const defaultReducer = () => ({});
export default class Engine<GS extends object = object, ST extends string = string> {
public config: EngineConfig<GS, ST>;
public store: StoreType<GS, ST>;
public nodeMap: Record<string, Node<GS>>;
constructor(config: EngineConfig<GS, ST>) {
this.config = config;
this.nodeMap = reduce((acc, node) => ({
...acc,
[node.id]: node,
}), {}, config.nodes) as Record<string, Node<GS>>;
const w = window as any;
this.store = createStore(
combineReducers({
game: config.reducer || defaultReducer,
engine: reducer
}),
w.__REDUX_DEVTOOLS_EXTENSION__ && w.__REDUX_DEVTOOLS_EXTENSION__(),
);
}
public dispatch(action: AnyAction) {
this.store.dispatch(action);
}
public checkSkill(name: ST, difficulty: number): boolean {
const dice = Math.floor(Math.random() * 20);
return pathOr(0, ["skills", name], this.state.skills) + dice >= difficulty;
}
public next(option?: Option<ST>): Line<GS, ST> {
if (this.state.ui.gameOver) {
return this.currentLine as Line<GS, ST>
}
public get currentLocation(): ILocation<GS> | null {
return R.propOr(null, this.state.location || '', this.state.nodeMap);
const nextLine = this.advanceLine(option);
if (nextLine) {
this.dispatch(expandBacklog(nextLine));
}

public get currentTime(): Chrono | null {
return R.propOr(null, 'chrono', this.state);
return nextLine ? nextLine : this.next(option);
}

private advanceLine(option?: Option<ST>): Line<GS, ST> | null {
if (option) {
const pass = option.skill
? this.checkSkill(option.skill.name, option.skill.difficulty)
: true;
const nextNode = !pass && option.skill?.failTo ? option.skill.failTo : option.value;
return this.changeNode(nextNode);
}

public get backlog(): ILine<GS, MT>[] {
return R.propOr([], 'backlog', this.state);
const currentNode = this.currentNode;
const currentLine = this.state.line;
if (currentNode && currentLine !== undefined) {
const nextLineIndex = currentLine + 1;
if (nextLineIndex < (currentNode.lines || []).length) {
return this.changeLine(currentNode.id, nextLineIndex);
} else if (currentNode.next) {
return this.changeNode(currentNode.next);
}
} else if (!currentNode || !currentLine) {
return this.changeNode(this.config.startAt);
}

public rollSkill(skill: string, difficulty: number) {
const roll = Math.floor(Math.random() * (20 - 1 + 1)) + 1; // roll 1d20
const skillLevel = R.pathOr(1, ['skills', skill], this.state);
return (roll + skillLevel) > difficulty;
}

private switchNode(node: string, gameState: GS): ILine<GS, MT>[] | null {
const nextNode = this.getNode(node);
const nextLine = nextNode?.lines[0] || null;
this.store.dispatch(actions.changeNode(node));
if (isLocation(nextNode)) {
this.switchLocation(node);
}
if (typeof nextLine === 'function') {
const result = this.augmentLine(nextLine(this.state, gameState, this.store.dispatch, this.config));
const defaultTime = result?.actor ? 1 : 0;
this.store.dispatch(actions.advanceTime(result?.time || defaultTime));
const line = result
? [result]
: this.next(gameState);
return line;
}
const result = this.augmentLine(nextLine);
const defaultTime = result?.actor ? 1 : 0;
this.store.dispatch(actions.advanceTime(result?.time || defaultTime));
const line = result
? [result]
: this.next(gameState);
return line;
}

private switchLine(line: number, gameState: GS): ILine<GS, MT>[] | null {
this.store.dispatch(actions.advanceLine(line));
const nextLine = this.currentNode?.lines[line];
const chrono = this.currentTime;
if (chrono && chrono.time === 1440 && this.config.onDayCycle) {
const newChrono = this.config.onDayCycle(chrono);
this.store.dispatch(actions.setChrono(newChrono));
}
if (typeof nextLine === 'function') {
const line = this.augmentLine(nextLine(this.state, gameState, this.store.dispatch, this.config));
const defaultTime = line?.actor ? 1 : 0;
this.store.dispatch(actions.advanceTime(line?.time || defaultTime));
return line ? [line] : this.next(gameState);
} else {
const line = this.augmentLine(nextLine);
const defaultTime = line?.actor ? 1 : 0;
this.store.dispatch(actions.advanceTime(line?.time || defaultTime));
return line ? [line] : this.next(gameState);
}
}

private switchLocation(node: string) {
this.store.dispatch(actions.changeLocation(node));
}

public next(gameState: GS, option?: ILineOption<GS>): ILine<GS, MT>[] | null {
if (!option) {
const nextLine = this.currentNode && this.currentNode.lines.length > this.state.line + 1
? this.state.line + 1
: null;
if (this.currentNode && nextLine) {
return this.switchLine(nextLine, gameState);
} else if (this.currentNode && !nextLine && this.currentNode.next) {
return this.switchNode(this.currentNode.next, gameState);
}
} else {
const checkResult = option.skill
? this.rollSkill(option.skill.name, option.skill.difficulty)
: true;
if (checkResult) {
return [
{ text: `${option.text}`, actor: null },
...(this.switchNode(option.value, gameState) || []),
];
}
if (option.skill?.failTo) {
return [
{ text: `${option.text}`, actor: null },
...(this.switchNode(option.skill.failTo, gameState) || []),
];
}
}
return null;
};

private getNode(node: string = ''): INode<GS, MT> | null {
return R.propOr(null, node, this.state.nodeMap);
}

private augmentLine(line: void | ILineRaw<GS, MT> | null): ILine<GS, MT> | null {
if (line) {
const actor = this.getNode(line.actorId) as IActor<GS, MT> | null;
return { ...line, actor };
} if (line === undefined) {
return null;
}
return line;
return null;
}

private get state(): EngineState<GS, ST> {
return this.store.getState().engine;
}

private get gameState(): GS {
return this.store.getState().game;
}

public get currentNode() {
return this.state.node
? this.getNode(this.state.node)
: null;
}

public get currentLine() {
return this.state.node && this.state.line
? this.getLine(this.state.node, this.state.line)
: null;
}

public get currentLocation() {
return this.state.location
? this.getLocation(this.state.location)
: null;
}

public getNode(id: string): Node<GS> | null {
return propOr(null, id, this.nodeMap);
}

public getActor(id: string): Actor<GS> | null {
return propOr(null, id, this.nodeMap);
};

public getLocation(id: string): Location<GS> | null {
return propOr(null, id, this.nodeMap);
}

private getLine(node: string, line: number): Line<GS, ST> | null {
const lineRaw: LineRaw<GS, ST> | null = pathOr(null, [node, "lines", line], this.nodeMap);
if (lineRaw) {
if (isLineFn(lineRaw)) {
const lineFn = lineRaw as LineFunction<GS, ST>;
const lineObj = this.augmentLine(
lineFn(this.state, this.gameState, this.store.dispatch)
);
return lineObj || null;
} else {
return this.augmentLine(lineRaw as LineObject) || null;
}
}
return null;
}

private augmentLine(lineObj?: LineObject | null): Line<GS> | null {
const actor = lineObj?.actorId ? this.getActor(lineObj.actorId) : undefined;
return lineObj ? {
...(lineObj as LineObject),
actor,
} : null;
}

private changeNode(node: string): Line<GS, ST> | null {
this.dispatch(changeNode(node));
this.dispatch(changeLine(0));
const nextNode = this.getNode(node);
if (nextNode && nextNode?.kind === 'location') this.changeLocation(nextNode.id);
const nextLine = this.getLine(node, 0);
return nextNode && nextLine ? nextLine : null;
}

private changeLine(node: string, line: number): Line<GS, ST> | null {
this.dispatch(changeNode(node));
this.dispatch(changeLine(line));

const nextLine = this.getLine(node, line);
return nextLine;
}

private changeLocation(node: string): void {
this.dispatch(changeLocation(node));
}
}

+ 0
- 7
src/engine/errors.ts View File

@@ -1,7 +0,0 @@
export class EngineError extends Error {
public message: string;
public constructor(message: string) {
super();
this.message = message;
}
}

+ 0
- 31
src/engine/index.ts View File

@@ -1,31 +0,0 @@
import * as R from 'ramda';
import { EngineConfig, NodeMap, EngineState, DayCycleFn } from './types';
import { Engine } from './Engine';
import { Reducer } from 'redux';
import { toast as toastify } from 'react-toastify';

const defaultDayCycleFn: DayCycleFn = R.identity;

export const toast = toastify;

export default <GS = object, MT = undefined, ST extends string = string>(config: EngineConfig<GS, MT>, reducer: Reducer) => {
const nodeMap: NodeMap<GS, MT> = R.indexBy(R.prop('id'), config.nodes);
const state: EngineState<GS, MT, ST> = {
nodeMap,
line: 0,
node: config.startNode,
skills: config.skills,
skillPoints: config.skillPointsOnStart,
chrono: config.chrono,
protectSkills: false,
backlog: [],
ui: {
showCustom: false,
skillsOpen: false,
menuOpen: true,
isOver: false,
}
}
const supplyConfig = R.merge(config, { onDayCycle: defaultDayCycleFn });
return new Engine<GS, MT, ST>(state, reducer, supplyConfig);
}

+ 0
- 215
src/engine/lib/store.ts View File

@@ -1,215 +0,0 @@
import * as R from 'ramda';
import { createStore, Reducer, combineReducers } from 'redux';
import { EngineState, Chrono } from 'discoteque/lib/engine/types';

type INIT = 'INIT';
type ENGINE_INIT = 'INIT-ENGINE';
type ENGINE_RESET = 'RESET';
type ENGINE_LOAD = 'LOAD-ENGINE';
type ENGINE_SET_STATE = 'SET-STATE';
type ENGINE_ADVANCE_LINE = 'ADVANCE-LINE';
type ENGINE_ADVANCE_TIME = 'ADVANCE-TIME';
type ENGINE_CHANGE_NODE = 'CHANGE-NODE';
type ENGINE_CHANGE_LOCATION = 'CHANGE-LOCATION';
type ENGINE_SET_SKILL = 'SET-SKILL';
type ENGINE_SET_SKILLPOINTS = 'SET-SKILLPOINTS';
type ENGINE_SET_CHRONO = 'SET-CHRONO';
type ENGINE_LOCK_SKILLS = 'LOCK-SKILLS';
type ENGINE_UNLOCK_SKILLS = 'UNLOCK-SKILLS';
type ENGINE_SET_CUSTOM = 'SHOW-CUSTOM';

type EngineActionType =
INIT
| ENGINE_INIT
| ENGINE_RESET
| ENGINE_ADVANCE_LINE
| ENGINE_CHANGE_NODE
| ENGINE_CHANGE_LOCATION
| ENGINE_SET_SKILL
| ENGINE_SET_SKILLPOINTS
| ENGINE_LOAD
| ENGINE_ADVANCE_TIME
| ENGINE_SET_CHRONO
| ENGINE_LOCK_SKILLS
| ENGINE_UNLOCK_SKILLS
| ENGINE_SET_STATE
| ENGINE_SET_CUSTOM;

interface IEngineAdvanceLine {
line: number;
}

interface IEngineChangeNode {
node: string;
}

interface IEngineChangeLocaiton {
location: string;
}

interface IEngineSetSkill {
skill: string,
value: number,
}

interface IEngineSetState {
state: any,
}

interface IEngineInitAction<GS> extends EngineState<GS> {};

type EngineActionData<GS = object> =
IEngineAdvanceLine
| IEngineChangeNode
| IEngineChangeLocaiton
| IEngineInitAction<GS>
| IEngineSetSkill
| IEngineSetState
| Chrono
| boolean
| string
| number
| null;

export interface IEngineStateAction<GS = object, MT = undefined, ST extends string = string> {
type: EngineActionType;
data?: EngineActionData<GS>;
}

export const initEngineState = <GS, MT, ST extends string = string>(state: EngineState<GS, MT, ST>): IEngineStateAction<GS> =>
({ type: 'INIT-ENGINE', data: state });
export const resetState = <GS, MT, ST extends string = string>(startNode: string): IEngineStateAction<GS, MT, ST> =>
({ type: 'RESET', data: startNode })
export const setState = <GS, MT, ST extends string = string>(state: any): IEngineStateAction<GS, MT, ST> =>
({ type: 'SET-STATE', data: state });
export const loadEngine = <GS, MT, ST extends string = string>(state: EngineState<GS, MT, ST>): IEngineStateAction<GS> =>
({ type: 'LOAD-ENGINE', data: state })
export const advanceLine = <GS, MT, ST extends string = string>(line: number): IEngineStateAction<GS, MT, ST> =>
({ type: 'ADVANCE-LINE', data: { line } });
export const advanceTime = <GS, MT, ST extends string = string>(time: number): IEngineStateAction<GS, MT, ST> =>
({ type: 'ADVANCE-TIME', data: time });
export const setChrono = <GS, MT, ST extends string = string>(chrono: Chrono | undefined): IEngineStateAction<GS, MT, ST> =>
({ type: 'SET-CHRONO', data: chrono });
export const changeNode = <GS, MT, ST extends string = string>(node: string): IEngineStateAction<GS, MT, ST> =>
({ type: 'CHANGE-NODE', data: { node } });
export const changeLocation = <GS, MT, ST extends string = string>(location: string): IEngineStateAction<GS, MT, ST> =>
({ type: 'CHANGE-LOCATION', data: { location } });
export const setSkill = <GS, MT, ST extends string = string>(skill: string, value: number): IEngineStateAction<GS, MT, ST> =>
({ type: 'SET-SKILL', data: { skill, value } });
export const setSkillpoints = <GS, MT, ST extends string = string>(value: number): IEngineStateAction<GS, MT, ST> =>
({ type: 'SET-SKILLPOINTS', data: value });
export const lockSkills = <GS, MT, ST extends string = string>(): IEngineStateAction<GS, MT, ST> =>
({ type: 'LOCK-SKILLS' });
export const unlockSkills = <GS, MT, ST extends string = string>(): IEngineStateAction<GS, MT, ST> =>
({ type: 'UNLOCK-SKILLS' });
export const setShowCustom = <GS, MT, ST extends string = string>(data: boolean): IEngineStateAction<GS, MT, ST> =>
({ type: 'SHOW-CUSTOM', data });

type InitStateData<GS = object> = {
engineState: EngineState,
gameState: GS,
};
export const initState = <GS = object>(data: InitStateData<GS>) =>
({ type: 'INIT', data });
export const setSkillMenu = (value: boolean) => ({
type: 'SET-STATE',
data: {
ui: { skillsOpen: value },
}
});

const getDefaultState = (enchanceState: Partial<EngineState> = {}): EngineState => R.mergeDeepRight({
nodeMap: {},
line: 0,
node: '',
skills: {},
backlog: [],
skillPoints: 0,
protectSkills: false,
ui: {
showCustom: false,
isOver: false,
skillsOpen: false,
menuOpen: true,
}
}, enchanceState) as EngineState;

const reducer = (state = getDefaultState(), action: IEngineStateAction): EngineState => {
if (action.type === 'INIT-ENGINE') {
return action.data as EngineState;
} else if (action.type === 'INIT') {
return {
...(action.data as unknown as InitStateData).engineState,
nodeMap: state.nodeMap,
};
} else if (action.type === 'RESET') {
const newState = {
...getDefaultState(),
line: 0,
node: action.data as string,
nodeMap: state.nodeMap,
skills: Object.keys(state.skills).reduce((acc, key) => ({ ...acc, [key]: 5 }), {}),
skillPoints: state.skillPoints,
};
return newState;
} else if (action.type === 'LOAD-ENGINE') {
const { nodeMap, ...newState } = action.data as EngineState;
return {
...state,
...newState,
};
} else if (action.type === 'SET-STATE') {
return R.mergeDeepRight(state, action?.data as object ||{});
} else if (action.type === 'ADVANCE-LINE') {
return { ...state, ...action.data as object };
} else if (action.type === 'SET-CHRONO') {
return { ...state, chrono: action.data as Chrono | undefined }
} else if (action.type === 'ADVANCE-TIME') {
const newTime = (state?.chrono?.time || 0) + (action.data as number);
return { ...state, chrono: {
...state?.chrono,
time: newTime <= 1440 ? newTime : 1,
} }
} else if (action.type === 'CHANGE-NODE') {
return { ...state, ...action.data as object, line: 0 };
} else if (action.type === 'CHANGE-LOCATION') {
return { ...state, ...action.data as object };
} else if (action.type === 'SET-SKILL') {
const data = action.data as IEngineSetSkill;
return { ...state, skills: { ...state.skills, [data.skill]: data.value } }
} else if (action.type === 'SET-SKILLPOINTS') {
const data = action.data as number;
return { ...state, skillPoints: data }
} else if (action.type === 'LOCK-SKILLS') {
return { ...state, protectSkills: true };
} else if (action.type === 'UNLOCK-SKILLS') {
return { ...state, protectSkills: false };
} else if (action.type === 'SHOW-CUSTOM') {
return {
...state,
ui: {
...state.ui,
showCustom: action.data as boolean,
}
};
}
return state;
}

export const stateSelector: <GS = object, MT = undefined, ST extends string = string>(state: object) => EngineState<GS, MT, ST> =
R.propOr(getDefaultState(), 'engine');

export const gameStateSelector: <GS = object>(state: object) => GS =
R.propOr(getDefaultState(), 'game');

function makeStore<T>(gameReducer: Reducer): T {
return createStore(
combineReducers({
engine: reducer,
game: gameReducer
}),
(window as any).__REDUX_DEVTOOLS_EXTENSION__ && (window as any).__REDUX_DEVTOOLS_EXTENSION__(),
);
}

export default makeStore;

+ 74
- 0
src/engine/store/actions.ts View File

@@ -0,0 +1,74 @@
import { ENGINE_ACTION, actionTypes } from '@/engine/store/types';
import { EngineState, Chrono, EngineUIState, Line } from '@/engine/types';
import { Action } from 'redux';

export type SetSkillPayload = {
name: string;
value: number;
}

export type EngineActionPayload =
Chrono | EngineState | Partial<EngineState> | number | string | SetSkillPayload | Partial<EngineUIState> | Line<any, any>;

export interface EngineAction extends Action<ENGINE_ACTION> {
payload: EngineActionPayload;
}

export const init = (payload: EngineState): EngineAction => ({
type: actionTypes.ENGINE_INIT,
payload,
});

export const restore = (payload: EngineState): EngineAction => ({
type: actionTypes.ENGINE_RESTORE,
payload,
});

export const patchState = (payload: Partial<EngineState>) => ({
type: actionTypes.ENGINE_PATCH_STATE,
payload,
});

export const changeLine = (payload: number): EngineAction => ({
type: actionTypes.ENGINE_CHANGE_LINE,
payload,
});

export const changeNode = (payload: string): EngineAction => ({
type: actionTypes.ENGINE_CHANGE_NODE,
payload,
});

export const changeLocation = (payload: string): EngineAction => ({
type: actionTypes.ENGINE_CHANGE_LOCATION,
payload,
});

export const setChrono = (payload: Chrono): EngineAction => ({
type: actionTypes.ENGINE_SET_CHRONO,
payload,
});

export const setSkill = (name: string, value: number): EngineAction => ({
type: actionTypes.ENGINE_SET_SKILL,
payload: { name, value },
});

export const setSkillPoints = (payload: number): EngineAction => ({
type: actionTypes.ENGINE_SET_SKILL_POINTS,
payload,
});

export const setUI = (payload: Partial<EngineUIState>): EngineAction => ({
type: actionTypes.ENGINE_SET_UI,
payload,
});

export const expandBacklog = (payload: Line<any, any>): EngineAction => ({
type: actionTypes.ENGINE_EXPAND_BACKLOG,
payload,
});

export const endGame = (): Action => ({
type: actionTypes.ENGINE_END_GAME,
});

+ 47
- 0
src/engine/store/reducer.ts View File

@@ -0,0 +1,47 @@
import { handleActions } from "redux-actions";
import { mergeDeepRight, append } from "ramda";

import { EngineState, EngineUIState, Line } from "@/engine/types";
import { actionTypes } from "@/engine/store/types";
import { EngineActionPayload, SetSkillPayload } from "@/engine/store/actions";

const reducer = handleActions<EngineState, EngineActionPayload>({
[actionTypes.ENGINE_INIT]: (_, action) => action.payload as EngineState,
[actionTypes.ENGINE_RESTORE]: (_, action) => action.payload as EngineState,
[actionTypes.ENGINE_PATCH_STATE]: (state, action) => mergeDeepRight(state, action.payload as Partial<EngineState>) as EngineState,
[actionTypes.ENGINE_CHANGE_LINE]: (state, action) => ({ ...state, line: action.payload as number }) as EngineState,
[actionTypes.ENGINE_CHANGE_NODE]: (state, action) => ({ ...state, node: action.payload as string }) as EngineState,
[actionTypes.ENGINE_CHANGE_LOCATION]: (state, action) => ({ ...state, location: action.payload as string }) as EngineState,
[actionTypes.ENGINE_SET_SKILL_POINTS]: (state, action) => ({ ...state, skillPoints: action.payload as number }) as EngineState,
[actionTypes.ENGINE_SET_SKILL]: (state, action) => {
const { name, value } = action.payload as SetSkillPayload;
return mergeDeepRight(state, { skills: { [name]: value } }) as EngineState;
},
[actionTypes.ENGINE_SET_UI]: (state, action) => {
return mergeDeepRight(
state,
{ ui: action.payload as Partial<EngineUIState> },
) as EngineState;
},
[actionTypes.ENGINE_EXPAND_BACKLOG]: (state, action) => {
return mergeDeepRight<EngineState, Partial<EngineState>>(
state,
{ backlog: append(action.payload as Line<any, any>, state.backlog) },
) as EngineState;
},
[actionTypes.ENGINE_END_GAME]: (state) => ({
...state,
ui: { ...state.ui, gameOver: true }
}) as EngineState,
}, {
backlog: [],
ui: {