Merge pull request #9 from rudism/typescript

Typescript rewrite
This commit is contained in:
Rudis Muiznieks 2019-04-28 17:53:45 -05:00 committed by GitHub
commit 736b65270a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
31 changed files with 1772 additions and 1505 deletions

4
.gitignore vendored
View file

@ -1,4 +1,2 @@
*.swp
bin/
build/
node_modules/
.DS_Store

View file

@ -1,72 +0,0 @@
module.exports = (grunt) ->
pkg = require './package.json'
grunt.initConfig
pkg: pkg
coffee:
compile:
options:
join: true
bare: true
files: [
'bin/ficdown.js': ['src/*.coffee']
]
stylus:
compile:
options:
compress: true
expand: true
files: [
'bin/example/player.css': ['src/example/*.styl']
]
uglify:
js:
files:
'bin/ficdown.min.js': [
'bin/ficdown.js'
]
copy:
static:
files: [
expand: true
flatten: true
src: ['src/*.html']
dest: 'bin/'
]
example:
files: [
expand: true
flatten: true
src: ['src/example/*.html', 'src/example/*.png', 'src/example/*.md', 'bin/ficdown.min.js']
dest: 'bin/example/'
]
watch:
js:
files: ['src/**/*.coffee']
tasks: ['build:js', 'copy:example']
css:
files: ['src/**/*.styl']
tasks: ['stylus:compile']
static:
files: ['src/**/*.html','src/**/*.js','src/**/*.md']
tasks: ['copy:static', 'copy:example']
for name of pkg.devDependencies when name.substring(0, 6) is 'grunt-'
grunt.loadNpmTasks name
grunt.registerTask 'build:js', [
'coffee:compile'
'uglify:js'
]
grunt.registerTask 'default', [
'coffee:compile'
'uglify:js'
'stylus:compile'
'copy:static'
'copy:example'
]

View file

@ -4,45 +4,39 @@ Ficdown is a system for building interactive fiction using MarkDown syntax, and
## Dependencies
Ficdown.js uses [jQuery](http://jquery.com) for DOM manipulation and [PageDown](https://code.google.com/p/pagedown/) to convert MarkDown into HTML.
The generated ficdown.js and ficdown.min.js include all dependencies ([JQuery](https://jquery.com) and [markdown-it](https://github.com/markdown-it/markdown-it)), so no additional scripts are required to play games.
## Bulding
You can compile, pack, and minify with these commands:
```
> npm install
> npm run build
> npm run pack
> npm run minify
```
You can combine all three `build`, `pack`, and `minify` steps with this command:
```
> npm run make
```
## Usage
You can obtain *ficdown.js* or *ficdown.min.js* from the latest version on the [releases](https://github.com/rudism/Ficdown.js/releases) page. Assuming you've uploaded it to your web server along with a Ficdown story file named *story.md*, your HTML document would look something like this:
You can obtain *ficdown.js* or *ficdown.min.js* from the latest version on the [releases](https://github.com/rudism/Ficdown.js/releases) page. See the example [test.html](https://github.com/rudism/Ficdown.js/blob/master/test.html) file for basic usage and styling. The example includes the story content in a hidden text area so it can run locally in a browser.
```html
<!DOCTYPE html>
<html lang="en">
<head>
<title>My Ficdown Story</title>
</head>
<body>
<!-- This container will be used by Ficdown.js to present the story -->
<div id="main"></div>
<!-- include Ficdown.js dependencies from CDNs -->
<script src="http://code.jquery.com/jquery-2.1.1.min.js"></script>
<script src="http://cdnjs.cloudflare.com/ajax/libs/pagedown/1.0/Markdown.Converter.min.js"></script>
<script src="http://cdnjs.cloudflare.com/ajax/libs/pagedown/1.0/Markdown.Sanitizer.min.js"></script>
<!-- include locally hosted Ficdown.js -->
<script src="ficdown.min.js"></script>
<script>
// retrieve the Ficdown source file story.md via ajax
$.get('story.md', function(data){
// after retrieving the file, parse it
story = parseText(data);
// after parsing the story, load it into the div with id='main'
player = new Player(story, 'main');
player.play();
}, 'text');
</script>
</body>
</html>
```javascript
var player = new Ficdown(playerOptions);
player.play();
```
You will probably want to do some styling to make the story look better. For an example stylesheet, see the example included in the Ficdown.js repository at [/src/example/player.styl](/src/example/player.styl).
Your `playerOptions` should be an object with the following properties:
- `source`: Your story's ficdown code. Either store it right in the html document, or make an XHR to pull the story content in from an external file, and put its content here.
- `id`: The id of a div on the page to inject the game into. For example if your html is `<div id='game'/>` then you would pass `game` here.
- `scroll` (optional): Set this to `true` if you want the player's full game history to remain on the screen and automatically scroll the page down whenever a new scene is added to the bottom. By default each scene will replace the previous one and the page will be scrolled to the top.
- `html` (optional): Set this to true if your ficdown file contains raw html that you want rendered. By default html will not be rendered.
- `startText` (optional): Set this to override the link text that begins the game.
- `endMarkdown` (optional): Set this to override the "game over" content that is displayed when the player reaches a scene with no more links to follow. Include an anchor with the href set to `restart()` if you want a link that will start the story over from the beginning.

1992
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -2,14 +2,24 @@
"name": "ficdown.js",
"version": "0.9.1",
"description": "A parser and player for Interactive Fiction written in Ficdown",
"dependencies": {},
"scripts": {
"build": "rm -rf ./build && tsc",
"pack": "browserify build/unpacked/main.js --standalone Ficdown > ./build/ficdown.js",
"minify": "uglifyjs build/ficdown.js > build/ficdown.min.js",
"make": "npm run build && npm run pack && npm run minify"
},
"dependencies": {
"core-js": "^3.0.1",
"jquery": "^3.4.0",
"markdown-it": "^8.4.2"
},
"devDependencies": {
"coffee-script": "^1.7.1",
"grunt": "^0.4.5",
"grunt-contrib-coffee": "^0.10.1",
"grunt-contrib-uglify": "^0.5.0",
"grunt-contrib-copy": "^0.5.0",
"grunt-contrib-watch": "^0.6.1",
"grunt-contrib-stylus": "^0.17.0"
"@types/core-js": "^2.5.0",
"@types/jquery": "^3.3.29",
"@types/markdown-it": "0.0.7",
"@types/node": "^11.13.8",
"browserify": "^16.2.3",
"typescript": "^3.4.5",
"uglify-js": "^3.5.9"
}
}

5
src/Model/Action.ts Normal file
View file

@ -0,0 +1,5 @@
export type Action = {
lineNumber: number,
state: string,
description: string,
}

4
src/Model/AltText.ts Normal file
View file

@ -0,0 +1,4 @@
export type AltText = {
passed?: string,
failed?: string,
}

6
src/Model/Anchor.ts Normal file
View file

@ -0,0 +1,6 @@
export type Anchor = {
anchor: string,
text: string,
href: string,
title: string,
}

14
src/Model/Block.ts Normal file
View file

@ -0,0 +1,14 @@
import { Line } from './';
export enum BlockType {
Action,
Scene,
Story,
}
export type Block = {
lineNumber: number,
type: BlockType,
name: string,
lines: Line[],
}

1
src/Model/BoolHash.ts Normal file
View file

@ -0,0 +1 @@
export type BoolHash = { [name: string]: boolean }

5
src/Model/Href.ts Normal file
View file

@ -0,0 +1,5 @@
export type Href = {
target: string,
conditions: string,
toggles: string,
}

4
src/Model/Line.ts Normal file
View file

@ -0,0 +1,4 @@
export type Line = {
lineNumber: number,
text: string,
}

7
src/Model/ParseError.ts Normal file
View file

@ -0,0 +1,7 @@
export class ParseError extends Error {
constructor(message: string, public lineNumber: number) {
super(lineNumber === 0
? message
: `[${ lineNumber }]: ${ message }`);
}
}

View file

@ -0,0 +1,8 @@
export type PlayerOptions = {
source: string, // ficdown story source
id: string, // id of div to inject game into
scroll?: boolean, // continuous scroll mode
html?: boolean, // allow html in story source
startText?: string, // custom link text to start game
endMarkdown?: string, // custom markdown when game ends
}

10
src/Model/Scene.ts Normal file
View file

@ -0,0 +1,10 @@
import { BoolHash } from './';
export type Scene = {
lineNumber: number,
name: string,
key: string,
description: string,
conditions?: BoolHash,
id?: string;
}

9
src/Model/Story.ts Normal file
View file

@ -0,0 +1,9 @@
import { Scene, Action } from './';
export type Story = {
name: string,
description: string,
firstScene: string,
scenes: { [key: string]: Scene[] },
actions: { [key: string]: Action },
}

11
src/Model/index.ts Normal file
View file

@ -0,0 +1,11 @@
export * from './Action';
export * from './AltText';
export * from './Anchor';
export * from './Block';
export * from './BoolHash';
export * from './Href';
export * from './Line';
export * from './ParseError';
export * from './PlayerOptions';
export * from './Scene';
export * from './Story';

117
src/Parser.ts Normal file
View file

@ -0,0 +1,117 @@
import {
Action,
Block,
BlockType,
BoolHash,
Line,
ParseError,
Scene,
Story,
} from './Model';
import { Util } from './Util';
export class Parser {
public static parse(source: string): Story {
const lines = source.split(/\n|\r\n/);
const blocks = this.extractBlocks(lines);
return this.parseBlocks(blocks);
}
private static getBlockType(hashCount: 1 | 2 | 3): BlockType {
switch(hashCount) {
case 1: return BlockType.Story;
case 2: return BlockType.Scene;
case 3: return BlockType.Action;
}
}
private static extractBlocks(lines: string[]): Block[] {
const blocks: Block[] = [];
let currentBlock: Block | undefined = undefined;
for(let i = 0; i < lines.length; i++) {
const match = lines[i].match(/^(#{1,3})\s+([^#].*)$/);
if(match) {
if(currentBlock) blocks.push(currentBlock);
currentBlock = {
lineNumber: i,
type: this.getBlockType(<1 | 2 | 3>match[1].length),
name: match[2],
lines: [],
};
} else if(currentBlock) {
currentBlock.lines.push({ lineNumber: i, text: lines[i] });
}
}
if(currentBlock) blocks.push(currentBlock);
return blocks;
}
private static parseBlocks(blocks: Block[]): Story {
let storyBlock: Block | undefined =
blocks.find(b => b.type === BlockType.Story);
if(!storyBlock) throw new ParseError('no story block', 0);
const storyName = Util.matchAnchor(storyBlock.name);
if(!storyName) throw new ParseError('no story name', storyBlock.lineNumber);
const storyHref = Util.matchHref(storyName.href);
if(!storyHref) throw new ParseError('no link to first scene', storyBlock.lineNumber);
const story: Story = {
name: storyName.text,
description: Util.trimText(storyBlock.lines.map(l => l.text).join("\n")),
firstScene: storyHref.target,
scenes: {},
actions: {},
};
for(let block of blocks) {
switch(block.type) {
case BlockType.Scene:
const scene = this.blockToScene(block);
if(!story.scenes[scene.key]) story.scenes[scene.key] = [];
story.scenes[scene.key].push(scene);
break;
case BlockType.Action:
const action = this.blockToAction(block);
story.actions[action.state] = action;
break;
}
}
return story;
}
private static blockToScene(block: Block): Scene {
const sceneName = Util.matchAnchor(block.name);
let name: string | undefined = undefined;
let key: string;
let conditions: BoolHash | undefined = undefined;
if(sceneName) {
name = sceneName.title
? Util.trimText(sceneName.title)
: Util.trimText(sceneName.text);
key = Util.normalize(sceneName.text);
const href = Util.matchHref(sceneName.href);
if(href && href.conditions) conditions =
Util.toBoolHash(href.conditions.split('&'));
} else {
name = Util.trimText(block.name);
key = Util.normalize(block.name);
}
return {
name,
key,
conditions,
description: Util.trimText(block.lines.map(l => l.text).join("\n")),
lineNumber: block.lineNumber,
};
}
private static blockToAction(block: Block): Action {
return {
state: Util.normalize(block.name),
description: Util.trimText(block.lines.map(l => l.text).join("\n")),
lineNumber: block.lineNumber,
};
}
}

186
src/Player.ts Normal file
View file

@ -0,0 +1,186 @@
import {
Action,
BoolHash,
PlayerOptions,
Scene,
Story,
} from './Model';
import { Parser } from './Parser';
import { Util } from './Util';
import * as Markdown from 'markdown-it';
import * as $ from 'jquery';
export class Player {
private converter: Markdown;
private container: JQuery<any>;
private playerState: BoolHash = {};
private visitedScenes: BoolHash = {};
private currentScene?: Scene;
private moveCounter: number = 0;
private story: Story;
private startText: string;
private endMarkdown: string;
constructor(private options: PlayerOptions) {
this.converter = new Markdown({
html: options.html,
});
this.story = Parser.parse(options.source);
this.startText = options.startText
? options.startText
: 'Click to start...';
this.endMarkdown = options.endMarkdown
? options.endMarkdown
: "## The End\n\nYou have reached the end of this story. [Click here](restart()) to start over.";
let i = 0;
for(let [key, scenes] of Object.entries(this.story.scenes)) {
for(let scene of scenes) scene.id = `#s${ ++i }`;
}
this.container = $(`#${ options.id }`);
this.container.addClass('ficdown').data('player', this);
}
public play(): void {
this.container.html(
this.converter.render(`# ${ this.story.name }\n\n${ this.story.description }\n\n[${ this.startText }](/${ this.story.firstScene })`));
this.wireLinks();
}
public handleHref(href: string): false {
const match = Util.matchHref(href);
let matchedScene: Scene | undefined = undefined;
const actions: Action[] = [];
if(match && match.toggles) {
const toggles = match.toggles.split('+');
for(let toggle of toggles) {
if(this.story.actions[toggle]) {
const action: Action = { ...this.story.actions[toggle] };
action.description = this.resolveDescription(action.description);
actions.push(action);
}
this.playerState[toggle] = true;
}
}
if(match && match.target) {
if(this.story.scenes[match.target]) {
for(let scene of this.story.scenes[match.target]) {
if(Util.conditionsMet(this.playerState, scene.conditions)) {
if(!matchedScene
|| !scene.conditions
|| !matchedScene.conditions
|| Object.keys(scene.conditions).length > Object.keys(matchedScene.conditions).length) {
matchedScene = scene;
}
}
}
}
}
if(matchedScene) {
this.currentScene = matchedScene;
}
const newScene: Scene = { ...this.currentScene! };
newScene.description = this.resolveDescription(newScene.description);
this.disableOldLinks();
let newContent = newScene.name ? `## ${ newScene.name }\n\n` : '';
for(let action of actions) {
newContent += `${ action.description }\n\n`;
}
newContent += newScene.description;
const newHtml = this.processHtml(newScene.id!, this.converter.render(newContent));
this.visitedScenes[newScene.id!] = true;
if(this.options.scroll) {
const scrollId = `move-${ this.moveCounter++ }`;
this.container.append($('<span/>').attr('id', scrollId));
this.container.append(newHtml);
$([document.documentElement, document.body]).animate({
scrollTop: $(`#${ scrollId }`).offset()!.top,
}, 1000);
} else {
this.container.html(newHtml);
window.scrollTo(0, 0);
}
this.wireLinks();
this.checkGameOver();
return false;
}
private resolveDescription(description: string): string {
for(let anchor of Util.matchAnchors(description)) {
const href = Util.matchHref(anchor.href);
if(href && href.conditions) {
const conditions = Util.toBoolHash(href.conditions.split('&'));
const satisfied = Util.conditionsMet(this.playerState, conditions);
const alts = Util.splitAltText(anchor.text);
let replace = satisfied ? alts.passed : alts.failed;
if(!replace) replace = '';
replace = replace.replace(Util.regexLib.escapeChar(), '');
if(replace === '' || (!href.toggles && !href.target)) {
description = description.replace(anchor.anchor, replace);
} else {
let newHref = href.target ? `/${ href.target }` : '';
newHref += href.toggles ? `#${ href.toggles }` : '';
const newAnchor = `[${ replace }](${ newHref })`;
description = description.replace(anchor.anchor, newAnchor);
}
}
}
description = description.replace(Util.regexLib.emptyListItem(), '');
return description;
}
private disableOldLinks(): void {
this.container.find('a:not(.external)').each((i, el) => {
const $this = $(el);
$this.addClass('disabled');
$this.unbind('click');
$this.click(() => false);
});
}
private processHtml(sceneId: string, html: string): string {
const temp = $('<div/>').append($.parseHTML(html));
if(this.visitedScenes[sceneId]) {
temp.find('blockquote').remove();
} else {
temp.find('blockquote').each((i, el) => {
const $this = $(el);
$this.replaceWith($this.html());
});
}
return temp.html();
}
private wireLinks(): void {
this.container.find('a:not(.disabled):not(.external)').each((i, el) => {
const $this = $(el);
const href = $this.attr('href');
if(href) {
if(!href.match(/^https?:\/\//)) {
$this.click(() => {
$this.addClass('chosen');
const player: Player = $this.parents('.ficdown').data('player');
return player.handleHref(href);
});
} else {
$this.addClass('external');
}
}
});
}
private checkGameOver(): void {
if(this.container.find('a:not(.disabled):not(.external)').length === 0) {
this.container.append(this.converter.render(this.endMarkdown));
const restartAnchor = this.container.find("a[href='restart()']");
const options = this.options;
restartAnchor.click(() => {
const game = $(`#${ options.id }`);
const player = new Player(options);
game.empty();
game.data('player', player);
player.play();
return false;
});
}
}
}

100
src/Util.ts Normal file
View file

@ -0,0 +1,100 @@
import { Anchor, Href, BoolHash, AltText } from './Model';
export class Util {
public static regexLib: { [name: string]: () => RegExp } = {
anchors: () => /(\[((?:[^\[\]]+|\[(?:[^\[\]]+|\[(?:[^\[\]]+|\[(?:[^\[\]]+|\[(?:[^\[\]]+|\[(?:[^\[\]]+|\[\])*\])*\])*\])*\])*\])*)\]\([ ]*((?:[^()\s]+|\((?:[^()\s]+|\((?:[^()\s]+|\((?:[^()\s]+|\((?:[^()\s]+|\((?:[^()\s]+|\(\))*\))*\))*\))*\))*\))*)[ ]*((['"])(.*?)\5[ ]*)?\))/gm,
href: () => /^(?:\/([a-zA-Z](?:-?[a-zA-Z0-9])*))?(?:\?((?:!?[a-zA-Z](?:-?[a-zA-Z0-9])*)(?:&!?[a-zA-Z](?:-?[a-zA-Z0-9])*)*)?)?(?:#((?:[a-zA-Z](?:-?[a-zA-Z0-9])*)(?:\+[a-zA-Z](?:-?[a-zA-Z0-9])*)*))?$/,
trim: () => /^\s+|\s+$/g,
altText: () => /^((?:[^|\\]|\\.)*)(?:\|((?:[^|\\]|\\.)+))?$/,
escapeChar: () => /\\(?=[^\\])/g,
emptyListItem: () => /^[ ]*-\s*([\r\n]+|$)/gm,
};
private static matchToAnchor(match: RegExpExecArray): Anchor {
const result: Anchor = {
anchor: match[1],
text: match[2],
href: match[3],
title: match[6],
};
if(result.href.indexOf('"') === 0) {
result.title = result.href.substring(1, result.href.length - 1);
result.href = '';
}
return result;
}
public static matchAnchor(text: string): Anchor | undefined {
const match = this.regexLib.anchors().exec(text);
if(match) return this.matchToAnchor(match);
}
public static matchAnchors(text: string): Anchor[] {
let match: RegExpExecArray | null;
const anchors: Anchor[] = [];
const regex = this.regexLib.anchors();
while((match = regex.exec(text)) !== null) {
anchors.push(this.matchToAnchor(match));
}
return anchors;
}
public static matchHref(text: string): Href | undefined {
const match = this.regexLib.href().exec(text);
if(match) {
return {
target: match[1],
conditions: match[2],
toggles: match[3],
};
}
}
public static trimText(text: string): string {
return text.replace(this.regexLib.trim(), '');
}
public static normalize(text: string): string {
return text.toLowerCase().replace(/^\W+|\W+$/g, '').replace(/\W+/g, '-');
}
public static toBoolHash(names: string[]): BoolHash | undefined {
if(names) {
const hash: BoolHash = {};
for(let name of names) {
if(name.indexOf('!') === 0) {
hash[name.substring(1, name.length)] = false;
} else {
hash[name] = true;
}
}
return hash;
}
}
public static conditionsMet(state: BoolHash, conditions?: BoolHash): boolean {
if(!conditions) return true;
for(let [cond, val] of Object.entries(conditions)) {
if((val && !state[cond]) || (!val && state[cond])) {
return false;
}
}
return true;
}
public static splitAltText(text: string): AltText {
const match = this.regexLib.altText().exec(text);
if(match) {
return {
passed: match[1],
failed: match[2],
};
}
return {};
}
}

View file

@ -1,33 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>The Robot King</title>
<meta charset="utf-8">
<meta name="HandheldFriendly" content="True" />
<meta name="MobileOptimized" content="320" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link href="http://fonts.googleapis.com/css?family=Maven+Pro:400,700" rel="stylesheet">
<link rel="stylesheet" href="player.css">
</head>
<body>
<div id="container" class="container">
<div id="main">
</div>
</div>
<script src="http://code.jquery.com/jquery-2.1.1.min.js"></script>
<script src="http://cdnjs.cloudflare.com/ajax/libs/pagedown/1.0/Markdown.Converter.min.js"></script>
<script src="http://cdnjs.cloudflare.com/ajax/libs/pagedown/1.0/Markdown.Sanitizer.min.js"></script>
<script src="ficdown.min.js"></script>
<script>
var story = location.search.replace('?', '');
if(!story){
story = 'story.md';
}
$.get(story, function(data){
story = parseText(data);
player = new Player(story, 'main');
player.play();
}, 'text');
</script>
</body>
</html>

View file

@ -1,39 +0,0 @@
html, body
height 100%
body
font-family 'Maven Pro', sans-serif
background url('science.png') fixed repeat
html, body, h1, h2, p, ul, div
margin 0
padding 0
ul
list-style-type circle
margin-left 1.33em
#container
background-color rgba(255,255,255,0.95)
max-width 800px
margin 0 auto
height 100%
overflow-y scroll
overflow-x hidden
#main
position relative
padding 5%
p, h1, h2, ul
margin-bottom 1em
a
color #c00
text-decoration none
&.disabled
color #999
&.chosen
color #000
&:hover
color #000
&:hover
text-decoration underline
color #f00
&.disabled
text-decoration none
color #999
cursor not-allowed

Binary file not shown.

Before

Width:  |  Height:  |  Size: 149 KiB

View file

@ -1,178 +0,0 @@
# [The Robot King](/robot-cave)
An experiment in markdown-based interactive fiction by Rudis Muiznieks.
This story was written in [Ficdown](http://rdsm.ca/ficdown), a subset of [Markdown](http://daringfireball.net/projects/markdown/) syntax with specific conventions for creating state-aware interactive fiction.
*Release r2014-07-01*
## Robot Cave
> You wake up and emit a great robot yawn as you stretch your metallic arms. Today is the big day! You have to start your new job working for the Robot King at the Robot Palace!
You're in your cave and you can see [a peg by the door where you usually hang your raincoat|your raincoat hanging by the door](?raincoat).
Your cave only has one tiny window, and through it you can see [the sun shining through the clouds|that it's raining heavily outside](?stopped-raining).
**What do you want to do?**
- [Go outside and start walking to the palace.](/outside)
- [|Wait for it to stop raining.](?stopped-raining#stopped-raining)
- [|Put on your raincoat.](?raincoat#raincoat)
### Raincoat
You take your raincoat and put it on. It fits perfectly!
### Stopped Raining
It feels like hours, but it finally stops raining. You hope you won't be late for your new job!
## Outside
> You step through the door and feel the water flowing over your metal body. Brrr! That's cold! You start to think that maybe getting your raincoat would be a good idea. This is just the kind of rain that might turn you into a rusty robot statue if you stay in it too long.
You're standing on your front porch in the pouring rain. You need to get to the palace for your new job, but you don't want to rust!
**What will you do?**
- [Continue walking to the palace.](/rusted)
- [Go back into your cave.](/robot-cave)
## [Outside](?stopped-raining)
You step through the door and feel the early afternoon sun warming your metal body. It feels good, but you were supposed to start your new job early in the morning!
You run as fast as you can all the way to the Robot Palace, but it's already too late.
"You were supposed to be here first thing in the morning," says the palace guard. "We can't have sleepy-head robots working at the Robot Palace! Try finding a different job instead."
**You've been fired!**
## Rusted!
You start walking toward the Robot Palace in the rain. Who needs a raincoat anyway? As you move down the path, rust starts forming on your legs and knees so you have to walk slower. Eventually the rust gets so bad that you can't move anymore at all!
As your whole body rusts over, you wonder what you could have been thinking. Only a crazy robot would ever go out into the rain without a raincoat!
You will have a long time to think about your mistake while you wait for another robot to come and help scrape off all the rust so you can move again. Since you never made it to the palace for your new job, you'll probably be fired.
**You have turned into a rusty robot statue!**
## [Outside](?raincoat)
You head out the door and into the rain. It's a good thing you put on your raincoat, because it's just the kind of rain that would probably turn you into a rusty robot statue if you stayed in it for too long.
You follow the road by your house all the way through town until you reach the door to the Robot Palace.
The palace guard looks you up and down. "What do you want?" he asks.
**What will you tell him?**
- ["I'm the new janitor-bot!"](/palace-gate#new-job)
- ["I'd like a tour of the palace!"](/palace-gate)
## Palace Gate
The robot guard looks at you and [nods|frowns](?new-job). "[Oh yeah, they told me to expect you. You're supposed to be starting today right?|We don't do tours on weekdays. Hey, aren't you the new janitor-bot who's starting today?](?new-job)"
**How will you answer?**
- ["Yup!"](/palace-entrance)
- ["Nope!"](/back-to-bed)
## Back to Bed
The robot guard looks at you with a confused expression on his face, then stops paying attention to you.
I guess you decided that you don't want a new job today after all. You turn around and walk all the way back home, where you hop back into bed for a quick nap.
Without a job, you fail to earn any money and you can no longer afford fuel to power yourself.
**You run out of fuel and shut down forever!**
## Palace Entrance
> The robot guard nods and ushers you into the palace through the large front doors.
> "You'll want to report to the Master Janitor Robot downstairs. He'll give you your uniform and get you started," the guard says, then quickly leaves and shuts the doors behind him.
The palace entrance is one of the biggest rooms you've ever seen! There are statues of knight-robots and pictures of all of the old Robot Kings going back for centuries lining the walls. The picture closest to you is a picture of the current Robot King. He looks a lot like you!
There is a grand double staircase leading up to the throne room, a hallway straight ahead that leads to the living quarters, and a door to your left that says "Stairs."
**Where do you want to go?**
- [Go upstairs to the throne room.](#throne-room)
- [Go through the hall to the living quarters.](/living-quarters)
- [Go downstairs to see the Master Janitor Robot.](/palace-basement)
### Throne Room
You start to ascend the stairs, but then think better of it. You wouldn't know what to do if you ran into the Robot King up there anyway!
## Living Quarters
You walk into the hall that leads to the living quarters, and find a gate blocking your way. There is a robot scanner installed on the gate. I guess it only opens for robots who live or work here. Maybe the Master Janitor Robot will have a way for you to get through.
[Go back to the palace entrance.](/palace-entrance#tried-gate)
## Palace Basement
> You walk down three flights of stairs until you reach the basement. The staircase is dark, but the basement is even darker. It's a little scary! You hope you can get the information you need from the Master Janitor Robot and get out of here as quickly as possible.
You're standing in the basement where new employees can pick up their uniforms and learn what their jobs are for the day.
[The Master Janitor Robot is pacing back and forth here, muttering to himself.|There is a funny looking robot here pacing back and forth, muttering to himself. That must be the Master Janitor Robot. When he notices you, he stops muttering and stares at you with crazy eyes.](?talked-to-master)
**What will you do?**
- [Go back upstairs.](/palace-entrance)
- [Ask the Master Janitor Robot what he's muttering about.](#talked-to-master+muttering)
- [|Ask the Master Janitor Robot about your uniform.](?uniform#talked-to-master+uniform)
- [Ask the Master Janitor Robot about the gate upstairs.](?tried-gate#talked-to-master+about-the-gate)
- [Ask the Master Janitor Robot about your job.](?uniform#started-job)
### Muttering
"Muttering?" says the Master Janitor Robot. "Was I muttering? I hadn't noticed."
The Master Janitor Robot pauses thoughtfully for a moment, then resumes muttering.
### Uniform
The Master Janitor Robot's eyes light up a pleasant shade of blue. "Ahh, you must be the new janitor-bot starting today!" he says.
He walks to a box in the corner and pulls out a blue janitor's uniform, then hands it to you. You put it on.
### About the Gate
"Ahh, yes, the gate," says the Master Janitor Robot. "Quite a clever contraption. There's a scanner attached that looks for a special device that's sewn into the [uniform I gave you|uniform that employees here wear](?uniform). [As I said, you'll want to head up there now to start cleaning room 13.](?started-job)"
### Started Job
["Like I said before, your|"Ready to get going?" says the Master Janitor Robot. He continues before you have a chance to answer. "Good, good. Your](?started-job) first job will be to clean room 13 in the living quarters. That's where the Robot King keeps all of his spare robes and crowns. There's a janitor's closet right next to that room where you can get a mop to clean the floors, and a duster to dust off the crowns."
The Master Janitor Robot scratches his chin for a moment, then resumes pacing back and forth and muttering to himself.
## [Living Quarters](?uniform)
You head into the hallway that leads to the living quarters and come to a large gate. A scanner attached to the gate lights up and beeps a few times. After a moment, you hear a click and a soft hiss as the gate opens to let you pass. Once you walk through, the gate hisses and clicks shut behind you.
You notice with some alarm that there's no scanner on the inside of the gate. You don't know how to get back out!
[Continue...](/living-quarters-2)
## [Living Quarters 2]("Living Quarters")
That's when you realize that you never asked the Master Janitor Bot what your job here was. You just took your uniform and left!
**You have failed to perform your new job because you never found out what it was.**
## [Living Quarters 2](?started-job "Living Quarters")
That's no problem though, because you already know what your job is. You continue down the hall, looking at and passing all of the doors until you come to the one marked with a "13." Right next to it is another door labeled "Janitor's Closet."
You open the closet and grab the mop and duster. You're so excited! Your first day as a janitor working for a Robot King that looks just like you, and you are about to enter a room containing all of his spare robes and crowns. What fun!
**You have reached the end of the intro to The Robot King.**

2
src/main.ts Normal file
View file

@ -0,0 +1,2 @@
require('core-js');
module.exports = require('./Player.js').Player;

View file

@ -1,76 +0,0 @@
parseText = (text) ->
lines = text.split /\n|\r\n/
blocks = extractBlocks lines
story = parseBlocks blocks
getBlockType = (hashCount) ->
switch hashCount
when 1 then 'story'
when 2 then 'scene'
when 3 then 'action'
extractBlocks = (lines) ->
blocks = []
currentBlock = null
for line in lines
match = line.match /^(#{1,3})\s+([^#].*)$/
if match?
if currentBlock != null
blocks.push currentBlock
currentBlock =
type: getBlockType match[1].length
name: match[2]
lines: []
else
currentBlock.lines.push line
if currentBlock != null
blocks.push currentBlock
return blocks
parseBlocks = (blocks) ->
storyBlock = null
for block in blocks
if block.type == 'story'
storyBlock = block
break
storyName = matchAnchor storyBlock.name
storyHref = matchHref storyName.href
story =
name: storyName.text
description: trimText storyBlock.lines.join "\n"
firstScene: storyHref.target
scenes: {}
actions: {}
for block in blocks
switch block.type
when 'scene'
scene = blockToScene block
if !story.scenes[scene.key]
story.scenes[scene.key] = []
story.scenes[scene.key].push scene
when 'action'
action = blockToAction block
story.actions[action.state] = action
return story
blockToScene = (block) ->
sceneName = matchAnchor block.name
if sceneName?
title = if sceneName.title? then trimText sceneName.title else trimText sceneName.text
key = normalize sceneName.text
href = matchHref sceneName.href
if href?.conditions?
conditions = toBoolHash href.conditions.split '&'
else
title = trimText block.name
key = normalize block.name
scene =
name: if title != '' then title else null
key: key
description: trimText block.lines.join "\n"
conditions: if conditions? then conditions else null
blockToAction = (block) ->
action =
state: normalize block.name
description: trimText block.lines.join "\n"

View file

@ -1,117 +0,0 @@
class Player
constructor: (
@story,
@id,
@startText = "Click to start...",
@endText = '## The End\n\nYou have reached the end of this story. <a id="restart" href="">Click here</a> to start over.'
) ->
@converter = new Markdown.Converter()
@container = $("##{@id}")
@container.addClass('ficdown').data 'player', this
@playerState = {}
@visitedScenes = {}
@currentScene = null
@moveCounter = 0
i = 0
scene.id = "s#{++i}" for scene in scenes for key, scenes of @story.scenes
play: ->
@container.html @converter.makeHtml "##{@story.name}\n\n#{@story.description}\n\n[#{@startText}](/#{@story.firstScene})"
@wireLinks()
wireLinks: ->
@container.find('a:not(.disabled):not(.external)').each (i) ->
$this = $(this)
if !$this.attr('href').match(/^https?:\/\//)?
$this.click ->
$this.addClass 'chosen'
player = $this.parents('.ficdown').data 'player'
player.handleHref $this.attr 'href'
return false
else
$this.addClass 'external'
resolveDescription: (description) ->
for anchor in matchAnchors description
href = matchHref anchor.href
if href.conditions?
conditions = toBoolHash href.conditions.split '&'
satisfied = conditionsMet @playerState, conditions
alts = splitAltText anchor.text
replace = if satisfied then alts.passed else alts.failed
if !replace?
replace = ''
replace = replace.replace regexLib.escapeChar, ''
if replace == '' or (!href.toggles? and !href.target?)
description = description.replace anchor.anchor, replace
else
newAnchor = "[#{replace}](#{ if href.target? then "/#{href.target}" else "" }#{ if href.toggles? then "##{href.toggles}" else "" })"
description = description.replace anchor.anchor, newAnchor
description = description.replace regexLib.emptyListItem, ''
return description
disableOldLinks: ->
@container.find('a:not(.external)').each (i) ->
$this = $(this)
$this.addClass 'disabled'
$this.unbind 'click'
$this.click -> return false
processHtml: (sceneid, html) ->
temp = $('<div/>').append $.parseHTML html
if @visitedScenes[sceneid]
temp.find('blockquote').remove()
else
temp.find('blockquote').each (i) ->
$this = $(this)
$this.replaceWith $this.html()
return temp.html()
checkGameOver: ->
if @container.find('a:not(.disabled):not(.external)').length == 0
@container.append @converter.makeHtml @endText
$('#restart').data('info', [@id, @story]).click ->
info = $(this).data 'info'
$("##{info[0]}").empty()
player = new Player info[1], info[0]
$("##{info[0]}").data 'player', player
player.play()
return false
handleHref: (href) ->
match = matchHref href
matchedScene = null
actions = []
if match?.toggles?
toggles = match.toggles.split '+'
for toggle in toggles
if @story.actions[toggle]?
action = $.extend {}, @story.actions[toggle]
action.description = @resolveDescription action.description
actions.push action
@playerState[toggle] = true
if match?.target?
if @story.scenes[match.target]?
for scene in @story.scenes[match.target]
if conditionsMet @playerState, scene.conditions
if !matchedScene? or !scene.conditions? or !matchedScene.conditions? or Object.keys(scene.conditions).length > Object.keys(matchedScene.conditions).length
matchedScene = scene
if matchedScene?
@currentScene = matchedScene
newScene = $.extend {}, @currentScene
newScene.description = @resolveDescription newScene.description
@disableOldLinks()
newContent = ""
newContent += "###{newScene.name}\n\n" if newScene.name?
newContent += "#{action.description}\n\n" for action in actions
newContent += newScene.description
newHtml = @processHtml newScene.id, @converter.makeHtml newContent
@visitedScenes[newScene.id] = true
scrollId = "move-#{@moveCounter++}"
@container.append $('<span/>').attr 'id', scrollId
@container.append newHtml
@wireLinks()
@checkGameOver()
@container.parent('.container').animate
scrollTop: $("##{scrollId}").offset().top - @container.offset().top
, 1000

View file

@ -1,52 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Ficdown.js Tests</title>
<meta charset="utf-8">
</head>
<body>
<div>
<textarea style="height:200px;width:600px;" id="input"># [Test Story](/test-scene)
This is a test story by Rudis Muiznieks
Version 1
## Test Scene
This is a test scene.scene
- [This is an option.](/test-scene-2)
- [This is another option.](#test-condition)
- [Another scene.](/test-scene-3)
### Test Condition
You have [|not](?test-condition) selected that option before.
## [Test Scene](?test-condition "Different Title")
This is the scene that matches a condition.
## Test Scene 2
This is a second scene
## [Test Scene 3]("")
This scene has no title.</textarea>
<div><button onclick="doIt()">Do It</button></div>
<pre id="output">
</pre>
</div>
<script src="ficdown.js"></script>
<script>
(function doIt(){
var storyText = document.getElementById('input').value;
var story = parseText(storyText);
document.getElementById('output').innerHTML = JSON.stringify(story,null,2);
})()
</script>
</body>
</html>

View file

@ -1,89 +0,0 @@
regexLib =
anchors: /(\[((?:[^\[\]]+|\[(?:[^\[\]]+|\[(?:[^\[\]]+|\[(?:[^\[\]]+|\[(?:[^\[\]]+|\[(?:[^\[\]]+|\[\])*\])*\])*\])*\])*\])*)\]\([ ]*((?:[^()\s]+|\((?:[^()\s]+|\((?:[^()\s]+|\((?:[^()\s]+|\((?:[^()\s]+|\((?:[^()\s]+|\(\))*\))*\))*\))*\))*\))*)[ ]*((['"])(.*?)\5[ ]*)?\))/gm
href: /^(?:\/([a-zA-Z](?:-?[a-zA-Z0-9])*))?(?:\?((?:!?[a-zA-Z](?:-?[a-zA-Z0-9])*)(?:&!?[a-zA-Z](?:-?[a-zA-Z0-9])*)*)?)?(?:#((?:[a-zA-Z](?:-?[a-zA-Z0-9])*)(?:\+[a-zA-Z](?:-?[a-zA-Z0-9])*)*))?$/
trim: /^\s+|\s+$/g
altText: /^((?:[^|\\]|\\.)*)(?:\|((?:[^|\\]|\\.)+))?$/
escapeChar: /\\(?=[^\\])/g
emptyListItem: /^[ ]*-\s*([\r\n]+|$)/gm
matchAnchor = (text) ->
re = new RegExp regexLib.anchors
match = re.exec text
if match?
result =
anchor: match[1]
text: match[2]
href: match[3]
title: match[6]
if result.href.indexOf('"') == 0
result.title = result.href.substring 1, result.href.length - 1
result.href = ''
return result
return match
matchAnchors = (text) ->
re = new RegExp regexLib.anchors
anchors = []
while match = re.exec text
anchor =
anchor: match[1]
text: match[2]
href: match[3]
title: match[6]
if anchor.href.indexOf('"') == 0
anchor.title = anchor.href.substring 1, anchor.href.length - 1
anchor.href = ''
anchors.push anchor
return anchors
trimText = (text) ->
text.replace regexLib.trim, ''
matchHref = (href) ->
re = new RegExp regexLib.href
match = re.exec href
if match?
result =
target: match[1]
conditions: match[2]
toggles: match[3]
return result
return match
normalize = (text) ->
text.toLowerCase().replace(/^\W+|\W+$/g, '').replace /\W+/g, '-'
toBoolHash = (names) ->
if !names?
return null
hash = {}
for name in names
if name.indexOf('!') == 0
hash[name.substring 1, name.length] = false
else
hash[name] = true
return hash
conditionsMet = (state, conditions) ->
met = true
for cond, val of conditions
if (val and !state[cond]) or (!val and state[cond])
met = false
break
return met
splitAltText = (text) ->
re = new RegExp regexLib.altText
match = re.exec text
if match?
result =
passed: match[1]
failed: match[2]
return result
return

View file

@ -1,3 +1,22 @@
<!DOCTYPE html>
<html lang='en'>
<head>
<meta charset='utf-8'>
<title>Ficdown.js Test</title>
<style>
a.disabled {
color: #999;
}
a.disabled.chosen {
color: #000;
}
a.disabled:hover {
cursor: not-allowed;
}
</style>
</head>
<body>
<textarea id='source' style='display: none;'>
# [Cloak of Darkness](/foyer)
A basic IF demonstration.
@ -59,3 +78,16 @@ The message, neatly marked in the sawdust, reads...
The message has been carelessly trampled, making it difficult to read. You can just distinguish the words...
**You have lost.**
</textarea>
<div id='game' />
<script src='build/ficdown.min.js'></script>
<script>
var player = new Ficdown({
id: 'game',
source: document.getElementById('source').value,
scroll: true,
});
player.play();
</script>
</body>
</html>

12
tsconfig.json Normal file
View file

@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["es2017", "dom"],
"module": "commonjs",
"moduleResolution": "node",
"baseUrl": "./src",
"outDir": "./build/unpacked",
"allowJs": true,
"strict": true
}
}