@@ -0,0 +1,11 @@ | |||
{ | |||
"root": true, | |||
"parser": "@typescript-eslint/parser", | |||
"plugins": [ | |||
"@typescript-eslint" | |||
], | |||
"extends": [ "airbnb-typescript" ], | |||
"parserOptions": { | |||
"project": "./tsconfig.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", | |||
@@ -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 | |||
@@ -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, | |||
}; |
@@ -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 /> | |||
</> | |||
); | |||
} |
@@ -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; | |||
`; |
@@ -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} {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; |
@@ -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', | |||
} |
@@ -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; |
@@ -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, | |||
} |
@@ -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 |
@@ -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; | |||
`; |
@@ -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; |
@@ -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> | |||
); | |||
}; |
@@ -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; | |||
} | |||
`; |
@@ -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'; |
@@ -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; |
@@ -1,4 +0,0 @@ | |||
declare module "*.css" { | |||
const cssModules: Record<string, string>; | |||
export default cssModules; | |||
} |
@@ -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)); | |||
} | |||
} |
@@ -1,7 +0,0 @@ | |||
export class EngineError extends Error { | |||
public message: string; | |||
public constructor(message: string) { | |||
super(); | |||
this.message = message; | |||
} | |||
} |
@@ -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); | |||
} |
@@ -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; |
@@ -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, | |||
}); |
@@ -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: { | |||