Open-source interactive fiction engine, powered by React, Redux and TypeScript. _ https://discoteque.pub/
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
Dale 9de6c7ee0b misc: Added homepage to package.json 4 tuntia sitten
src misc: Minor visual touches 4 tuntia sitten
.envrc feat: Working engine, nix package 1 kuukausi sitten
.eslintrc.json feat: Rework engine and renderer - stage 1 1 viikko sitten
.gitignore feat: Use emotion for css 3 viikkoa sitten
README.md misc: Better dependencies in README 3 viikkoa sitten
default.nix feat: Working engine, nix package 1 kuukausi sitten
package.json misc: Added homepage to package.json 4 tuntia sitten
shell.nix feat: Rework engine and renderer - stage 1 1 viikko sitten
tsconfig.json feat: Rework engine and renderer - stage 1 1 viikko sitten
webpack.config.js feat: Rework engine and renderer - stage 1 1 viikko sitten
yarn.lock feat: Time and Date support, saves support, main menu 5 päivää sitten
yarn.nix feat: Status bar improvements 4 viikkoa sitten

README.md

Discoteque

Text-based interactive ficiton with RPG elements

Allows you to create simple RPG-esque text stories that follow basic principles.

  1. You can read text that is spoken by characters or just presented as is (“narration”).
  2. You can navigate branching dialogue.
  3. You can distribute and use skills to pass dialogue checks and gain them.
  4. State can be used to track progress / remember decisions / set “VN-like” flags.
  5. Time passes with each line

Full Demo (Source Code for Demo)

Usage

With seed

Just clone example seed project, then edit game.ts, skills.ts and store.ts to develop your game.

git clone https://code.gensokyo.social/Gensokyo.social/discoteque-seed.git my-game
cd my-game
yarn
yarn start

With create-react-app as template

(Optional, but provides for stable webpack set up that we're not covering here)

Start with https://create-react-app.dev/docs/adding-typescript/

npx create-react-app my-app --template typescript

Remove all contents from src/

Install following pre-requisits

yarn add discoteque react-toastify redux emotion @emotion/core
yarn add -D @types/react-redux

Follow these steps

Create game store & reducer

Create a store and reducer to process in-game actions and change state

// store.ts
import { createStore, Action } from 'redux';
import { resetGame } from 'discoteque/lib/engine/lib/utils';

export interface IGameState {
    myCheck: boolean;
}

const defaultState: IGameState = {
  myCheck: false,
};

type ACTION = 'INIT' | 'set-check';
interface IGameAction extends Action {
    type: ACTION;
}

interface ISetCheckAction extends IGameAction {
    check: boolean;
}

interface InitAction extends IGameAction {
    data: { gameState: IGameState };
}

export const setCheck = (check: boolean): ISetCheckAction => ({
    type: 'set-check', check,
});

export const reducer = (state: IGameState = defaultState, action: IGameAction): IGameState => {
  switch (action.type) {
      case 'set-check':
        const checkAction = action as ISetCheckAction;
        return ({ ...state, myCheck: checkAction.check });
      // 'INIT' is a reserved Engine action type, used to initialize both game and engine state from save
      // Since game state is defined along with game's script and not included with engine, we have to perform reseting ourselves
      // `resetGame` is a supplied utility function that lets us perform necessary reduce without cluttering the rest of reducer
      case 'INIT':
        const initAction = action as InitAction;
        return resetGame(state, initAction);
      default:
        return state;
  }
}

export default createStore(reducer);

Create skills

Create default skills

// skills.ts
import { SkillSet } from "discoteque/lib/engine/types";

export type GameSkills = 'test';

export const skills: SkillSet<GameSkills> = {
  test: 5,
};

export const skillDescriptions: Record<GameSkills, string> = {
  test: 'This is a test skill',
}

export default skills;

Create script

// game.ts
import { INode, IActor, ILocation } from 'discoteque/lib/engine/types';
import { setState } from 'discoteque/lib/engine/lib/store';
import { IGameState, setCheck } from './store';
import { awardSkill, toast } from 'discoteque/lib/engine/lib/utils';

// Create Nodes
export const nodes: INode<IGameState>[] = [
    { id: 'beginning', kind: 'node', next: 'choice', lines: [
        // Lines are basically objects which specify how line should look
        { actorId: 'char_exampler', text: 'Hi! This is an example of Discoteque!' },
        { actorId: 'char_exampler', text: 'Let\'s try picking options' },
    ] },
    { id: 'choice', kind: 'node', next: 'choice', lines: [
        // Make player pick an answer
        { text: 'Your choice?', options: [
            // This answer is gated behind a skill check (dice throw)
            // name and difficulty are self-explanatory, failTo specifies node to fallback to if check is failed
            // failTo is REQUIRED
            { text: 'Let\'s test a skill', value: 'test_success', skill: { name: 'test', difficulty: 15, failTo: 'test_fail' } },
            // This one has no costs or prerequisites!
            { text: 'Let me out!', value: 'exit' },
        ] }
    ] },
    { id: 'test_success', kind: 'node', next: 'choice', lines: [
        // Line can be a funciton which takes a number of parameters and returns a line object
        (_, { myCheck }, dispatch) => {
            if (!myCheck) {
                // We can dispatch redux events to modify game's store!
                dispatch(setCheck(true))
                return { actorId: 'char_exampler', text: "Yay! You passed a check!" }
            } else {
                return { text: 'You think you\'ve already passed this check, so no need to do it again' }
            }
        },
    ]},
    { id: 'test_fail', kind: 'node', next: 'choice', lines: [
        ({ skillPoints }, _, dispatch) => {
            // This is a helper function from discoteque lib
            // It awards one skill point to player and dispatches a toast popup
            awardSkill(dispatch, skillPoints);
            return { actorId: 'char_exampler', text: 'You\'ve failed... Not a problem! Take a skill point and I\'ll give another if you fail again!' };
        }
    ] },
    { id: 'exit', kind: 'node', next: 'choice', lines: [
        (_, _gameState, dispatch) => {
            dispatch(setState({ ui: { isOver: true } }));
            return { actorId: 'char_exampler' , text: 'Bye-bye!' };
        },
    ] }
];

// Create actors
export const actors: IActor<IGameState>[] = [
    { id: 'char_exampler', kind: 'actor', name: 'Exampler!', lines: [] },
];

// Create locations
export const locations: ILocation<IGameState>[] = [
    { id: 'loc_discoville', kind: 'location', name: 'Discoville!', next: 'beginning', lines: [
        { text: 'It\'s a beautiful day at Discoville today!' },
        { text: 'A friendly feller is approaching you.' }
    ] },
];

Create game config

// config.ts
import { EngineConfig } from "discoteque/lib/engine/types";
import { IGameState, reducer } from "./store";
import skills, { GameSkills, skillDescriptions } from "./skills";
import { nodes, actors, locations } from "./game";

const config: EngineConfig<IGameState, undefined, GameSkills> = {
    // Pass the initial skills
    skills: skills,
    // Set menu text values
    menu: {
        title: 'Example Story',
        description: 'An Example Discoteque Story',
    },
    // Define how many points player can spend on start
    skillPointsOnStart: 3,
    // Define starting node
    startNode: 'loc_discoville',
    // Define time object
    chrono: {
        time: 750,
    },
    // Give skills some desctiptions
    skillDescriptoins: skillDescriptions,
    // Provide game's reducer
    reducer: reducer,
    // Supply the actual script (nodes)
    nodes: [
        ...nodes,
        ...actors,
        ...locations,
    ],
};

export default config;

Use config in your app

Replace default project's index.tsx with this

// index.tsx
import makeApp from 'discoteque/lib';
import { injectGlobal } from 'emotion';
import config from './config';

import 'react-toastify/dist/ReactToastify.css';

// Set up default font
import font from "discoteque/src/assets/fonts/AnticSlab-Regular.woff2";
injectGlobal`
    @font-face {
        font-family: 'Antic Slab Regular';
        src:  url(${font}) format('woff2');
    }
    body {
        font-family: 'Antic Slab Regular';
    }
`;

// Import your config file

// Make an app. Mounting onto DOM is already handled by App.
makeApp(config);

Development

Run build

yarn build
yarn link
yarn link discoteque