/* exported StartGame */ /* exported StartGame */ // Include core files /** * Rulebook system * Allows for dynamic creation of complex rulesets/filters to be applied to existing verbs, raw text and regex'd input. * Uses method chaining for expressive style. */ var Rules = function () { this.rules = { 'before': {}, 'after': {}, 'internal': {} }; // Add default rule filter groups // TODO: I don't remember how these work this.filterGroups.default = new RuleFilterGroup(`default`, RuleFilterGroup.MODE_AND); this.filterGroups.region = new RuleFilterGroup(`region`, RuleFilterGroup.MODE_OR); this.filterGroups.location = new RuleFilterGroup(`location`, RuleFilterGroup.MODE_OR); this.filterGroups.target = new RuleFilterGroup(`target`, RuleFilterGroup.MODE_OR); this.filterGroups.modifier = new RuleFilterGroup(`modifier`, RuleFilterGroup.MODE_OR); this.filterGroups.internal = new RuleFilterGroup(`internal`, RuleFilterGroup.MODE_OR); // Add default objects this.registerObject(`player`, null); this.registerObject(`actor`, function (action) { return action.actor; }); this.registerObject(`location`, function (action) { return action.actor.location(); }); this.registerObject(`target`, function (action) { return action.target; }); this.registerObject(`region`, function (action) { return action.actor.location.region; }); }; Rules.prototype = { // Constants ACTION_CANCEL: 0, // Cancel action ACTION_PREPEND: 1, // Prepend output and continue as normal ACTION_APPEND: 1, // Finish action then append output ACTION_NONE: 3, // Not handled, run action as usual RULE_BEFORE: `before`, // Standard rule, happens before action processing RULE_AFTER: `after`, // After rule, happens after action processing RULE_INTERNAL: `internal`, // Internal rule, handled specially // Rule list active: true, rules: {}, filterGroups: {}, // Object list objects: {}, // Helpers 'add': function (rule) { this.rules[rule.type][rule.key] = rule; }, 'remove': function (book, key) { delete this.rules[book][key]; }, 'registerObject': function (key, object) { this.objects[key] = object; }, // Check command against rulebook 'check': function (type, action) { console.log(`Rulebook: Checking book '`+type+`'`); if (!this.active) { return action; } for (var r in this.rules[type]) { this.rules[type][r].run(action); if (action.mode === this.ACTION_CANCEL) { break; } } return action; }, 'pause': function () { this.active = false; }, 'start': function () { this.active = true; } }; /** * Rule Filter Group constructor * Filter groups are used to organize related filters and specify whether ALL or ANY are required. * * @param key * @param mode * @constructor */ var RuleFilterGroup = function (key, mode) { this.key = key; this.mode = mode; }; RuleFilterGroup.prototype = { MODE_AND: `and`, MODE_OR: `or`, key: null, mode: null }; var Rulebook = new Rules(); /** * Filter constructor. A filter is a generic processor used by a rule. It always includes a callback and can * optionally include additional parameters specified for the rule. When a callback is executed, it will be given: * - actor (the entity initiating the action) * - action (the text of the actor's action) * - params (the saved parameters for the rule) * @param callback function * @param params object */ var RuleFilter = function (callback, params) { this.callback = callback; this.params = params; this.type = this.TYPE_DEFAULT; }; RuleFilter.prototype = { TYPE_DEFAULT: 0, TYPE_REVERSE: 1, 'callback': function () { }, 'params': {}, 'type': null }; RuleFilter.prototype.run = function (self, action) { var result = this.callback(self, action, this.params); return (this.type == this.TYPE_REVERSE) ? !result : result; }; var understand = function (key) { return new Rule(key); }; var Rule = function (key) { this.key = key; this.commandType = null; this.command = null; this.filters = {}; this.response = null; this.mode = Rulebook.ACTION_NONE; this.responseText = ``; this.prepend = ``; this.type = Rulebook.RULE_BEFORE; this.currentData = {}; for (var g in Rulebook.filterGroups) { this.filters[g] = []; } }; Rule.prototype = { // Constants COMMAND_TEXT: 0, COMMAND_REGEX: 1, COMMAND_VERB: 2, COMMAND_CALLBACK: 3, // Storage for current instance 'key': null, 'commandType': null, 'command': null, 'filters': [], 'response': null, 'mode': null, 'responseText': ``, 'prepend': ``, 'type': Rulebook.RULE_BEFORE, 'currentData': null, 'doUntil': function () { return false; }, // Register and unregister self 'start': function () { console.log(`Started Rule: ` + this.key); Rulebook.add(this); return this; }, 'stop': function () { Rulebook.remove(this.type, this.key); }, // Execute the rule 'run': function (action) { console.log(`Checking Rule '` + this.key + `'...`); this.currentData = {'action': action}; // Validate command and fail fast // Action is expected to be {'verb':verb,'text':text} switch (this.commandType) { case this.COMMAND_TEXT: if ($.inArray(action.text, this.command) < 0) { return this.exit(Rulebook.ACTION_NONE); } break; case this.COMMAND_REGEX: if (action.text.match(this.command) === null) { return this.exit(Rulebook.ACTION_NONE); } break; case this.COMMAND_VERB: if (action.verb != this.command) { return this.exit(Rulebook.ACTION_NONE); } break; case this.COMMAND_CALLBACK: if (!this.command(this, action)) { return this.exit(Rulebook.ACTION_NONE); } break; } // Run filters for (var g in this.filters) { for (var f in this.filters[g]) { if (!this.filters[g][f].run(this, action)) { // Filter failed, bail return this.exit(); } } } // Check 'do until' condition if (this.doUntil(action)) { console.log(`Rulebook: Stopping action '`+this.key+`'`); this.stop(); this.exit(Rulebook.ACTION_NONE); } // Return response return this.respond(); }, 'exit': function (mode) { if (typeof mode != `undefined`) { this.currentData.action.mode = mode; } return this.currentData.action; }, 'respond': function () { if (this.response !== null) { if (typeof this.response == `string`) { queueGMOutput(this.response); } else { return this.response(this, this.currentData.action); } } return this.exit(); }, // Helper methods 'addFilter': function (callback, params, group) { group = group || `default`; this.filters[group].push(new RuleFilter(callback, params)); }, 'addReverseFilter': function (callback, params, group) { group = group || `default`; var rf = new RuleFilter(callback, params); rf.type = RuleFilter.TYPE_REVERSE; this.filters[group].push(rf); }, 'checkAttribute': function (params, action) { var attribute = this.getAttribute(params.chain, action); var result = false; var value = params.value; // Validate attribute if (typeof attribute == `undefined`) { console.log(`FAILED TO FIND ATTRIBUTE REFERENCE '` + params.chain + `'`); return result; } // Get value result first. It could be a raw value, an attribute reference, or a function. if (typeof params.value == `string`) { value = this.getAttribute(params.value, action); } else if (typeof params.value == `function`) { value = params.value(params, action); } // Handle operator. Could be a function or one of the following: // Math: > < >= <= = == // Set: contains // Any operator can be preceded by ! to negate it, e.g. !contains if (typeof params.operator == `function`) { // Callback operator result = action(attribute, value, action); } else { // Standard operator var operator = params.operator; var negate = (operator[0] == `!`); // Trim ! from operator if (negate) { operator = operator.substr(1); if (operator.length == 0) { operator = `==`; } } // Special case operators if (operator == `in`) { // Swap 'in' operator as reverse alias for 'contains' operator var tmp = attribute; value = attribute; attribute = tmp; operator = `contains`; } if (operator == `=` || operator == `==`) { result = attribute == value; } else if (operator == `>`) { result = attribute > value; } else if (operator == `<`) { result = attribute < value; } else if (operator == `>=`) { result = attribute >= value; } else if (operator == `<=`) { result = attribute <= value; } else if (operator == `contains`) { // Set contains item if (attribute instanceof Entity) { // Attribute is an Entity, check its inventory / children result = attribute.hasChild(value); } else if (typeof attribute == `object`) { // Attribute is a key/value object, check its properties result = attribute.hasOwnProperty(value); } else if ($.isArray(attribute)) { // Attribute is an array, check its contents result = attribute.indexOf(value) >= 0; } } else if (operator == `containsType`) { // Set contains item of given type result = attribute.hasChildOfType(value); } else if (operator == `is`) { // Reference is of given type if (attribute instanceof Entity) { // Entity has component(s) if (typeof value == `string`) { result = attribute.hasComponent(value); } else if ($.isArray(value)) { result = attribute.components.indexOf(value) >= 0; } } else { // Non-entity matches type result = (typeof attribute == value); } } if (negate) { result = !result; } } return result; }, 'getAttribute': function (chain, action) { // If this isn't a rulebook object, return the string if (!$.isArray(chain) || !Rulebook.objects.hasOwnProperty(chain[0])) { return chain; } var value = Rulebook.objects[chain[0]]; if (typeof value == `function`) { value = value(action); } for (var i = 1; i < chain.length; i++) { // If the child property doesn't exist, return if (!value.hasOwnProperty(chain[i])) { return; } else if (typeof value[chain[i]] == `function`) { // Otherwise execute/get as new value value = ECS.run(value, chain[i]); } else { value = value[chain[i]]; } } return value; }, // Chain: Flag rule as internal 'internal': function (key) { this.type = Rulebook.RULE_INTERNAL; this.addFilter(function (self, action, params) { return action.internal == params.key; }, {'key': key}, `internal`); return this; }, // Chain: Flag rule type 'book': function(type) { this.type = type; return this; }, // Chain: Add simple text match 'text': function (text) { if(!Array.isArray(text)) { text = [text]; } this.command = text; this.commandType = this.COMMAND_TEXT; return this; }, // Chain: Add verb match 'verb': function (verb) { this.command = ECS.getAction(verb); this.commandType = this.COMMAND_VERB; return this; }, // Chain: Add regex match 'regex': function (pattern) { this.command = pattern; this.commandType = this.COMMAND_REGEX; return this; }, // Chain: Add callback match 'action': function (callback) { this.command = callback; this.commandType = this.COMMAND_CALLBACK; return this; }, // Chain: Location filter 'in': function (loc) { this.addFilter(function (self, action, params) { return action.actor.locationIs(params.loc); }, {'loc': loc}, `location`); return this; }, // Chain: Not In Location filter 'notIn': function (loc) { this.addFilter(function (self, action, params) { return !action.actor.locationIs(params.loc); }, {'loc': loc}, `location`); return this; }, // Chain: Region filter 'inRegion': function (text) { this.addFilter(function (self, action, params) { return action.actor.regionIs(params.text); }, {'text': text}, `region`); return this; }, // Chain: Not in Region filter 'notInRegion': function (text) { this.addFilter(function (self, action, params) { return !action.actor.regionIs(params.text); }, {'text': text}, `region`); return this; }, // If/While (aliases), add a filter callback to restrict requirements 'if': function (callback, group) { return this.while(callback, group); }, 'while': function (callback, group) { this.addFilter(callback, {}, group); return this; }, // Chain: Reverse while condition 'unless': function (callback, group) { this.addReverseFilter(callback, {}, group); return this; }, // Chain: Restrict to certain entity targets 'on': function (target) { if(typeof target == `string`) { target = ECS.getEntity(target); } this.addFilter(function (self, action, params) { return params.target == action.target; }, {'target': target}, `target`); return this; }, // Chain: Restrict to certain modifiers 'modifier': function (modifier) { this.addFilter(function (self, action, params) { return action.modifiers.indexOf(params.modifier) >= 0; }, {'modifier': modifier}, `modifier`); return this; }, // Chain: Check for matching attribute 'attribute': function (a, operator, value) { value = (value) ? value : null; // Explode attribute var chain = a.split(`.`); this.addFilter(function (self, action, params) { return self.checkAttribute(params, action); }, {'chain': chain, 'operator': operator, 'value': value}); return this; }, // Chain: check for matching target entity 'entity': function (t) { if(t instanceof Entity) { t = t.key; } return this.attribute(`target.key`,`=`,t); }, // Chain: Return a raw response if the requirements are met 'as': function (response, mode) { this.response = response; this.mode = mode; return this; }, // Chain: Execute a callback if the requirements are met 'do': function (callback) { this.response = callback; return this; }, // Chain: Execute a callback and then cancel the rule 'doOnce': function (callback) { var f = function(self, action) { callback(self,action); self.stop(); action.mode = Rulebook.ACTION_CANCEL; }; this.response = f; return this; }, // Chain: Specify a condition at which the Rule will be removed 'until': function (callback) { this.doUntil = callback; return this; }, // Chain: Append a response for after the default response 'append': function (response) { this.responseText = response; this.mode = Rulebook.ACTION_APPEND; return this; } }; // // Entity-Component System // // Utility functions function isArray(value) { return toString.apply(value) === `[object Array]`; } // // Core stuff // var ecs = ECS = { availableModules: {}, modules: {}, components: {}, entities: {}, systems: [], actions: {}, nouns: {}, internalActions: {}, tick: true, // Last entity added, by type lastOfType: {}, // General-purpose storage data: {} }; ECS.setData = function (key, value) { this.data[key] = value; }; ECS.getData = function (key) { if (this.data.hasOwnProperty(key)) { return this.data[key]; } return null; }; ECS.isValidMenuOption = function (options, command) { for (var o in options) { if (options[o].command == command.toLowerCase() || options[o].text.toLowerCase() == command) { return true; } } return false; }; ECS.getMenuOption = function (options, command) { if (typeof options == `string`) { options = ECS.getData(options); } for (var o in options) { if (options[o].command == command.toLowerCase() || options[o].text.toLowerCase() == command) { return options[o]; } } return false; }; ECS.getMenuOptionValue = function (options, command) { command = command.toLowerCase(); for (var o in options) { if (options[o].command == command || options[o].text.toLowerCase() == command) { return options[o].command; } } return false; }; ECS.setOptions = function (obj, options) { $.extend(true, obj, options); }; ECS.isComponentLoaded = function (component) { return (typeof this.components[component] != `undefined`); }; ECS.getComponent = function (component) { return this.components[component]; }; ECS.hasAction = function (action) { return (typeof this.actions[action] != `undefined`); }; ECS.getAction = function (action) { if (!this.hasAction(action)) { return null; } return this.actions[action]; }; ECS.run = function (object, callback, args) { if (typeof args != `object`) { args = {}; } if (object.hasOwnProperty(callback)) { if (typeof object[callback] === `string`) { return object[callback]; } return object[callback](args); } return ``; }; ECS.runCallbacks = function (object, callback, args) { // Add target object to args list if (typeof args != `object`) { args = {}; } args.obj = object; // Get callback list from object if (object.hasOwnProperty(callback)) { var callbacks = object[callback]; if (typeof callbacks == `object`) { for (var i = 0; i < callbacks.length; i++) { if (callbacks[i](args)) { return true; } } } } return false; }; ECS.runFilters = function (object, callback, args) { // Add target object to args list if (typeof args != `object`) { args = {}; } args.obj = object; // Get callback list from object if (object.hasOwnProperty(callback)) { var callbacks = object[callback]; if (typeof callbacks == `object`) { for (var i = 0; i < callbacks.length; i++) { if (callbacks[i](args) === false) { return false; } } } } return true; }; ECS.addInternalAction = function (key, callback) { this.internalActions[key] = callback; }; ECS.runInternalAction = function (key, data) { data = $.extend({}, NLP.lastAction, data); data.internal = key; // Check rules var action = Rulebook.check(`internal`, data); if (action.mode === Rulebook.ACTION_CANCEL) { return action.output; } else if (action.mode === Rulebook.ACTION_APPEND) { return this.internalActions[key](data) + action.output; } return this.internalActions[key](data); }; ECS.findEntityByName = function (noun, scope) { if (typeof this.nouns[noun] != `undefined`) { for (var n in this.nouns[noun]) { var tmp = this.nouns[noun][n]; var isOk = (scope == null); var isGlobal = (tmp.scope == `global`); var isLocal = (scope == `local` && tmp.visibleFrom(player.location())); var isInPlayerInventory = (tmp.parent == player); if (isOk || isInPlayerInventory || isLocal || isGlobal) { return tmp; } } } return null; }; ECS.hasEntity = function (key) { return this.entities.hasOwnProperty(key); }; ECS.getEntity = function (key) { if (this.hasEntity(key)) { return this.entities[key]; } return null; }; ECS.findEntity = function (component, noun) { if (this.entities.hasOwnProperty(noun)) { if (this.entities[noun].components.indexOf(component) >= 0) { return this.entities[noun]; } } return null; }; ECS.findEntitiesByComponent = function (component) { var matches = []; for (var e in this.entities) { if (this.entities[e].components.indexOf(component) >= 0) { matches.push(this.entities[e]); } } return matches; }; ECS.getEntityPrefix = function (e) { if(typeof key != `object`) { e = this.getEntity(e); } return ECS.run(e, `prefix`); }; ECS.init = function (modules) { for (var m in modules) { console.log(`INITIALIZING MODULE `+modules[m]); this.modules[modules[m]] = this.availableModules[modules[m]]; this.modules[modules[m]].init(); } }; // // System stuff // var System = function (name, options) { this.name = name; this.components = []; this.priority = 1; this.onTick = function () { }; }; // Add system to module ecs.s = function (system) { var index = 0; // Get index based on priority if (this.systems.length > 0) { // Find first item of lower priority (higher #) for (var s in this.systems) { if (this.systems[s].priority > system.priority) { index = s; } } } // Insert system into list based on priority index this.systems.splice(index, 0, system); }; // // Module stuff // var Module = function (name, options) { this.name = name; this.dependencies = []; // Required modules this.components = []; // Components supplied by this module this.systems = []; // Systems supplied by this module this.actions = []; // Actions supplied by this module ECS.setOptions(this, options); }; // Default init function for modules Module.prototype.init = function () { console.log(`Module '` + this.name + `' initialized [default].`); }; // Add system to module Module.prototype.s = function (system) { this.systems.push(system); }; // Add component to module Module.prototype.c = function (name, options) { this.components[name] = options; }; // Add action to module Module.prototype.a = function (name, options) { if (!options.hasOwnProperty(`modifiers`)) { options.modifiers = []; } if (!options.hasOwnProperty(`filters`)) { options.filters = []; } this.actions[name] = options; }; // Add module to ECS ecs.m = ecs.module = function (module) { if (typeof this.modules[module.name] !== `undefined`) { console.log(`Module '` + module.name + `' already loaded.`); return; } // Register module this.availableModules[module.name] = module; // Register systems for (var s in module.systems) { ecs.s(module.systems[s]); } // Register components for (var c in module.components) { ecs.c(c, module.components[c]); } // Register actions for (var a in module.actions) { for (var i = 0; i < module.actions[a].aliases.length; i++) { this.actions[module.actions[a].aliases[i]] = module.actions[a]; } console.log(`Added action '` + a + `' from module '` + module.name + `'`); } }; // Get module from ECS ecs.getModule = function (module) { return this.modules[module]; }; // // Component stuff // var Component = function () { this.name = ``; this.parent = null; // Parent component to inherit from this.dependencies = []; // Required components this.onAdd = []; }; // Default init for components. Does nothing. Component.prototype.onInit = function () { }; // Register component with ECS ECS.c = ecs.c = ecs.component = function (name, options) { if (this.isComponentLoaded(name)) { console.log(`Component '` + name + `' already loaded.`); return; } // Create component instance var instance = new Component(); instance.name = name; // Set options for (var option in options) { instance[option] = options[option]; } // Add component to internal list this.components[name] = instance; // Run component's init callback this.components[name].onInit(); }; // // Entity stuff // var Entity = function () { this.key = ``; // Identifier this.parent = null; // Parent entity this.children = []; // Child entities this.empty = true; // Whether the entity is empty (has no children) this.components = []; // Component list, for convenience / searching this.onComponentAdd = []; // Add component callback this.tags = []; // Tag list; generally the same as the component list this.persist = [`parent`]; // Raw attributes to persist this.persistActive = true; // Save objects by default this.isVisible = [ function(args){ // Entity is in location return args.location == args.obj.location(); }, function(args){ // Entity is scenery in region return args.obj.regionIs(args.location.region) && args.obj.hasComponent(`scenery`); } ]; // Visibility callbacks }; // Entity prototype; includes global variables Entity.prototype = { contextActions: [], }; // Default init for entities Entity.prototype.init = function () { console.log(`Entity '` + this.key + `' initialized [default].`); }; // Check for component on entity Entity.prototype.hasComponent = function (component) { return (this.components.indexOf(component) >= 0); }; // Alias for hasComponent Entity.prototype.is = function (component) { return this.hasComponent(component); }; // Add component to entity Entity.prototype.c = function (component) { // Skip if already loaded for this entity if (this.hasComponent(component)) { return true; } // Make sure component is available if (ecs.isComponentLoaded(component)) { var success = true; var tmp = $.extend(true, {'e': this}, ecs.getComponent(component)); // Load dependencies for (var c in tmp.dependencies) { success &= this.c(tmp.dependencies[c]); } // Add to entity's component list this.components.push(component); // Add to tag list this.tags.push(component); // Handle onComponentAdd callback if (tmp.hasOwnProperty(`onAdd`)) { var callbacks = tmp.onAdd; if (!$.isArray(tmp.onAdd)) { callbacks = [tmp.onAdd]; } for (c in callbacks) { this.onComponentAdd.push(callbacks[c]); } delete tmp.onAdd; } // Handle persist data to avoid overwrite if (typeof tmp.persist != `undefined`) { $.merge(tmp.persist, this.persist); } // Load component $.extend(true, this, tmp); console.log(`Added component '` + component + `' to entity '` + this.key + `'`); return success; } console.log(`Failed to load component '` + component + `' or dependent component for Entity '` + this.name + `'`); return false; }; // Update stats for entity Entity.prototype.updateStats = function () { // Set entity as empty if it has no children this.empty = (this.children.length == 0); }; // Add child to entity Entity.prototype.addChild = function (e) { if (this.hasChild(e.key)) { return; } this.children.push(e); e.parent = this; ECS.runCallbacks(this, `onAddChild`, {'child': e}); this.updateStats(); }; // Remove child from entity Entity.prototype.removeChild = function (e) { var i = this.children.indexOf(e); if (i >= 0) { this.children.splice(i, 1); } ECS.runCallbacks(this, `onRemoveChild`, {'child': e}); this.updateStats(); }; // Check if entity has a specific child, by key Entity.prototype.hasChild = function (e) { var key = e; if(typeof e != `string`) { key = e.key; } for (e in this.children) { if (this.children[e].key == key) { return true; } } return false; }; // Find children with matching component Entity.prototype.findChildren = function (component) { var matches = []; for (var c in this.children) { if (this.children[c].hasComponent(component)) { matches.push(this.children[c]); } } return matches; }; // Automatically determine article (a, an, etc) from name Entity.prototype.article = function () { var vowels = [`a`, `e`, `i`, `o`, `u`]; var firstLetter = this.name.substring(0, 1); var article = `a`; // For proper nouns, use no article if(firstLetter == firstLetter.toUpperCase()) { return ``; } if (vowels.indexOf(firstLetter.toLowerCase()) >= 0) { article = `an`; } return article; }; // Save an entity Entity.prototype.save = function () { var e = this; var data = {'key': this.key, 'values': {}}; $.each(this.persist, function (i, v) { data.values[v] = ecs.getSaveValue(e[v]); }); return data; }; // Load an entity Entity.prototype.load = function (data) { var e = this; $.each(this.persist, function (i, v) { if (typeof data.values[v] != `undefined`) { var value = data.values[v][1]; if (data.values[v][0] == `reference`) { value = ECS.entities[value]; } if (v == `parent` && e.parent != null) { e.parent.removeChild(e); } if ((v == `place` || v == `parent`) && value instanceof Entity) { value.addChild(e); } e[v] = value; } }); // If parent and place don't match, remove from place children if (this.parent != null && this.parent != this.place && this.place instanceof Entity) { this.place.removeChild(this); } }; // Add context action Entity.prototype.addContext = function(callback) { this.contextActions.push(callback); }; // Get context actions Entity.prototype.getContextActions = function () { var actions = []; for(var a in this.contextActions) { var tmp = this.contextActions[a](this); if(tmp !== false) { actions.push(tmp); } } return actions; }; // Get a value from the entity, or a default value if specified Entity.prototype.get = function (key,defaultValue) { if(this.hasOwnProperty(key)) { return this[key]; } return defaultValue; }; // Set a value on the entity Entity.prototype.set = function (key,value) { this[key] = value; }; // Check visibility Entity.prototype.visibleFrom = function(location) { return ECS.runCallbacks(this, `isVisible`, {'location': location}); }; // Add entity to ECS ECS.e = ECS.entity = function (key, components, options) { // Create instance var e = new Entity(); e.key = key; // Check for existing key and abort if found // This is considered an unrecoverable error if(ECS.hasEntity(key)) { throw `ECS: Entity with key '`+key+`' already exists.`; } // Load components if (!isArray(components) || components.length == 0) { components = [`thing`]; } for (var c in components) { var componentName = components[c]; if (!e.c(componentName)) { return; } } // Handle persist data to avoid overwrite if (typeof options.persist != `undefined`) { $.merge(options.persist, e.persist); } // Set options ECS.setOptions(e, options); // Add tags if (typeof options.extraTags !== `undefined`) { e.tags = e.tags.concat(options.extraTags); } // Add nouns for entity (used by NLP) if (!e.hasOwnProperty(`nouns`)) { e.nouns = []; } // Add full name to noun list e.nouns.push(e.name); for (var i = 0; i < e.nouns.length; i++) { var noun = e.nouns[i].toLowerCase(); if (!isArray( this.nouns[noun] )) { this.nouns[noun] = []; } this.nouns[noun].push(e); } // Execute Add Component callbacks ECS.runCallbacks(e, `onComponentAdd`); // Init entity e.init(); // Add entity to ECS this.entities[key] = e; // Save entry to 'last of type' list for(c in components) { ECS.lastOfType[components[c]] = e; } return e; }; // Remove entity from ECS ecs.removeEntity = function (e) { // Remove from noun list if (e.hasOwnProperty(`nouns`)) { for (var i = 0; i < e.nouns.length; i++) { delete this.nouns[e.nouns[i]]; } } // Remove from entity list delete this.entities[e.key]; }; ecs.moveEntity = function (e, d) { // Get target entity from key if(typeof e == `string`) { e = ECS.getEntity(e); } // Get destination entity from key if (typeof d == `string`) { d = ECS.getEntity(d); } // Remove entity from current location, if any if (e.place != null) { e.place.removeChild(e); } // Add entity to destination d.addChild(e); if(d.hasComponent(`place`)) { e.place = d; } }; // Get save-safe value from an entity ecs.getSaveValue = function (v) { if (typeof v == `undefined`) { return [`null`, null]; } if (v instanceof Entity) { return [`reference`, v.key]; } return [`value`, v]; }; // Save ECS ecs.save = function () { var data = { 'seed': seed, 'entities': {} }; for (var i in this.entities) { if (this.entities[i].persistActive) { data.entities[this.entities[i].key] = this.entities[i].save(); } } return data; }; // Load from json string ecs.load = function (data) { data = JSON.parse(data); // Replace current seed with value from save data seed = data.seed; for (var i in data.entities) { var e = data.entities[i]; this.entities[e.key].load(data.entities[i]); } }; /** * Counter helpers to keep track of how many times an arbitrary event has happened. */ ECS.setData(`counters`, {}); /** * Get the count value for a given key, or set a new value. * * @param string key * @param int value * @returns */ var count = function (key, value) { var counters = ECS.getData(`counters`); // Set a new value for a given key if (typeof value != `undefined`) { counters[key] = value; return; } // Check if the given key exists, and return the value if it does if (counters.hasOwnProperty(key)) { return counters[key]; } // The requested key doesn't exist return null; }; /** * Increment a counter. * * @param key */ var incrementCounter = function(key) { var c = count(key); c = (c) ? c + 1 : 1; count(key, c); }; /** * Decrement a counter. * * @param key */ var decrementCounter = function(key) { var c = count(key); c = (c) ? c - 1 : -1; count(key, c); }; /** * Check if the given key matches the given value * * @param key * @param value * @returns {boolean} */ var nth = function (key, value) { return count(key) === value; }; /** * Check if the given key has a value of 1 * Alias for nth(key,1) * * @param key * @returns {boolean} */ var first = function (key) { return nth(key, 1); }; /** * Check if the given key has a value of 2 * Alias for nth(key,2) * * @param key * @returns {boolean} */ var second = function (key) { return nth(key, 2); }; /** * Check if the given key has a value of 3 * Alias for nth(key,3) * * @param key * @returns {boolean} */ var third = function (key) { return nth(key, 3); }; /** * Check if the given key is on a repeating nth count. * * @param key * @param n * @returns {boolean} */ var everyNth = function (key, n) { console.log(`CHECKING `+key+` for `+n); return (count(key) % n ) == 0; }; /** * Check if the given key is on a repeating 2nd count. * Alias for everyNth(key,2) * * @param key * @returns {boolean} */ var everyOther = function(key) { return everyNth(key, 2); }; /** * Check if the given key is on a repeating 3rd count. * Alias for everyNth(key,3) * * @param key * @returns {boolean} */ var everyThird = function(key) { return everyNth(key, 3); }; Handlebars.registerHelper(`first`, function(counter, options) { if(first(counter)) { return new Handlebars.SafeString(options.fn(this)); } }); Handlebars.registerHelper(`second`, function(counter, options) { if(second(counter)) { return new Handlebars.SafeString(options.fn(this)); } }); Handlebars.registerHelper(`third`, function(counter, options) { if(third(counter)) { return new Handlebars.SafeString(options.fn(this)); } }); Handlebars.registerHelper(`nth`, function(counter, options) { if(nth(counter, options.hash.n)) { return new Handlebars.SafeString(options.fn(this)); } }); Handlebars.registerHelper(`everyOther`, function(counter, options) { if(everyOther(counter)) { return new Handlebars.SafeString(options.fn(this)); } }); Handlebars.registerHelper(`everyThird`, function(counter, options) { if(everyThird(counter)) { return new Handlebars.SafeString(options.fn(this)); } }); Handlebars.registerHelper(`everyNth`, function(counter, options) { console.log(options.hash); if(everyNth(counter, options.hash.n)) { return new Handlebars.SafeString(options.fn(this)); } }); // // Natural Language Processor // var Action = function (actor, text) { this.actor = actor; this.string = text; this.verb = null; this.target = null; this.nouns = []; this.modifiers = []; this.output = ``; }; Action.prototype = { actor: null, string: ``, verb: null, target: null, nouns: [], modifiers: [], output: ``, update: function (data) { $.extend(this, data); } }; var Response = function (mode, output) { this.mode = mode; this.out = output; }; Response.prototype = { 'output':function(action) { if(typeof this.out == `string`) { action.output += this.out; } else if(typeof this.out == `function`) { this.out(action); } } }; var nlp = NLP = { // Response flags RESPONSE_BEFORE: 0, RESPONSE_AFTER: 1, RESPONSE_INSTEAD: 2, // Trigger a tick for the current action // Time only passes for ticks, so non-tick actions will not trigger most systems 'tick': true, // Verb list 'verbs': [], // Command Interrupt // Used for modules to interrupt the normal game cycle and take direct input (e.g. inputting a name) // Null by default. Modules should attach a callback as needed. The callback is responsible for self-deactivation. 'command_interrupt': [], // Pattern list // Cannot start with a modifier under any circumstances. Any command that would make sense that way should be handled as an interrupt or a rule. // In order of priority (highest to lowest): 'patterns': [ `VERB`, // inventory `VERB NOUN`, // eat baby `VERB MODIFIER`, // saunter west `VERB MODIFIER NOUN`, // get in closet `VERB NOUN MODIFIER`, // turn wheel clockwise `VERB NOUN MODIFIER NOUN`, // attack goblin with hammer `VERB MODIFIER MODIFIER NOUN`, // look north through telescope `VERB MODIFIER NOUN MODIFIER NOUN`, // look through the telescope at bob // Overflow patterns `VERB MODIFIER NOUN MODIFIER TEXT`, // talk to goblin about greatest fears `VERB MODIFIER MODIFIER TEXT`, // look north through telescope saucily `VERB NOUN MODIFIER TEXT`, // ask bob about back pain `VERB MODIFIER TEXT` // talk about floops ], // Current action data 'actor': null, 'currentAction': [], 'lastAction': null, 'afterAction': null, // Prev action reference for After rulebook // Command interrupt function 'interrupt': function (init, callback) { if(typeof callback == `undefined`) { console.log(`NLP: INTERRUPT MISSING CALLBACK, REJECTING`); return; } this.command_interrupt.push({'init': init, 'callback': callback}); this.init_next_interrupt(); }, // Simple command interrupt variant 'interrupt_simple': function (command, response) { this.interrupt( null, function (string) { if (string == command) { queueOutput(response); } else { NLP.parse(string); } return true; } ); }, // Initialize next interrupt 'init_next_interrupt': function () { if (this.command_interrupt.length > 0 && this.command_interrupt[0].init !== null) { this.command_interrupt[0].init(); this.command_interrupt[0].init = null; } }, // Create and register a new action 'newAction': function(input_string) { var action = new Action(this.actor, input_string); this.currentAction.push(action); this.lastAction = action; return action; }, // Get top action in stack 'topAction': function() { var c = this.currentAction.length; return (c > 0) ? this.currentAction[c] : undefined; }, // Clean up and exit 'exit': function(output) { this.currentAction.pop(); return output; }, // Parsing function. Translates input like 'look at box' to something internally useful // Actions are generally dispatched directly, so return values are only used when parsing has failed 'parse': function (input_string) { // Clear previous action data var currentAction = this.newAction(input_string); // Convert multiple spaces/whitespaces characters to a single space input_string = input_string.replace(/\s{2,}/g, ` `); var string = input_string; // Make sure command isn't empty if(typeof string == `undefined` || !string.length) { console.log(`NLP: Empty command string!`); console.trace(); } // Trigger a tick by default this.tick = true; // If a command interrupt is enabled, skip normal input if (this.command_interrupt.length > 0) { var interrupt = this.command_interrupt.shift(); var handled = interrupt.callback(string); if (handled) { console.log(`Interrupt handled, removing`); this.init_next_interrupt(); return this.exit(); } // Wasn't handled, return the interrupt to the beginning of the queue console.log(`Interrupt not handled, re-queuing`); this.command_interrupt.unshift(interrupt); return this.exit(); } // Convert string to lowercase for easier matching to verbs/nouns // If case-sensitive matching is required, a command interrupt must be used input_string = input_string.toLowerCase(); var target = null; var modifier = null; var halt = false; var tmp = null; // Loop through patterns and attempt to match against string var verb = null; // Only one verb is allowed var modifiers = null; // Any number of modifiers are allowed var nouns = null; // Any number of nouns are allowed for (var p = 0; p < this.patterns.length; p++) { // If a hard halt has been triggered, cancel here // Used in cases where the input is obviously malformed, such as GO NORTH NORTH if (halt) { break; } // Reset the modifier and noun lists. Some items may have been parsed last time, // even if the pattern was not successful overall modifiers = []; nouns = []; // Make a copy of the input string, since we're going to modify it var parse_string = input_string; // Tokenize the command // Using the term 'tokenize' generously var command_tokens = parse_string.toLowerCase().split(` `); // Get current pattern and tokenize var pattern = this.patterns[p]; var pattern_tokens = pattern.toLowerCase().split(` `); // If there are less tokens in the command than in the pattern, skip this pattern; it won't be a match if (command_tokens.length < pattern_tokens.length) { continue; } // Loop through pattern tokens and start matching by type for (var t = 0; t < pattern_tokens.length; t++) { var token = pattern_tokens[t]; // Run matching function if (token == `verb`) { tmp = this.matchVerb(parse_string); if (tmp.match != null) { verb = tmp.match; parse_string = tmp.string; } else { // Didn't find matching verb, bail break; } } else if (token == `modifier`) { tmp = this.matchModifier(parse_string, verb); if (tmp.match != null) { if (modifiers.length > 0 && modifiers[0] == tmp.match) { // Can't match the same modifier twice halt = true; break; } modifiers.push(tmp.match); parse_string = tmp.string; } else { // Didn't find modifier, bail break; } } else if (token == `noun`) { tmp = this.matchNoun(parse_string); if (tmp.match != null) { nouns.push(tmp.match); parse_string = tmp.string; } else { // Didn't find noun, bail console.log(`NLP: No noun match for token '`+parse_string+`'`); break; } } else if (token == `text`) { // Some verbs allow overflow words to be parsed specially // Example: writing text on a sign, or indicating a topic of discussion if (verb.hasOwnProperty(`overflow`) && verb.overflow && parse_string.length > 0) { nouns.push(parse_string); parse_string = ``; } else { // Overflow not allowed, bail break; } } } // If we've matched all tokens in the pattern, break out if (parse_string.length == 0) { console.log(`MATCHED PATTERN: ` + pattern); break; } } // Get target (for rule purposes) target = (nouns.length > 0) ? nouns[0] : null; // Build data for action currentAction.update({ 'verb': verb, // The matched verb object 'text': input_string, // The original input string (some verbs and rules will use it) 'target': target, // The first target object 'modifiers': modifiers, // A list of modifiers fed to the pattern 'nouns': nouns, // A list of nouns fed to the pattern }); // Pre-process verb if(verb && verb.hasOwnProperty(`pre`)) { verb.pre(currentAction); } // Run 'Before' ruleset currentAction = Rulebook.check(`before`, currentAction); if (currentAction.mode === Rulebook.ACTION_CANCEL) { return this.exit(); } console.log(currentAction); if(currentAction.output) { queueOutput(currentAction.output); currentAction.output = ``; } // No commands without verbs are allowed if (verb == null) { return this.exit(`
I don't understand.
`); } // If remainder parse string length is not 0, we failed to fully parse the string // Fail to avoid unintended consequences if (parse_string.length > 0) { // Get the understood portion of the command var clean_string = string.replace(` ` + parse_string, ``); // Allow actions to handle broken inputs on their own, if the verb was matched but the rest of the string didn't quite make sense if (typeof verb.onBadInput == `function`) { return this.exit(verb.onBadInput(parse_string)); } return this.exit(`I understood everything up until '` + parse_string + `'. You want to ` + clean_string.toUpperCase() + `, plus something.
`); } // Run verb filters // Some verbs only act on certain types of targets, for example if (verb.filters.length > 0) { var args = {'action': currentAction, 'verb': verb, 'nouns': nouns}; if (!ECS.runFilters(verb, `filters`, args)) { return this.exit(); } } // Let target handle action if appropriate callback is provided // Objects can define their own behaviors for verbs, which will bypass the standard verb behavior var performDefault = true; var objectCallback = `onAction.` + verb.aliases[0].toUpperCase().replace(/[\s\-]/g, `.`); var response = new Response(NLP.RESPONSE_AFTER, null); if (nouns.length > 0 && typeof nouns[0][objectCallback] == `function`) { response = nouns[0][objectCallback](currentAction); if (typeof response == `string`) { response = new Response(NLP.RESPONSE_INSTEAD, response); } else if (typeof response == `boolean`) { performDefault = response; response = new Response(performDefault ? NLP.RESPONSE_BEFORE : NLP.RESPONSE_INSTEAD, null); } } if(response.mode == NLP.RESPONSE_BEFORE) { response.output(currentAction); } if (response.mode != NLP.RESPONSE_INSTEAD) { var result = verb.callback(currentAction); if(typeof result == `string`) { currentAction.output += result; } } if(response.mode == NLP.RESPONSE_AFTER) { response.output(currentAction); } // Save action for After Rulebook this.afterAction = currentAction; return this.exit(currentAction.output); }, // Match verb 'matchVerb': function (string) { var tokens = string.split(` `); // Loop through token list, trying to parse longest string first (most tokens) for (var i = tokens.length; i > 0; i--) { var verb = tokens.slice(0, i).join(` `); var action = ECS.getAction(verb); if (action != null) { return {'match': action, 'string': tokens.slice(i).join(` `)}; } } return {'match': null, 'string': string}; }, // Match modifier for action 'matchModifier': function (string, action) { var tokens = string.split(` `); // Loop through token list, trying to parse longest string first (most tokens) for (var i = tokens.length; i > 0; i--) { var modifier = tokens.slice(0, i).join(` `); if (action.modifiers.indexOf(modifier) >= 0) { // Return found match and leftover string return {'match': modifier, 'string': tokens.slice(i).join(` `)}; } } // No match found return {'match': null, 'string': string}; }, // Match noun 'matchNoun': function (string) { var tokens = string.split(` `); // Loop through token list, trying to parse longest string first (most tokens) for (var i = tokens.length; i > 0; i--) { var noun = tokens.slice(0, i).join(` `); var object = ECS.findEntityByName(noun, `local`); if (object != null) { return {'match': object, 'string': tokens.slice(i).join(` `)}; } } return {'match': null, 'string': string}; }, }; var changelog = [ { 'version':`0.6.2`, 'notes':[ `Added bridge music files`, `Improved music transition handling`, `Added support for linked music files and playback resume`, `Added some missing scenery` ] }, { 'version':`0.6.1`, 'notes':[ `First playtest`, `Added missing scenery in most underground locations`, `Most locations linked for Prologue, Acts 1-3` ] }, { 'version':`0.2.1`, 'notes':[ `Added Response handling to NLP for more flexible onAction callbacks.`, `Added bridge direction text to hot springs.`, `Fixed Bridge state handling.`, ] }, { 'version':`0.2.0`, 'notes':[ `Stubbed in Act 1, Act 2, Act 3 locations (all added and linked).`, `Added sea witch encounter.`, `Added Act 1 transition and starting sequence.`, `Added Bridge zones and Underground.`, `Reworked process for obtaining gate key.`, `Conversation and output handling improvements.`, ] }, { 'version':`0.1.0`, 'notes':[ `Fleshed out starting forest region with new locations and scenery.`, `Added bonus descriptions for races and classes.`, `Added music and closed captioning support.`, `Stubbed in social module.`, `Improved matching of menu elements.`, `Interrupts can now be queued instead of nested.`, `Revamped callback handling.`, `Revamped handling of multiple objects with overlapping nouns.` ] }, { 'version':`0.0.5`, 'notes':[ `Added basic SAVE and LOAD support`, `Added LOAD option to endgame screen`, `Added onRemoveChild callback to handle removal of objects from containers`, `Stubbed out Acts 1-3 modules` ] }, { 'version':`0.0.4`, 'notes':[ `Added Systems with game tick support`, `Added Living system for NPC activities`, `Moved player to campaign module`, `Added queueGMOutput convenience function`, `THE BLACK BOX:`, `Troglodyte will now get annoyed and eventually angry when attacked`, `Troglodyte will attack and kill player.` ] }, { 'version':`0.0.3`, 'notes':[ `Added basic combat action, hp handling, and death state`, `Added basic hug action`, `Added callback fallback (callback can indicate action not handled, fallback to generic)`, `Added basic dice roller`, `Fixed room descriptions when in darkness`, `THE BLACK BOX:`, `Changed torch to orb to eliminate need for on/off right now`, `Fixed double echo issue with Musty Cave entry interrupt`, `Added death/roll/vomit sequence for troglodyte death`, `Added a couple end-game states`, `First BB version where end state can be reached` ] }, { 'version':`0.0.2`, 'notes':[ `Remove old entries from output list to avoid infinitely-expanding DOM.`, `Added RAINBOW SWORD and MUSTY CAVE to Black Box module.`, `Refactored ECS to avoid nesting properties per-component.`, `Added support for callback lists and filter lists to ECS.`, `Added Darkness module with Emitter component and light attributes for places.`, `Added action filters to prevent actions in certain circumstances, e.g. darkness.`, `Added simple 'last input' feature (hit up arrow to select previous input string).`, `Added object list to room descriptions. Components and objects can specify whether they are listed automatically.`, `Improved item tagging.`, `Improved container/supporter descriptions.`, `Improved local object listings.`, `Added missing descriptions for items/places in Black Box module.` ] }, { 'version':`0.0.1`, 'notes':[ `Started keeping a changelog.`, `Basic ECS structure.`, `Working starting location, intro sequence, and common actions (movement, look, etc).`, `Support for scenery items.`, `Ability to TAKE items.`, `Queued output with optional effects (fade, etc).`, `Command interrupts for special input handling (e.g. 'what is your name?')`, `Handlebars templating with common handlers (tagged items, paragraphs, etc).` ] } ]; // // Create World // var game = null; var player = null; var Display = {}; var Engine = { // Global variables 'outputTimer': null, 'outputProgress': null, 'waiting': false, 'blockId': 0, 'flags': [], 'seed': Math.random(), 'inputEnabled': false, 'lastInput': ``, 'inputLimit': 100, 'inputQueue': [], 'inputReplayQueue': {'default':[]}, 'outputQueue': [], 'outputQueueDeferred': [], // Default engine functions 'init': function() { // Get hash (url fragment) flags var tmp = window.location.hash.substr(1); this.flags = tmp.split(`,`); }, 'stopWaiting':function() { $(`.input`).removeClass(`waiting waiting-1 waiting-2 waiting-3`).attr(`data-waiting`, 0); this.inputEnabled = true; this.waiting = false; Display.giveInputFocus(); // Execute queued input if (this.inputQueue.length > 0) { this.execute(this.inputQueue.shift()); } }, 'hasFlag': function(flag) { return (this.flags.indexOf(flag) >= 0); } }; /** * Queues text output. * * @param tmp the unprocessed text to output * @param delay the delay to add, in milliseconds * @param data extra data to use in the text template * @param deferred defer the output as part of action processing */ var queueOutput = Engine.queueOutput = function(tmp, delay, data, deferred) { if(typeof tmp == `undefined`) { console.log(`UNDEFINED OUTPUT`); console.trace(); } if (typeof(deferred) === `undefined`) { deferred = true; } if (typeof delay == `undefined`) { delay = 0; } var output = {'tmp': tmp, 'data': data, 'delay': delay}; if(deferred) { Engine.outputQueueDeferred.push(output); } else { Engine.outputQueue.push(output); } }; /** * Queues output for a specific character * * @param character the character speaking * @param tmp the unprocessed text to output * @param delay the delay to add, in milliseconds * @param data extra data to use in the text template */ var queueCharacterOutput = Engine.queueCharacterOutput = function(character, tmp, delay, data) { queueOutput(getSpeechTag(character)+tmp, delay, data); }; /** * Queues output prefixed with a GM tag. Convenience function. * * @param tmp the unprocessed text to output * @param delay the delay to add, in milliseconds * @param data extra data to use in the text template */ var queueGMOutput = Engine.queueGMOutput = function(tmp, delay, data) { queueOutput(`{{gm}}` + tmp + `
`, delay, data); }; /** * Queues output only if the originator (the source of the event) and * the player are in the same location. * * TODO: This should be deprecated and replaced with something less ridiculous. Entwining display logic and game logic like this is super dumb. * * @param source the originating entity * @param tmp the unprocessed text to output * @param delay the delay to add, in milliseconds * @param data extra data to use in the text template */ var queueLocalOutput = Engine.queueLocalOutput = function(source, tmp, delay, data) { if (source.location() == player.location()) { queueGMOutput(tmp, delay, data); } }; /** * Pushes all deferred output to the regular output queue. */ var processDeferredOutputQueue = Engine.processDeferredOutputQueue = function() { while(Engine.outputQueueDeferred.length > 0) { Engine.outputQueue.push(Engine.outputQueueDeferred.shift()); } }; /** * Process the output queue. * * Handles output delays, special effects, and input toggling. */ var processOutputQueue = Engine.processOutputQueue = function() { var effect = null; var delay = 100; window.clearInterval(Engine.outputProgress); if (Engine.outputQueue.length > 0) { // Shift item from beginning of queue var item = Engine.outputQueue.shift(); // Flag Engine as waiting Engine.waiting = true; // Set timer for next item // disabled for faster testing delay = item.delay; if (delay > 0 || delay == `auto`) { $(`.input`).animate({'color': `transparent`}, 200); $(`.input`).addClass(`waiting`); Engine.inputEnabled = false; } // Parse queued item var output = parse(item.tmp, item.data); // Handle auto delay if(delay == `auto`) { // Count words in the output var words = $(output).text().split(` `).length; // Assuming read speed of 300WPM, padded by 20% + 500ms // 300WPM is 5 words per second delay = 500 + Math.round((words / 5) * 1000 * 1.2); } // Disable delay in debug mode if(Engine.hasFlag(`debug`)) { delay = 0; } // Handle effect if set if (typeof item.data != `undefined` && typeof item.data.effect != `undefined`) { effect = item.data.effect; } // Wrap output var classes = (item.data && item.data.classes) ? item.data.classes : []; var id = `block-` + (Engine.blockId++); output = `{{text}}
`, 'echo': `{{p 'player' text}}`, }; // Conditional helper // Invoke with {{when value '<=' otherValue}} // Credit: http://stackoverflow.com/a/16315366/96089 Handlebars.registerHelper(`when`, function (v1, operator, v2, options) { switch (operator) { case `==`: return (v1 == v2) ? options.fn(this) : options.inverse(this); case `===`: return (v1 === v2) ? options.fn(this) : options.inverse(this); case `<`: return (v1 < v2) ? options.fn(this) : options.inverse(this); case `<=`: return (v1 <= v2) ? options.fn(this) : options.inverse(this); case `>`: return (v1 > v2) ? options.fn(this) : options.inverse(this); case `>=`: return (v1 >= v2) ? options.fn(this) : options.inverse(this); case `&&`: return (v1 && v2) ? options.fn(this) : options.inverse(this); case `||`: return (v1 || v2) ? options.fn(this) : options.inverse(this); default: return options.inverse(this); } }); // Expression-based conditional helper // Credit: http://stackoverflow.com/a/21915381/96089 Handlebars.registerHelper(`xif`, function (expression, options) { return Handlebars.helpers[`x`].apply(this, [expression, options]) ? options.fn(this) : options.inverse(this); }); Handlebars.registerHelper(`x`, function (expression) { var fn = function(){}, result; // in a try block in case the expression have invalid javascript try { // create a new function using Function.apply, notice the capital F in Function fn = Function.apply( this, [ `window`, // or add more '_this, window, a, b' you can add more params if you have references for them when you call fn(window, a, b, c); `return ` + expression + `;` // edit that if you know what you're doing ] ); } catch (e) { console.warn(`[warning] {{x ` + expression + `}} is invalid javascript`, e); } // then let's execute this new function, and pass it window, like we promised // so you can actually use window in your expression // i.e expression ==> 'window.config.userLimit + 10 - 5 + 2 - user.count' // // or whatever try { // if you have created the function with more params // that would like fn(window, a, b, c) result = fn.call(this, window); } catch (e) { console.warn(`[warning] {{x ` + expression + `}} runtime error`, e); } // return the output of that result, or undefined if some error occured return result; }); // Header helper (wraps text in a header tag with optional classes) // Invoke with {{header 'classes here' text}} Handlebars.registerHelper(`header`, function (classes, text) { return new Handlebars.SafeString( `` + text + `
` ); }); // Box helper (create an announcement box with title and text) // Invoke with {{box title text}} Handlebars.registerHelper(`box`, function (title, text, classes) { return new Handlebars.SafeString( `` + text + `