Browse Source

feat: Separate package, bump version

v2.0.0rc
Dale 1 year ago
parent
commit
b66bf0d3c7
Signed by: Deiru GPG Key ID: AA250C0277B927E1
  1. 17
      package.json
  2. 14
      src/components/App.tsx
  3. 41
      src/components/Playback.tsx
  4. 69
      src/components/Renderer/index.tsx
  5. 34
      src/components/SkillTree.tsx
  6. 8
      src/engine/Engine.ts
  7. 6
      src/engine/index.ts
  8. 60
      src/engine/lib/store.ts
  9. 75
      src/engine/types.d.ts
  10. 84
      src/engine/types.ts
  11. 343
      src/game/index.ts
  12. 10
      src/game/lib/skills.ts
  13. 69
      src/game/lib/store.ts
  14. 7
      src/index.tsx
  15. 18
      tsconfig.json
  16. 90
      webpack.config.js
  17. 1137
      yarn.lock

17
package.json

@ -1,8 +1,15 @@
{
"name": "text-engine",
"version": "0.1.0",
"main": "src/index.ts",
"name": "discoteque",
"version": "1.0.0",
"main": "lib/index.js",
"types": "lib/index.d.ts",
"description": "Interactive Ficiton engine with RPG elements",
"license": "MIT",
"keywords": [
"game",
"engine"
],
"author": "Dale Twokey",
"scripts": {
"dev": "./node_modules/.bin/webpack-dev-server --config webpack.config.js",
"build": "./node_modules/.bin/webpack --config webpack.config.js"
@ -26,8 +33,6 @@
"@types/react-click-outside-hook": "^1.0.0",
"@types/react-dom": "^16.9.8",
"@types/react-redux": "^7.1.9",
"@types/react-toastify": "^4.1.0",
"@types/redux": "^3.6.0",
"css-loader": "^4.2.2",
"file-loader": "^6.1.0",
"html-webpack-plugin": "^4.4.1",
@ -36,7 +41,7 @@
"ts-node": "^9.0.0",
"typescript": "^4.0.2",
"typescript-plugin-css-modules": "^2.4.0",
"webpack": "^4.44.1",
"webpack": "5.0.0-rc.0",
"webpack-cli": "^3.3.12",
"webpack-dev-server": "^3.11.0"
}

14
src/components/App.tsx

@ -1,20 +1,22 @@
import React, { useMemo } from 'react';
import config from '@/game';
import Playback from './Playback';
import { Renderer } from './Renderer';
import createEngine from '@/engine';
import { Engine } from '@/engine/Engine';
import { IGameState, reducer } from '@/game/lib/store';
import { Provider } from 'react-redux';
import { Reducer } from 'redux';
import { ToastContainer } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css'
import { EngineConfig } from '@/engine/types';
export default () => {
const engine: Engine<IGameState> = useMemo(
() => createEngine<IGameState>(config, reducer as Reducer),
type AppProps<GS = object, MT = undefined, ST extends string = string> = {
config: EngineConfig<GS, MT, ST>
}
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],
)

41
src/components/Playback.tsx

@ -4,43 +4,36 @@ import * as R from 'ramda';
import { Engine } from '@/engine/Engine';
import styles from '@/components/Playback.module.css';
import { IGameState, setState, stateSelector } from '@/game/lib/store';
import { ILine, ILineOption, Chrono, ILocation, EngineConfig } from '@/engine/types';
import { useSelector, useDispatch } from 'react-redux';
import { setState, stateSelector } from '@/engine/lib/store';
type PlaybackRenderFn = (
engine: Engine<IGameState>,
engineConfig: EngineConfig<IGameState>,
backlog: ILine<IGameState>[],
location: ILocation<IGameState> | null,
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<IGameState>) => void,
next: (opt?: ILineOption<GS>) => void,
) => React.ReactNode;
interface PlaybackProps {
engine: Engine<IGameState>;
children: PlaybackRenderFn;
engineConfig: EngineConfig<IGameState>,
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 checkLine = (v: ILine | null | undefined): v is ILine => (v !== null) && (v !== undefined);
//
const restart = () => location.reload();
const Playback: React.FC<PlaybackProps> = ({ engine, children, engineConfig }) => {
const Playback = <GS, MT, ST extends string, >({ engine, children, engineConfig }: PlaybackProps<GS, MT, ST>) => {
const dispatch = useDispatch();
const state = useSelector(stateSelector)
const state = useSelector(stateSelector) as any;
const backlog = state.backlog || [];
useEffect(() => {
if (backlog.length === 0) dispatch(setState({ backlog: [engine.currentLine] }));
}, [backlog])
const next = (opt?: ILineOption<IGameState>) => {
const next = (opt?: ILineOption<GS>) => {
if (!state.isOver) {
const nextLine = engine.next(state, opt)
const nextLine = engine.next(state as any, opt)
if (nextLine) {
const backlog = engine.gameState.backlog;
const backlog = engine.backlog;
dispatch(setState({ backlog: [...backlog, ...nextLine].filter(R.identity) }))
}
}
@ -49,7 +42,7 @@ const Playback: React.FC<PlaybackProps> = ({ engine, children, engineConfig }) =
return state ? (
<div className={styles.playbackContainer}>
{children(
engine, engineConfig, state.backlog || [], engine.currentLocation,
engine, engineConfig, engine.backlog, engine.currentLocation as any,
engine.currentTime, next,
)}
{state.isOver && <span onClick={restart} className={styles.playbackRestart}>Restart</span>}

69
src/components/Renderer/index.tsx

@ -3,8 +3,7 @@ import * as R from 'ramda';
import { useSelector, useDispatch } from 'react-redux';
import styles from './style.module.css';
import { IGameState, stateSelector, initState as gameInit, resetState as gameReset, setState } from '@/game/lib/store';
import { stateSelector as engineStateSelector, loadEngine as engineInit, lockSkills } from '@/engine/lib/store';
import { stateSelector as engineStateSelector, gameStateSelector, setState, initState, resetState } from '@/engine/lib/store';
import { IActor, ILine, ILineOption, ILocation, EngineState, Chrono, EngineConfig } from '@/engine/types';
import { toast } from 'react-toastify';
import SkillTree from '@/components/SkillTree';
@ -18,15 +17,15 @@ type TextLogProps = {
}
type OptionsProps = {
options: ILineOption<IGameState>[];
next: (opt?: ILineOption<IGameState>) => void;
options: ILineOption<any>[];
next: (opt?: ILineOption<any>) => void;
}
type RendererProps = {
backlog: ILine<IGameState>[];
config: EngineConfig<IGameState>;
next: (opt?: ILineOption<IGameState>) => void;
location?: ILocation<IGameState> | null;
backlog: ILine<any, any>[];
config: EngineConfig<any, any>;
next: (opt?: ILineOption<any>) => void;
location?: ILocation<any, any> | null;
time: Chrono | null;
}
@ -93,7 +92,7 @@ const formatOptionSkill = (skill: string, difficulty: number) => {
}
export const Options = ({ options, next }: OptionsProps) => {
const gameState = useSelector(stateSelector) as IGameState;
const engineState = useSelector(engineStateSelector) as EngineState;
return (
<div className={styles.optionsContainer}>
{!!(options && options.length) && (
@ -110,25 +109,25 @@ export const Options = ({ options, next }: OptionsProps) => {
)}
{!!(!options || !options.length) && (
<a className={styles.optionsNext} onClick={() => next()}>
{gameState.isOver ? 'THE END' : 'Click to Advance...' }
{engineState.ui.isOver ? 'THE END' : 'Click to Advance...' }
</a>
)}
</div>
);
}
type SaveFile = {
type SaveFile<GS = object> = {
engineState: EngineState;
gameState: IGameState;
gameState: GS;
};
const saveGame = (engineState: EngineState, gameState: IGameState): void => {
const { nodeMap, ...cleanEngineState } = engineState;
const saveGame: Record<string, SaveFile> = {
'Save1': { engineState: cleanEngineState as EngineState, gameState: {
...gameState,
backlog: R.takeLast(1000, gameState.backlog),
} },
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!');
@ -173,7 +172,7 @@ const formatTime = (time: number): string => {
const Toolbar: React.FC<ToolbarProps> = ({ location, time }) => {
const dispatch = useDispatch();
const engineState = useSelector(engineStateSelector) as EngineState;
const gameState = useSelector(stateSelector) as IGameState;
const gameState = useSelector(gameStateSelector) as any;
const doSave = () => {
saveGame(engineState, gameState);
@ -183,8 +182,7 @@ const Toolbar: React.FC<ToolbarProps> = ({ location, time }) => {
const doLoad = () => {
const newStates = loadGame();
if (newStates) {
dispatch(gameInit(newStates.gameState))
dispatch(engineInit(newStates.engineState))
dispatch(initState(newStates));
toast.success('Loaded successfully!');
return true;
}
@ -193,11 +191,7 @@ const Toolbar: React.FC<ToolbarProps> = ({ location, time }) => {
}
const openSkillMenu = () => {
const newState = {
...gameState,
ui: R.merge(gameState.ui, { skillsOpen: true })
};
dispatch(setState(newState));
dispatch(setState({ ui: { skillsOpen: true } }));
}
const exitToMenu = () => {
@ -231,34 +225,33 @@ const Toolbar: React.FC<ToolbarProps> = ({ location, time }) => {
)
}
const formatName = (actor: IActor<IGameState> | null) => {
const formatName = (actor: IActor<any> | null) => {
if (actor) {
return `[${actor.name || 'NONAME'}]: `;
}
return ``;
}
const lineToStr = (line: ILine<IGameState>): string => `${formatName(line.actor)}${line.text}`;
const lineToStr = (line: ILine<any>): string => `${formatName(line.actor)}${line.text}`;
export const Renderer: React.FC<RendererProps> = ({ backlog = [], ...props }) => {
const dispatch = useDispatch();
const gameState = useSelector(stateSelector) as IGameState;
const engineState = useSelector(engineStateSelector) as EngineState;
const next = (opt?: ILineOption<IGameState>) => {
if (!gameState.isOver) {
const next = (opt?: ILineOption<any>) => {
if (!engineState.ui.isOver) {
props.next(opt)
}
}
const startGame = () => {
dispatch(gameReset());
dispatch(resetState(props.config.startNode));
dispatch(setState({ ui: { menuOpen: false, skillsOpen: true } }));
}
const doLoad = () => {
const newStates = loadGame();
if (newStates) {
dispatch(gameInit(newStates.gameState))
dispatch(engineInit(newStates.engineState))
dispatch(initState(newStates));
toast.success('Loaded successfully!');
return;
}
@ -270,14 +263,14 @@ export const Renderer: React.FC<RendererProps> = ({ backlog = [], ...props }) =>
const hasOptions = line?.options?.length && line.options;
const image = line?.actor?.image;
const showGame = !gameState.ui.skillsOpen && !gameState.ui.menuOpen;
const showGame = !engineState.ui.skillsOpen && !engineState.ui.menuOpen;
return (
<div
className={styles.rendererContainer}
>
{gameState.ui.menuOpen && (<Menu startGame={startGame} loadGame={doLoad} />)}
{gameState.ui.skillsOpen && (
{engineState.ui.menuOpen && (<Menu startGame={startGame} loadGame={doLoad} />)}
{engineState.ui.skillsOpen && (
<SkillTree skillDescriptions={props.config.skillDescriptoins} />
)}
{showGame && (<>

34
src/components/SkillTree.tsx

@ -3,38 +3,36 @@ import * as R from 'ramda';
import styles from './SkillTree.module.css';
import { useDispatch, useSelector } from 'react-redux';
import { stateSelector, setSkill, setSkillpoints } from '@/engine/lib/store';
import { IGameState, setState } from '@/game/lib/store';
import { stateSelector, setSkill, setSkillpoints, setState } from '@/engine/lib/store';
import { EngineState } from '@/engine/types';
import { GameSkills } from '@/game/lib/skills';
import cn from 'classnames';
type Props = {
skillDescriptions?: Record<GameSkills, string>;
skillDescriptions?: Record<string, string>;
}
export const SkillTree: FC<Props> = ({ skillDescriptions = {} }) => {
export const SkillTree: FC<Props> = <GS, MT, ST extends string>({ skillDescriptions = {} }: Props) => {
const dispatch = useDispatch();
const engineState: EngineState<IGameState, undefined, GameSkills> = useSelector(stateSelector);
const engineState = useSelector(stateSelector) as unknown as EngineState<GS, MT, ST>;
const pointsLeft = engineState.skillPoints;
const skills: GameSkills[] = Object.keys(engineState.skills) as GameSkills[];
const skills: ST[] = Object.keys(engineState.skills) as ST[];
const minSkills: Record<GameSkills, number> = useMemo(() => {
const minSkills: Record<ST, number> = useMemo(() => {
if (engineState.protectSkills) {
return engineState.skills;
}
return {} as any;
}, [engineState.protectSkills]);
const [skillOpen, setSkillOpen] = useState<GameSkills | null>(null);
const [skillOpen, setSkillOpen] = useState<ST | null>(null);
const blockDecrement = (skill: GameSkills) => {
const blockDecrement = (skill: ST) => {
const minSkill = R.propOr(0, skill, minSkills) as number;
return engineState.skills[skill] <= minSkill;
}
const incrementSkill = (skill: GameSkills) => () => {
const incrementSkill = (skill: ST) => () => {
const oldValue = engineState.skills[skill];
const newValue = oldValue + 1;
if (pointsLeft) {
@ -43,7 +41,7 @@ export const SkillTree: FC<Props> = ({ skillDescriptions = {} }) => {
}
}
const decrementSkill = (skill: GameSkills) => () => {
const decrementSkill = (skill: ST) => () => {
const oldValue = engineState.skills[skill];
const newValue = oldValue - 1;
const minSkill = R.propOr(0, skill, minSkills) as number;
@ -60,7 +58,7 @@ export const SkillTree: FC<Props> = ({ skillDescriptions = {} }) => {
}
}
const toggleSkillOpen = (name: GameSkills) => () => {
const toggleSkillOpen = (name: ST) => () => {
if (skillOpen === name) {
setSkillOpen(null);
} else {
@ -68,6 +66,10 @@ export const SkillTree: FC<Props> = ({ skillDescriptions = {} }) => {
}
}
const getDescription = (name: ST): string | undefined => {
return skillDescriptions[name];
}
return (
<div className={styles.skillTree}>
<div className={styles.skillHeader}>
@ -79,11 +81,11 @@ export const SkillTree: FC<Props> = ({ skillDescriptions = {} }) => {
<li key={skill} className={styles.skillItem}>
<div className={styles.skillRow}>
<a
className={cn(styles.skillName, { [styles.skillNameClick]: !!skillDescriptions[skill] })}
className={cn(styles.skillName, { [styles.skillNameClick]: !!getDescription(skill) })}
onClick={toggleSkillOpen(skill)}
>
{skill}
{!!skillDescriptions[skill] && ' (?)'}
{!!getDescription(skill) && ' (?)'}
</a>
<div>
<button onClick={decrementSkill(skill)} className={cn(
@ -104,7 +106,7 @@ export const SkillTree: FC<Props> = ({ skillDescriptions = {} }) => {
[styles.skillDescClosed]: skillOpen !== skill,
[styles.skillDescOpen]: skillOpen === skill,
})}>
{skillDescriptions[skill]}
{getDescription(skill)}
</div>
)}
</li>

8
src/engine/Engine.ts

@ -17,7 +17,9 @@ export class Engine<GS = object, MT = undefined, ST extends string = string> {
public constructor(state: EngineState<GS, MT, ST>, gameReducer: Reducer, onDayCycle: DayCycleFn) {
this.onDayCycle = onDayCycle;
this.store = makeStore(gameReducer) as EngineStore<GS, MT, ST>;
this.store.dispatch(actions.initState(state))
this.store.dispatch(actions.initEngineState(state))
const firstLine = this.currentLine || this.next(this.gameState);
this.store.dispatch(actions.setState({ backlog: [firstLine] }));
}
private get state(): EngineState<GS, MT, ST> {
@ -49,6 +51,10 @@ export class Engine<GS = object, MT = undefined, ST extends string = string> {
return R.propOr(null, 'chrono', this.state);
}
public get backlog(): ILine<GS, MT>[] {
return R.propOr([], 'backlog', this.state);
}
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);

6
src/engine/index.ts

@ -15,6 +15,12 @@ export default <GS = object, MT = undefined, ST extends string = string>(config:
skillPoints: config.skillPointsOnStart,
chrono: config.chrono,
protectSkills: false,
backlog: [],
ui: {
skillsOpen: false,
menuOpen: true,
isOver: false,
}
}
return new Engine<GS, MT, ST>(state, reducer, config.onDayCycle || defaultDayCycleFn);
}

60
src/engine/lib/store.ts

@ -2,9 +2,11 @@ import * as R from 'ramda';
import { createStore, Reducer, combineReducers } from 'redux';
import { EngineState, Chrono } from '@/engine/types';
type INIT = 'INIT';
type ENGINE_INIT = 'INIT-ENGINE';
type ENGINE_RESET = 'RESET-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';
@ -16,7 +18,8 @@ type ENGINE_LOCK_SKILLS = 'LOCK-SKILLS';
type ENGINE_UNLOCK_SKILLS = 'UNLOCK-SKILLS';
type EngineActionType =
ENGINE_INIT
INIT
| ENGINE_INIT
| ENGINE_RESET
| ENGINE_ADVANCE_LINE
| ENGINE_CHANGE_NODE
@ -27,7 +30,8 @@ type EngineActionType =
| ENGINE_ADVANCE_TIME
| ENGINE_SET_CHRONO
| ENGINE_LOCK_SKILLS
| ENGINE_UNLOCK_SKILLS;
| ENGINE_UNLOCK_SKILLS
| ENGINE_SET_STATE;
interface IEngineAdvanceLine {
line: number;
@ -46,19 +50,35 @@ interface IEngineSetSkill {
value: number,
}
interface IEngineSetState {
state: any,
}
interface IEngineInitAction<GS> extends EngineState<GS> {};
type EngineActionData<GS = object> = IEngineAdvanceLine | IEngineChangeNode | IEngineChangeLocaiton | IEngineInitAction<GS> | IEngineSetSkill | string | number | Chrono | null;
type EngineActionData<GS = object> =
IEngineAdvanceLine
| IEngineChangeNode
| IEngineChangeLocaiton
| IEngineInitAction<GS>
| IEngineSetSkill
| IEngineSetState
| Chrono
| string
| number
| null;
export interface IEngineStateAction<GS = object, MT = undefined, ST extends string = string> {
type: EngineActionType;
data?: EngineActionData<GS>;
}
export const initState = <GS, MT, ST extends string = string>(state: EngineState<GS, MT, ST>): IEngineStateAction<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-ENGINE', data: startNode })
({ 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> =>
@ -80,19 +100,40 @@ export const lockSkills = <GS, MT, ST extends string = string>(): IEngineStateAc
export const unlockSkills = <GS, MT, ST extends string = string>(): IEngineStateAction<GS, MT, ST> =>
({ type: 'UNLOCK-SKILLS' });
type InitStateData<GS> = {
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 = (): EngineState => ({
nodeMap: {},
line: 0,
node: '',
skills: {},
backlog: [],
skillPoints: 0,
protectSkills: false,
ui: {
isOver: false,
skillsOpen: false,
menuOpen: true,
}
});
const reducer = (state = getDefaultState(), action: IEngineStateAction): EngineState => {
if (action.type === 'INIT-ENGINE') {
return action.data as EngineState;
} else if (action.type === 'RESET-ENGINE') {
} else if (action.type === 'INIT') {
return (action.data as any).engineState as EngineState;
} else if (action.type === 'RESET') {
const newState = {
...getDefaultState(),
line: 0,
@ -108,6 +149,8 @@ const reducer = (state = getDefaultState(), action: IEngineStateAction): EngineS
...state,
...newState,
};
} else if (action.type === 'SET-STATE') {
return R.merge(state, action?.data as object ||{});
} else if (action.type === 'ADVANCE-LINE') {
return { ...state, ...action.data as object };
} else if (action.type === 'SET-CHRONO') {
@ -139,6 +182,9 @@ const reducer = (state = getDefaultState(), action: IEngineStateAction): EngineS
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,

75
src/engine/types.d.ts

@ -1,75 +0,0 @@
import { Dispatch } from "redux";
interface ILineOption<ST = object> {
text: string;
value: string;
skill?: { name: string, difficulty: number, failTo: string }
onPick?: (state: ST, dispatch: Dispatch<any>) => void;
}
interface ILineRaw<ST, MT = undefined> {
actorId?: string;
text: string;
options?: ILineOption<ST>[];
meta?: MT;
time?: number;
}
type LineFn<ST = any, MT = undefined, SST extends string = string> = (engineState: EngineState<ST, MT, SST>, gameState: ST, gameDispatch: Dispatch<any>) => ILineRaw<ST, MT> | null | void;
type BuiltinNodeKind = 'node' | 'actor' | 'location';
interface INode<GS = object, MT = undefined, KT = BuiltinNodeKind> {
id: string;
image?: string;
kind: KT;
lines: Array<ILineRaw<GS, MT> | LineFn<GS, MT>>;
next?: string;
name?: string;
meta?: MT,
}
interface IActor<GS = object, MT = undefined> extends INode<GS, MT, 'actor'> {
name: string;
}
interface ILocation<GS = object, MT = undefined> extends INode<GS, MT, 'location'> {
name: string;
}
interface ILine<GS = object, MT = undefined> extends ILineRaw<GS, MT> {
actor: IActor<GS, MT> | null,
}
type Node<GS = object, MT = undefined, KT = BuiltinNodeKind> = INode<GS, MT, KT> | IActor<GS, MT> | ILocation<GS, MT>;
type Chrono = {
time: number;
date?: string;
}
type DayCycleFn = (chrono: Chrono) => Chrono;
type EngineConfig<GS = object, MT = undefined, ST extends string = string, KT = BuiltinNodeKind> = {
nodes: Node<GS, MT, KT>[];
skills: SkillSet<ST>;
skillPointsOnStart: number;
startNode: string;
chrono?: Chrono;
onDayCycle?: DayCycleFn;
skillDescriptoins?: Record<ST, string>;
}
type NodeMap<GS = object, MT = undefined, KT = BuiltinNodeKind> = Record<string, Node<GS, MT, KT>>;
type EngineState<GS = object, MT = undefined, ST extends string = string, KT = BuiltinNodeKind> = {
nodeMap: NodeMap<GS, MT, KT>,
line: number;
node: string;
location?: string;
chrono?: Chrono;
skills: SkillSet<ST>
skillPoints: number;
protectSkills: boolean;
}
type SkillSet<IT extends string> = Record<IT, number>;

84
src/engine/types.ts

@ -0,0 +1,84 @@
import { Dispatch, Reducer } from "redux";
export interface ILineOption<ST = object> {
text: string;
value: string;
skill?: { name: string, difficulty: number, failTo: string }
onPick?: (state: ST, dispatch: Dispatch<any>) => void;
}
export interface ILineRaw<ST, MT = undefined> {
actorId?: string;
text: string;
options?: ILineOption<ST>[];
meta?: MT;
time?: number;
}
export type LineFn<ST = any, MT = undefined, SST extends string = string> = (engineState: EngineState<ST, MT, SST>, gameState: ST, gameDispatch: Dispatch<any>) => ILineRaw<ST, MT> | null | void;
export type BuiltinNodeKind = 'node' | 'actor' | 'location';
export interface INode<GS = object, MT = undefined, KT = BuiltinNodeKind> {
id: string;
image?: string;
kind: KT;
lines: Array<ILineRaw<GS, MT> | LineFn<GS, MT>>;
next?: string;
name?: string;
meta?: MT,
}
export interface IActor<GS = object, MT = undefined> extends INode<GS, MT, 'actor'> {
name: string;
}
export interface ILocation<GS = object, MT = undefined> extends INode<GS, MT, 'location'> {
name: string;
}
export interface ILine<GS = object, MT = undefined> extends ILineRaw<GS, MT> {
actor: IActor<GS, MT> | null,
}
export type Node<GS = object, MT = undefined, KT = BuiltinNodeKind> = INode<GS, MT, KT> | IActor<GS, MT> | ILocation<GS, MT>;
export type Chrono = {
time: number;
date?: string;
}
export type DayCycleFn = (chrono: Chrono) => Chrono;
export type EngineConfig<GS = object, MT = undefined, ST extends string = string, KT = BuiltinNodeKind> = {
nodes: Node<GS, MT, KT>[];
skills: SkillSet<ST>;
skillPointsOnStart: number;
startNode: string;
chrono?: Chrono;
onDayCycle?: DayCycleFn;
skillDescriptoins?: Record<ST, string>;
reducer: Reducer,
}
export type NodeMap<GS = object, MT = undefined, KT = BuiltinNodeKind> = Record<string, Node<GS, MT, KT>>;
export type EngineState<GS = object, MT = undefined, ST extends string = string, KT = BuiltinNodeKind> = {
nodeMap: NodeMap<GS, MT, KT>,
line: number;
node: string;
location?: string;
protectSkills: boolean;
backlog: ILine<GS, MT>[];
chrono?: Chrono;
skills: SkillSet<ST>
skillPoints: number;
ui: {
isOver: boolean;
skillsOpen: boolean;
menuOpen: boolean;
}
}
export type SkillSet<IT extends string> = Record<IT, number>;

343
src/game/index.ts

@ -1,343 +0,0 @@
import { IGameState, setState, toggleSkillMenu } from '@/game/lib/store';
import { INode, IActor, EngineConfig, ILineOption, ILocation } from '@/engine/types';
import { toast } from 'react-toastify';
import skills, { GameSkills } from './lib/skills';
import { setSkillpoints, lockSkills } from '@/engine/lib/store';
import { Dispatch } from 'redux';
const awardSkill = (dispatch: Dispatch, gameState: IGameState, skillPoints: number) => {
dispatch(setSkillpoints(skillPoints + 1));
toast("[Skill +1] You have been awarded a skill point!", {
onClick: () => dispatch(toggleSkillMenu(gameState)),
});
}
const nodes: INode<IGameState>[] = [
{
id: 'trying_out',
kind: 'node',
next: 'pre_choice',
lines: [
{ actorId: 'char_helper', text: "Hi!" },
(_, _g, dispatch) => dispatch(lockSkills()) && null,
{ actorId: 'char_helper', text: "Lets's start by advancing some lines!" },
{ actorId: 'char_helper', text: "This should be easy enough for you!" },
{ actorId: 'char_helper', text: "See?" },
{ text: "You think you're getting the hang of it!" },
{ actorId: 'char_helper', text: "Here's an exceptionally long line specifically designed to annoy me (and you!) and test us (and our patience!) on how long a single line could be. Turns out - pretty long!" },
{ actorId: 'char_helper', text: "Now let's try something harder. Left, or right?" },
{ actorId: 'char_helper', text: "This will use your two skills, tired and manic, representing two most important parts of your personality!" },
{ actorId: 'char_helper', text: "Failing the check on the left will allow you to go again." },
{ actorId: 'char_helper', text: "Failing the check on the right will instantly end the game." },
{ actorId: 'char_helper', text: "You could also try going outside." },
({ skillPoints }, gameState, dispatch) => {
awardSkill(dispatch, gameState, skillPoints);
return { actorId: 'char_helper', text: "By the way, here's a skill point just for you!" }
},
{ actorId: 'char_helper', text: "You can spend it using \"Skills\" menu in \"Options\", in the upper left corner! " },
{ actorId: 'char_helper', text: "Going outside will change your location, as displayed at the top." },
{ actorId: 'char_helper', text: "Current time is displayed in the upper right corner, along with current date (if you want it to!)" },
{ actorId: 'char_helper', text: "Turns out, talking takes time!" },
{ actorId: 'char_helper', text: "It has no logical consequence, but helps you as the player navigate the game better." },
]
},
{
kind: 'node',
id: 'failed_choice_tired',
next: 'pre_choice',
lines: [
{ text: '[FAIL] You tried very hard, but just weren\'t really in the mood' },
],
},
{
kind: 'node',
id: 'failed_choice_manic',
next: 'exit',
lines: [
{ text: '[FAIL] You were so overhyped you couldn\'t even say what you wanted to say' },
{ actorId: 'char_helper', text: 'Well, this is the end.' },
(_, _gameState, dispatch) => {
dispatch(setState({ isOver: true }))
},
],
},
{
id: 'pre_choice',
kind: 'node',
lines: [
(_, gameState) => {
const counterRight = gameState.visitedRight;
const counterLeft = gameState.visitedLeft;
const combinedCounter = counterRight + counterLeft;
const showExit = combinedCounter >= 3;
return { actorId: 'char_helper', text: 'So, left, or right?', options: [
{ text: '"I want to go outside!"', value: 'pre_outside' },
{ text: '"Left!"', value: 'left_choice', skill: { name: 'tired', difficulty: 17, failTo: 'failed_choice_tired' } },
{ text: '"Right!"', value: 'right_choice', skill: { name: 'manic', difficulty: 9, failTo: 'failed_choice_manic' } },
showExit && { 'text': 'Let me out, please', value: 'exit' }
].filter(x => x) as ILineOption[] };
}
]
},
{
id: 'pre_outside',
kind: 'node',
next: 'outside',
lines: [
(_, { beenOutside }) => beenOutside
? ({ actorId: 'char_helper', text: 'Ok, ok, I\'m not going anywhere' })
: ({ actorId: 'char_helper', text: 'Oki! Come find me inside the house if you wanna chat!', time: 10 }),
(_, { beenOutside }) => beenOutside
? { actorId: 'char_helper', text: 'I\'ll be here if you need me.' }
: { actorId: 'char_helper', text: 'Just be careful with the trap door. Many tried to find it, and many have failed.', time: 10 },
],
},
{
id: 'left_choice',
kind: 'node',
next: 'pre_choice',
lines: [
{ text: 'Pausing to think, you have decided to pick left option' },
(_, gameState) => {
const { visitedLeft } = gameState;
return visitedLeft ? (
{ actorId: 'char_helper', text: 'Huh. I think we\'ve been here before, no?' }
) : { actorId: 'char_helper', text: 'Good choice! As i\'ve said, picking left will lead you back.' };
},
({ skillPoints }, gameState, dispatch) => {
if ((gameState.visitedLeft + 1) === 3) {
awardSkill(dispatch, gameState, skillPoints);
}
dispatch(setState({ visitedLeft: gameState.visitedLeft + 1 }));
},
{ actorId: 'char_helper', text: 'Pick the other choice next time, maybe?' },
]
},
{
id: 'right_choice',
kind: 'node',
next: 'pre_choice',
lines: [
{ text: 'Without hesistation, you decided to go straight for the right option!' },
{ actorId: 'char_helper', text: 'Well, you passed this check. Try the other one now, and don\'t forget to fail this one.' },
(_, gameState, dispatch) => {
dispatch(setState({ visitedRight: gameState.visitedRight + 1 }));
}
]
},
{
id: 'exit',
kind: 'node',
lines: [
{ actorId: 'char_helper', text: 'Ok, it\'s time for us to part ways. We\'re done here' },
{ actorId: 'char_helper', text: 'Thanks, but this is the end.' },
(_engineState, _gameState, dispatch) => {
dispatch(setState({ isOver: true }))
},
]
},
{
id: 'mailbox',
kind: 'node',
next: 'outside',
lines: [
{ text: 'There\'s a letter inside.' },
{ text: 'You open it to take a look...' },
{ text: '"Welcome to Discoteque! An engine for games of adventure, danger, and low cunning!"' },
({ skillPoints }, gameState, dispatch) => {
if (!gameState.readMailbox) {
dispatch(setState({ readMailbox: true }));
awardSkill(dispatch, gameState, skillPoints);
}
return { text: `"No browser should be without one!" ${!gameState.readMailbox ? "[+1 Skill Point]" : ""}`, time: 5 };
}
]
},
{
id: 'trapdoor_end',
kind: 'node',
lines: [
{ text: 'You went to search for a trapdoor to the great underground empire!'},
(_engineState, _gameState, dispatch) => {
dispatch(setState({ isOver: true }));
return { text: 'It got dark and you got eaten by a grue.', time: 240 }
},
]
},
{
id: 'trapdoor_win',
kind: 'node',
lines: [
{ text: 'You went to search for a trapdoor to the great underground empire!', time: 240 },
{ text: 'You have found the coveted trap door! Many adventures await you...' },
(_engineState, _gameState, dispatch) => {
dispatch(setState({ isOver: true }));
return { text: 'But not in this demo!' }
},
]
},
{
id: 'about_trapdoor_fail',
kind: 'node', next: 'dialogue_helper',
lines: [
{ text: 'To ask this question you need to formulate it first...' },
{ text: 'But you can\'t be bothered to do it right now' },
],
},
{
'id': 'dialogue_helper', kind: 'node',
lines: [
(_, { talkedAboutTrapdoor }) => {
const trapdoorTalkSkill = talkedAboutTrapdoor ? 0 : 10;
return { text: 'What do you want to ask?', options: [
{ text: '"Tell me about that choice."', value: 'about_choice' },
{ text: '"Tell me about the trapdoor"', value: 'about_trapdoor', skill: {
name: 'tired', difficulty: trapdoorTalkSkill, failTo: 'about_trapdoor_fail'
} },
{ text: '"I\'m ready to pick"', value: 'pre_choice' },
{ text: '"Just came by to say hi!"', value: 'inside' },
] };
},
],
},
{
'id': 'about_choice', kind: 'node', next: 'dialogue_helper',
lines: [
(_, gameState, dispatch) => {
dispatch(setState({ askedAboutChoice: true }));
return gameState.askedAboutChoice
? { actorId: 'char_helper', text: 'Huh? I thought I already explained it to you...' }
: null;
},
{ actorId: 'char_helper', text: 'Well, there\'s not much to tell. It\'s just a demo Dale devised to showcase his engine.' },
{ actorId: 'char_helper', text: 'Don\' ask questions, just pick. Tell me when you\'re ready to pick.' },
]
},
{
id: 'about_trapdoor', kind: 'node', next: 'dialogue_helper',
lines: [
{ actorId: 'char_helper', text: 'Well, it\'s a cat in the box kind of thing.' },
{ actorId: 'char_helper', text: 'Some claim they have seen and used it. Others say it\'s a joke...' },
{ actorId: 'char_helper', text: 'What I know for sure is that those who aren\'t [MANIC] enough to take on the door are destined to fail.' },
{ actorId: 'char_helper', text: 'They will wander the house, going in circles, again and again...' },
{ actorId: 'char_helper', text: 'Searching for the door, in vain...' },
{ actorId: 'char_helper', text: 'Until it gets so, so dark that they get eaten by a fearsome grue, without even noticing!' },
{ actorId: 'char_helper', text: 'What a *grue*some end, right?' },
{ text: 'Helper starts laughing and can\'t help themselves. You don\'t appreciate the joke though.' },
{ actorId: 'char_helper', text: 'Well, enought about gruesome things, let\'s get back to the topic of chosing' },
({ skillPoints }, gameState, dispatch) => {
const { talkedAboutTrapdoor } = gameState;
if (!talkedAboutTrapdoor) {
dispatch(setState({ talkedAboutTrapdoor: true }));
awardSkill(dispatch, gameState, skillPoints);
return { text: 'You feel like this will help you search for the trap door. [+1 Skill Point]' };
}
}
]
},
{
id: 'look_trapdoor', kind: 'node', next: 'inside',
lines: [
{ text: 'You scan the room for the presence of a trapdoor...' },
{ text: 'But nothing comes up!' },
{ text: 'It\'s almost as if it\'s there but every time you look at it moves to another place.' },
{ text: 'Or perhaps it is always just right out of the corner of your eye...' },
({ skillPoints }, gameState, dispatch) => {
const { lookedAtTrapdoor } = gameState;
if (lookedAtTrapdoor) {
return { text: 'Looks like you will have to search more thoroughly.' }
}
dispatch(setState({ lookedAtTrapdoor: true }));
awardSkill(dispatch, gameState, skillPoints)
return { text: 'You feel like you\'ll have an easier time looking for the trapdoor properly. [+1 Skill Point]' }
},
],
}
]
// { actorId: 'char_helper', text: '' },
const actors: IActor<IGameState>[] = [
{ 'id': 'char_helper', kind: 'actor', name: 'Helper', image: require('@/assets/images/user.png').default, lines: [
{ actorId: 'char_helper', text: 'Back again, huh?' },
{ actorId: 'char_helper', text: 'Then I guess you\'re ready to pick right from left now.', },
], next: 'dialogue_helper' },
{ 'id': 'char_you', kind: 'actor', name: 'You', lines: [] },
]
const locations: ILocation<IGameState>[] = [
{ 'id': 'outside', kind: 'location', name: 'Great Outdoors', lines: [
(_, _gameState, dispatch) => {
dispatch(setState({ beenOutside: true }));
return { text: 'You are standing in an open field, west of house.' };
},
{ text: 'The sea of green extends into all directions, as far as your eye can see.' },
{ text: 'What now?', options: [
{ text: 'Look inside mailbox', value: 'mailbox' },
{ text: 'Go inside the house', value: 'inside' },
] },
] },
{ 'id': 'inside', kind: 'location', name: 'Inside House', lines: [
{ text: 'You are standing inside a small wooden house. The setup feels familiar... ' },
{ text: 'You almost expect there to be a trapdoor to a great underground empire.' },
{ text: 'You can see Helper here as well. They are standing in the corner, gesturing you to come and talk to them.' },
(_, { lookedAtTrapdoor, talkedAboutTrapdoor }) => {
const searchDifficulties = [
lookedAtTrapdoor && 10,
talkedAboutTrapdoor && 5,
].filter(id => id) as Array<number>;
const searchDifficulty = searchDifficulties.reduce(
(acc, val) => acc - val,
25,
);
const searchEffects = [
lookedAtTrapdoor && '[+10 from scanning the room]',
talkedAboutTrapdoor && '[+5 from talking about trap door with Helper]',
].filter(id => id);
return { text: 'What will you do now?', options: [
{ text: 'Go back outside', value: 'outside' },
{ text: 'Talk to Helper', value: 'char_helper' },
{ text: 'Scan the room for the trapdoor', value: 'look_trapdoor' },
{
text: `Search thoroughly for the trapdoor ${searchEffects.join(' ')}`,
value: 'trapdoor_win',
skill: { name: 'manic', difficulty: searchDifficulty, failTo: 'trapdoor_end' }
},
] }
},
] },
];
interface IGameConfig extends EngineConfig<IGameState, undefined, GameSkills> {
}
export const config: IGameConfig = {
startNode: 'trying_out',
nodes: [
...nodes,
...actors,
...locations,
],
chrono: {
time: 1435,
date: "10 September",
},
onDayCycle: (time) => {
const day = time.date?.substr(0, 2) || "10";
const restOfDate = time.date?.substr(2) || " September";
const dayNum = Number(day) !== NaN ? Number(day) : 10;
return {
time: 0,
date: `${dayNum + 1}${restOfDate}`,
}
},
skills,
skillPointsOnStart: 5,
skillDescriptoins: {
'manic': 'Out of this world at the highest pace',
'tired': 'Slow burning and contemplation',
}
}
export default config;

10
src/game/lib/skills.ts

@ -1,10 +0,0 @@
import { SkillSet } from "@/engine/types";
export type GameSkills = 'tired' | 'manic';
const skills: SkillSet<GameSkills> = {
tired: 5,
manic: 5,
};
export default skills;

69
src/game/lib/store.ts

@ -1,69 +0,0 @@
import * as R from 'ramda';
import { createStore, Action } from 'redux';
import { ILine } from '@/engine/types';
export interface IGameState {
visitedLeft: number;
isOver: boolean;
askedAboutChoice: boolean;
visitedRight: number;
beenOutside: boolean;
lookedAtTrapdoor: boolean,
talkedAboutTrapdoor: boolean,
readMailbox: boolean,
backlog: ILine<IGameState>[];
ui: {
skillsOpen: boolean;
menuOpen: boolean;
}
}
export interface IGameStateAction extends Action {
type: 'PATCH-GAME' | 'INIT-GAME' | 'RESET-GAME';
state?: IGameState;
}
export const setState = (state: object): IGameStateAction => ({ type: 'PATCH-GAME', state: (state as IGameState) });
export const initState = (state: IGameState): IGameStateAction => ({ type: 'INIT-GAME', state });
export const resetState = (): IGameStateAction => ({ type: 'RESET-GAME' })
export const toggleSkillMenu = (gameState: IGameState) => ({
type: 'PATCH-GAME',
state: {
ui: R.merge(gameState.ui, { skillsOpen: !gameState.ui.skillsOpen }),
}
});
const defaultState: IGameState = {
visitedLeft: 0,
visitedRight: 0,
askedAboutChoice: false,
isOver: false,
beenOutside: false,
lookedAtTrapdoor: false,
talkedAboutTrapdoor: false,
readMailbox: false,
backlog: [],
ui: {
skillsOpen: false,
menuOpen: true,
}
};
export const reducer = (state: IGameState = defaultState, action: IGameStateAction): IGameState => {
switch (action.type) {
case 'PATCH-GAME':
return ({ ...state, ...action.state });
case 'INIT-GAME':
return action.state || state;
case 'RESET-GAME':
return defaultState;
default:
return state;
}
}
export const stateSelector: (state: object) => IGameState = R.propOr(defaultState, 'game');
export default createStore(reducer);

7
src/index.tsx

@ -4,5 +4,10 @@ import ReactDOM from 'react-dom';
import App from '@/components/App';
import '@/index.css';
import { EngineConfig } from '@/engine/types';
ReactDOM.render(<App />, document.getElementById('root'));
function initApp<GS = object, MT = undefined, ST extends string = string>(config: EngineConfig<GS, MT, ST>) {
ReactDOM.render(<App<GS, MT, ST> config={config} />, document.getElementById('root'));
}
export default initApp;

18
tsconfig.json

@ -3,15 +3,17 @@
"target": "es2018",
"module": "commonjs",
"lib": [
"es2015",
"es2016",
"es2017",
"es2018",
"es2019",
"es2020",
"esNext",
"dom"
"es2015",
"es2016",
"es2017",
"es2018",
"es2019",
"es2020",
"esNext",
"dom"
],
"outDir": "./lib",
"declaration": true,
"jsx": "react",
"sourceMap": true,
"strict": true,

90
webpack.config.js

@ -1,50 +1,48 @@
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
mode: 'none',
entry: {
app: path.join(__dirname, 'src', 'index.tsx')
mode: 'production',
entry: {
index: path.join(__dirname, 'src', 'index.tsx')
},
target: 'node',
resolve: {
alias: {
'@': path.resolve(__dirname, 'src/'),
},
target: 'web',
resolve: {
alias: {
'@': path.resolve(__dirname, 'src/'),
},
extensions: ['.ts', '.tsx', '.js']
},
module: {
rules: [
{
test: /\.tsx?$/,
use: 'ts-loader',
exclude: '/node_modules/'
},
{
test: /\.css$/i,
use: ['style-loader', 'css-loader'],
},
{
test: /\.(woff|woff2|eot|ttf|otf)$/,
use: [
'file-loader',
],
},
{
test: /\.(png|jpg|jpeg|gif|mp4)$/,
use: [
'file-loader',
],
},
],
},
output: {
filename: '[name].js',
path: path.resolve(__dirname, 'dist')
},
plugins: [
new HtmlWebpackPlugin({
template: path.join(__dirname, 'src', 'index.html')
})
]
}
extensions: ['.ts', '.tsx', '.js']
},
module: {
rules: [
{
test: /\.tsx?$/,
use: 'ts-loader',
exclude: '/node_modules/'
},
{
test: /\.css$/i,
use: ['style-loader', 'css-loader'],
},
{
test: /\.(woff|woff2|eot|ttf|otf)$/,
use: [
'file-loader',
],
},
{
test: /\.(png|jpg|jpeg|gif|mp4)$/,
use: [
'file-loader',
],
},
],
},
output: {
filename: '[name].js',
module: true,
path: path.resolve(__dirname, 'lib')
},
experiments: {
outputModule: true,
}
};

1137
yarn.lock
File diff suppressed because it is too large
View File

Loading…
Cancel
Save