/** * Copyright 2022 Christopher Pollett * * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ /** * Frise Game Library */ /** * */ /** * */ function elt(id) { return document.getElementById(id); } /** * */ function tag(name) { return document.getElementsByTagName(name); } /** * */ function sel(selector) { return document.querySelectorAll(selector); } /** * */ function xelt(id) { return makeGameObject(elt(id)); } /** * */ function xsel(selector) { let tag_objects = sel(selector); return makeGameObjects(tag_objects); } /** * */ function xtag(name) { let tag_objects = tag(name); return makeGameObjects(tag_objects); } /** * */ function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } /** * */ function proceedClick(message) { let game_content = elt("game-content"); game_content.innerHTML += `<a id='proceed-click' href=''>${message}</a>`; return new Promise(resolve => elt('proceed-click').addEventListener( "click", resolve)); } /** * */ function makeGameObjects(tag_objects) { let game_objects = {}; for (const tag_object of tag_objects) { let game_object = makeGameObject(tag_object); if (game_object) { game_objects[game_object.id] = game_object; } } return game_objects; } /** * Upper cases first letter of a string * @param String str string to upper case first letter of * @return String result of uppercasing */ function upperFirst(str) { if (!str) { return ""; } let upper_first = str.charAt(0).toUpperCase(); return upper_first + str.slice(1); } /** * Used as part of the closure of makeGameObject so as that every game * object has an id. */ var object_counter = 0; function makeGameObject(dom_object) { if (!dom_object || dom_object.tagName.substring(0, 2) != "X-") { return null; } let tag_name = dom_object.tagName.slice(2); let type = dom_object.getAttribute("type"); if (type) { type = upperFirst(type); } else if (tag_name && tag_name != "OBJECT") { type = upperFirst(tag_name.toLowerCase()); } else { type = "Object"; } let game_object; if (type == "Location") { game_object = new Location(); } else { game_object = {}; } if (dom_object.id) { game_object.id = dom_object.id; } else { game_object.id = "oid" + object_counter; object_counter++; } for (const child of dom_object.children) { if (child.tagName.substring(0, 2) != "X-") { continue; } let attribute_name = child.tagName.slice(2); if (attribute_name) { attribute_name = attribute_name.toLowerCase() if (attribute_name == 'present') { if (!game_object[attribute_name]) { game_object[attribute_name] = []; } let check = child.getAttribute("ck"); if (!check) { check = ""; } game_object[attribute_name].push([check, child.innerHTML]); } else { game_object[attribute_name] = child.innerHTML; } } } game_object.type = type; return game_object; } function toggleDisplay(id, display_type) { if (display_type === undefined) { display_type = "block"; } obj = elt(id); if (obj.style.display == display_type) { value = "none"; } else { value = display_type; } obj.style.display = value; if (value == "none") { obj.setAttribute('aria-hidden', true); } else { obj.setAttribute('aria-hidden', false); } } function toggleOptions(elt_id, stop_pos) { let game_content = elt('game-content'); let elt_obj = elt(elt_id); if ((!elt_obj.style.left && !elt_obj.style.right) || elt_obj.style.left == '0px' || elt_obj.style.right == '0px') { elt_obj.style.left = (stop_pos - 300) + 'px'; if (is_mobile) { elt_obj.style.width = "240px"; } game_content.style.left = "55px"; game_content.style.width = "calc(100% - 40px)"; elt_obj.style.backgroundColor = 'white'; } else { elt_obj.style.left = '0px'; game_content.style.left = "300px"; game_content.style.width = "calc(100% - 480px)"; if (is_mobile) { elt_obj.style.width = "100%"; game_content.style.left = "100%"; } elt_obj.style.backgroundColor = 'lightgray'; } } /** * */ function addListenersAnchors(anchors) { let call_toggle = false; if (arguments.length > 1) { if (arguments[1]) { call_toggle = true; } } for (const anchor of anchors) { let hash = anchor.getAttribute("href"); if (hash && hash[0] == "#") { anchor.addEventListener('click', (event) => { if (!anchor.classList.contains('disabled')) { game.takeTurn(hash); if (call_toggle) { toggleOptions('main-nav', 0); } } event.preventDefault(); if (window.location.hash) { delete window.location.hash; } }); } } } /** * */ function disableSavesAndInventory() { let save_links = sel('[href~="#saves"]'); for (const save_link of save_links) { save_link.classList.add('disabled'); } let inventory_links = sel('[href~="#inventory"]'); for (const inventory_link of inventory_links) { inventory_link.classList.add('disabled'); } } /** * */ function enableSavesAndInventory() { let save_links = sel('[href~="#saves"]'); for (const save_link of save_links) { save_link.classList.remove('disabled'); } let inventory_links = sel('[href~="#inventory"]'); for (const inventory_link of inventory_links) { inventory_link.classList.remove('disabled'); } } /** * */ function interpolateVariables(text) { let old_text = ""; while (old_text != text) { old_text = text; text = text.replace(/obj(?:ect)?\(([^)]+)\);?/, "game.objects['$1']"); text = text.replace(/loc(?:ation)?\(([^)]+)\);?/, "game.locations['$1']"); } old_text = ""; while (old_text != text) { old_text = text; let interpolate_matches = text.match(/\$\{([^\}]+)\}/); if (interpolate_matches && interpolate_matches[1]) { let interpolate_var = interpolate_matches[1]; if (interpolate_var.replace(/\s+/, "") != "") { interpolate_var = interpolate_var.replaceAll(/\>\;?/g, ">"); interpolate_var = interpolate_var.replaceAll(/\<\;?/g, "<"); interpolate_var = interpolate_var.replaceAll(/\&\;?/g, "&"); if (interpolate_var) { let interpolate_value = eval(interpolate_var); text = text.replace(/\$\{([^\}]+)\}/, interpolate_value); } } } } return text; } class Location { present = []; /** * */ async renderPresentation() { disableSavesAndInventory(); let game_content = elt("game-content"); game_content.innerHTML = ""; for (let section of this.present) { if (!section[1]) { continue; } let [check_result, proceed, pause] = this.evaluateCheckCondition(section[0]); let prepared_section = this.prepareSection(section[1]); if (check_result) { if (proceed) { let old_inner_html = game_content.innerHTML; event = await proceedClick(proceed); event.preventDefault(); game_content.innerHTML = old_inner_html; } else if (pause) { await sleep(pause); } game_content.innerHTML += prepared_section; } } game_content.innerHTML += "<div class='footer-space'></div>"; let anchors = sel("#game-content a, #game-content x-button"); addListenersAnchors(anchors); enableSavesAndInventory(); } /** * */ evaluateCheckCondition(condition) { let check; let proceed = ""; let pause = 0; if (condition == "") { check = ""; } else { check = condition; let old_check = ""; while (old_check != check) { old_check = check; check = check.replace(/obj(?:ect)?\(([^)]+)\);?/, "game.objects['$1']"); check = check.replace(/loc(?:ation)?\(([^)]+)\);?/, "game.locations['$1']"); let click_pattern = /(?:clickProceed|waitClick)\(([^)]+)\);?/; let click_match = check.match(click_pattern); if (click_match) { proceed = click_match[1]; check = ""; break; } let pause_pattern = /(?:sleep|delay|pause)\(([^)]+)\);?/; let pause_match = check.match(pause_pattern); if (pause_match) { pause += parseInt(pause_match[1]); } check = check.replace(pause_pattern, ""); } } let check_result = (check.replace(/\s+/, "") != "") ? eval(check) : true; return [check_result, proceed, pause]; } /** * */ prepareSection(section) { let old_section = ""; let quote = `(?:(?:'([^']*)')|(?:"([^"]*)"))`; section = interpolateVariables(section); while (section != old_section) { old_section = section; let speaker_pattern = new RegExp( "\<x-speaker[^\>]+name\s*\=\s*("+quote+")[^\>]*>", 'i'); let speaker_match = section.match(speaker_pattern); if (speaker_match) { let speaker_id = speaker_match[3]; if (speaker_id && game.objects[speaker_id]) { let speaker = game.objects[speaker_id]; let name = speaker.name; let icon = speaker.icon; let html_fragment = speaker_match[0]; let html_fragment_mod = html_fragment.replace(/name\s*\=/, "named="); if (icon) { section = section.replace(html_fragment, html_fragment_mod + "<figure><img src='"+speaker.icon+"' " + "loading='lazy' ></figure><div>" + name + "</div><hr>"); } } } } return section; } } class Game { timestamp; objects; locations; history; future_history; constructor() { this.history = []; this.future_history = []; this.addMainNav(); this.initializeObjectsLocations(); } /** * */ addMainNav() { let body_objs = tag("body"); if (body_objs[0] === undefined) { return; } let body_obj = body_objs[0]; let main_nav_objs = tag("x-main-nav"); if (main_nav_objs[0] === undefined) { return; } let main_nav_obj = main_nav_objs[0]; let game_screen = elt('game-screen'); if (!game_screen) { body_obj.innerHTML = '<div id="game-screen"></div>' + body_obj.innerHTML; game_screen = elt('game-screen'); } game_screen.innerHTML = ` <div id="main-bar"> <button id="main-toggle" class="float-left"><span class="main-close">≡</button> </div> <div id="main-nav"> <button id="previous-history">←</button> <button id="next-history">→</button> <div id="game-nav"> ${main_nav_obj.innerHTML} </div> </div> <div id="game-content"></div>`; elt('main-toggle').onclick = (evt) => { toggleOptions('main-nav', 0); }; elt('next-history').onclick = (evt) => { this.nextHistory(); } elt('previous-history').onclick = (evt) => { this.previousHistory(); } let anchors = sel('#game-nav a, #game-nav x-button'); addListenersAnchors(anchors, is_mobile); } /** * */ initializeScreen() { if(!is_mobile) { return; } let html = tag("html")[0]; html.classList.add("mobile"); let head = tag("head")[0]; head.innerHTML += `<meta name="viewport" `+ `content="width=device-width, initial-scale=1.0" >` toggleOptions('main-nav', 0); } /** * */ initializeObjectsLocations() { this.objects = xtag("x-object"); this.locations = xtag("x-location"); for (const oid in this.objects) { let object = this.objects[oid]; if (object.position) { let location_name = object.position; if (this.locations[location_name]) { let location = this.locations[location_name] if (typeof location.items == "undefined") { location.items = []; } location.items.push(object.id); } } } } /** * */ captureState() { let now = new Date(); let date = now.getFullYear() + '-' + (now.getMonth() + 1) + '-' + now.getDate(); let time = now.getHours() + ":" + now.getMinutes() + ":" + now.getSeconds(); game.timestamp = date + " " + time; return JSON.stringify({ timestamp: game.timestamp, objects: this.objects, locations: this.locations }); } /** * */ restoreState(game_save) { let game_state = JSON.parse(game_save); if (!game_state || !game_state.timestamp || !game_state.objects || !game_state.locations) { alert("Does not appear to be a valid game save"); return false; } this.timestamp = game_state.timestamp; this.objects = game_state.objects; let locations = game_state.locations; this.locations = {}; for (const location_name in locations) { let location_object = locations[location_name]; let location = new Location(); for (const field in location_object) { location[field] = location_object[field]; } this.locations[location_name] = location; } return true; } /** * */ clearHistory() { this.history = []; this.future_history = []; } /** * */ previousHistory() { if (this.history.length == 0) { return; } let current_state = this.captureState(); this.future_history.push(current_state); let previous_game_state = this.history.pop(); this.restoreState(previous_game_state); sessionStorage.current = previous_game_state; this.describeMainCharacterLocation(); if (this.history.length == 0) { elt('previous-history').disabled = true; } elt('next-history').disabled = false; } /** * */ nextHistory() { if (this.future_history.length == 0) { return; } let current_state = this.captureState(); this.history.push(current_state); let next_game_state = this.future_history.pop(); this.restoreState(next_game_state); sessionStorage.current = next_game_state; this.describeMainCharacterLocation(); if (this.future_history.length == 0) { elt('next-history').disabled = true; } elt('previous-history').disabled = false; } /** * */ initSlotStates() { let saves_location = game.locations['saves']; for (const field in saves_location) { let slot_matches = field.match(/^slot(\d+)/); if (slot_matches && slot_matches[1]) { let slot_number = parseInt(slot_matches[1]); console.log("slot" + slot_number); let game_save = sessionStorage.getItem("slot" + slot_number); if (game_save) { let game_state = JSON.parse(game_save); saves_location["slot" + slot_number] = 'Load'; saves_location["delete" + slot_number] = ""; saves_location["filled" + slot_number] = 'filled'; saves_location["filename" + slot_number] = game_state.timestamp; } else { saves_location["slot" + slot_number] = 'Save'; saves_location["filled" + slot_number] = 'not-filled'; saves_location["delete" + slot_number] = "disabled"; saves_location["filename" + slot_number] = '...'; } } } } /** * */ saveLoadSlot(slot_number) { slot_number = parseInt(slot_number); let saves_location = game.locations['saves']; let game_state = sessionStorage.getItem("slot" + slot_number); if (game_state) { this.clearHistory(); sessionStorage.current = game_state; this.restoreState(game_state); } else { let save_state = this.captureState(); game_state = this.history[this.history.length - 1]; this.restoreState(game_state); saves_location['filename' + slot_number] = this.timestamp; sessionStorage.setItem("slot" + slot_number, game_state); this.restoreState(save_state); this.evaluateAction(saves_location['default-action']); } } /** * */ deleteSlot(slot_number) { slot_number = parseInt(slot_number); let saves_location = game.locations['saves']; sessionStorage.removeItem("slot" + slot_number); saves_location['filled' + slot_number] = "not-filled"; saves_location['delete' + slot_number] = "disabled"; saves_location['slot' + slot_number] = "Save"; saves_location['filename' + slot_number] = "..."; } /** * */ deleteSlotAll() { let i = 1; let saves_location = game.locations['saves']; while (typeof saves_location['filename' + i] !== 'undefined') { this.deleteSlot(i); i++; } } /** * */ load() { let file_load = elt('file-load'); if (!file_load) { file_load = document.createElement("input"); file_load.type = "file"; file_load.id = 'file-load'; file_load.style.display = 'none'; file_load.addEventListener('change', (event) => { let to_read = file_load.files[0]; let file_reader = new FileReader(); file_reader.readAsText(to_read, 'UTF-8') file_reader.addEventListener('load', (load_event) => { let game_state = load_event.target.result; this.clearHistory(); sessionStorage.current = game_state; this.restoreState(game_state); game.describeMainCharacterLocation(); }); }); } file_load.click(); } /** * */ save() { let game_state = this.history[this.history.length - 1]; let file = new Blob([game_state], {type: "plain/text"}); let link = document.createElement("a"); link.href = URL.createObjectURL(file); link.download = "game_save.txt"; link.click(); } /** * */ takeTurn(hash) { if (hash == "#previous") { this.previousHistory(); return; } else if (hash == "#next") { this.nextHistory(); return; } let new_game_state; if (sessionStorage.current) { new_game_state = sessionStorage.current; } if (!this.moveMainCharacter(hash)) { console.log("exiting"); return; } else { console.log("continuing"); } this.future_history = []; elt('next-history').disabled = true; if (sessionStorage.current) { this.history.push(new_game_state); } this.evaluateDefaultActions(this.objects); this.evaluateDefaultActions(this.locations); sessionStorage.current = this.captureState(); this.describeMainCharacterLocation(); if (this.history.length == 0) { elt('previous-history').disabled = true; } else { elt('previous-history').disabled = false; } } /** * */ evaluateDefaultActions(x_entities) { for (const object_name in x_entities) { let game_entity = x_entities[object_name]; if (game_entity && game_entity['default-action']) { this.evaluateAction(game_entity['default-action']); } } } /** * */ moveObject(object_id, destination) { let move_object = this.objects[object_id]; if (!move_object || !this.locations[destination]) { return false; } let old_position = move_object.position; let old_location = this.locations[old_position]; old_location.items.filter((value) => { value != object_id; }); move_object.position = destination; let new_location = this.locations[destination]; if (!new_location.items) { new_location.items = []; } new_location.items.push(object_id); return true; } /** * */ moveMainCharacter(hash) { if (!hash || hash <= 1) { return true; } hash = hash.substring(1); let hash_parts = hash.split("=>"); let destination = hash_parts.pop(); for (const hash_part of hash_parts) { let hash_matches = hash_part.match(/([^\(]+)(\(([^\)]+)\))?\s*/); let action = elt(hash_matches[1]); let args = []; if (typeof hash_matches[3] !== 'undefined') { args = hash_matches[3].split(","); } if (action && action.tagName == 'X-ACTION') { let code = action.innerHTML; if (code) { this.evaluateAction(code, args); } } } if (destination == "exit") { this.describeMainCharacterLocation(); return false; } if (destination == "previous") { this.previousHistory(); return false; } if (destination == "next") { this.nextHistory(); return false; } this.moveObject('main-character', destination); return true; } /** * */ evaluateAction(code) { var args = []; if (arguments.length > 1) { if (arguments[1]) { args = arguments[1]; } } let old_code = ""; while (old_code != code) { old_code = code; code = code.replace(/obj(?:ect)?\(([^)]+)\);?/, "game.objects['$1']"); code = code.replace(/loc(?:ation)?\(([^)]+)\);?/, "game.locations['$1']"); } eval(code); } /** * */ describeMainCharacterLocation() { let main_character = this.objects['main-character']; let position = main_character.position; console.log(position); let location = this.locations[position]; location.renderPresentation(); } } /** * */ var game; /** * */ var is_mobile = window.matchMedia("(max-width: 1000px)").matches; /** * */ async function initGame() { game = new Game(); if (sessionStorage.current) { game.restoreState(sessionStorage.current); } game.takeTurn(""); game.clearHistory(); game.initializeScreen(); }