Browse Source

feat: Rework engine and renderer - stage 1

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

11
.eslintrc.json

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

9
package.json

@ -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",

3
shell.nix

@ -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
src/assets/fonts/AnticSlab-Regular.ttf

BIN
src/assets/fonts/AnticSlab-Regular.woff2

BIN
src/assets/fonts/antic-slab.zip

BIN
src/assets/images/user.png

Before

Width: 512  |  Height: 512  |  Size: 12 KiB

17
src/assets/style-vars.ts

@ -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,
};

77
src/components/App.tsx

@ -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 />
</>
);
}

32
src/components/Dropdown.styles.ts

@ -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;
`;

47
src/components/Dropdown.tsx

@ -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;

58
src/components/Menu.styles.ts

@ -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',
}

28
src/components/Menu.tsx

@ -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;

27
src/components/Playback.styles.ts

@ -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,
}

55
src/components/Playback.tsx

@ -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

34
src/components/Renderer/Image.styles.ts

@ -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;
`;

20
src/components/Renderer/Image.tsx

@ -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;

296
src/components/Renderer/index.tsx

@ -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>
);
};

265
src/components/Renderer/styles.ts

@ -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;
}
`;

158
src/components/SkillTree.styles.ts

@ -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';

157
src/components/SkillTree.tsx

@ -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;

4
src/declarations.d.ts

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

312
src/engine/Engine.ts

@ -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)