Compare commits
No commits in common. "main" and "v0.9.1" have entirely different histories.
|
@ -1,2 +1,4 @@
|
|||
build/
|
||||
*.swp
|
||||
bin/
|
||||
node_modules/
|
||||
.DS_Store
|
||||
|
|
|
@ -0,0 +1,72 @@
|
|||
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'
|
||||
]
|
21
LICENSE.txt
21
LICENSE.txt
|
@ -1,21 +0,0 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2020 Rudis Muiznieks
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
71
README.md
71
README.md
|
@ -1,45 +1,48 @@
|
|||
# Ficdown.js
|
||||
|
||||
Ficdown is a system for building interactive fiction using MarkDown syntax, and Ficdown.js is a Javascript library for presenting Ficdown stories interactively in a web browser.
|
||||
Ficdown is a system for building interactive fiction using MarkDown syntax, and Ficdown.js is a Javascript library for presenting Ficdown stories interactively in a web browser. See [Ficdown.com](http://www.ficdown.com/) for more information.
|
||||
|
||||
## Dependencies
|
||||
|
||||
The generated ficdown.js and ficdown.min.js include all dependencies ([JQuery](https://jquery.com), [markdown-it](https://github.com/markdown-it/markdown-it), and [core-js](https://github.com/zloirock/core-js)), 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
|
||||
```
|
||||
Ficdown.js uses [jQuery](http://jquery.com) for DOM manipulation and [PageDown](https://code.google.com/p/pagedown/) to convert MarkDown into HTML.
|
||||
|
||||
## Usage
|
||||
|
||||
You can obtain *ficdown.js* or *ficdown.min.js* from the latest version on the [releases](https://code.sitosis.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.
|
||||
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:
|
||||
|
||||
```javascript
|
||||
var player = new Ficdown(playerOptions);
|
||||
player.play();
|
||||
```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>
|
||||
```
|
||||
|
||||
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.
|
||||
- `scrollParent` (optional): The container to scroll. Used even if `scroll=false` to reset the page position to the top whenever a new scene is displayed. Defaults to the document or `body` element of the page.
|
||||
- `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.
|
||||
- `start` (optional): Javascript callback function to execute when the user clicks the first link to begin the story.
|
||||
- `finish` (optional): Javascript callback function to execute when the story has finished.
|
||||
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).
|
||||
|
|
File diff suppressed because it is too large
Load Diff
29
package.json
29
package.json
|
@ -1,26 +1,15 @@
|
|||
{
|
||||
"name": "ficdown.js",
|
||||
"version": "2.0.4",
|
||||
"version": "0.9.1",
|
||||
"description": "A parser and player for Interactive Fiction written in Ficdown",
|
||||
"license": "MIT",
|
||||
"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.6.5",
|
||||
"jquery": "^3.5.1",
|
||||
"markdown-it": "^10.0.0"
|
||||
},
|
||||
"dependencies": {},
|
||||
"devDependencies": {
|
||||
"@types/core-js": "^2.5.3",
|
||||
"@types/jquery": "^3.3.38",
|
||||
"@types/markdown-it": "^10.0.1",
|
||||
"@types/node": "^13.13.5",
|
||||
"browserify": "^16.5.1",
|
||||
"typescript": "^3.8.3",
|
||||
"uglify-js": "^3.9.2"
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
export type Action = {
|
||||
lineNumber: number,
|
||||
state: string,
|
||||
description: string,
|
||||
}
|
|
@ -1,4 +0,0 @@
|
|||
export type AltText = {
|
||||
passed?: string,
|
||||
failed?: string,
|
||||
}
|
|
@ -1,6 +0,0 @@
|
|||
export type Anchor = {
|
||||
anchor: string,
|
||||
text: string,
|
||||
href: string,
|
||||
title: string,
|
||||
}
|
|
@ -1,14 +0,0 @@
|
|||
import { Line } from './';
|
||||
|
||||
export enum BlockType {
|
||||
Action,
|
||||
Scene,
|
||||
Story,
|
||||
}
|
||||
|
||||
export type Block = {
|
||||
lineNumber: number,
|
||||
type: BlockType,
|
||||
name: string,
|
||||
lines: Line[],
|
||||
}
|
|
@ -1 +0,0 @@
|
|||
export type BoolHash = { [name: string]: boolean }
|
|
@ -1,5 +0,0 @@
|
|||
export type Href = {
|
||||
target: string,
|
||||
conditions: string,
|
||||
toggles: string,
|
||||
}
|
|
@ -1,4 +0,0 @@
|
|||
export type Line = {
|
||||
lineNumber: number,
|
||||
text: string,
|
||||
}
|
|
@ -1,7 +0,0 @@
|
|||
export class ParseError extends Error {
|
||||
constructor(message: string, public lineNumber: number) {
|
||||
super(lineNumber === 0
|
||||
? message
|
||||
: `[${ lineNumber }]: ${ message }`);
|
||||
}
|
||||
}
|
|
@ -1,11 +0,0 @@
|
|||
export type PlayerOptions = {
|
||||
source: string, // ficdown story source
|
||||
id: string, // id of div to inject game into
|
||||
scroll?: boolean, // continuous scroll mode
|
||||
scrollParent?: string, // id of the parent container to scroll
|
||||
html?: boolean, // allow html in story source
|
||||
startText?: string, // custom link text to start game
|
||||
endMarkdown?: string, // custom markdown when game ends
|
||||
start?: () => void, // callback when the game starts
|
||||
finish?: () => void, // callback when the game finishes
|
||||
}
|
|
@ -1,10 +0,0 @@
|
|||
import { BoolHash } from './';
|
||||
|
||||
export type Scene = {
|
||||
lineNumber: number,
|
||||
name: string,
|
||||
key: string,
|
||||
description: string,
|
||||
conditions?: BoolHash,
|
||||
id?: string;
|
||||
}
|
|
@ -1,9 +0,0 @@
|
|||
import { Scene, Action } from './';
|
||||
|
||||
export type Story = {
|
||||
name: string,
|
||||
description: string,
|
||||
firstScene: string,
|
||||
scenes: { [key: string]: Scene[] },
|
||||
actions: { [key: string]: Action },
|
||||
}
|
|
@ -1,11 +0,0 @@
|
|||
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
117
src/Parser.ts
|
@ -1,117 +0,0 @@
|
|||
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.title != null ? storyName.title : 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 != null
|
||||
? 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,
|
||||
};
|
||||
}
|
||||
}
|
208
src/Player.ts
208
src/Player.ts
|
@ -1,208 +0,0 @@
|
|||
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;
|
||||
|
||||
private startCallback?: () => void;
|
||||
private finishCallback?: () => void;
|
||||
private started: boolean = false;
|
||||
|
||||
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);
|
||||
|
||||
this.startCallback = options.start;
|
||||
this.finishCallback = options.finish;
|
||||
}
|
||||
|
||||
public play(): void {
|
||||
this.container.html(
|
||||
this.converter.render(`${ this.story.name ? `# ${this.story.name}\n\n` : '' }${ this.story.description }\n\n[${ this.startText }](/${ this.story.firstScene })`));
|
||||
this.wireLinks();
|
||||
}
|
||||
|
||||
public handleHref(href: string): false {
|
||||
if (this.startCallback && !this.started) {
|
||||
this.started = true;
|
||||
this.startCallback();
|
||||
}
|
||||
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)) {
|
||||
const sceneConds = scene.conditions
|
||||
? Object.keys(scene.conditions).length : 0;
|
||||
const matchConds = matchedScene && matchedScene.conditions
|
||||
? Object.keys(matchedScene.conditions).length : 0;
|
||||
if(!matchedScene || sceneConds > matchConds) {
|
||||
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);
|
||||
const scrollParent = this.options.scrollParent
|
||||
? $(`#${ this.options.scrollParent }`)
|
||||
: $([document.documentElement, document.body]);
|
||||
scrollParent.animate({
|
||||
scrollTop: $(`#${ scrollId }`).offset()!.top,
|
||||
}, 1000);
|
||||
} else {
|
||||
this.container.html(newHtml);
|
||||
if (this.options.scrollParent) {
|
||||
$(`#${ this.options.scrollParent }`)[0].scrollTop = 0;
|
||||
} else {
|
||||
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;
|
||||
});
|
||||
if (this.finishCallback) {
|
||||
this.finishCallback();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
100
src/Util.ts
100
src/Util.ts
|
@ -1,100 +0,0 @@
|
|||
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: match[1],
|
||||
text: match[2],
|
||||
href: match[3],
|
||||
title: match[6],
|
||||
};
|
||||
if(result.href.indexOf('"') === 0 || 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 {};
|
||||
}
|
||||
}
|
|
@ -0,0 +1,33 @@
|
|||
<!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>
|
|
@ -0,0 +1,39 @@
|
|||
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.
After Width: | Height: | Size: 149 KiB |
|
@ -0,0 +1,178 @@
|
|||
# [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.**
|
|
@ -1,22 +1,3 @@
|
|||
<!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.
|
||||
|
@ -78,16 +59,3 @@ 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>
|
|
@ -1,2 +0,0 @@
|
|||
import 'core-js/es/object/entries';
|
||||
module.exports = require('./Player.js').Player;
|
|
@ -0,0 +1,76 @@
|
|||
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"
|
|
@ -0,0 +1,117 @@
|
|||
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
|
|
@ -0,0 +1,52 @@
|
|||
<!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>
|
|
@ -0,0 +1,89 @@
|
|||
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
|
|
@ -1,12 +0,0 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": ["es2017.object", "dom"],
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node",
|
||||
"baseUrl": "./src",
|
||||
"outDir": "./build/unpacked",
|
||||
"allowJs": true,
|
||||
"strict": true
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue