1
0
Fork 0
mirror of https://gitlab.com/Oreolek/black_phone.git synced 2024-06-26 03:50:56 +03:00

Salet conversion WIP

This commit is contained in:
Alexander Yakovlev 2016-02-08 18:38:44 +07:00
parent f497b78e27
commit 074f78d1ab
16 changed files with 1249 additions and 2191 deletions

View file

@ -10,25 +10,23 @@ sass = require('gulp-sass')
uglify = require('gulp-uglify')
buffer = require('vinyl-buffer')
zip = require('gulp-zip')
_ = require('lodash')
concat = require('gulp-concat')
reload = browserSync.reload
html = (target) ->
return () ->
return gulp.src(['html/index.html','html/en.html'])
.pipe(gulp.dest(target));
return gulp.src(['html/index.html']).pipe(gulp.dest(target))
# Images
img = (target) ->
return () ->
return gulp.src(['img/*.png', 'img/*.jpeg', 'img/*.jpg'])
.pipe(gulp.dest(target));
return gulp.src(['img/*.png', 'img/*.jpeg', 'img/*.jpg']).pipe(gulp.dest(target))
# Audio assets
audio = (target) ->
return () ->
return gulp.src(['audio/*.mp3'])
.pipe(gulp.dest(target));
return gulp.src(['audio/*.mp3']).pipe(gulp.dest(target))
gulp.task('html', html('./build'))
gulp.task('img', img('./build/img'))
@ -37,21 +35,14 @@ gulp.task('audio', audio('./build/audio'))
gulp.task('sass', () ->
gulp.src('sass/main.scss')
.pipe(sass({outputStyle: 'compressed'}).on('error', sass.logError))
.pipe(gulp.dest('./build/css'));
.pipe(gulp.dest('./build/css'))
)
sources = [
'./game/begin.coffee',
'./game/story.coffee',
'./game/end.coffee',
]
opts = _.assign({}, watchify.args, {
bundler = watchify(browserify({
entries: ["./build/game/main.coffee"]
debug: true
transform: [coffeify]
});
bundler = watchify(browserify(opts))
}))
bundle = () ->
return bundler.bundle()
@ -60,15 +51,16 @@ bundle = () ->
.pipe(gulp.dest('./build/game'))
gulp.task('concatCoffee', () ->
return gulp.src(sources)
.pipe(concat('./main.coffee'))
.pipe(gulp.dest('./build/game'))
return gulp.src([
'./game/begin.coffee',
'./game/story.coffee',
]).pipe(concat('./main.coffee')).pipe(gulp.dest('./build/game'))
)
gulp.task('coffee', ['concatCoffee'], bundle)
bundler.on('update', bundle);
bundler.on('log', gutil.log);
bundler.on('update', bundle)
bundler.on('log', gutil.log)
gulp.task('build', ['html', 'img', 'sass', 'coffee', 'audio'])
@ -85,7 +77,7 @@ gulp.task('serve', ['build'], () ->
gulp.watch(['./html/*.html'], ['html'])
gulp.watch(['./sass/*.scss'], ['sass'])
gulp.watch(['./img/*.png', './img/*.jpeg', './img/*.jpg'], ['img'])
gulp.watch(['./lib/*.coffee', './lib/*.js', './game/*.coffee'], ['coffee'])
gulp.watch(['./game/*.coffee'], ['coffee']);
gulp.watch(['./build/css/main.css'], sassListener)
gulp.watch(
@ -93,25 +85,25 @@ gulp.task('serve', ['build'], () ->
browserSync.reload)
)
gulp.task('html-dist', html('./dist'));
gulp.task('img-dist', img('./dist/img'));
gulp.task('audio-dist', audio('./dist/audio'));
gulp.task('html-dist', html('./dist'))
gulp.task('img-dist', img('./dist/img'))
gulp.task('audio-dist', audio('./dist/audio'))
gulp.task('legal-dist', () ->
return gulp.src(['LICENSE.txt'])
.pipe(gulp.dest("./dist"));
);
.pipe(gulp.dest("./dist"))
)
gulp.task('sass-dist', () ->
return gulp.src('./sass/main.scss')
.pipe(sass({outputStyle: 'compressed'}))
.pipe(gulp.dest('./dist/css'));
);
.pipe(gulp.dest('./dist/css'))
)
distBundler = browserify({
debug: false,
entries: ['./build/game/main.coffee'],
transform: ['coffeeify']
});
})
gulp.task('coffee-dist', ['concatCoffee'], () ->
return distBundler.bundle()
@ -119,13 +111,20 @@ gulp.task('coffee-dist', ['concatCoffee'], () ->
.pipe(buffer())
.pipe(uglify())
.on('error', gutil.log)
.pipe(gulp.dest('./dist/game'));
);
.pipe(gulp.dest('./dist/game'))
)
gulp.task('dist', ['html-dist', 'img-dist', 'sass-dist', 'coffee-dist', 'audio-dist', 'legal-dist']);
gulp.task('dist', [
'html-dist',
'img-dist',
'sass-dist',
'coffee-dist',
'audio-dist',
'legal-dist'
])
gulp.task('zip', ['dist'], () ->
return gulp.src('dist/**')
.pipe(zip('dist.zip'))
.pipe(gulp.dest('.'));
);
.pipe(gulp.dest('.'))
)

View file

@ -2,31 +2,3 @@
### A walk through someone else's apartment
*Provided under the terms of MIT license, see LICENSE.txt*
#### Hacks and whistles
"Black phone" boasts a new technical feature: additional room exits.
This is something I greatly missed coming from INSTEAD.
I had to hack Undum and expose ("export") its `processClick` function so I could make an additional interface block.
The technical side is simple: my [Undum-commonjs](https://github.com/oreolek/undum-commonjs) fork exports `processClick` function.
Every situation that is a room (there are non-room situations, mind) has an array of `ways`.
This array has every other room this one connects to.
On entering a room, the `before` function calls for `update_ways` function that reads `ways` array and updates the UI.
It's not automatic but the game is small enough for this to work.
Undum has a bug: every "once" link becomes clickable again when you visit the same situation.
For example, you can save the game, load it again and click everything the second time.
The game exploits this because you can visit any room again and examine everything you missed.
There is also a slightly debatable UI hack: as every link is "once" link (it gets deactivated once it's called),
I've sped up every click by 250ms or so like this:
document.onmousedown = (e) ->
e.target.click()
A "click" in Javascript is an event of clicking the mouse button and releasing it.
The "mousedown" event is just clicking the button, not waiting for the release.
I didn't bother with intricacies, so the right mouse click is treated the same as the left one.

View file

@ -2,73 +2,78 @@
# This work is licensed under the Creative Commons Attribution-ShareAlike 4.0 International License.
# To view a copy of this license, visit http://creativecommons.org/licenses/by-sa/4.0
markdown = require('../../lib/markdown.coffee')
room = require("../../lib/room.coffee")
obj = require('../../lib/obj.coffee')
dialogue = require('../../lib/dialogue.coffee')
oneOf = require('../../lib/oneOf.coffee')
require('../../lib/interface.coffee')
undum = require('../../lib/undum.js')
Salet = require('../../lib/salet.coffee')
undum.game.id = "6a9909a4-586a-4089-bd18-26da684d1c8d"
undum.game.version = "2.0"
salet = new Salet
salet.view.init(salet)
salet.game_id = "6a9909a4-586a-4089-bd18-26da684d1c8d"
salet.game_version = "2.0"
salet.init = () ->
_paq.push(['setCustomDimension', 1, false])
@character.view_smash = 1
@character.money = 0
@character.code = ""
@character.knows_the_code = 0
@character.box_opened = 0
$(document).ready(() ->
salet.beginGame()
)
way_to = (content, ref) ->
return "<a href='#{ref}' class='way'>#{content}</a>"
textlink = (content, ref) ->
return "<a href='./_writer_#{ref}' class='once'>#{content}</a>"
actlink = (content, ref) ->
return "<a href='./#{ref}' class='once'>#{content}</a>"
textcycle = (content, ref) ->
return "<a href='./_replacer_#{ref}' class='cycle' id='#{ref}'>#{content}</a>"# usage: writemd( system, "Text to write")
objlink = (content, ref) ->
return "<a href='./_act_#{ref}' class='once'>#{content}</a>"
writemd = (system, text) ->
text = markdown(text)
system.write(text)
money = (salet, amount) ->
salet.character.money = salet.character.money + amount
money = (character, amount) ->
character.sandbox.money = character.sandbox.money + amount
code_can_input = (salet) ->
return salet.character.code.length < 8
code_can_input = (character) ->
return character.sandbox.code.length < 8
code_print = (character) ->
mask = 8 - character.sandbox.code.length
retval = character.sandbox.code
code_print = (salet) ->
mask = 8 - salet.character.code.length
retval = salet.character.code
if mask > 0
for i in [1..mask]
retval += "_"
return retval
code_input = (character, digit) ->
if code_can_input(character)
character.sandbox.code = character.sandbox.code + digit
code_input = (salet, digit) ->
if code_can_input(salet)
salet.character.code = salet.character.code + digit
code_reset = (character) ->
character.sandbox.code = ""
code_reset = (salet) ->
salet.character.code = ""
code_check = (character, system) ->
if character.sandbox.code.length >= 8
code_check = (salet) ->
output = ""
if salet.character.code.length >= 8
# There is an Undum.. let's call it a feature
# that prevents the player from entering "3112".
# You see, you can't select the situation 1 when you are
# already in this situation, so you can't input 1 twice.
if character.sandbox.code == "01012017"
character.sandbox.box_opened = 1
if character.sandbox.knows_the_code == 0
writemd(system, """
if salet.character.code == "01012017"
salet.character.box_opened = 1
if salet.character.knows_the_code == 0
output += """
Is he an extraordinary puzzle cracker or was it a sheer luck, but Ronald manages to *guess* the code.
""")
"""
else
writemd(system, """
output += """
New Year 2017.
L. Y. must be Leonard Yakovlev, a famous painter.
Some tabloids tried to connect him with Ana but it seemed like a weak link.
By that logic, his sketch is worth more than all the cash here.
Ronald thinks about it, but decides to "let the woman have her memories".
""")
writemd(system, """
"""
output += """
\n\n
Something clicks and box opens.
The phone is slick, black and light in Ronald's hand.
@ -77,48 +82,21 @@ code_check = (character, system) ->
Ronald has no other business here.
It's time to go.
""")
system.doLink("bedroom")
"""
salet.goTo("bedroom")
else
writemd(system, "Something clicks and the display resets, but the box stays locked.")
output += "Something clicks and the display resets, but the box stays locked."
if character.sandbox.code == "000000"
writemd(system, "Of course, Ronald didn't hope it would be that easy.")
output += "\n\nOf course, Ronald didn't hope it would be that easy."
character.sandbox.code = ""
code_reset(salet)
return output
room "start",
room "start", salet,
before: () ->
dsc: """
Peter opened his door to find an empty silent corridor.
He went to the neighbor's door and met a closed door.
Ronald was working inside, quietly walking around the apartment.
He began the inspection from #{way_to('the living room.', 'living-room')}
"""
is_visited = (situation) ->
situations = undum.game.situations[situation]
if situations
return Boolean situations.visited
return 0
# N-th level examine function
level = (text, mark) ->
$("#content .#{mark}").fadeOut()
return markdown(text, mark)
lvl1 = (text) ->
$("#content .lvl2").fadeOut()
$("#content .lvl3").fadeOut()
$("#content .lvl4").fadeOut()
level(text, "lvl1")
lvl2 = (text) ->
$("#content .lvl3").fadeOut()
$("#content .lvl4").fadeOut()
level(text, "lvl2")
lvl3 = (text) ->
$("#content .lvl4").fadeOut()
level(text, "lvl3")
lvl4 = (text) ->
level(text, "lvl4")

View file

@ -1,10 +0,0 @@
undum.game.init = (character, system) ->
_paq.push(['setCustomDimension', 1, false])
character.sandbox.view_smash = 1
character.sandbox.money = 0
character.sandbox.code = ""
character.sandbox.knows_the_code = 0
character.sandbox.box_opened = 0
window.onload = undum.begin

View file

@ -1,4 +1,6 @@
room "living-room",
# This room has no description.
# The description is constructed from the objects within.
room "living-room", salet,
title: "Living room"
enter: (character, system, from) ->
if (from == "start")
@ -7,17 +9,15 @@ room "living-room",
audio.volume = 0.5
audio.play()
ways: ["bedroom", "kitchen", "balcony"]
dsc: """
#{textlink("A book stand", "bookcase")} is hanging above #{textlink("a television set.", "tv")}
"""
objects:
window: obj "window",
objects: [
obj "window",
act: """
The moon is full today.
It illuminates the apartment, makes the things stand out in some weird angles.
"""
dsc: "Ronald is standing in a dark room with a big {{window}}"
walls: obj "walls",
order: 0
dsc: "Ronald is standing in a dark room with a big {{window}}."
obj "walls",
dsc: "{{The walls}} are covered with a dingy rose wallpaper."
act: """
There are colorful photographs on the walls.
@ -27,28 +27,26 @@ room "living-room",
A sunset burning in a vast ocean.
A black monolith standing on sand dunes.
"""
door: obj "door",
obj "door",
dsc: "Oh, and {{the door Ronald came into}} the apartment is there, too."
act: (character, system) ->
if character.sandbox.box_opened == 0
writemd(system, lvl1("Ronald has a job here. It's still on."))
order: 99
act: (salet) ->
if salet.character.box_opened == 0
return "Ronald has a job here. It's still on."
else
system.doLink("exitdoor")
writers:
bookcase: (character, system) ->
lvl1("""
Either Anastasia has a very conflicting taste in books, or she has no taste at all. Let's see...
#{textlink("“Soviet Black Magic: a Lost Art”,", "magic")}
#{textlink("”My Dinner with Zane”,", "zane")}
#{textlink("The Scientific Dictionary of Everything,", "dictionary")}
#{textlink("“Silent Things”,", "silent")}
#{textlink("”Also sprach Zarathustra”,", "zaratustra")}
#{textlink("”Pepperoni poker”,", "pepper")}
#{textlink("”Jazz sauce. Culinary collection”", "culinary")}
and #{textlink("Classico.", "classico")}
""")
magic: (character, system) ->
lvl2("""
salet.goTo("exitdoor")
obj "zane",
level: 1,
act: """
A three-part romantic urban fantasy detective about a girl who solves murders while dining
with her best werewolf friend in an Orient café.
It looks rather new, the pages are still white and straight.
Maybe she didn't catch the right moment to read this.. or maybe just forgot about it.
"""
obj "magic",
level: 1,
act: """
Soviet Black Magic: a Lost Art
The author, who calls himself The Master, describes the lost art of Soviet Union *magija.*
@ -57,230 +55,277 @@ room "living-room",
This is a cheap paperback edition, but she read this at least a couple of times.
The pages have dogged ears, and coffee stains too.
Ronald can even see a hint of lipstick smearing the variety show retelling.
""")
zane: (character, system) ->
lvl2("""
A three-part romantic urban fantasy detective about a girl who solves murders while dining
with her best werewolf friend in an Orient café.
It looks rather new, the pages are still white and straight.
Maybe she didn't catch the right moment to read this.. or maybe just forgot about it.
""")
dictionary: (character, system) ->
lvl2("""
"""
obj "dictionary",
level: 1,
act: """
A big fat Dictionary of Everything, issued in 1989.
Nobody reads every page of these.
Ronald doubts Anastasia got to read at least one page.
""")
silent: (character, system) ->
lvl2("""
"""
obj "silent",
level: 1,
act: """
Silent Things, a story where nothing speaks and nothing happens.
*And she actually read that.*
""")
zaratustra: (character, system) ->
if character.sandbox.seen_zaratustra?
lvl2("""
This book already gave Ronald everything he wanted.
No need to read it, not a bit.
""")
else
money(character, 20000)
lvl2("""
Nietsche's four-part novel about The Man, The Superman and everything in-between.
It's surprisingly worn down.
She took this book out a lot.
*And she actually read that.*
"""
obj "zaratustra",
level: 1,
act: (salet) ->
if salet.character.seen_zaratustra?
return """
This book already gave Ronald everything he wanted.
No need to read it, not a bit.
"""
else
money(salet, 20000)
return """
Nietsche's four-part novel about The Man, The Superman and everything in-between.
It's surprisingly worn down.
She took this book out a lot.
Was she secretly a philosophy nut?
An Übermensch dreamer?
Was she secretly a philosophy nut?
An Übermensch dreamer?
No, of course not.
Ronald opens the book and finds a stash of money inside.
""")
pepper: (character, system) ->
lvl2("""
No, of course not.
Ronald opens the book and finds a stash of money inside.
"""
obj "pepper",
level: 1,
act: """
An "ironic woman detective" who plays harp and solves murders!
It's a trash book filled with blatant product placement. Looks untouched.
""")
culinary: (character, system) ->
lvl2("An old culinary book. Nothing about it.")
classico: (character, system) ->
lvl2("""
"""
obj "culinary",
level: 1,
act: "An old culinary book. Nothing about it."
obj "classico",
level: 1,
act: """
A history of classical music, from the prehistoric times to the 1970s, a brand new edition with passages about psychedelic rock.
The Bach section is bookmarked.
""")
tv: (character, system) ->
lvl1("""
"""
obj "tv",
dsc: "#{objlink("A book stand", "bookcase")} is hanging above {{a television set.}}"
order: 1
act: """
An expensive 40-something inch TV standing on a stylish black stand. The room looks kinda small for that monster.
""")
"""
obj "bookcase",
order: 0
act: """
Either Anastasia has a very conflicting taste in books, or she has no taste at all. Let's see...
#{objlink("“Soviet Black Magic: a Lost Art”,", "magic")}
#{objlink("”My Dinner with Zane”,", "zane")}
#{objlink("The Scientific Dictionary of Everything,", "dictionary")}
#{objlink("“Silent Things”,", "silent")}
#{objlink("”Also sprach Zarathustra”,", "zaratustra")}
#{objlink("”Pepperoni poker”,", "pepper")}
#{objlink("”Jazz sauce. Culinary collection”", "culinary")}
and #{objlink("Classico.", "classico")}
"""
]
room "bedroom",
room "bedroom", salet,
title: "Bedroom"
ways: ["living-room", "kitchen", "bathroom"]
dsc: (character, system) ->
dsc: (salet) ->
return """
The bedroom is spacious; its walls are lavender green, almost white in the moonlight.
#{textlink("A massive wardrobe", "wardrobe")} occupies one of the walls.
On the wall across #{objlink("a big bed", "bed")} hangs #{objlink("a full sized mirror.", "mirror")}
On the wall across #{textlink("a big bed", "bed")} hangs #{textlink("a full sized mirror.", "mirror")}
#{if character.sandbox.box_opened == 0
#{if salet.character.box_opened == 0
"On a small table near the bed is an ornate #{way_to("wooden box.", "box")}"
else ""}
"""
writers:
bed: () -> lvl1("""
A double bed with flower-embroidered sheets.
She left several days ago.
The sheets are still fresh.
""")
wardrobe: () -> lvl1("""
Anastasia's wardrobe is very high-maintenance.
It has a built-in ironing board (with an iron hanged nearby), with 5 drawer rows for #{textlink("lingerie,", "lingerie")} #{textlink("accessories", "accessories")}, #{textlink("shoes.", "shoes")}, #{textlink("hats", "hats")} and.. #{textlink("audio players.", "mp3")}
objects: [
obj "bed",
dsc: ""
order: 2
act: """
A double bed with flower-embroidered sheets.
She left several days ago.
The sheets are still fresh.
"""
obj "wardrobe",
dsc: "{{A massive wardrobe}} occupies one of the walls.",
order: 1,
act: """
Anastasia's wardrobe is very high-maintenance.
It has a built-in ironing board (with an iron hanged nearby), with 5 drawer rows for #{objlink("lingerie,", "lingerie")} #{objlink("accessories", "accessories")}, #{objlink("shoes.", "shoes")}, #{objlink("hats", "hats")} and.. #{objlink("audio players.", "mp3")}
On the hangers are #{textlink("cashmere coat,", "coat")} #{textlink("sport jacket,","jacket")} #{textlink("jeans,", "jeans")} #{textlink("green shirt,", "gshirt")} #{textlink("a red sleeveless shirt", "rshirt")}, #{textlink("an orange vest,", "vest")} #{textlink("knee-length flower dress,", "dress")} #{textlink("another flower dress,", "adress")} #{textlink("alpaca coat,","coat")} #{textlink("a short skirt", "skirt")} and #{textlink("a big collection of dancing costumes.", "costumes")}
""")
coat: () ->
lvl2("A warm coat for the cold winter.")
jacket: () ->
lvl2("""
On the hangers are #{objlink("cashmere coat,", "coat")} #{objlink("sport jacket,","jacket")} #{objlink("jeans,", "jeans")} #{objlink("green shirt,", "gshirt")} #{objlink("a red sleeveless shirt", "rshirt")}, #{objlink("an orange vest,", "vest")} #{objlink("knee-length flower dress,", "dress")} #{objlink("another flower dress,", "adress")} #{objlink("alpaca coat,","coat")} #{objlink("a short skirt", "skirt")} and #{objlink("a big collection of dancing costumes.", "costumes")}
"""
obj "coat",
level: 1
act: "A warm coat for the cold winter."
obj "jacket",
level: 1,
act: """
A light expensive jacket.
An unusual material, must be something high-tech.
It's slightly used.
""")
jeans: () ->
lvl2("Just a pair of women jeans, nothing special.")
gshirt: () ->
lvl2("""
"""
obj "jeans",
level: 1,
act: "Just a pair of women jeans, nothing special."
obj "gshirt",
level: 1,
act: """
A green shirt, looks very worn.
It's not remarkable in any way but maybe she just loves it very much.
""")
rshirt: () ->
lvl2("""
"""
obj "rshirt",
level: 1,
act: """
A red women-cut shirt.
She didn't wear it much.
""")
vest: () ->
lvl2("""
"""
obj "vest",
level: 1,
act: """
It looks like a life vest but actually it's a fashionable piece of warm clothing.
It was trendy last year.
*Why do I know this, is something wrong with me?*
""")
dress: () ->
lvl2("""
"""
obj "dress",
level: 1,
act: """
Just a dress.
Lots of flower embroidery, no pockets.
*Impractical.*
""")
adress: () ->
lvl2("These flowers are not like that flowers.")
coat: (character, system) ->
if character.sandbox.seen_coat?
return lvl2("""
A warm grey alpaca coat for the bleak autumn times.
It's one of her favorites.
""")
else
character.sandbox.seen_coat = 1
money(character, 4000)
return lvl2("""
A warm coat.. hey, what's this?
One of the pockets is loaded with cash!
""")
skirt: () ->
lvl2("""
"""
obj "adress",
level: 1,
act: "These flowers are not like that flowers."
obj "coat",
level: 1,
act: (salet) ->
if salet.character.seen_coat?
return """
A warm grey alpaca coat for the bleak autumn times.
It's one of her favorites.
"""
else
salet.character.seen_coat = 1
money(salet, 4000)
return """
A warm coat.. hey, what's this?
One of the pockets is loaded with cash!
"""
obj "skirt",
level: 1,
act: """
This hanger has only a short skirt.
Maybe there was something else on it?
Who knows.
""")
costumes: () ->
lvl2("""
"""
obj "costumes",
level: 1,
act: """
Ana is an exotic dancer.
She has her own dance style, and these exquisite costumes are made just for her moves and motions.
""")
mp3: () ->
lvl2("""
"""
obj "mp3",
level: 1,
act: """
Wow, this woman LOVES her players!
There are MP3 players, CD players, portable DVD, walk-on clips, sport hands-free players, underwater ones.
These are all rather cheap, though, compared to *something else* in this room.
""")
hats: () ->
lvl2("""
"""
obj "hats",
level: 1,
act: """
These look very old-style, very Mary Poppins-like.
Maybe that's just a trend or whatever.
""")
lingerie: () ->
lvl2("Ronald won't be digging in that.")
accessories: () ->
lvl2("""
"""
obj "lingerie",
level: 1,
act: "Ronald won't be digging in that."
obj "accessories",
level: 1,
act: """
A cross necklace, three metal bracelets and lots of uncomplicated earrings and hair pins.
A dozen of scarfs or so.
No diamonds, no rings, no *jewelry.*
On the other hand, her Instagram nickname is *bareboned mane shaker.*
""")
shoes: () ->
lvl2("""
"""
obj "shoes",
level: 1,
act: """
Anastasia doesn't care for the footwear fashion.
These 4 pairs of combat boots and 13 pairs of ballet flats can attest that.
Of course, there are sandals, loafers, flat dress shoes.. That's a strong dislike for heels... or maybe it's a medical problem?
""")
mirror: () ->
lvl1("""
"""
obj "mirror"
order: 3
dsc: ""
act: """
The mirror looks directly at the bed.
Kinky, though not very much *Feng Shui* in it.
#{textlink("The frame","frame")} depicts various artists (all women) making sculptures of men.
#{objlink("The frame","frame")} depicts various artists (all women) making sculptures of men.
It's a very *unusual* art.
""")
frame: () ->
lvl2("""
"""
obj "frame"
level: 1,
act: """
On a close examination, the frame isn't attached to the wall.
There is #{textlink("a safe", "safe")} behind the mirror!
""")
safe: (character) ->
if character.sandbox.seen_safe?
return lvl3("""
The safe is locked with a regular lock.
Ronald tries two keys.
The first of them opens the door.
There is #{objlink("a safe", "safe")} behind the mirror!
"""
obj "safe"
level: 2
act: (salet) ->
if salet.character.seen_safe?
return """
The safe is locked with a regular lock.
Ronald tries two keys.
The first of them opens the door.
There are #{textlink("money", "money")} inside, and #{textlink("a rough sketch.", "sketch")}
""")
else
return lvl3("""
The safe is open now.
There are #{objlink("money", "money")} inside, and #{objlink("a rough sketch.", "sketch")}
"""
else
return """
The safe is open now.
There is #{textlink("a rough sketch", "sketch")} inside.
""")
money: (character, system) ->
character.sandbox.seen_safe = 1
money(character, 50000)
lvl4("""
It's a big cash.
Odd that she didn't take this when she left.
But someone's fault just makes Ronald's payday now.
""")
sketch: () ->
lvl4("""
There is #{objlink("a rough sketch", "sketch")} inside.
"""
obj "money",
level: 3
act: (salet) ->
salet.character.seen_safe = 1
money(salet, 50000)
return """
It's a big cash pile.
Odd that she didn't take this when she left.
But someone's fault just makes Ronald's payday now.
"""
obj "sketch",
level: 3
act: """
It's a portrait of Anastasia.
She bites her lower lip slightly.
Her eyes are sad, or maybe concerned with something.
The sketch is signed: *"L. Y. - 2017"*
""")
"""
]
room "kitchen",
room "kitchen", salet,
title: "Kitchen"
ways: ["living-room", "bedroom"]
dsc: """
The white, perfectly clean kitchen could be called spartan: #{textlink("a fridge,", "fridge")} a microwave and #{textlink("a big table", "table")} where one can eat whatever she "cooked" that way.
The white, perfectly clean kitchen could be called spartan: #{objlink("a fridge,", "fridge")} a microwave
and #{objlink("a big table", "table")} where one can eat whatever she "cooked" that way.
"""
writers:
fridge: () ->
lvl1("""
objects: [
obj "fridge",
dsc: ""
act: """
No magnets or stickers on the outside.
The door opens easily.
*If only the hinges on the apartment doors were as good as refrigerator ones.*
@ -296,30 +341,35 @@ room "kitchen",
A jar of raspberry preserve, half-finished.
And enough frozen pizzas to last a month.
""")
table: (character, system) ->
if character.sandbox.seen_table?
return lvl1("A letter's still there. Nothing new about it.")
else
character.sandbox.seen_table = 1
return lvl1("""
There's something on the table.
"""
obj "table",
dsc: ""
act: (salet) ->
if salet.character.seen_table?
return "A letter's still there. Nothing new about it."
else
salet.character.seen_table = 1
return """
There's something on the table.
It looks like a formal letter.
It's in French, though, so Ronald won't be able to read it.
He's sure it's recent (`24.03.2018`) and it's about something-something QUANTUM AUDIO.. armement?
""")
It looks like a formal letter.
It's in French, though, so Ronald won't be able to read it.
He's sure it's recent (`24.03.2018`) and it's about something-something QUANTUM AUDIO.. armement?
"""
]
room "bathroom",
before: (character,system) ->
writemd(system,"Ronald doesn't want to search the bathroom. It's too private a room to enter.")
index = undum.game.situations["bedroom"].ways.indexOf("bathroom")
undum.game.situations["bedroom"].ways.splice(index, 1)
return false
room "bathroom", salet,
before: (salet) ->
bedroom = salet.getRoom("bedroom")
index = bedroom.ways.indexOf("bathroom")
bedroom.ways.splice(index, 1)
return "Ronald doesn't want to search the bathroom. It's too private a room to enter."
enter: (salet) ->
return salet.goTo("bedroom")
title: "Bathroom"
ways: ["bedroom"]
room "balcony",
room "balcony", salet,
title: "Balcony"
ways: ["living-room"]
dsc: """
@ -327,17 +377,19 @@ room "balcony",
It's an amazing night.
The whole town is lit by moonlight, standing perfectly still.
On a short stand is #{textlink("an ashtray","ashtray")} with some ash in it.
On a short stand is #{objlink("an ashtray","ashtray")} with some ash in it.
"""
writers:
ashtray: (character) ->
character.sandbox.knows_the_code = 1
return lvl1("""
She completely smoked out two cigarettes here.
There's also a #{textlink("piece of paper nearby,", "paper")} half-burnt.
""")
paper: () ->
lvl2("""
objects: [
obj "ashtray",
act: (salet) ->
salet.character.knows_the_code = 1
return """
She completely smoked out two cigarettes here.
There's also a #{objlink("piece of paper nearby,", "paper")} half-burnt.
"""
obj "paper",
level: 1,
act: """
It's a letter, written by hand on a thick sheet of what must be an A4 paper.
The handwriting is wobbly and the first three quarters of the sheet is gone, but the ending is legible.
@ -346,16 +398,17 @@ room "balcony",
I will fly to you no matter what.*
&nbsp;&nbsp;&nbsp; *L. Y.*
""")
"""
]
room "box",
room "box", salet,
ways: ["bedroom"]
choices: "#box"
dsc: (character, system) ->
dsc: (salet) ->
return """
It's a red wood, very expensive.
And this box is locked with a digital code key.
#{if is_visited(this) == 0
#{if salet.isVisited(this.name) == 0
"""
Ronald takes out a vial from his pocket. He coats the keys with a bright white powder.
@ -366,47 +419,47 @@ room "box",
}
"""
room "smash",
canView: (character) ->
character.sandbox.view_smash == 1
room "smash", salet,
canView: (salet) ->
salet.character.view_smash == 1
optionText: "Smash the box"
before: (character) ->
character.sandbox.view_smash = 0
before: (salet) ->
salet.character.view_smash = 0
choices: "#box"
tags: ["box"]
dsc: "Ronald still needs the phone in this box. A very high-tech fragile phone. Smashing isn't an option."
safe_button = (number) ->
room "put#{number}",
safe_button = (number, salet) ->
room "put#{number}", salet,
choices: "#box"
tags: ["box"]
optionText: "Enter #{number}"
before: (character) ->
code_input(character, number)
canChoose: (character) ->
code_can_input(character)
after: (character, system) ->
code_check(character, system)
dsc: (character) -> """
Ronald presses button #{number}. The display is #{code_print(character)} now.
before: (salet) ->
code_input(salet, number)
canChoose: (salet) ->
code_can_input(salet)
after: (salet) ->
code_check(salet)
dsc: (salet) -> """
Ronald presses button #{number}. The display is #{code_print(salet)} now.
"""
safe_button(1)
safe_button(2)
safe_button(7)
safe_button(0)
safe_button(1, salet)
safe_button(2, salet)
safe_button(7, salet)
safe_button(0, salet)
room "reset",
room "reset", salet,
choices: "#box"
tags: ["box"]
optionText: "Reset the display"
before: (character) ->
code_reset(character)
before: (salet) ->
code_reset(salet)
dsc: """
Ronald presses Backspace until the display is empty.
"""
room "exitdoor",
room "exitdoor", salet,
ways: ["living-room"]
choices: "#door"
dsc: """
@ -417,14 +470,14 @@ room "exitdoor",
Someone's shadow is under the doorframe.
"""
room "finale",
room "finale", salet,
before: () ->
_paq.push(['setCustomDimension', 1, true])
$("#tools_wrapper").hide()
optionText: "Use the Phone"
tags: ["door"]
ways: []
dsc: (character, system) -> """
dsc: (salet) -> """
"LOADING... 100%"
Ronald opens the door and presses his finger to the phone screen.
@ -443,8 +496,8 @@ room "finale",
Well, that was a good night.
#{if character.sandbox.money > 0
"The pocket is heavy with #{character.sandbox.money} rubles and the phone."
#{if salet.character.money > 0
"The pocket is heavy with #{salet.character.money} rubles and the phone."
else
"The phone is heavy in the pocket."
}

View file

@ -70,22 +70,6 @@
</div>
</div> <!-- End of div.page -->
<div id="ui_library">
<div id="quality" class="quality">
<span class="name" data-attr="name"></span>
<span class="value" data-attr="value"></span>
</div>
<div id="quality_group" class="quality_group">
<h2 data-attr="title"></h2>
<div class="qualities_in_group">
</div>
</div>
<hr id="turn_separator">
</div>
<div id="content_library"></div>
<audio id="bgsound" preload="auto"><source src="audio/bgr.mp3" type='audio/mpeg; codecs="mp3"'></audio>
<!-- CDN JS Libraries -->
<script type="text/javascript" src="//cdnjs.cloudflare.com/ajax/libs/marked/0.3.5/marked.min.js"></script>

View file

@ -1,19 +0,0 @@
# Cycling interface.
# Rooms: cycle through this.cycle_gallery
# Objects: cycle through this.cycle_gallery
cyclelink = (content) ->
return "<a href='./_replacer_cyclewriter' class='cycle' id='cyclewriter'>#{content}</a>"
cycle = (responses, name, character) ->
if typeof responses == "function"
responses = responses()
character.sandbox.cycle_index ?= [] # initialize with empty array
character.sandbox.cycle_index[name] ?= 0 # initialize with 0
response = responses[character.sandbox.cycle_index[name]]
character.sandbox.cycle_index[name]++
if character.sandbox.cycle_index[name] == responses.length
character.sandbox.cycle_index[name] = 0
return cyclelink(response)
module.exports = cycle

View file

@ -15,8 +15,8 @@ Usage:
Point out a thing in her purse (mildly)
""", "character.sandbox.mild = true"
###
dialogue = (title, startTag, endTag, text, effect) ->
retval = room(randomid(), {
dialogue = (title, salet, startTag, endTag, text, effect) ->
retval = room(randomid(), salet, {
optionText: title
dsc: text
clear: false # backlog is useful in dialogues

View file

@ -1,16 +0,0 @@
###
Salet interface configuration.
###
undum = require('./undum.js')
$(document).ready(() ->
$("#ways").on("click", "a", (event) ->
event.preventDefault()
undum.processClick($(this).attr("href"))
)
$("#inventory").on("click", "a", (event) ->
event.preventDefault()
)
$("#load").on("click", "a", (event) ->
window.location.reload()
)
)

View file

@ -4,7 +4,7 @@ Implies that you don't mix up your tabs and spaces.
Copyright 2015 Bruno Dias
###
normaliseTabs = (text) ->
unless text?
unless text? and typeof(text) == "string"
return ""
lines = text.split('\n');
indents = lines

View file

@ -1,5 +1,5 @@
markdown = require('./markdown.coffee')
undum = require('./undum.js')
require('./salet.coffee')
objlink = (content, ref) ->
return "<a href='./_act_#{ref}' class='once'>#{content}</a>"
@ -22,26 +22,29 @@ class SaletObj
return null
for key, value of spec
this[key] = value
level: 0
look: (character, system, f) =>
if @dsc
text = markdown(@dsc.fcall(this, character, system, f))
text = "<span class='look lvl#{@level}'>" + text + "</span>"
level: 0 # if > 0 it's hidden
order: 0 # you can use this to sort the descriptions
look: (system, f) =>
if @dsc and @dsc != ""
text = markdown(@dsc.fcall(this, system, f).toString())
text = system.view.wrapLevel(text, @level)
# replace braces {{}} with link to _act_
return parsedsc(text, @name)
takeable: false
take: (character, system) => "You take the #{@name}." # taking to inventory
act: (character, system) => "You don't find anything extraordinary about the #{@name}." # object action
dsc: (character, system) => "You see a {{#{@name}}} here." # object description
inv: (character, system) => "It's a {{#{@name}.}}" # inventory description
take: (system) => "You take the #{@name}." # taking to inventory
act: (system) => "You don't find anything extraordinary about the #{@name}." # object action
dsc: (system) => "You see a {{#{@name}}} here." # object description
inv: (system) => "It's a {{#{@name}.}}" # inventory description
location: ""
put: (location) =>
@level = 0 # this is scenery
if undum.game.situations[location]?
undum.game.situations[location].take(this)
if salet.rooms[location]?
salet.rooms[location].take(this)
@location = location
delete: () =>
undum.game.situations[@location].objects.remove(this)
delete: (location = false) =>
if location == false
location = @location
salet.rooms[location].drop(this)
obj = (name, spec) ->
spec ?= {}

View file

@ -1,100 +1,55 @@
# I confess that this world model heavily borrows from INSTEAD engine. - A.Y.
undum = require('./undum.js')
require('./salet.coffee')
obj = require('./obj.coffee')
markdown = require('./markdown.coffee')
cycle = require('./cycle.coffee')
assert = (msg, assertion) -> console.assert assertion, msg
Function.prototype.fcall = Function.prototype.call;
Boolean.prototype.fcall = () ->
return this
String.prototype.fcall = () ->
return this
way_to = (content, ref) ->
return "<a href='#{ref}' class='way' id='waylink-#{ref}'>#{content}</a>"
# jQuery was confused by this point where's the context so I did it vanilla-way
print = (content) ->
if content == ""
return
if typeof content == "function"
content = content()
block = document.getElementById("current-situation")
if block
block.innerHTML = block.innerHTML + markdown(content)
else
console.error("No current situation found.")
Array::remove = (e) -> @[t..t] = [] if (t = @indexOf(e)) > -1
addClass = (element, className) ->
if (element.classList)
element.classList.add(className)
else
element.className += ' ' + className
cls = (system) ->
system.clearContent()
system.clearContent("#intro")
update_ways = (ways, name) ->
content = ""
distances = []
if ways
document.querySelector(".ways h2").style.display = "block"
for way in ways
if undum.game.situations[way]?
title = undum.game.situations[way].title.fcall(this, name)
content += way_to(title, way)
distances.push({
key: way
distance: undum.game.situations[way].distance
})
else
document.querySelector(".ways h2").style.display = "none"
document.getElementById("ways").innerHTML = content
min = Infinity
min_key = []
for node in distances
if node.distance < min
min = node.distance
min_key = [node.key]
if node.distance == min
min_key.push(node.key)
if min < Infinity
for node in min_key
addClass(document.getElementById("waylink-#{node}"), "destination")
picture_tag = (picture) ->
extension = picture.substr((~-picture.lastIndexOf(".") >>> 0) + 2)
if (extension == "webm")
return """
<video src="#{picture}" controls>
Your browser does not support the video tag for some reason.
You won't be able to view this video in this browser.
</video>
"""
return "<img class='img-responsive' src='#{picture}' alt='Room illustration'>"
class SaletRoom extends undum.Situation
class SaletRoom
constructor: (spec) ->
undum.Situation.call(this, spec)
for index, value of spec
this[index] = value
return this
visited: 0
title: "Room"
objects: {}
# room illustration image, VN-style. Can be a GIF or WEBM. Can be a function.
pic: false
canView: true
canChoose: true
priority: 1
displayOrder: 1
tags: []
choices: ""
optionText: "Choice"
dsc: false # room description
extendSection: false
distance: Infinity # distance to the destination
clear: true # clear the screen on entering the room?
entering: (system, from) =>
###
I call SaletRoom.exit every time the player exits to another room.
Unlike @after this gets called after the section is closed.
It's a styling difference.
###
exit: (character, system, to) =>
exit: (system, to) =>
return true
###
@ -105,7 +60,7 @@ class SaletRoom extends undum.Situation
The upstream Undum version does not allow you to redefine @enter function easily but allows custom @exit one.
It was renamed as @entering to achieve API consistency.
###
enter: (character, system, from) =>
enter: (system, from) =>
return true
###
@ -117,62 +72,79 @@ class SaletRoom extends undum.Situation
My version of `enter` splits the location description from the effects.
Also if f == this.name (we're in the same location) the `before` and `after` callbacks are ignored.
###
entering: (character, system, f) =>
entering: (system, f) =>
if @clear and f?
cls(system)
system.view.clearContent()
if f != @name and f?
@visited++
if undum.game.situations[f].exit?
undum.game.situations[f].exit(character, system, @name)
if system.rooms[f].exit?
system.rooms[f].exit system, @name
if @enter
@enter character, system, f
@enter system, f
current_situation = ""
room_content = ""
if not @extendSection
classes = if @classes then ' ' + @classes.join(' ') else ''
situation = document.getElementById('current-situation')
if situation?
situation.removeAttribute('id')
room = document.getElementById('current-room')
if room?
room.removeAttribute('id')
# Javascript DOM manipulation functions like jQuery's append() or document.createElement
# don't work like a typical printLn - they create *DOM nodes*.
# You can't leave an unclosed tag just like that. So we have to buffer the output.
current_situation = "<section id='current-situation' data-situation='#{@name}' class='situation-#{@name}#{classes}'>"
room_content = "<section id='current-room' data-room='#{@name}' class='room-#{@name}#{classes}'>"
if f != @name and @before?
current_situation += markdown(@before.fcall(this, character, system, f))
room_content += markdown(@before.fcall(this, system, f))
current_situation += @look character, system, f
room_content += @look system, f
if f != @name and @after?
current_situation += markdown(@after.fcall(this, character, system, f))
room_content += markdown(@after.fcall(this, system, f))
if not @extendSection
current_situation += "</section>"
room_content += "</section>"
system.write(current_situation)
system.view.write(room_content)
if @choices
system.writeChoices(system.getSituationIdChoices(@choices, @minChoices, @maxChoices))
system.view.writeChoices(system, system.getSituationIdChoices(@choices, @maxChoices))
if system.autosave
system.saveGame()
###
An internal function to get the room's description and the descriptions of
every object in this room.
###
look: (character, system, f) =>
update_ways(@ways, @name)
look: (system, f) =>
system.view.updateWays(system, @ways, @name)
retval = ""
if @pic
retval += '<div class="pic">'+picture_tag(@pic.fcall(this, character, system, f))+'</div>'
retval += '<div class="pic">'+system.view.pictureTag(@pic.fcall(this, system, f))+'</div>'
# Print the room description
if @dsc
retval += markdown(@dsc.fcall(this, character, system, f))
if @dsc and @dsc != ""
dsc = @dsc.fcall(this, system, f).toString()
retval += markdown(dsc)
for name, thing of @objects
retval += thing.look()
objDescriptions = []
for thing in @objects
console.log thing
if thing.name and typeof(thing.look) == "function" and thing.level == 0 and thing.look(system, f)
objDescriptions.push ({
order: thing.order,
content: thing.look(system, f)
})
objDescriptions.sort((a, b) ->
return a.order - b.order
)
for description in objDescriptions
retval += description.content
return retval
@ -183,7 +155,7 @@ class SaletRoom extends undum.Situation
@objects[thing.name] = thing
# BUG: for some really weird reason if the call is made in init function or
# during the initialization, this ALSO puts the thing in the start room.
undum.game.situations["start"].objects = {}
salet.rooms["start"].objects = {}
drop: (name) =>
delete @objects[name]
@ -192,25 +164,29 @@ class SaletRoom extends undum.Situation
Object action. A function or a string which comes when you click on the object link.
You could interpret this as an EXAMINE verb or USE one, it's your call.
###
act: (character, system, action) =>
act: (system, action) =>
if (link = action.match(/^_(act|cycle)_(.+)$/)) #object action
for name, thing of @objects
if name == link[2]
for thing in @objects
if thing.name == link[2]
if link[1] == "act"
# If it's takeable, the player can take this object.
# If not, we check the "act" function.
if thing.takeable
character.sandbox.inventory.push thing
system.character.inventory.push thing
@drop name
cls(system)
@entering.fcall(this, character, system, @name)
return print(thing.take.fcall(thing, character, system))
system.view.clearContent()
@entering.fcall(this, system, @name)
return system.view.write(thing.take.fcall(thing, system).toString())
if thing.act
return print(thing.act.fcall(thing, character, system))
elseif link[1] == "cycle"
# TODO object cyclewriter
system.view.changeLevel(thing.level)
return system.view.write(
system.view.wrapLevel(
thing.act.fcall(thing, system).toString(),
thing.level
)
)
# the loop is done but no return came - match not found
console.error("Could not find #{link[1]} in current room.")
console.error("Could not find #{link[2]} in current room.")
# we're done with objects, now check the regular actions
actionClass = action.match(/^_(\w+)_(.+)$/)
@ -218,17 +194,16 @@ class SaletRoom extends undum.Situation
responses = {
writer: (ref) ->
content = that.writers[ref].fcall(that, character, system, action)
content = that.writers[ref].fcall(that, system, action)
output = markdown(content)
system.writeInto(output, '#current-situation')
system.view.write(output)
replacer: (ref) ->
content = that.writers[ref].fcall(that, character, system, action)
output = "<span>"+content+"</span>" # <p> tags are usually bad for replacers
system.replaceWith(output, '#'+ref)
content = that.writers[ref].fcall(that, system, action)
system.view.replace(content, '#'+ref)
inserter: (ref) ->
content = that.writers[ref].fcall(that, character, system, action)
content = that.writers[ref].fcall(that, system, action)
output = markdown(content)
system.writeInto(output, '#'+ref)
system.view.write(output, '#'+ref)
}
if (actionClass)
@ -239,7 +214,7 @@ class SaletRoom extends undum.Situation
throw new Error("Tried to call undefined writer: #{action}");
responses[responder](ref);
else if (@actions.hasOwnProperty(action))
@actions[action].call(this, character, system, action);
@actions[action].call(this, system, action);
else
throw new Error("Tried to call undefined action: #{action}");
@ -256,22 +231,30 @@ class SaletRoom extends undum.Situation
node.distance = current_room.distance + 1
candidates.push(node)
register: () =>
register: (salet) =>
if not @name?
console.error("Situation has no name")
return this
undum.game.situations[@name] = this
salet.rooms[@name] = this
return this
writers:
cyclewriter: (character) ->
cycle(this.cycle, this.name, character)
cyclewriter: (salet) ->
responses = @cycle
if typeof responses == "function"
responses = responses()
cycleIndex = window.localStorage.getItem("cycleIndex")
cycleIndex ?= 0
response = responses[cycleIndex]
cycleIndex++
if cycleIndex == responses.length
cycleIndex = 0
window.localStorage.setItem("cycleIndex", cycleIndex)
return salet.view.cycleLink(response)
room = (name, spec) ->
room = (name, salet, spec) ->
spec ?= {}
spec.name = name
retval = new SaletRoom(spec)
retval.register()
return retval
return new SaletRoom(spec).register(salet)
module.exports = room

445
lib/salet.coffee Normal file
View file

@ -0,0 +1,445 @@
markdown = require('./markdown.coffee')
SaletView = require('./view.coffee')
Random = require('./random.js')
languages = require('./localize.coffee')
###
fcall() (by analogy with fmap) is added to the prototypes of both String and
Function. When called on a Function, it's an alias for Function#call();
when called on a String, it only returns the string itself, discarding any input.
###
Function.prototype.fcall = Function.prototype.call;
Boolean.prototype.fcall = () ->
return this
String.prototype.fcall = () ->
return this
assert = (msg, assertion) -> console.assert assertion, msg
class Character
inventory: []
###
This is the control structure, it has minimal amount of data and
this data is volatile anyway (as in, it won't get saved).
###
class Salet
# REDEFINE THIS IN YOUR GAME
game_id: null
game_version: "1.0"
autosave: true
rnd: null
time: 0
# Corresponding room names to room objects.
rooms: {}
# The unique id of the starting room.
start: "start"
# Regular expression to catch every link action.
# Salet's default is a general URL-safe expression.
linkRe: /^([0-9A-Za-z_-]+|\.)(\/([0-9A-Za-z_-]+))?$/
character: new Character
###
This function is called at the start of the game. It is
normally overridden to provide initial character creation
(setting initial quality values, setting the
character-text. This is optional, however, as set-up
processing could also be done by the first situation's
enter function.
###
init: () ->
###
This function is called before entering any new
situation. It is called before the corresponding situation
has its `enter` method called.
###
enter: (oldSituationId, newSituationId) ->
###
Hook for when the situation has already been carried out
and printed.
###
afterEnter: (oldSituationId, newSituationId) ->
###
This function is called before carrying out any action in
any situation. It is called before the corresponding
situation has its `act` method called.
If the function returns true, then it is indicating that it
has consumed the action, and the action will not be passed
on to the situation. Note that this is the only one of
these global handlers that can consume the event.
###
beforeAction: (situationId, actionId) ->
###
This function is called after carrying out any action in
any situation. It is called after the corresponding
situation has its `act` method called.
###
afterAction: (situationId, actionId) ->
###
This function is called after leaving any situation. It is
called after the corresponding situation has its `exit`
method called.
###
exit: (oldSituationId, newSituationId) ->
###
Returns a list of situation ids to choose from, given a set of
specifications.
This function is a complex and powerful way of compiling
implicit situation choices. You give it a list of situation ids
and situation tags (if a single id or tag is needed just that
string can be given, it doesn't need to be wrapped in a
list). Tags should be prefixed with a hash # to differentiate
them from situation ids. The function then considers all
matching situations in descending priority order, calling their
canView functions and filtering out any that should not be
shown, given the current state. Without additional parameters
the function returns a list of the situation ids at the highest
level of priority that has any valid results. So, for example,
if a tag #places matches three situations, one with priority 2,
and two with priority 3, and all of them can be viewed in the
current context, then only the two with priority 3 will be
returned. This allows you to have high-priority situations that
trump any lower situations when they are valid, such as
situations that force the player to go to one destination if
the player is out of money, for example.
If a maxChoices value is given, then the function will not
return any more than the given number of results. If there are
more than this number of results possible, then the highest
priority resuls will be guaranteed to be returned, but the
lowest priority group will have to fight it out for the
remaining places. In this case, a random sample is chosen,
taking into account the frequency of each situation. So a
situation with a frequency of 100 will be chosen 100 times more
often than a situation with a frequency of 1, if there is one
space available. Often these frequencies have to be taken as a
guideline, and the actual probabilities will only be
approximate. Consider three situations with frequencies of 1,
1, 100, competing for two spaces. The 100-frequency situation
will be chosen almost every time, but for the other space, one
of the 1-frequency situations must be chosen. So the actual
probabilities will be roughly 50%, 50%, 100%. When selecting
more than one result, frequencies can only be a guide.
Before this function returns its result, it sorts the
situations in increasing order of their displayOrder values.
###
getSituationIdChoices: (listOfOrOneIdsOrTags, maxChoices) ->
datum = null
i = 0
# First check if we have a single string for the id or tag.
if (typeof(listOfOrOneIdsOrTags) == 'string')
listOfOrOneIdsOrTags = [listOfOrOneIdsOrTags]
# First we build a list of all candidate ids.
allIds = []
for tagOrId in listOfOrOneIdsOrTags
if (tagOrId.substr(0, 1) == '#')
ids = @getRoomsTagged(tagOrId.substr(1))
for id in ids
allIds.push(id)
else #it's an id, not a tag
allIds.push(tagOrId)
#Filter out anything that can't be viewed right now.
currentRoom = @getCurrentRoom()
viewableRoomData = []
for roomId in allIds
room = @rooms[roomId]
assert(room, "unknown_situation".l({id:roomId}))
if (room.canView.fcall(this, currentRoom))
viewableRoomData.push({
priority: room.priority
id: roomId
displayOrder: room.displayOrder
})
# Then we sort in descending priority order.
viewableRoomData.sort((a, b) ->
return b.priority - a.priority
)
committed = []
# if we need to filter out the results
if (maxChoices? && viewableRoomData.length > maxChoices)
viewableRoomData = viewableRoomData[-maxChoices..]
for candidateRoom in viewableRoomData
committed.push({
id: candidateRoom.id
displayOrder: candidateRoom.displayOrder
})
# Now sort in ascending display order.
committed.sort((a, b) ->
return a.displayOrder - b.displayOrder
)
# And return as a list of ids only.
result = []
for i in committed
result.push(i.id)
return result
# This is the data on the player's progress that gets saved.
progress: {
# A random seed string, used internally to make random
# sequences predictable.
seed: null
# Keeps track of the links clicked, and when.
sequence: [],
# The time when the progress was saved.
saveTime: null
}
# The Id of the current situation the player is in.
current: null;
# Tracks whether we're in interactive mode or batch mode.
interactive: true
# The system time when the game was initialized.
startTime: null
# The stack of links, resulting from the last action, still be to resolved.
linkStack: null
getCurrentRoom: () =>
if (@current)
return @rooms[@current]
return null
# Gets the unique id used to identify saved games.
getSaveId: (slot = "") ->
return 'salet_'+@game_id+'_'+@game_version#+'_'+slot
# This gets called when a link needs to be followed, regardless
# of whether it was user action that initiated it.
processLink: (code) =>
# Check if we should do this now, or if processing is already underway.
if @linkStack != null
@linkStack.push(code)
return
@view.mark_all_links_old
# We're processing, so make the stack available.
@linkStack = []
# Handle each link in turn.
@processOneLink(code);
while (@linkStack.length > 0)
code = @linkStack.shift()
@processOneLink(code)
# We're done, so remove the stack to prevent future pushes.
@linkStack = null;
# Scroll to the top of the new content.
@view.endOutputTransaction()
# We're able to save, if we weren't already.
@view.enableSaving()
goTo: (roomId) ->
return @processLink(roomId)
###
This gets called to actually do the work of processing a code.
When one doLink is called (or a link is clicked), this may set call
code that further calls doLink, and so on. This method processes
each one, and processLink manages this.
###
processOneLink: (code) ->
match = code.match(@linkRe)
assert(match, "link_not_valid".l({link:code}))
situation = match[1]
action = match[3]
# Change the situation
if situation != '.' and situation != @current_room
@doTransitionTo(situation)
else
# We should have an action if we have no situation change.
assert(action, "link_no_action".l())
# Carry out the action
if (action)
room = @getCurrentRoom()
if (room and @beforeAction)
# Try the global act handler
consumed = @beforeAction(room, action)
if (consumed != true)
room.act(this, action)
if (@afterAction)
@afterAction(this, room, action)
# This gets called when the user clicks a link to carry out an action.
processClick: (code) ->
now = (new Date()).getTime() * 0.001
@time = now - @startTime
@progress.sequence.push({link:code, when:@time})
@processLink(code)
# Transitions between situations.
doTransitionTo: (newRoomId) ->
oldRoomId = @current
oldRoom = @getCurrentRoom()
newRoom = @rooms[newRoomId]
assert(newRoom, "unknown_situation".l({id:newRoomId}))
# We might not have an old situation if this is the start of the game.
if (oldRoom and @exit)
@exit(oldRoomId, newRoomId)
@current = newRoomId
# Remove links and transient sections.
@view.removeTransient(@interactive)
# Notify the incoming situation.
if (@enter)
@enter(oldRoomId, newRoomId)
newRoom.entering(this, oldRoomId)
# additional hook for when the situation text has already been printed
if (@afterEnter)
@afterEnter(oldRoomId, newRoomId)
###
Erases the character in local storage. This is permanent!
To restart the game afterwards, we perform a simple page refresh.
This guarantees authors don't have to care about "tainting" the
game state across save/erase cycles, meaning that character.sandbox
no longer has to be the end-all be-all repository of game state.
###
eraseSave: (force = false) =>
saveId = @getSaveId() # save slot
if (localStorage.getItem(saveId) and (force or confirm("erase_message".l())))
localStorage.removeItem(saveId)
window.location.reload()
# Find and return a list of ids for all situations with the given tag.
getRoomsTagged: (tag) =>
result = []
for id, room of @rooms
for i in room.tags
if (i == tag)
result.push(id)
break
return result
# Saves the character and the walking history to local storage.
saveGame: () ->
# Store when we're saving the game, to avoid exploits where a
# player loads their file to gain extra time.
now = (new Date()).getTime() * 0.001
@progress.saveTime = now - @startTime
# Save the game.
window.localStorage.setItem(@getSaveId(), JSON.stringify({
progress: @progress,
character: @character
}))
# Switch the button highlights.
@view.disableSaving()
@view.enableErasing()
@view.enableLoading()
# Loads the game from the given data
loadGame: (saveFile) ->
@progress = saveFile.progress
@character = saveFile.character
@rnd = new Random(@progress.seed)
# Don't load the save if it's an autosave at the first room (start).
# We don't need to clear the screen.
if saveFile.progress? and saveFile.progress.sequence.length > 1
@view.clearContent()
# Now play through the actions so far:
if (@init)
@init()
# Run through all the player's history.
interactive = false
for step in @progress.sequence
# The action must be done at the recorded time.
@time = step.when
@processLink(step.link)
interactive = true
# Reverse engineer the start time.
now = new Date().getTime() * 0.001
startTime = now - @progress.saveTime
view: new SaletView
beginGame: () ->
@view.fixClicks()
# Handle storage.
saveFile = false
if (@view.hasLocalStorage())
saveFile = localStorage.getItem(@getSaveId())
if (saveFile)
try
@loadGame(JSON.parse(saveFile))
@view.disableSaving()
@view.enableErasing()
catch err
console.log "There was an error loading your save. The save is deleted."
console.error err
@eraseSave(true)
else
@progress.seed = new Date().toString()
character = new Character()
@rnd = new Random(@progress.seed)
@progress.sequence = [{link:@start, when:0}]
# Start the game
@startTime = new Date().getTime() * 0.001
if (@init)
@init(character)
# Do the first state.
@doTransitionTo(@start)
getRoom: (name) ->
return @rooms[name]
# Just an alias for getCurrentRoom
here: () -> @getCurrentRoom()
isVisited: (name) ->
place = @getRoom(name)
if place
return Boolean place.visited
return 0
module.exports = Salet

File diff suppressed because it is too large Load diff

303
lib/view.coffee Normal file
View file

@ -0,0 +1,303 @@
markdown = require('./markdown.coffee')
###
Salet interface configuration.
In a typical MVC structure, this is the View.
Only it knows about the DOM structure.
Other modules just use its API or prepare the HTML for insertion.
You don't need to call this module from the game directly.
The abstraction goal here is to provide the author with a freedom to style his
game as he wants to. The save and erase buttons are not necessary buttons,
but they could be something else entirely. (That's why IDs are hardcoded.)
###
assert = (msg, assertion) -> console.assert assertion, msg
way_to = (content, ref) ->
return "<a href='#{ref}' class='way'>#{content}</a>"
addClass = (element, className) ->
if (element.classList)
element.classList.add(className)
else
element.className += ' ' + className
class SaletView
init: (salet) ->
$("#content, #ways").on("click", "a", (event) ->
event.preventDefault()
a = $(this)
href = a.attr('href')
if (href.match(salet.linkRe))
if (a.hasClass("once") || href.match(/[?&]once[=&]?/))
salet.view.clearLinks(href)
salet.processClick(href)
)
$("#inventory").on("click", "a", (event) ->
event.preventDefault()
alert("Not done yet")
)
$("#load").on("click", "a", (event) ->
window.location.reload()
)
if (@hasLocalStorage())
$("#erase").click((event) ->
event.preventDefault()
return salet.eraseSave()
)
$("#save").click((event) ->
event.preventDefault()
return salet.saveGame()
)
disableSaving: () ->
$("#save").addClass('disabled')
enableSaving: () ->
$("#save").removeClass('disabled')
enableErasing: () ->
$("#erase").removeClass('disabled')
disableErasing: () ->
$("#erase").addClass('disabled')
enableLoading: () ->
$("#load").removeClass('disabled')
disableLoading: () ->
$("#load").addClass('disabled')
# Scrolls the top of the screen to the specified point
scrollTopTo: (value) ->
$('html,body').animate({scrollTop: value}, 500)
# Scrolls the bottom of the screen to the specified point
scrollBottomTo: (value) ->
scrollTopTo(value - $(window).height())
# Scrolls all the way to the bottom of the screen
scrollToBottom: () ->
scrollTopTo($('html').height() - $(window).height());
###
Removes all content from the page, clearing the main content area.
If an elementSelector is given, then only that selector will be
cleared. Note that all content from the cleared element is removed,
but the element itself remains, ready to be filled again using @write.
###
clearContent: (elementSelector = "#content") ->
if (elementSelector == "#content") # empty the intro with the content
document.getElementById("intro").innerHTML = ""
document.querySelector(elementSelector).innerHTML = ""
prepareContent: (content) ->
if typeof content == "function"
content = content()
if content instanceof jQuery
content = content[0].outerHTML
return content.toString()
# Write content to current room
write: (content, elementSelector = "#current-room") ->
if content == ""
return
content = @prepareContent(content)
block = document.querySelector(elementSelector)
if block
block.innerHTML = block.innerHTML + markdown(content)
else
# most likely this is the starting room
block = document.getElementById("content")
block.innerHTML = content
# Replaces the text in the given block with the given text.
# !! Does not call markdown on the provided text. !!
replace: (content, elementSelector) ->
if content == ""
return
content = @prepareContent(content)
block = document.querySelector(elementSelector)
block.innerHTML = content
###
Turns any links that target the given href into plain
text. This can be used to remove action options when an action
is no longer available. It is used automatically when you give
a link the 'once' class.
###
clearLinks: (code) ->
for a in $("#content").find("a[href='" + code + "']")
html = a.innerHTML
a = $(a)
a.replaceWith($("<span>").addClass("ex_link").html(html))
return true
###
Given a list of situation ids, this outputs a standard option
block with the situation choices in the given order.
The contents of each choice will be a link to the situation,
the text of the link will be given by the situation's
outputText property. Note that the canChoose function is
called, and if it returns false, then the text will appear, but
the link will not be clickable.
Although canChoose is honored, canView and displayOrder are
not. If you need to honor these, you should either do so
manually, ot else use the `getSituationIdChoices` method to
return an ordered list of valid viewable situation ids.
###
writeChoices: (salet, listOfIds) ->
if (not listOfIds? or listOfIds.length == 0)
return
currentRoom = salet.getCurrentRoom();
$options = $("<ul>").addClass("options");
for roomId in listOfIds
room = salet.rooms[roomId]
assert(room, "unknown_situation".l({id:roomId}))
if (room == currentRoom)
continue
optionText = room.optionText.fcall(salet, currentRoom)
if (!optionText)
optionText = "choice".l({number:i+1})
$option = $("<li>")
$a = $("<span>")
if (room.canChoose.fcall(this, salet, currentRoom))
$a = $("<a>").attr({href: roomId})
$a.html(optionText)
$option.html($a)
$options.append($option)
@write($options)
# Marks all links as old. This gets called in a `processLink` function.
mark_all_links_old: () ->
$('.new').removeClass('new')
# Removes links and transient sections.
# Arguments:
# interactive - if we're working in interactive mode (or we're loading a save)
removeTransient: (interactive = false) ->
for a in $('#content').find('a')
a = $(a)
if (a.hasClass('sticky') || a.attr("href").match(/[?&]sticky[=&]?/))
return;
a.replaceWith($("<span>").addClass("ex_link").html(a.html()))
contentToHide = $('#content .transient, #content ul.options')
contentToHide.add($("#content a").filter(() ->
return $(this).attr("href").match(/[?&]transient[=&]?/)
))
if (interactive)
contentToHide.animate({opacity: 0}, 350).
slideUp(500, () ->
$(this).remove()
)
else
contentToHide.remove()
# Remove every section marked as a different level.
# For a link level 0, we hide every link of level 1 and above.
# It's for the player to focus.
changeLevel: (level) ->
maxLevel = 6
if level < maxLevel
i = level + 1
hideArray = []
while i <= maxLevel
hideArray.push("#content .lvl"+i)
i++
directive = hideArray.join(", ")
$(directive).fadeOut("slow")
wrapLevel: (text, level) ->
return "<span class='lvl#{level}'>"+text+'</span>'
# At last, we scroll the view so that .new objects are in view.
endOutputTransaction: () =>
if !@interactive
return; # We're loading a save; do nothing at all.
$new = $('.new')
viewHeight = $(window).height()
newTop = newBottom = newHeight = optionHeight = 0
if $new.length == 0
return; # Somehow, there's nothing new.
newTop = $new.first().offset().top
newBottom = $new.last().offset().top + $new.last().height()
newHeight = newBottom - newTop
# We take the options list into account, because we don't want the new
# content to scroll offscreen when the list disappears. So we calculate
# scroll points as though the option list was already gone.
if ($('.options').not('.new').length)
optionHeight = $('.options').not('new').height()
if (newHeight > (viewHeight - optionHeight - 50))
# The new content is too long for our viewport, so we scroll the
# top of the new content to roughly 75% of the way up the viewport's
# height.
scrollTopTo(newTop-(viewHeight*0.25) - optionHeight);
else
if (newTop > $('body').height() - viewHeight)
# If we scroll right to the bottom, the new content will be in
# view. So we do that.
scrollToBottom();
else
# Our new content is too far up the page. So we scroll to place
# it somewhere near the bottom.
scrollBottomTo(newBottom+100 - optionHeight);
# Feature detection
hasLocalStorage: () ->
return window.localStorage?
# Any point that an option list appears, its options are its first links.
fixClicks: () ->
$("body").on('click', "ul.options li", (event) ->
# Make option clicks pass through to their first link.
link = $("a", this)
if (link.length > 0)
$(link.get(0)).click()
)
updateWays: (salet, ways, name) ->
content = ""
distances = []
if ways then for way in ways
if salet.rooms[way]?
title = salet.rooms[way].title.fcall(this, name)
content += way_to(title, way)
distances.push({
key: way
distance: salet.rooms[way].distance
})
else
document.querySelector(".ways h2").style.display = "none"
document.getElementById("ways").innerHTML = content
min = Infinity
min_key = []
for node in distances
if node.distance < min
min = node.distance
min_key = [node.key]
if node.distance == min
min_key.push(node.key)
if min < Infinity
for node in min_key
waylink = document.getElementById("waylink-#{node}")
if waylink
addClass(waylink, "destination")
pictureTag: (picture) ->
extension = picture.substr((~-picture.lastIndexOf(".") >>> 0) + 2)
if (extension == "webm")
return """
<video src="#{picture}" controls>
Your browser does not support the video tag for some reason.
You won't be able to view this video in this browser.
</video>
"""
return "<img class='img-responsive' src='#{picture}' alt='Room illustration'>"
cycleLink: (content) ->
return "<a href='./_replacer_cyclewriter' class='cycle' id='cyclewriter'>#{content}</a>"
module.exports = SaletView

View file

@ -8,6 +8,7 @@
"browserify": "^9.0.8",
"browserify-shim": "^3.8.8",
"coffeeify": "^1.0.0",
"coffee-script": "^1.10.0",
"gulp": "^3.8.11",
"gulp-uglify": "^1.2.0",
"gulp-coffee": "^2.3.1",
@ -15,7 +16,6 @@
"gulp-zip": "^3.0.2",
"gulp-concat": "^2.6.0",
"gulp-sass": "^2.1.1",
"lodash": "^3.6.0",
"vinyl-buffer": "^1.0.0",
"vinyl-source-stream": "^1.1.0",
"watchify": "^3.1.0"