(function(){function r(e,n,t){function o(i,f){if(!n[i]){if(!e[i]){var c="function"==typeof require&&require;if(!f&&c)return c(i,!0);if(u)return u(i,!0);var a=new Error("Cannot find module '"+i+"'");throw a.code="MODULE_NOT_FOUND",a}var p=n[i]={exports:{}};e[i][0].call(p.exports,function(r){var n=e[i][1][r];return o(n||r)},p,p.exports,r,e,n,t)}return n[i].exports}for(var u="function"==typeof require&&require,i=0;i<t.length;i++)o(t[i]);return o}return r})()({1:[function(require,module,exports){ module.exports = { images_url: "/assets/images" } },{}],2:[function(require,module,exports){ module.exports = { build: { protected_dirs: ["assets", "style", "views", "standard"], default_meta_keys: ["title", "description", "image", "open_graph", "json_ld"], }, }; },{}],3:[function(require,module,exports){ module.exports = { images_url: `/assets/images`, data_url: `/assets/data`, }; },{}],4:[function(require,module,exports){ "use strict"; const MtlAnimation = require("./model/animation"); const MentaloEngine = require("./mentalo-engine"); const MtlScene = require("./model/scene"); const MtlGame = require("./model/game"); const MtlSoundTrack = require("./model/sound-track"); const SCENE_TYPES = require("./model/scene-types"); const MtlChoice = require("./model/choice"); const MtlGameObject = require("./model/game-object"); const FrameRateController = require("./lib/frame-rate-controller"); const font_tools = require("./lib/font-tools"); const color_tools = require("./lib/color-tools"); const shape_tools = require("./lib/shape-tools"); module.exports = { MentaloEngine, MtlAnimation, MtlScene, MtlGame, MtlSoundTrack, SCENE_TYPES, MtlChoice, MtlGameObject, FrameRateController, font_tools, color_tools, shape_tools, }; },{"./lib/color-tools":5,"./lib/font-tools":6,"./lib/frame-rate-controller":7,"./lib/shape-tools":8,"./mentalo-engine":9,"./model/animation":10,"./model/choice":11,"./model/game":13,"./model/game-object":12,"./model/scene":17,"./model/scene-types":16,"./model/sound-track":18}],5:[function(require,module,exports){ "use strict"; /** * Helpers to work with color values */ /** * Get a RGB color value in String from either "rgb(x,x,x)" or #xxxxxx * @param {String} color A RGB color value as String in the form: #ffffff or rgb(x,x,x) * @returns {Array<Uint8>} An array of 3 values from 0 to 256 for [RED, GREEN, BLUE] */ function color_str_to_rgb_array(color) { color = color.toLowerCase(0); if (color.includes("rgb")) { return color.replaceAll(/[a-z\(\)w]/g, "") .split(",") .slice(0, 3) .map(n => parseInt(n)); } else { return color.replace("#", "") .match(/.{0,2}/g) .slice(0, 3) .map(hex => parseInt(hex, 16)); } } /** * @param {String} color A color value (#xxxxxx or rbg(x,x,x)) * @returns The average between each tone of the given color. */ function get_average_rgb_color_tone(color) { return color_str_to_rgb_array(color).reduce((tot, n) => tot + n / 3, 0); } /** * Gets an array of 3 8bits integers for red green and blue and and return a color value as an hexadecimal string #xxxxxx * @param {Array<Uint8>} rgb * @returns {String} The hexacdecimal rgb value of the color including the hash character: #xxxxxx */ function rgb_array_to_hex(rgb) { return `#${rgb.slice(0, 3).map(n => { const hex = n.toString(16); return hex.length < 2 ? '0' + hex : hex; }).join('')}`; } /** * Gets an array of 4 8bits integers for red green blue and transparency channel and return a color value as an hexadecimal string #xxxxxx * @param {Array<Uint8>} rgb * @returns {String} The hexacdecimal rgba value of the color including the hash character: #xxxxxxxx */ function rgba_array_to_hex(rgba) { rgba = rgba.length === 4 ? rgba : rgba.concat([255]) return `#${rgba.slice(0, 4).map(n => { const hex = n.toString(16); return hex.length < 2 ? '0' + hex : hex; }).join('')}`; } /** * Gets a background color as argument and return a calculated optimal foreground color. * For example if the background is dark the function will return a lighten version of the background. * @param {String} base_color A RGB color value in hexadecimal form: #ffffff * @returns */ function get_optimal_visible_foreground_color(base_color) { // create a 2D 1px canvas context const tmp_canvas = document.createElement("canvas"); tmp_canvas.width = 1; tmp_canvas.height = 1; const ctx = tmp_canvas.getContext("2d"); // fill it with the given color ctx.fillStyle = base_color; ctx.fillRect(0, 0, 1, 1); // Get either a semi-transparent black or semi-transparent white regarding the base color is above or below an avg of 127 const superpo_color = get_average_rgb_color_tone(base_color) > 127 ? "#0000003f" : "#ffffff3f"; // Fill the pixel with the semi-transparent black or white on top of the base color ctx.fillStyle = superpo_color; ctx.fillRect(0, 0, 1, 1); // Return the flatten result of the superposition return rgb_array_to_hex(Array.from(ctx.getImageData(0, 0, 1, 1).data)); } /** * @param {Array<Uint8; 4>} col1 A RGBA color value as an array of 4 0 to 256 integers * @param {Array<Uint8; 4>} col2 A RGBA color value as an array of 4 0 to 256 integers * @returns {Boolean} true if the 2 colors are equals */ function same_rgba(col1, col2) { return col1[0] === col2[0] && col1[1] === col2[1] && col1[2] === col2[2] && col1[3] === col2[3]; } module.exports = { get_average_rgb_color_tone, get_optimal_visible_foreground_color, color_str_to_rgb_array, rgb_array_to_hex, rgba_array_to_hex, same_rgba, }; },{}],6:[function(require,module,exports){ "use strict"; /** * Helpers to works with default web fonts */ const FONT_FAMILIES = [ { category: "Sans serif", values: [ { value: "sans-serif", text: "sans-serif" }, { text: "Arial", value: "Arial, sans-serif" }, { text: "Helvetica", value: "Helvetica, sans-serif" }, { text: "Verdana", value: "Verdana, sans-serif" }, { text: "Trebuchet MS", value: "Trebuchet MS, sans-serif" }, { text: "Noto Sans", value: "Noto Sans, sans-serif" }, { text: "Gill Sans", value: "Gill Sans, sans-serif" }, { text: "Avantgarde", value: "Avantgarde, TeX Gyre Adventor, URW Gothic L, sans-serif" }, { text: "Optima", value: "Optima, sans-serif" }, { text: "Arial Narrow", value: "Arial Narrow, sans-serif" } ], }, { category: "Serif", values: [ { text: "serif", value: "serif" }, { text: "Times", value: "Times, Times New Roman, serif" }, { text: "Didot", value: "Didot, serif" }, { text: "Georgia", value: "Georgia, serif" }, { text: "Palatino", value: "Palatino, URW Palladio L, serif" }, { text: "Bookman", value: "Bookman, URW Bookman L, serif" }, { text: "New Century Schoolbook", value: "New Century Schoolbook, TeX Gyre Schola, serif" }, { text: "American Typewriter", value: "American Typewriter, serif" } ], }, { category: "Monospace", values: [ { text: "monospace", value: "monospace" }, { text: "Andale Mono", value: "Andale Mono, monospace" }, { text: "Courrier New", value: "Courier New, monospace" }, { text: "Courrier", value: "Courier, monospace" }, { text: "FreeMono", value: "FreeMono, monospace" }, { text: "OCR A Std", value: "OCR A Std, monospace" }, { text: "DejaVu Sans Mono", value: "DejaVu Sans Mono, monospace" }, ], }, { category: "Cursive", values: [ { text: "cursive", value: "cursive" }, { text: "Comic Sans MS", value: "Comic Sans MS, Comic Sans, cursive" }, { text: "Apple Chancery", value: "Apple Chancery, cursive" }, { text: "Bradley Hand", value: "Bradley Hand, cursive" }, { text: "Brush Script MT", value: "Brush Script MT, Brush Script Std, cursive" }, { text: "Snell Roundhand", value: "Snell Roundhand, cursive" }, { text: "URW Chancery L", value: "URW Chancery L, cursive" }, ], } ]; const TEXT_ALIGN_OPTIONS = [ { value: "left", text: "Left" }, // TODO trad or icon { value: "right", text: "Right" }, { value: "center", text: "Center" }, ]; const FONT_STYLE_OPTIONS = [ { value: "normal", text: "Normal" }, { value: "italic", text: "Italic" }, // TODO trad or icon ]; const FONT_WEIGHT_OPTIONS = [ { value: "1", text: "Thin" }, { value: "normal", text: "Normal" }, { value: "900", text: "Bold" }, ]; /** * Gets an object description of a font and returns it formatted as a string that can be given to a 2D drawing context. * Example : ctx.font = get_canvas_font({font_family:"monospace", font_size: 32}) * @param {Object} font_data An object description of the font settings, with font_size, font_family, font_style, font_weight.s * @returns {String} */ function get_canvas_font(font_data = {}) { const { font_size = 20, font_family = "sans-serif", font_style = "normal", font_weight = "normal" } = font_data; const font_variant = "normal"; return `${font_style} ${font_variant} ${font_weight} ${font_size.toFixed()}px ${font_family}`; } /** * @returns {Object} A map of all defined constants for font families, text align options, font styles and font weights. */ function get_font_options() { return { font_families: FONT_FAMILIES, text_align_options: TEXT_ALIGN_OPTIONS, font_style_options: FONT_STYLE_OPTIONS, font_weight_options: FONT_WEIGHT_OPTIONS, }; } /** * An approximation of the average width of a character for a 2D drawing context with a given font configuration. * @param {CanvasRenderingContext2D} ctx * @returns */ function get_canvas_char_size(ctx) { const str = `Lorem ipsum dolor Sit amet, Consectetur adipiscing elit`; const width = ctx.measureText(str).width / str.length; const height = ctx.measureText("M").width * 1.1; const text_line_height = height * 1.2; return { width, height, text_line_height, } } module.exports = { get_canvas_font, get_font_options, get_canvas_char_size, FONT_FAMILIES, TEXT_ALIGN_OPTIONS, FONT_STYLE_OPTIONS, FONT_WEIGHT_OPTIONS }; },{}],7:[function(require,module,exports){ "use strict"; /** * A little helper class to control the rendering frame rate */ class FrameRateController { /** * @param {Integer} fps The wanted frame rate in frame per second */ constructor(fps) { this.tframe = performance.now(); this.interval = 1000 / fps; // convert in milliseconds this.initial = true; } /** * @returns {Boolean} true if the defined interval is elapsed. */ nextFrameReady() { if (this.initial) { this.initial = false; return true; } const now = performance.now(); const elapsed = now - this.tframe; const ready = elapsed > this.interval; if (ready) { this.tframe = now - (elapsed % this.interval); } return ready; } } module.exports = FrameRateController; },{}],8:[function(require,module,exports){ "use strict"; /** * Draws a rectangle on a canvas 2D context with support of corner rounding and border options. * @param {CanvasRenderingContext2D} ctx * @param {Integer} x * @param {Integer} y * @param {Integer} width * @param {Integer} height * @param {Object} options */ function draw_rect(ctx, x, y, width, height, options = { rounded_corners_radius: 0, border: { width: 0, color: "rgba(0,0,0,0)" }, fill_color: "black", }) { const { rounded_corners_radius = 0, border = { width: 0, color: "rgba(0,0,0,0)" }, fill_color = "black", fill_image } = options; const smallest_axis = Math.min(width, height); const radius = rounded_corners_radius > smallest_axis / 2 ? smallest_axis / 2 : rounded_corners_radius; ctx.save(); ctx.beginPath(); ctx.arc(x + radius, y + radius, radius, Math.PI, 3 * Math.PI / 2); ctx.lineTo(x + width - radius, y); ctx.arc(x + width - radius, y + radius, radius, 3 * Math.PI / 2, 0); ctx.lineTo(x + width, y + height - radius); ctx.arc(x + width - radius, y + height - radius, radius, 0, Math.PI / 2); ctx.lineTo(x + radius, y + height); ctx.arc(x + radius, y + height - radius, radius, Math.PI / 2, Math.PI); ctx.closePath(); ctx.clip(); if (fill_image) { ctx.drawImage( fill_image.src, 0, 0, fill_image.src.width, fill_image.src.height, fill_image.dx, fill_image.dy, fill_image.dw, fill_image.dh, ) } else { ctx.fillStyle = fill_color; ctx.fillRect(x, y, width, height); } if (border.width > 0) { ctx.strokeStyle = border.color; ctx.lineWidth = border.width; ctx.stroke(); } ctx.restore(); } module.exports = { draw_rect, } },{}],9:[function(require,module,exports){ "use strict"; const MtlGame = require("./model/game"); const SCENE_TYPES = require("./model/scene-types"); const MtlRender = require("./render/render"); const { supported_locales, get_translated } = require("./translation"); /** * The encapsulating class of all the components of the Mentalo game engine. * This is the class that should be used from outside to run a game. */ class MentaloEngine { /** * Initialize A MtlGame instance, populates it with the gama_data given as param. * Initializes the DOM element to contain the rendering of the engine. * Defines the listeners for Escape key and initialize a MtlRender instance. * @param {Object} params * Parameter params.game_data is required and should be the result of the exportation format of the Mentalo editor app. */ constructor(params) { this.game = new MtlGame(); this.game.load_data(params.game_data); this.game.on_load_resources(() => this.loading = false); this.use_locale = params.use_locale && supported_locales.includes(params.use_locale) ? params.use_locale : "en"; /** * Allow to exit game by typing Escape or q * @param {Event} e */ this.escape_listener = e => { const k = e.key.toLowerCase(); (k === "escape" || k === "q") && this.quit(); }; window.addEventListener("keydown", this.escape_listener); this.on_quit_game = function () { if (this.used_default_container) { this.container.remove(); } window.removeEventListener("keydown", this.escape_listener); params.on_quit_game && params.on_quit_game(); }; this.used_default_container = false; this.container = params.container || (() => { const el = document.createElement("div"); el.style.display = "flex"; el.style.justifyContent = "center"; el.style.alignItems = "center"; el.style.position = "absolute"; el.style.top = 0; el.style.bottom = 0; el.style.left = 0; el.style.right = 0; el.style.zIndex = 1000; el.style.overflow = "hidden"; document.body.appendChild(el); this.used_default_container = true return el; })(); this.render = new MtlRender({ container: this.container, fullscreen: params.fullscreen, frame_rate: params.frame_rate, get_game_settings: this.get_game_settings.bind(this), get_game_scenes: this.get_game_scenes.bind(this), get_scene: this.get_scene.bind(this), get_inventory: this.get_inventory.bind(this), on_game_object_click: this.on_game_object_click.bind(this), on_drop_inventory_object: this.on_drop_inventory_object.bind(this), on_choice_click: this.on_choice_click.bind(this), }); this.loading = true; this.will_quit = false; } /** * Returns a fragment of the game instance ui_options, which are the styling settings of the game interface. * Can be the settings for text_boxes, choices_panel, inventory, etc. * @param {String} key The key of the wanted interface settings. * @returns {Object} The fragment of this.game.game_ui_options for the given key. */ get_game_settings(key) { return this.game.game_ui_options[key]; } /** * @returns {Array<MtlScene>} Returns an array of the scenes of the game. */ get_game_scenes() { return this.game.scenes; } /** * @returns {MtlScene} The scene that is currently displayed */ get_scene() { return this.game.get_scene(); } /** * Returns the game objects currently saved in the inventory. * @returns {Set<MtlGameObject>} */ get_inventory() { return this.game.state.inventory; } /** * Sets the flag this.will_quit to true. * This flag will be parsed in the rendering loop and the engine will then effectively exit the game calling __exit(). */ quit() { this.will_quit = true; } /** * Clean the render instance, stops the rendering loop and calls the on_quit_game callback. * This is called when the rendering loop reads the this.will_quit flag as true. */ __quit() { window.cancelAnimationFrame(window.mentalo_engine_animation_id); this.render.exit_fullscreen(); this.render.clear_event_listeners(); this.game.get_soundtrack().stop(); this.on_quit_game(); } /** * Initialize the rendering loop and the MtlRender instance. * Also checks that the first loaded scene has an image to load. * If it doesn't the game will exit immediately in order to avoid having a black screen. * ( If the first scene doesn't have an image to load, the game.on_load_resource callback will * never be called and the engine will remain in loading state. ) */ init() { window.requestAnimationFrame = window.requestAnimationFrame || window.mozRequestAnimationFrame || window.webkitRequestAnimationFrame || window.msRequestAnimationFrame; window.cancelAnimationFrame = window.cancelAnimationFrame || window.mozCancelAnimationFrame; if (window.mentalo_engine_animation_id) { window.cancelAnimationFrame(window.mentalo_engine_animation_id); } this.render.init(); if (this.game.get_scene().animation.empty) { alert(get_translated("First scene has an empty image, game cannot be loaded", this.use_locale)); this.quit(); } } /** * Clear the event listeners and rendering element related to the current scene from the MtlRender instance, * And sets the game current scene state to a new index. * @param {Integer} index */ go_to_scene(index) { this.render.reset_user_error_popup(); this.render.clear_event_listeners(); this.game.get_soundtrack().stop(); this.game.go_to_scene(index); this.render.clear_children(); this.render.reset_text_box_visibility(); } /** * Event listener for doubleclick on a game object. * Adds the object to inventory. * @param {MtlGameObject} obj */ on_game_object_click(obj) { this.game.inventory_has_empty_slot() && this.game.add_object_to_inventory(obj); } /** * All callback called from the inventory render component when an object image is clicked in the inventory. * Removes the object from inventory. * @param {MtlGameObject} obj */ on_drop_inventory_object(obj) { this.game.remove_object_from_inventory(obj); } /** * Callback called from the choices_panel render component. * Handles the click on a choice. * @param {MtlChoice} choice * @returns */ on_choice_click(choice) { const { destination_scene_index, use_objects } = choice; if (destination_scene_index === -1) { // -1 is default index when the choice destination scene is not set. this.render.set_user_error_popup({ text: get_translated("Destination scene has not been set.", this.use_locale), }); return; } if (destination_scene_index === -2) { // -2 is the index used to indicate that destination scene is just the end of program. this.quit() return; } // Handle the case where the clicked choice requires some objects to be presents in the inventory. if (use_objects.value) { const inventory = this.get_inventory(); const objs = []; for (const it of use_objects.items) { const ob = Array.from(inventory).find(o => o.name === it.name); if (ob) { objs.push(ob); } else { this.render.set_user_error_popup({ text: use_objects.missing_object_message, }); return; } } objs.forEach(o => use_objects.items .find(it => o.name === it.name) .consume_object && this.game.consume_game_object(o)); } this.go_to_scene(destination_scene_index); } /** * Callback called when a scene of type "Cinematic" reaches end. */ on_cinematic_end() { const scene = this.game.get_scene(); if (scene.end_cinematic_options.quit) { this.quit(); } else if (scene.end_cinematic_options.destination_scene_index === -1) { this.render.set_user_error_popup({ text: get_translated("Next scene has not been set.", this.use_locale), on_close: this.quit.bind(this), }); } else { this.go_to_scene(scene.end_cinematic_options.destination_scene_index); } } /** * The rendering loop. * Handles the different states of the engine (quit, loading or runnning normally) * @returns The id of the registered window.requestAnimationFrame */ run_game() { if (this.will_quit) { this.__quit(); return; } else if (this.loading) { this.render.draw_loading(); } else { if (this.get_scene()._type === SCENE_TYPES.CINEMATIC) { this.game.update_cinematic_timeout(); if (this.game.is_cinematic_ended()) { this.on_cinematic_end(); } } const sound_track = this.game.get_soundtrack(); if (sound_track.loaded && !sound_track.is_playing()) { sound_track.play(); } this.render.draw_game(); } window.mentalo_engine_animation_id = requestAnimationFrame(this.run_game.bind(this)); } } module.exports = MentaloEngine; },{"./model/game":13,"./model/scene-types":16,"./render/render":19,"./translation":20}],10:[function(require,module,exports){ "use strict"; const Loadable = require("./loadable"); /** * The data type used for a MtlScene animation */ class MtlAnimation extends Loadable { /** * Initialize an empty instance of Image HtmlElement, * and registers the onload callback to update the instance with the loaded image actual dimensions. */ constructor() { super(new Image(), "image", "load"); this.image.onload = () => this.update_dimensions(); this.name = ""; this.dimensions = { width: 0, height: 0, }; this.frame_nb = 1; this.frame = 0; this.speed = 1; this.play_once = false; this.initialized = false; this.finished = false; } /** * Copies the dimensions of the loaded image at the root level of the instance. * Deletes the canvas dimensions precalculations created by the SceneAnimation component */ update_dimensions() { this.dimensions = { width: this.image.width / this.frame_nb, height: this.image.height, }; if (this.canvas_precalc) { delete this.canvas_precalc; } } /** * Populates the instance with loaded litteral data. * @param {Object} data */ init(data) { this.empty = data.src === ""; this.name = data.name; this.image.src = data.src; this.frame_nb = data.frame_nb; this.frame = 0; this.speed = data.speed; this.play_once = data.play_once; this.initialized = true; } /** * Increments the framcount argument if the animation next frame is ready to be rendered. * @param {Integer} framecount */ update_frame(framecount) { this.finished = this.play_once && this.frame === this.frame_nb - 1; if (this.frame_nb > 1 && framecount % this.speed === 0 && !this.finished) { this.frame = this.frame + 1 <= this.frame_nb - 1 ? this.frame + 1 : 0; } } /** * Reset the frame state. */ reset_frame() { this.finished = false; this.frame = 0; } } module.exports = MtlAnimation; },{"./loadable":15}],11:[function(require,module,exports){ "use strict"; /** * The data type used for the choices of a MtlScene */ class MtlChoice { /** * Initializes the instance with given data or default empty data. * @param {Object} data */ constructor(data = { text: "", destination_scene_index: -1, use_objects: { value: false, items: [], missing_object_message: "", } }) { this.load_data(data); } /** * Populates the instance with litteral description data * @param {Object} data */ load_data(data) { this.text = data.text; this.destination_scene_index = data.destination_scene_index; this.use_objects = data.use_objects; } } module.exports = MtlChoice; },{}],12:[function(require,module,exports){ "use strict"; const Loadable = require("./loadable"); /** * The data type used for the game_objects field of a MtlScene */ class MtlGameObject extends Loadable { /** * Initializes the instance with an empty image and zero position. */ constructor() { super(new Image(), "image", "load"); this.name = ""; this.position = { x: 0, y: 0 }; this.state = {}; } /** * Populates the instance with a litteral description data object * @param {Object} data */ load_data(data) { this.image.src = data.image; this.name = data.name; this.position = data.position; } /** * @returns {Object<Integer, Integer} A {width, height} object for the dimensions of the image. */ get_dimensions() { return { width: this.image.width, height: this.image.height, } } /** * Returns the bounding box coordinates of the object image as a {top, right, bottom, left} object. * @returns {Object<Int, Int, Int, Int>} */ get_bounds() { const dims = this.get_dimensions(); return { top: this.position.y, right: this.position.x + dims.width, bottom: this.position.y + dims.height, left: this.position.x, } } } module.exports = MtlGameObject; },{"./loadable":15}],13:[function(require,module,exports){ "use strict"; const MtlScene = require("./scene"); const SCENE_TYPES = require("./scene-types"); /** * The data type to encapsulate a loaded game. */ class MtlGame { /** * Initialize the instance with default data. */ constructor() { this.name = ""; this.scenes = [new MtlScene()]; // Game interface styling preferences this.game_ui_options = { // The overall look of the innterface general: { background_color: "#000000", animation_canvas_dimensions: { width: 600, ratio: "4:3", height: function () { const split_ratio = this.ratio.split(":").map(n => parseInt(n)); const factor = split_ratio[1] / split_ratio[0]; return Number((this.width * factor).toFixed()); }, }, }, // The text box displayed on top of a scene image if a message is defined text_boxes: { background_color: "#222222", font_size: 20, font_style: "normal", font_weight: "normal", font_family: "Arial, sans-serif", font_color: "#ffffff", text_align: "left", padding: 20, margin: 20, border_width: 0, rounded_corners_radius: 0, }, // The panel containing the choices buttons below the scene image. choices_panel: { background_color: "#222222", font_size: 20, font_family: "sans", font_color: "#ffffff", font_style: "normal", text_align: "left", font_weight: "normal", container_padding: 2,// unit em (font size related) choice_padding: 1.2,// idem active_choice_background_color: "rgba(255,255,255,.4)", active_choice_border_width: 0, active_choice_rounded_corners_radius: 0, }, // the inventory grid drawn at the right of the scene image inventory: { background_color: "#222222", columns: 1, rows: 4, gap: 10, padding: 20, slot_rounded_corner_radius: 0, slot_border_width: 2, }, }; this.starting_scene_index = 0; // The state of the running game this.state = { scene: this.starting_scene_index, inventory: new Set(), cinematic_timeout: { inc: 0, last_update_time: -1, timeout: false, }, }; // This state will be incremented for each when it finishes to load its image and sound resources. // The loaded_scenes value must be equal to the scenes number for the game to consider that all resources are loaded. this.load_state = { loaded_scenes: 0, }; } /** * Returns the scene that is currently displayed * @returns {MtlScene} */ get_scene() { return this.scenes[this.state.scene]; } /** * @returns {MtlSoundTrack} the soundtrack that is set for the current scene */ get_soundtrack() { return this.get_scene().sound_track; } /** * Updates the state with a new scene index. * @param {Integer} index The index of the wanted scene */ go_to_scene(index) { const current_scene = this.get_scene(); current_scene.animation.reset_frame(); if (current_scene._type == SCENE_TYPES.CINEMATIC) { this.state.cinematic_timeout = { inc: 0, last_update_time: -1, timeout: false, }; } this.state.scene = index; } /** * This is called if the current scene is of type Cinematic. * UIncrements the cinematic_timeout counter and update the * cinematic_timeout.timeout state to true if the cinematic * has been running as much or more time than what is defined in scene.cinematic_duration. */ update_cinematic_timeout() { if (this.state.cinematic_timeout.timeout) return; const t = new Date().getTime(); if (this.state.cinematic_timeout.last_update_time === -1) { this.state.cinematic_timeout.last_update_time = t; } this.state.cinematic_timeout.inc = t - this.state.cinematic_timeout.last_update_time; this.state.cinematic_timeout.timeout = this.get_scene().cinematic_duration * 1000 <= this.state.cinematic_timeout.inc; } /** * Returns a state wether the running cinematic is ended or not * @returns {Boolean} */ is_cinematic_ended() { const scene = this.get_scene(); if (scene.cinematic_duration === 0) return scene.animation.finished; return this.state.cinematic_timeout.timeout; } /** * Takes a game object as parameter and references it in the inventory state. * @param {MtlGameObject} obj */ add_object_to_inventory(obj) { this.state.inventory.add(obj); } /** * @returns {Boolean} true if inventory has an empty slot. */ inventory_has_empty_slot() { const { columns, rows } = this.game_ui_options.inventory; return this.state.inventory.size < columns * rows; } /** * Take a game object as argument and removes its reference from the inventory state. * @param {MtlGameObject} obj */ remove_object_from_inventory(obj) { this.state.inventory.delete(obj); } /** * Takes a game object as argument, removes the reference from inventory 7 * and delete the object from the scene it's defined into. * @param {MtlGameObject} obj */ consume_game_object(obj) { this.remove_object_from_inventory(obj); for (const s of this.scenes) { const found_obj = s.game_objects.find(o => o === obj); if (found_obj) { s.game_objects.splice(s.game_objects.indexOf(found_obj), 1); break; } } } /** * Returns true if the number of scenes having finished to load their loadable resources is equal to the total number of scenes. * @returns {Boolean} */ all_resources_loaded() { return this.load_state.loadable_elements === this.load_state.loaded_elements; } /** * Returns a concatenation of all game objects of all scenes. * @returns {Array<MtlGameObject>} */ get_game_objects() { return this.scenes.reduce((acc, scene) => acc.concat(scene.game_objects), []); } /** * Populates the instance with a litteral game desriptor. * The expected data format is identical to what's exported from the Mentalo app editor, * or what's returned from the Mentalo API database. * @param {Object} data */ load_data(data) { this.name = data.name; this.starting_scene_index = data.starting_scene_index || 0; this.state.scene = this.starting_scene_index; this.scenes = data.scenes.map(scene => { const scene_instance = new MtlScene(); scene_instance.load_data(scene); scene_instance.on_load_group_complete(() => { this.load_state.loaded_scenes++; if (this.load_state.loaded_scenes === this.scenes.length) { this.on_load_resources_callback && this.on_load_resources_callback(); } }) return scene_instance; }); this.game_ui_options = Object.assign(data.game_ui_options, { general: { background_color: data.game_ui_options.general.background_color, animation_canvas_dimensions: Object.assign(data.game_ui_options.general.animation_canvas_dimensions, { height: function () { const split_ratio = this.ratio.split(":").map(n => parseInt(n)); const factor = split_ratio[1] / split_ratio[0]; return Number((this.width * factor).toFixed()); }, }), } }); } /** * Sets the callback to call when all resources of all scenes are fully loaded. * @param {Function} callback */ on_load_resources(callback) { this.on_load_resources_callback = callback; } } module.exports = MtlGame; },{"./scene":17,"./scene-types":16}],14:[function(require,module,exports){ "use strict"; /** * A structure to manage a group of object that extends from the ./Loadable class. * For each handled loadable, increments a loadable_elements state, and once the loadable has finished * to load whatever it needs to load, the loaded_elements state is incremented. */ class LoadableGroup { constructor() { this.loadable_elements = 0; this.loaded_elements = 0; } /** * Attaches a on_load callback to the Loadable object given as argument. * Increments the loadable_elements state. * @param {Loadable} object Any object that extends the Loadable class */ add_loadable(object) { this.loadable_elements++; /** * The callback that will be call by the Loadable object when it will have finished to load what it needs to load. */ object.on_load(() => { this.loaded_elements++; if (this.loadable_elements === this.loaded_elements && this.on_load_group_complete_custom_callback) { this.on_load_group_complete_custom_callback(); } }); } /** * Defines a custom callback to call when all handled loadable objects have finished loading. * @param {Function} callback */ on_load_group_complete(callback) { this.on_load_group_complete_custom_callback = callback; } } module.exports = LoadableGroup; },{}],15:[function(require,module,exports){ "use strict"; /** * An abstract type that must be inherited by any object that have a resource to load. * Can be an image or a sound. */ class Loadable { /** * Initialization of the actual loadable element. * @param {HTMLElement} loadable_element Image() or Audio() for example * @param {String} field_name the name of the field that references the loadable element in the instance. * @param {String} event_name the name of the event that is dispatched by the loadable * html element when it has loaded a resource ("load" or "loadeddata") */ constructor(loadable_element, field_name, event_name) { this.loaded = false; this.init_loadable_element(loadable_element, field_name, event_name); } /** * Initializes event listeners for the registered loadable_element. * @param {HTMLElement} loadable_element * @param {String} field_name the name of the field that references the element * @param {String} event_name the event to use for resource loading (images uses onload, audio uses onloadeddata ...) */ init_loadable_element(loadable_element, field_name, event_name) { this[field_name] = loadable_element; this.load_listener = () => { this.loaded = true; this.on_load_callback(); this.clear(); }; this[field_name].addEventListener(event_name, this.load_listener); this.clear = () => { this[field_name].removeEventListener(event_name, this.load_listener); } } /** * Sets a callback to call when the loadable_element has finished to load its resource. * @param {Function} callback */ on_load(callback) { this.on_load_custom_callback = callback; } /** * The callback that's called when the loadable element as finished loading its resource */ on_load_callback() { this.loaded = true; this.on_load_custom_callback && this.on_load_custom_callback(); } } module.exports = Loadable; },{}],16:[function(require,module,exports){ /** * An enum like object to describe the 2 possible type that can have a MtlScene */ module.exports = { PLAYABLE: "Playable", CINEMATIC: "Cinematic", }; },{}],17:[function(require,module,exports){ "use strict"; const SCENE_TYPES = require("./scene-types"); const MtlAnimation = require("./animation"); const MtlSoundTrack = require("./sound-track"); const MtlChoice = require("./choice"); const MtlGameObject = require("./game-object"); const LoadableGroup = require("./loadable-group"); /** * The type used for the scenes of a MtlGame. * Extends the LoadableGroup class because it manages multiple objects of the class Loadable. */ class MtlScene extends LoadableGroup { /** * Initializes the instance with default empty data. */ constructor() { super(); const default_data = { name: "", _type: SCENE_TYPES.PLAYABLE, animation: new MtlAnimation(), sound_track: new MtlSoundTrack(), choices: [], text_box: "", game_objects: [], cinematic_duration: 0, end_cinematic_options: { destination_scene_index: -1, quit: false, } }; this.name = default_data.name; this._type = default_data._type; this.animation = default_data.animation; this.sound_track = default_data.sound_track; this.choices = default_data.choices; this.text_box = default_data.text_box; this.game_objects = default_data.game_objects; this.cinematic_duration = default_data.cinematic_duration || 0; this.end_cinematic_options = default_data.end_cinematic_options; } /** * Populates the instance from a litteral descriptor. * Creates and populates the instances of Animation, Soundtrack and GameObjects * @param {Object} data */ load_data(data) { this.name = data.name; this._type = data._type; const animation = new MtlAnimation(); animation.init(data.animation); this.animation.clear(); this.animation = animation; this.animation.image.src && this.add_loadable(this.animation); const sound_track = new MtlSoundTrack(); sound_track.init(data.sound_track); this.sound_track.clear(); this.sound_track = sound_track; this.sound_track.src && this.add_loadable(this.sound_track); this.choices = data.choices.map(c => new MtlChoice(c)); this.text_box = data.text_box; this.cinematic_duration = data.cinematic_duration || 0; this.game_objects = data.game_objects.map(gob => { const inst = new MtlGameObject(); inst.load_data(gob); this.add_loadable(inst); return inst; }); this.end_cinematic_options = data.end_cinematic_options; } } module.exports = MtlScene; },{"./animation":10,"./choice":11,"./game-object":12,"./loadable-group":14,"./scene-types":16,"./sound-track":18}],18:[function(require,module,exports){ "use strict"; const Loadable = require("./loadable"); /** * The data type used to represent and manage the soundtrack of a scene */ class MtlSoundTrack extends Loadable { constructor() { super(new Audio(), "audio", "loadeddata"); this.name = ""; this.initialized = false; } /** * Reset the instance to empty values. */ reset() { this.init_loadable_element(new Audio(), "audio", "loadeddata"); this.name = ""; this.initialized = false; } /** * Populates the instance from a litteral descriptor * @param {Object} data */ init(data) { this.empty = data.src === ""; this.audio.src = data.src; this.name = data.name; this.audio.loop = data._loop; this.initialized = true; } /** * Plays the audio resource. */ play() { this.audio.play(); } /** * Stops the audio player. */ stop() { this.audio.pause(); this.audio.currentTime = 0; } /** * Returns the state of the inner audio element * @returns {Boolean} */ is_playing() { return !this.audio.paused } } module.exports = MtlSoundTrack; },{"./loadable":15}],19:[function(require,module,exports){ "use strict"; const FrameRateController = require("../lib/frame-rate-controller"); const { get_optimal_visible_foreground_color } = require("../lib/color-tools"); const { get_canvas_font, get_canvas_char_size } = require("../lib/font-tools"); const SCENE_TYPES = require("../model/scene-types"); const ChoiceCpt = require("../ui-components/choice-cpt"); const ChoicesPanelCpt = require("../ui-components/choices-panel-cpt"); const ClosingIconCpt = require("../ui-components/closing-icon-cpt"); const GameObjectCpt = require("../ui-components/game-object-cpt"); const InventoryCpt = require("../ui-components/inventory-cpt"); const InventoryObjectCpt = require("../ui-components/inventory-object-cpt"); const InventorySlotCpt = require("../ui-components/inventory-slot-cpt"); const SceneAnimationCpt = require("../ui-components/scene-animation-cpt"); const TextBoxCpt = require("../ui-components/text-box-cpt"); const UserErrorPopup = require("../ui-components/user-error-popup"); /** * The class that handles all interactions with the canvas 2D drawing context to draw the game. */ class MtlRender { constructor(params) { this.params = params; const frame_rate = this.params.frame_rate || 30; this.fps_controller = new FrameRateController(frame_rate); this.params.container.style.backgroundColor = this.params.get_game_settings("general").background_color; this.canvas = document.createElement("canvas"); this.canvas.style.backgroundColor = "black"; window.mentalo_drawing_context = this.canvas.getContext("2d"); this.canvas_dimensions = {}; this.canvas_zones = {}; this.event_listeners = { game_objects: [], inventory: [], text_box: [], }; this.state = { user_error_popup: { is_set: false, text: "", on_close: function () { } } }; this.loading_frame = 0; } /** * Initializes the rendering of the game, creates the components, the canvas zones and requests full screen. */ init() { if (this.params.fullscreen) { this.set_full_screen(); } this.set_canvas_dimensions(); this.create_canvas_zones(); this.params.container.appendChild(this.canvas); const ctx = window.mentalo_drawing_context; ctx.mozImageSmoothingEnabled = false; ctx.webkitImageSmoothingEnabled = false; ctx.msImageSmoothingEnabled = false; ctx.imageSmoothingEnabled = false; this.create_components(); } /** * Removes the event listeners attached to the components. */ clear_event_listeners() { Object.values(this.components).forEach(cpt => { cpt.clear_event_listeners(); }); } /** * Removes the elements rendered as children of the rendering components * (Example a TextBoxCpt is a child the scene_animation component) * @param {Object} options can defined a set of constructor names to exclude from clean up */ clear_children(options = { exclude: [] }) { Object.values(this.components).forEach(cpt => { cpt.clear_children(options); }); } /** * Reset the scene text box visibility state to true. * The text box visibility state is controlled by the scene_animation component and not directly by the text box component. */ reset_text_box_visibility() { this.components.scene_animation.set_text_box_visibility(true) } /** * Tries to enable the window fullscreen mode. */ set_full_screen() { const body = document.body; body.requestFullScreen = body.requestFullScreen || body.webkitRequestFullScreen || body.msRequestFullscreen || body.mozRequestFullScreen; try { body.requestFullScreen(); } catch (err) { console.error(err.message) } } /** * Exits the window fullscreen mode */ exit_fullscreen() { if (document.fullscreenElement) { document.exitFullscreen(); } } /** * Updates the error popup state with new values given as argument. * @param {Object} params Must have a text<String> entry and a on_close<Function> entry. */ set_user_error_popup(params) { this.state.user_error_popup = { is_set: true, text: params.text, on_close: function () { params.on_close && params.on_close(); }, }; this.clear_event_listeners(); // Clears children excluding everything but errorpopup this.clear_children({ exclude: ["TextBoxCpt", "InventoryCpt", "ChoicesPanelCpt", "GameObjectCpt"] }); } /** * Unsets error popup */ clear_user_error_popup() { this.reset_user_error_popup(); this.clear_event_listeners(); this.clear_children({ exclude: ["TextBoxCpt", "InventoryCpt", "ChoicesPanelCpt", "GameObjectCpt"] }); } /** * Sets error popup to empty values */ reset_user_error_popup() { this.state.user_error_popup = { is_set: false, text: "", on_close: function () { } }; } /** * Callback called when error popup is closed */ on_close_user_error_popup() { this.state.user_error_popup.on_close(); this.clear_user_error_popup(); } /** * Parses game data and calculates optimal dimensions for the canvas. */ set_canvas_dimensions() { const { get_game_settings } = this.params; const screen_dim = { width: window.screen.width, height: window.screen.height, }; const choices_panel_settings = get_game_settings("choices_panel") const ctx = this.canvas.getContext("2d"); this.canvas_dimensions = (() => { const image_h = get_game_settings("general").animation_canvas_dimensions.height(); const image_w = get_game_settings("general").animation_canvas_dimensions.width; const inventory_w = this.get_inventory_base_width(); const base_w = image_w + inventory_w; const choices_panel_h = (() => { ctx.save(); ctx.font = get_canvas_font(choices_panel_settings); const max_lines_per_row = this.get_choices_max_lines_per_row(); const char_size = get_canvas_char_size(ctx); const text_line_h = char_size.text_line_height; ctx.restore(); return (max_lines_per_row[0] * text_line_h + (2 * choices_panel_settings.choice_padding * char_size.width)) + (max_lines_per_row[1] > 0 ? (max_lines_per_row[1] * text_line_h + (2 * choices_panel_settings.choice_padding * char_size.width)) : 0) + (2 * choices_panel_settings.container_padding * char_size.width); })(); const max_playground_width = screen_dim.width - 200; const max_playground_height = screen_dim.height - 200; const base_h = image_h + choices_panel_h; const screen_ratio = screen_dim.width / screen_dim.height; const playground_ratio = base_w / base_h; const result = { width: 0, height: 0, image: { width: 0, height: 0 }, inventory: { width: 0, height: 0 }, choices_panel: { width: 0, height: 0 }, scale_ratio: 1, }; if (screen_ratio > playground_ratio) { // Screen is more panoramic than game canvas so we scale game canvas to maximum height const scaled_w = max_playground_height * playground_ratio result.width = scaled_w <= max_playground_width ? scaled_w : max_playground_width; } else { // Image is more panoramic so its scaled to the maximum width const scaled_h = max_playground_width * (base_h / base_w) result.height = scaled_h <= max_playground_height ? scaled_h : max_playground_height; result.width = result.height * playground_ratio; } result.scale_ratio = result.width / base_w; result.image = { width: image_w * result.scale_ratio, height: image_h * result.scale_ratio, }; result.inventory = { width: inventory_w * result.scale_ratio, height: result.image.height, }; result.choices_panel = { width: result.width, height: choices_panel_h * result.scale_ratio, }; result.height = result.image.height + result.choices_panel.height; return result; })(); this.canvas.width = this.canvas_dimensions.width; this.canvas.height = this.canvas_dimensions.height; } /** * @returns {Integer} The maximum number of rows that the choices_panel can have in the loaded game. */ get_max_choices_row_per_scene() { let choices_max_row_nb = 1; if (Math.max(...this.params.get_game_scenes().map(s => s.choices.length)) > 2) { choices_max_row_nb = 2 } return choices_max_row_nb; } /** * @returns {Integer} The maximum number of text rows that a choice can have in the loaded game */ get_choices_max_lines_per_row() { const choices_max_row_nb = this.get_max_choices_row_per_scene(); const formatted_choices_scenes = this.get_scenes_formatted_choices(); // get the largest number un text lines per choice row const max_lines_per_row = [0, 0]; Array.from({ length: choices_max_row_nb }).forEach((_row, i) => { const slice_index = i === 0 ? [0, 2] : [2, 4]; const largest = formatted_choices_scenes .map(s_choices => Math.max(...s_choices .slice(...slice_index) .map(c => c.text_lines.length))) .reduce((res, nb) => Math.max(res, nb), 0); max_lines_per_row[i] = largest; }); return max_lines_per_row; } /** * The width of the inventory panel * @returns {Integer} */ get_inventory_base_width() { const { get_game_settings } = this.params; const image_h = get_game_settings("general").animation_canvas_dimensions.height(); const inventory_style = get_game_settings("inventory"); const gap = inventory_style.gap; const h = image_h - (2 * inventory_style.padding); const gap_h = (inventory_style.rows - 1) * gap; const gap_w = (inventory_style.columns - 1) * gap const slot_side = (h - gap_h) / inventory_style.rows; return (inventory_style.columns * slot_side) + (2 * inventory_style.padding) + gap_w; } /** * Parses the raw text of each choices in each scene and returns them with * an additional text_lines field with the text split into lines ready to be rendered in canvas. * @returns {Array<{...MtlChoice, text_lines<Array<String>>}} */ get_scenes_formatted_choices() { const scenes = this.params.get_game_scenes(); const { get_game_settings } = this.params; const settings = this.params.get_game_settings("choices_panel"); const ctx = window.mentalo_drawing_context; ctx.save(); ctx.font = get_canvas_font(settings); const char_size = get_canvas_char_size(ctx); ctx.restore(); const container_padding = settings.container_padding * char_size.width; const choice_padding = settings.choice_padding * char_size.width; const container_width = get_game_settings("general").animation_canvas_dimensions.width + this.get_inventory_base_width() - (2 * container_padding); const choice_max_width = ((container_width / 2) - (2 * choice_padding)) * .9; // width * .9 is an error offset const max_chars_per_row = choice_max_width / char_size.width; return scenes.map(s => s.choices.map(c => { const words = c.text.split(" "); const lines = [""]; let line_i = 0; words.forEach(w => { if ((lines[line_i] + w).length >= max_chars_per_row) { line_i++; lines.push(""); } lines[line_i] = `${lines[line_i]}${lines[line_i] === "" ? "" : " "}${w}`; }); return Object.assign({ ...c }, { text_lines: lines }); })); } /** * Precalculates the bounding boxes for each canvas zone. */ create_canvas_zones() { const metrics = this.canvas_dimensions; const { get_game_settings } = this.params; this.canvas_zones = { root: { left: 0, top: 0, right: metrics.width, bottom: metrics.height, width: metrics.width, height: metrics.height, padding: 0, }, scene_animation: { left: 0, top: 0, right: metrics.image.width, bottom: metrics.image.height, width: metrics.image.width, height: metrics.image.height, clear_color: "black", padding: 0, }, inventory: { left: metrics.image.width, top: 0, right: metrics.width, bottom: metrics.inventory.height, width: metrics.width - metrics.image.width, height: metrics.inventory.height, clear_color: get_game_settings("inventory").background_color, padding: get_game_settings("inventory").padding, }, choices_panel: { left: 0, top: metrics.image.height, right: metrics.width, bottom: metrics.height, width: metrics.width, height: metrics.height - metrics.image.height, clear_color: get_game_settings("choices_panel").background_color, padding: get_game_settings("choices_panel").container_padding }, }; } /** * @returns {Boolean} true if scene is of type Playable */ scene_is_not_cinematic() { return this.params.get_scene()._type === SCENE_TYPES.PLAYABLE; } /** * Creates the tree of all the components to render. All component extends the UiComponent class. * The root component instances (Scene animation, Inventory, Choices panel) will only be created once, * but children components are functions of their parents so they are recreated when get_children is called on a parent component. * For example A TextBoxCpt is a children a SceneAnimationCpt, so the text box component will be recreated each time * scene_animation.get_children() is called. This allow to display those components as functions of dynamical states. */ create_components() { const { get_game_settings, get_inventory, get_scene, get_game_scenes } = this.params; const { scale_ratio } = this.canvas_dimensions; const scene_is_not_cinematic = this.scene_is_not_cinematic.bind(this); this.components = { scene_animation: new SceneAnimationCpt({ bounding_zone: this.canvas_zones.scene_animation, get_animation: () => get_scene().animation, next_frame_ready: () => this.fps_controller.nextFrameReady(), get_children: () => { const scene = get_scene(); return scene.game_objects.map(o => { const parent_zone = this.canvas_zones.scene_animation; const obj_pos = { x: (o.position.x * scale_ratio) + parent_zone.left, y: (o.position.y * scale_ratio) + parent_zone.top, }; const obj_dim = { w: o.image.width * scale_ratio, h: o.image.height * scale_ratio, }; const obj_bounds = { left: obj_pos.x, right: obj_pos.x + obj_dim.w, top: obj_pos.y, bottom: obj_pos.y + obj_dim.h, width: obj_dim.w, height: obj_dim.h, }; const game_objects_cpt_params = { bounding_zone: obj_bounds, position: obj_pos, dimensions: obj_dim, image: o.image, is_in_inventory: () => get_inventory().has(o), }; const obj_cpt = new GameObjectCpt(game_objects_cpt_params); obj_cpt.add_event_listener({ event_type: "mousemove", listener: e => { if (!get_inventory().has(o)) { const cursor_is_over_obj = e.offsetX >= obj_bounds.left && e.offsetX <= obj_bounds.right && e.offsetY >= obj_bounds.top && e.offsetY <= obj_bounds.bottom; obj_cpt.state.draw_border = cursor_is_over_obj; } } }); obj_cpt.add_event_listener({ event_type: "click", listener: e => { if (!get_inventory().has(o) && e.offsetX >= obj_bounds.left && e.offsetX <= obj_bounds.right && e.offsetY >= obj_bounds.top && e.offsetY <= obj_bounds.bottom) { this.params.on_game_object_click(o); this.clear_event_listeners(); this.clear_children({ exclude: "TextBoxCpt" }); } } }); return obj_cpt; }) .concat(scene.text_box ? [ (() => { const text_box_settings = get_game_settings("text_boxes") const text_box_bounds = (() => { const parent_zone = this.canvas_zones.scene_animation; const padding = text_box_settings.padding * scale_ratio; const margin = text_box_settings.margin * scale_ratio; const left = parent_zone.left + margin; const right = parent_zone.right - margin; const bottom = parent_zone.bottom - margin; const top = bottom - (2 * padding); return { left, right, top, bottom, width: right - left, height: bottom - top, } })(); const closing_icon_radius = 10 * scale_ratio; const closing_icon_center = { x: text_box_bounds.right - (closing_icon_radius / 2), y: text_box_bounds.top + (closing_icon_radius / 2), }; const closing_icon_params = { color: text_box_settings.font_color, radius: closing_icon_radius, center: closing_icon_center, background_color: text_box_settings.background_color, line_width: Math.floor(2 * scale_ratio), bounding_zone: { left: closing_icon_center.x - closing_icon_radius, right: closing_icon_center.x + closing_icon_radius, top: closing_icon_center.y - closing_icon_radius, bottom: closing_icon_center.y + closing_icon_radius, width: 2 * closing_icon_radius, height: 2 * closing_icon_radius, }, }; const text_box = new TextBoxCpt({ text: get_scene().text_box, settings: Object.assign({ ...text_box_settings }, { font_size: text_box_settings.font_size * scale_ratio, padding: text_box_settings.padding * scale_ratio, margin: text_box_settings.margin * scale_ratio, rounded_corners_radius: text_box_settings.rounded_corners_radius * scale_ratio, border_width: text_box_settings.border_width * scale_ratio, }), bounding_zone: text_box_bounds, get_visibility_state: () => this.components.scene_animation.state.text_box_visible, get_children: () => [new ClosingIconCpt(closing_icon_params)], }); const close_text_box_icon = text_box.children[0]; text_box.children[0].add_event_listener({ event_type: "click", listener: e => { const bounds = close_text_box_icon.params.bounding_zone; const click_over_icon = e.offsetX >= bounds.left && e.offsetX <= bounds.right && e.offsetY >= bounds.top && e.offsetY <= bounds.bottom; const { scene_animation } = this.components; if (click_over_icon && scene_animation.state.text_box_visible) { scene_animation.set_text_box_visibility(false); } } }); return text_box; })(), ] : []) .concat(this.state.user_error_popup.is_set ? [ (() => { const { text } = this.state.user_error_popup; const default_font = get_canvas_font(); const use_settings = get_game_settings("text_boxes"); const parent_bounds = this.canvas_zones.scene_animation; const ctx = window.mentalo_drawing_context; ctx.save(); ctx.font = default_font; const char_size = get_canvas_char_size(ctx); ctx.restore(); const popup_width = parent_bounds.width / 3; const padding = char_size.height * 2; const max_chars_per_row = (popup_width - (2 * padding)) / char_size.width; const text_lines = [""]; let line_i = 0; text.split(" ").forEach(word => { if (`${text_lines[line_i]}${word} `.length > max_chars_per_row) { line_i++; text_lines.push(""); } text_lines[line_i] += `${word} `; }); const line_h = char_size.text_line_height; const popup_height = (text_lines.length * line_h) + (2 * padding); const background_color = use_settings.background_color; const text_color = use_settings.font_color; const popup_bounds = { left: parent_bounds.left + (parent_bounds.width / 2) - (popup_width / 2), top: parent_bounds.top + (parent_bounds.height / 2) - (popup_height / 2), right: parent_bounds.left + (parent_bounds.width / 2) + (popup_width / 2), bottom: parent_bounds.top + (parent_bounds.height / 2) - (popup_height / 2) + popup_height, width: popup_width, height: popup_height, clear_color: background_color, }; const popup_params = { bounding_zone: popup_bounds, font: default_font, font_metrics: char_size, text_lines, text_color, padding, get_children: () => { const center = { x: popup_bounds.right - 5, y: popup_bounds.top + 5, }; const radius = 15; const closing_icon_bounds = { left: center.x - radius, right: center.x + radius, top: center.y - radius, bottom: center.y + radius, width: radius * 2, height: radius * 2, }; const closing_icon_params = { color: text_color, radius, center, background_color, line_width: 2, bounding_zone: closing_icon_bounds, }; const closing_icon = new ClosingIconCpt(closing_icon_params); closing_icon.add_event_listener({ event_type: "click", listener: e => { if (e.offsetX >= closing_icon_bounds.left && e.offsetX <= closing_icon_bounds.right && e.offsetY >= closing_icon_bounds.top && e.offsetY <= closing_icon_bounds.bottom) { this.on_close_user_error_popup(); } } }); return [closing_icon]; } }; const popup = new UserErrorPopup(popup_params); return popup; })() ] : []); }, }), inventory: new InventoryCpt({ bounding_zone: this.canvas_zones.inventory, get_inventory, is_visible: scene_is_not_cinematic, invisible_clear_color: get_game_settings("general").background_color, get_children: () => { const slots = []; let settings = get_game_settings("inventory"); settings = { ...settings, slot_rounded_corner_radius: settings.slot_rounded_corner_radius * scale_ratio, slot_border_width: settings.slot_border_width * scale_ratio, }; const slots_zone = { left: this.canvas_zones.inventory.left + settings.padding, top: this.canvas_zones.inventory.top + settings.padding, right: this.canvas_zones.inventory.right - settings.padding, bottom: this.canvas_zones.inventory.bottom - settings.padding, width: this.canvas_zones.inventory.width - (2 * settings.padding), height: this.canvas_zones.inventory.height - (2 * settings.padding) }; const gap = settings.gap; const h = slots_zone.height; const gap_h = (settings.rows - 1) * gap; const slot_side = (h - gap_h) / settings.rows; const slots_total_w = (slot_side * settings.columns) + (gap * (settings.columns - 1)); const center_slot_x_offset = (slots_zone.width - (slots_total_w)) / 2; let slot_i = 0; Array.from({ length: settings.rows }).forEach((_row, i) => { const top = slots_zone.top + (i * (slot_side + gap)); Array.from({ length: settings.columns }).forEach((_col, j) => { const left = slots_zone.left + (j * (slot_side + gap)) + center_slot_x_offset; const slot_bounds = { left, top, right: left + slot_side, bottom: top + slot_side, width: slot_side, height: slot_side }; const stroke_color = get_optimal_visible_foreground_color(settings.background_color); const slot_params = { bounding_zone: slot_bounds, stroke_color, is_visible: scene_is_not_cinematic, settings, get_children: () => { const inventory_object = new InventoryObjectCpt({ slot_index: slot_i, is_visible: scene_is_not_cinematic, settings, stroke_color, get_game_object: index => { const obj = Array.from(get_inventory())[index]; if (obj) { const obj_dim = obj.get_dimensions(); const obj_ratio = obj_dim.width / obj_dim.height; const scaled_dim = { width: 0, height: 0 }; const pos = { x: 0, y: 0 }; if (obj_ratio > 1) { // object image landscape oriented scaled_dim.width = slot_side; scaled_dim.height = slot_side * (obj_dim.height / obj_dim.width); pos.x = slot_bounds.left; pos.y = slot_bounds.top + ((slot_side / 2) - (scaled_dim.height / 2)); } else if (obj_ratio < 1) { // portrait oriented scaled_dim.height = slot_side; scaled_dim.width = slot_side * obj_ratio; pos.y = slot_bounds.top; pos.x = slot_bounds.left + ((slot_side / 2) - (scaled_dim.width / 2)); } else { // square scaled_dim.width = slot_side; scaled_dim.height = slot_side; pos.x = slot_bounds.left; pos.y = slot_bounds.top; } return { ref: obj, position: pos, dimensions: scaled_dim, }; } else { return undefined; } }, bounding_zone: slot_bounds, get_children: () => [ new ClosingIconCpt({ is_visible: scene_is_not_cinematic, color: "#bf5e43", // Some kind of red center: { x: slot_bounds.left + (slot_side / 2), y: slot_bounds.top + (slot_side / 2) }, radius: slot_side / 4, line_width: slot_side / 20 > 2 ? slot_side / 20 : 2, bounding_zone: { ...slot_bounds, clear_color: "rgba(0,0,0,0.2)" }, }), ] }); inventory_object.add_event_listener({ event_type: "mousemove", listener: e => { const obj = inventory_object.params.get_game_object(inventory_object.params.slot_index); const bounds = inventory_object.params.bounding_zone; inventory_object.state.draw_inventory_close_icon = inventory_object.params.is_visible() && !!obj && get_scene().game_objects.includes(obj.ref) && ( e.offsetX >= bounds.left && e.offsetX <= bounds.right && e.offsetY >= bounds.top && e.offsetY <= bounds.bottom ); } }); inventory_object.children[0].add_event_listener({ event_type: "click", listener: e => { if (!inventory_object.params.is_visible()) return false; const obj = inventory_object.params.get_game_object(inventory_object.params.slot_index); const bounds = inventory_object.params.bounding_zone; const mouse_over_slot = e.offsetX >= bounds.left && e.offsetX <= bounds.right && e.offsetY >= bounds.top && e.offsetY <= bounds.bottom; if (!!obj && mouse_over_slot && get_scene().game_objects.includes(obj.ref)) { this.params.on_drop_inventory_object(obj.ref); this.clear_event_listeners(); this.clear_children({ exclude: "TextBoxCpt" }); } } }); return [inventory_object]; }, }; const slot = new InventorySlotCpt(slot_params); slots.push(slot); slot_i++; }); }); return slots; }, }), choices_panel: (() => { const panel_params = { bounding_zone: this.canvas_zones.choices_panel, is_visible: scene_is_not_cinematic, invisible_clear_color: get_game_settings("general").background_color, get_children: () => Array.from({ length: this.get_max_choices_row_per_scene() * 2 }).map((_, i) => { const base_settings = get_game_settings("choices_panel") const choices_settings = Object.assign({ ...base_settings }, { font_size: base_settings.font_size * scale_ratio, active_choice_border_width: base_settings.active_choice_border_width * scale_ratio, active_choice_rounded_corners_radius: base_settings.active_choice_rounded_corners_radius * scale_ratio, }); const ctx = window.mentalo_drawing_context; ctx.save(); ctx.font = get_canvas_font(choices_settings); const char_w = get_canvas_char_size(ctx).width; choices_settings.container_padding *= char_w; choices_settings.choice_padding *= char_w; const writable_parent_zone = { left: this.canvas_zones.choices_panel.left + choices_settings.container_padding, right: this.canvas_zones.choices_panel.right - choices_settings.container_padding, top: this.canvas_zones.choices_panel.top + choices_settings.container_padding, bottom: this.canvas_zones.choices_panel.bottom - choices_settings.container_padding, width: 0, height: 0, }; writable_parent_zone.width = writable_parent_zone.right - writable_parent_zone.left; writable_parent_zone.height = writable_parent_zone.bottom - writable_parent_zone.top; const choice_width = writable_parent_zone.width / 2; const max_lines_per_row = this.get_choices_max_lines_per_row(); const text_line_h = get_canvas_char_size(ctx).text_line_height; const choices_height_per_row = [ (max_lines_per_row[0] * text_line_h) + (2 * choices_settings.choice_padding), (max_lines_per_row[1] > 0 ? (max_lines_per_row[1] * text_line_h) + (2 * choices_settings.choice_padding) : 0), ]; const choice_bounds = { left: writable_parent_zone.left + ((i % 2) * choice_width), top: writable_parent_zone.top + ((i > 1 ? 1 : 0) * choices_height_per_row[0]), right: writable_parent_zone.left + ((i % 2) * choice_width) + choice_width, bottom: writable_parent_zone.top + ((i > 1 ? 1 : 0) * choices_height_per_row[1]) + choices_height_per_row[i > 1 ? 1 : 0], width: choice_width, height: choices_height_per_row[i > 1 ? 1 : 0], }; const choice_cpt = new ChoiceCpt({ is_visible: scene_is_not_cinematic, bounding_zone: choice_bounds, text_line_h, get_formatted_choice: () => { const scene_choices = this.get_scenes_formatted_choices()[ get_game_scenes().indexOf(get_scene()) ]; return scene_choices.length - 1 >= i ? scene_choices[i] : undefined; }, settings: choices_settings, }); choice_cpt.add_event_listener({ event_type: "mousemove", listener: e => choice_cpt.state.active = choice_cpt.params.is_visible() && !!choice_cpt.params.get_formatted_choice() && e.offsetX >= choice_bounds.left && e.offsetX <= choice_bounds.right && e.offsetY >= choice_bounds.top && e.offsetY <= choice_bounds.bottom }); choice_cpt.add_event_listener({ event_type: "click", listener: e => { const choice = choice_cpt.params.get_formatted_choice(); if (choice_cpt.params.is_visible() && !!choice && e.offsetX >= choice_bounds.left && e.offsetX <= choice_bounds.right && e.offsetY >= choice_bounds.top && e.offsetY <= choice_bounds.bottom) { const success = this.params.on_choice_click(choice); if (success) { this.clear_event_listeners(); this.clear_children(); } } } }); ctx.restore(); return choice_cpt; }) }; return new ChoicesPanelCpt(panel_params); })(), }; } /** * Draw a loading state on the black screen while some game resources are loading. * This shouldn't show up a lot except if some resources are really big... */ draw_loading() { const ctx = window.mentalo_drawing_context; ctx.save(); ctx.font = '25px monospace'; ctx.fillStyle = "black"; ctx.fillRect(0, 0, window.innerWidth, window.innerHeight); ctx.fillStyle = "white"; ctx.fillText("Loading", 50, window.innerHeight / 2); const dots = Array.from({ length: ++this.loading_frame }).map(() => '.').join(''); ctx.font = '8px monospace'; ctx.fillText(dots, 50, window.innerHeight / 2 + 20); ctx.restore(); } /** * Executes the draw method of each registered component. */ draw_game() { Object.values(this.components).forEach(cpt => cpt.draw()); } } module.exports = MtlRender; },{"../lib/color-tools":5,"../lib/font-tools":6,"../lib/frame-rate-controller":7,"../model/scene-types":16,"../ui-components/choice-cpt":21,"../ui-components/choices-panel-cpt":22,"../ui-components/closing-icon-cpt":23,"../ui-components/game-object-cpt":24,"../ui-components/inventory-cpt":25,"../ui-components/inventory-object-cpt":26,"../ui-components/inventory-slot-cpt":27,"../ui-components/scene-animation-cpt":28,"../ui-components/text-box-cpt":29,"../ui-components/user-error-popup":31}],20:[function(require,module,exports){ const supported_locales = ["en", "fr", "es"]; /** * Translations for the default error messages. */ const translations = { "First scene has an empty image, game cannot be loaded": { fr: "La première scène a une image vide, le jeu ne peut pas être chargé", es: "La primera escena tiene una imagen vacía, el juego no se puede cargar" }, "Destination scene has not been set.": { fr: "La scène de destination n'a pas été définie.", es: "La escena de destino no ha sido definida.", }, "Next scene has not been set.": { fr: "La scène suivante n'a pas été définie", es: "La siguiente escena no ha sido definida.", }, }; function get_translated(str, locale) { return translations[str] && locale !== "en" && supported_locales.includes(locale) ? translations[str][locale] : str; } module.exports = { get_translated, supported_locales, }; },{}],21:[function(require,module,exports){ "use strict"; const { get_canvas_font } = require("../lib/font-tools"); const { draw_rect } = require("../lib/shape-tools"); const MtlUiComponent = require("./ui-component"); /** * The rendering component for a scene choice. */ class ChoiceCpt extends MtlUiComponent { constructor(params) { super(params); } /** * Draw the choice on the registered 2D drawing context */ draw() { super.draw(); const { get_formatted_choice, bounding_zone, settings, text_line_h, } = this.params; const writable_zone = { left: bounding_zone.left + settings.choice_padding, right: bounding_zone.right - settings.choice_padding, top: bounding_zone.top + settings.choice_padding, bottom: bounding_zone.bottom - settings.choice_padding, width: bounding_zone.width - (2 * settings.choice_padding), height: bounding_zone.height - (2 * settings.choice_padding), }; const choice = get_formatted_choice(); const ctx = window.mentalo_drawing_context; ctx.textAlign = settings.text_align; ctx.font = get_canvas_font(settings); ctx.fillStyle = settings.font_color; ctx.textBaseline = "top"; if (choice) { if (this.state.active) { draw_rect(ctx, bounding_zone.left, bounding_zone.top, bounding_zone.width, bounding_zone.height, { fill_color: settings.active_choice_background_color, rounded_corners_radius: settings.active_choice_rounded_corners_radius, border: { width: settings.active_choice_border_width, color: settings.font_color, } }); } const get_text_x_position = function () { switch (ctx.textAlign) { case "left": return writable_zone.left; case "right": return writable_zone.right; case "center": return writable_zone.left + (writable_zone.width / 2); } }; choice.text_lines.forEach((line, i) => { const text_pos = { x: get_text_x_position(), y: writable_zone.top + (i * text_line_h), }; ctx.fillText(line, text_pos.x, text_pos.y); }); } } } module.exports = ChoiceCpt; },{"../lib/font-tools":6,"../lib/shape-tools":8,"./ui-component":30}],22:[function(require,module,exports){ "use strict"; const MtlUiComponent = require("./ui-component"); /** * The rendering component for the game choices panel */ class ChoicesPanelCpt extends MtlUiComponent { constructor(params) { super(params); } /** * Draw the choices panel and the choices as children components on the registered 2D drawing context */ draw() { super.draw(); if (this.params.is_visible()) { this.clear_bounding_zone(); this.draw_children(); } else { this.clear_bounding_zone({ clear_color: this.params.invisible_clear_color }) } } } module.exports = ChoicesPanelCpt; },{"./ui-component":30}],23:[function(require,module,exports){ "use strict"; const MtlUiComponent = require("./ui-component"); /** * A rendering component that displays a closing icon (a cross inside a circle) */ class ClosingIconCpt extends MtlUiComponent { constructor(params) { super(params); } /** * Uses standard vectorial drawing methods to draw a circle and a cross at * given coordinates on the registered 2D drawing context. */ draw() { super.draw(); this.clear_bounding_zone(); const { color, center, radius = 5, line_width = 2, background_color = "rgba(0,0,0,0)" } = this.params; const ctx = window.mentalo_drawing_context; const padding = radius / 1.5; const cross_coords = { left: center.x - radius + padding, right: center.x + radius - padding, top: center.y - radius + padding, bottom: center.y + radius - padding, }; ctx.fillStyle = background_color; ctx.beginPath(); ctx.arc(center.x, center.y, radius, 0, 2 * Math.PI); ctx.fill(); ctx.strokeStyle = color; ctx.lineWidth = line_width; ctx.beginPath(); ctx.arc(center.x, center.y, radius, 0, 2 * Math.PI); ctx.stroke(); ctx.beginPath(); ctx.moveTo(cross_coords.left, cross_coords.top); ctx.lineTo(cross_coords.right, cross_coords.bottom); ctx.moveTo(cross_coords.left, cross_coords.bottom) ctx.lineTo(cross_coords.right, cross_coords.top); ctx.stroke(); } } module.exports = ClosingIconCpt; },{"./ui-component":30}],24:[function(require,module,exports){ "use strict"; const MtlUiComponent = require("./ui-component"); /** * A component that draws a GameObject */ class GameObjectCpt extends MtlUiComponent { constructor(params) { super(params); } /** * Draws the image of the object on the canvas. */ draw() { super.draw(); const { position, dimensions, image, is_in_inventory } = this.params; if (is_in_inventory()) { return; } const ctx = window.mentalo_drawing_context; ctx.drawImage( image, 0, 0, image.width, image.height, position.x, position.y, dimensions.w, dimensions.h, ); if (this.state.draw_border) { ctx.lineWidth = 1; ctx.strokeStyle = "rgba(180, 180, 180, 0.5)"; ctx.strokeRect(position.x - 5, position.y - 5, dimensions.w + 10, dimensions.h + 10); } } } module.exports = GameObjectCpt; },{"./ui-component":30}],25:[function(require,module,exports){ "use strict"; const MtlUiComponent = require("./ui-component"); /** * The component that draws the inventory panel */ class InventoryCpt extends MtlUiComponent { constructor(params) { super(params); } /** * Draws the panel on the canvas, T * the grid slots and the game object images are draws as children components (InventorySlotCpt and InventoryObjectCpt) */ draw() { super.draw(); if (this.params.is_visible()) { this.clear_bounding_zone(); this.draw_children(); } else { this.clear_bounding_zone({ clear_color: this.params.invisible_clear_color }) } } } module.exports = InventoryCpt; },{"./ui-component":30}],26:[function(require,module,exports){ "use strict"; const { draw_rect } = require("../lib/shape-tools"); const MtlUiComponent = require("./ui-component"); /** * The component to display a game object image inside a grid slot of the inventory panel. */ class InventoryObjectCpt extends MtlUiComponent { constructor(params) { super(params); } /** * If a game object exists for the slot bound to this instance, its image will be drawn cropped inside a bounding rectangle. * When a game object is hovered in the inventory, the draw_inventory_closing_icon is updated and a red cross will be drawn * on top of the object image. If the cross get's clicked the object is removed from inventory. */ draw() { super.draw(); const { get_game_object, slot_index, settings, bounding_zone, stroke_color } = this.params; const game_object = get_game_object(slot_index); if (game_object) { const ctx = window.mentalo_drawing_context; const image = game_object.ref.image; draw_rect(ctx, bounding_zone.left, bounding_zone.top, bounding_zone.width, bounding_zone.height, { fill_image: { src: image, dw: game_object.dimensions.width, dh: game_object.dimensions.height, dx: game_object.position.x, dy: game_object.position.y, }, rounded_corners_radius: settings.slot_rounded_corner_radius, border: { width: settings.slot_border_width, color: stroke_color, }, }); if (this.state.draw_inventory_close_icon) { this.draw_children(); // drop object icon } } } } module.exports = InventoryObjectCpt; },{"../lib/shape-tools":8,"./ui-component":30}],27:[function(require,module,exports){ "use strict"; const { draw_rect } = require("../lib/shape-tools"); const MtlUiComponent = require("./ui-component"); /** * The component to draw a grid slot in the inventory panel. */ class InventorySlotCpt extends MtlUiComponent { constructor(params) { super(params); } /** * Draws a rectangle as the grid slot. * Calls draw_children to draw the image of the game object stored in that slot if there is one. */ draw() { super.draw(); const { bounding_zone, stroke_color, settings } = this.params; const { left, top, width, height } = bounding_zone; const ctx = window.mentalo_drawing_context; draw_rect(ctx, left, top, width, height, { fill_color: "rgba(0,0,0,0)", rounded_corners_radius: settings.slot_rounded_corner_radius, border: { width: settings.slot_border_width, color: stroke_color, } }); this.draw_children(); } } module.exports = InventorySlotCpt; },{"../lib/shape-tools":8,"./ui-component":30}],28:[function(require,module,exports){ "use strict"; const MtlUiComponent = require("./ui-component"); /** * The component that draws the scene animation. */ class SceneAnimationCpt extends MtlUiComponent { constructor(params) { super(params); this.framecount = 0; // This state controls the visibility of the text box child component. this.state.text_box_visible = true; } /** * Sets the text_box_visibility state. * @param {Boolean} value */ set_text_box_visibility(value) { this.state.text_box_visible = value; } /** * Increments the animation frame count */ update_framecount() { this.framecount = this.framecount + 1 <= Number.MAX_SAFE_INTEGER ? this.framecount + 1 : 0; } /** * Draw the scene animation on the canvas at the frame given by the animation state * and draw each child component like text box, game objects and error popup. */ draw() { super.draw(); const { bounding_zone, get_animation, next_frame_ready } = this.params; if (next_frame_ready()) { this.clear_bounding_zone(); const ctx = window.mentalo_drawing_context; const animation = get_animation(); animation.update_frame(this.framecount); const dim = animation.dimensions; const w = dim.width; const h = dim.height; const offsetX = animation.frame * w; const cw = bounding_zone.width; const ch = bounding_zone.height; // center the image if (!animation.canvas_precalc) { animation.canvas_precalc = { dx: bounding_zone.left, dy: bounding_zone.top, dw: cw, dh: ch, }; if (w / h > cw / ch) { // image is more panoramic than canvas animation.canvas_precalc.dw = cw; animation.canvas_precalc.dh = cw * (h / w); // center vertically animation.canvas_precalc.dy = bounding_zone.top + ((ch - animation.canvas_precalc.dh) / 2); } else if (cw / ch > w / h) { // canvas is more panoramic animation.canvas_precalc.dh = ch; animation.canvas_precalc.dw = ch * (w / h); // center horizontally animation.canvas_precalc.dx = bounding_zone.left + ((cw - animation.canvas_precalc.dw) / 2); } else { // same ratio animation.canvas_precalc.dh = ch; animation.canvas_precalc.dw = cw; } } const { dx, dy, dw, dh } = animation.canvas_precalc; ctx.drawImage( animation.image, offsetX, 0, w, h, dx, dy, dw, dh ); this.draw_children(); this.update_framecount(); } } } module.exports = SceneAnimationCpt; },{"./ui-component":30}],29:[function(require,module,exports){ "use strict"; const { get_canvas_font, get_canvas_char_size } = require("../lib/font-tools"); const { draw_rect } = require("../lib/shape-tools"); const MtlUiComponent = require("./ui-component"); /** * The component that handles the displaying of a scene text boxe. */ class TextBoxCpt extends MtlUiComponent { constructor(params) { super(params); this.state.text = {}; } /** * Initialize precalculations for the text box, text dimensions, split lines, etc. */ init() { const { text, settings, bounding_zone } = this.params; const ctx = window.mentalo_drawing_context; ctx.font = get_canvas_font(settings); const char_size = get_canvas_char_size(ctx); const line_height = char_size.height * 1.2; const error_offset = 3 * char_size.width; const chars_per_line = (bounding_zone.width - error_offset) / char_size.width; const output_lines = []; const lines = text.split("\n"); lines.forEach(line => { const res = [""]; let target_i = 0; for (const word of line.split(" ")) { if ((res[target_i] + word + " ").length > chars_per_line) { res.push(""); target_i++; } res[target_i] += word + " "; } res.forEach(l => output_lines.push(l)); }); const text_height = output_lines.length * line_height; const updated_bounds = Object.assign({ ...bounding_zone }, { height: bounding_zone.height + text_height, top: bounding_zone.top - text_height, }); this.state.text = { lines: output_lines, line_height, bounding_zone: updated_bounds, }; // Update closing icon position const closing_icon = this.children[0]; let closing_icon_bounds = closing_icon.params.bounding_zone; closing_icon_bounds = Object.assign(closing_icon_bounds, { top: closing_icon_bounds.top - text_height, bottom: closing_icon_bounds.bottom - text_height, }); closing_icon.params.center.y -= text_height; this.state.initialized = true; } /** * Draw the text box and the text inside of it. * The text is not drawn at once, the characters are drawn one by one at each frame * and a local text.stream state is updated until text is complete. */ draw() { if (!this.params.get_visibility_state()) { return; } super.draw(); const { settings } = this.params; if (!this.state.initialized) { this.init(); } const ctx = window.mentalo_drawing_context; const { lines, line_height, bounding_zone } = this.state.text; ctx.font = get_canvas_font(settings); ctx.textAlign = settings.text_align; ctx.textBaseline = "top"; const box_pos = { x: bounding_zone.left, y: bounding_zone.top }; const box_width = bounding_zone.width; const box_height = bounding_zone.height; draw_rect(ctx, box_pos.x, box_pos.y, box_width, box_height, { fill_color: settings.background_color, rounded_corners_radius: settings.rounded_corners_radius, border: { width: settings.border_width, color: settings.font_color, }, }); ctx.fillStyle = settings.font_color; const get_text_position = () => { const { padding } = settings; switch (ctx.textAlign) { case "left": return { x: box_pos.x + padding, y: box_pos.y + padding, } case "right": return { x: box_pos.x + box_width - padding, y: box_pos.y + padding, } case "center": return { x: box_pos.x + box_width / 2, y: box_pos.y + padding, } default: return { x: box_pos.x + padding, y: box_pos.y + padding, } } }; const text_pos = get_text_position(); this.state.text.stream = this.state.text.stream || { line_chars: 0, line_index: 0, complete: false, }; const streamed_lines = lines.map((line, i) => { const stream_state = this.state.text.stream; if (i < stream_state.line_index || stream_state.complete) { return line; } else if (i > stream_state.line_index) { return ""; } else { const slice = line.slice(0, ++stream_state.line_chars); if (slice.length === line.length) { stream_state.complete = lines.length - 1 === stream_state.line_index; stream_state.line_index = stream_state.complete ? stream_state.line_index : stream_state.line_index + 1; stream_state.line_chars = 0; } return slice; } }); streamed_lines.forEach(line => { ctx.fillText(line, text_pos.x, text_pos.y); text_pos.y += line_height; }); if (this.state.text.stream.complete) { this.draw_children(); // draw closing_icon } } } module.exports = TextBoxCpt; },{"../lib/font-tools":6,"../lib/shape-tools":8,"./ui-component":30}],30:[function(require,module,exports){ "use strict"; const { draw_rect } = require("../lib/shape-tools"); /** * A generic class that must be extended by all components registered in MtlRender.components. * It provides helping methods to handle event listeners and children components. */ class MtlUiComponent { constructor(params) { this.set_str_identifier(); this.params = params; this.params.event_listeners = this.params.event_listeners || []; this.params.get_children = this.params.get_children || (() => []); this.params.is_visible = this.params.is_visible || (() => true); this.children = []; this.children_set = false; this.set_children(); this.state = { event_listeners_initialized: false, }; this.init_event_listeners(); } /** * Provide a identifier for the object. Default is contructor name. * @param {String} str */ set_str_identifier(str = this.constructor.name) { this.str_identifier = str; } /** * Draws a black rectangle on all the surface of the bounding zone defined for this component * @param {Object} options can provide a clear_color<String> rgba value */ clear_bounding_zone(options = {}) { const { bounding_zone } = this.params; draw_rect(window.mentalo_drawing_context, bounding_zone.left, bounding_zone.top, bounding_zone.width, bounding_zone.height, { fill_color: options.clear_color || bounding_zone.clear_color || "rgba(0,0,0,0)" }); } /** * Removes the event listeners attached to this component and to children components. * @param {Object} options can pass a recursive flag wether children must be cleared or not. Default is true. */ clear_event_listeners(options = { recursive: true }) { this.params.event_listeners.forEach(ref => { window.removeEventListener(ref.event_type, ref.listener); }); if (options.recursive) { this.children.forEach(child => { child.clear_event_listeners(); }); } this.state.event_listeners_initialized = false; } /** * Removes children components from this component. * Options can define some component to exlude identifying them by their str_identifier. * The excluded component will be kept as persistent components. * @param {Object} options */ clear_children(options) { const { exclude } = options; const persistent_children = []; this.children.forEach(child => { if (exclude.includes(child.str_identifier)) { persistent_children.push(child); } }); this.children = persistent_children; this.children_set = false; } /** * Register an array of children components in this component. * This is called when chlidren_set is false. Which happend at initialization and when clear_children is called. * Doing this allows saving the get_children() calculation at each frame. * This children components are concatenated recursively with their own children components. */ set_children() { this.children = this.children.concat( this.params.get_children() .filter(child => { // If this child has been kept as a persistent component, keep it as it. const keep_previous_child = this.children.find(ch => ch.str_identifier === child.str_identifier); return !keep_previous_child; }) ); this.children_set = true; } /** * Attaches a event listener to this component * @param {Object} obj A descriptor of the event listener like : * { * event_type: "click", * listener: e => { * ... something to do on click that component * } * } */ add_event_listener(obj) { const len = this.params.event_listeners.push(obj); const ref = this.params.event_listeners[len - 1]; window.addEventListener(ref.event_type, ref.listener); } /** * Attaches the event listeners given as parameter to the component */ init_event_listeners() { const { event_listeners } = this.params; event_listeners.forEach(obj => this.add_event_listener(obj)); this.state.event_listeners_initialized = true } /** * Call the draw method of the children component */ draw_children() { if (!this.children_set) { this.set_children(); } this.children.forEach(c => c.draw()); } /** * Initializes the event listeners if it's not already done. * This must be called by the inherited call draw method. */ draw() { if (!this.state.event_listeners_initialized) { this.init_event_listeners(); } } } module.exports = MtlUiComponent; },{"../lib/shape-tools":8}],31:[function(require,module,exports){ "use strict"; const MtlUiComponent = require("./ui-component"); /** * A component to display an error message in a popup */ class UserErrorPopup extends MtlUiComponent { constructor(params) { super(params); } /** * Draw the popup to the canvas. * Draws the box, then draw the text lines. * draw_children is for the closing_icon. */ draw() { super.draw(); this.clear_bounding_zone(); const { text_lines, bounding_zone, font, font_metrics, text_color, padding } = this.params; const inner_bounds = { left: bounding_zone.left + padding, right: bounding_zone.right - padding, top: bounding_zone.top + padding, bottom: bounding_zone.bottom - padding, width: bounding_zone.width, height: bounding_zone.height, }; const ctx = window.mentalo_drawing_context; ctx.save(); ctx.font = font; ctx.fillStyle = text_color; ctx.textBaseLine = "top"; ctx.textAlign = "left"; const line_h = font_metrics.text_line_height; text_lines.forEach((line, i) => { ctx.fillText(line, inner_bounds.left, inner_bounds.top + (i * line_h)); }); ctx.restore(); this.draw_children(); } } module.exports = UserErrorPopup; },{"./ui-component":30}],32:[function(require,module,exports){ "use strict"; module.exports = { register_key: "objectToHtmlRender", /** * Register "this" as a window scope accessible variable named by the given key, or default. * @param {String} key */ register(key) { const register_key = key || this.register_key; window[register_key] = this; }, /** * This must be called before any other method in order to initialize the lib. * It provides the root of the rendering cycle as a Javascript object. * @param {Object} renderCycleRoot A JS component with a render method. */ setRenderCycleRoot(renderCycleRoot) { this.renderCycleRoot = renderCycleRoot; }, event_name: "objtohtml-render-cycle", /** * Set a custom event name for the event that is trigger on render cycle. * Default is "objtohtml-render-cycle". * @param {String} evt_name */ setEventName(evt_name) { this.event_name = evt_name; }, /** * This is the core agorithm that read an javascript Object and convert it into an HTML element. * @param {Object} obj The object representing the html element must be formatted like: * { * tag: String // The name of the html tag, Any valid html tag should work. div, section, br, ul, li... * xmlns: String // This can replace the tag key if the element is an element with a namespace URI, for example an <svg> tag. * See https://developer.mozilla.org/en-US/docs/Web/API/Document/createElementNS for more information * style_rules: Object // a object providing css attributes. The attributes names must be in JS syntax, * like maxHeight: "500px", backgrouncColor: "#ff2d56", margin: 0, etc. * contents: Array or String // This reprensents the contents that will be nested in the created html element. * <div>{contents}</div> * The contents can be an array of other objects reprenting elements (with tag, contents, etc) * or it can be a simple string. * // All other attributes will be parsed as html attributes. They can be anything like onclick, href, onchange, title... * // or they can also define custom html5 attributes, like data, my_custom_attr or anything. * } * @returns {HTMLElement} The output html node. */ objectToHtml(obj) { if (!obj) return document.createElement("span"); // in case of invalid input, don't block the whole process. const objectToHtml = this.objectToHtml.bind(this); const { tag, xmlns } = obj; const node = xmlns !== undefined ? document.createElementNS(xmlns, tag) : document.createElement(tag); const excludeKeys = ["tag", "contents", "style_rules", "state", "xmlns"]; Object.keys(obj) .filter(attr => !excludeKeys.includes(attr)) .forEach(attr => { switch (attr) { case "class": node.classList.add(...obj[attr].split(" ").filter(s => s !== "")); break; case "on_render": if (!obj.id) { node.id = `${btoa(JSON.stringify(obj).slice(0, 127)).replace(/\=/g, '')}${window.performance.now()}`; } if (typeof obj.on_render !== "function") { console.error("The on_render attribute must be a function") } else { this.attach_on_render_callback(node, obj.on_render); } break; default: if (xmlns !== undefined) { node.setAttributeNS(null, attr, obj[attr]) } else { node[attr] = obj[attr]; } } }); if (obj.contents && typeof obj.contents === "string") { node.innerHTML = obj.contents; } else { obj.contents && obj.contents.length > 0 && obj.contents.forEach(el => { switch (typeof el) { case "string": node.innerHTML = el; break; case "object": if (xmlns !== undefined) { el = Object.assign(el, { xmlns }) } node.appendChild(objectToHtml(el)); break; } }); } if (obj.style_rules) { Object.keys(obj.style_rules).forEach(rule => { node.style[rule] = obj.style_rules[rule]; }); } return node; }, on_render_callbacks: [], /** * This is called if the on_render attribute of a component is set. * @param {HTMLElement} node The created html element * @param {Function} callback The callback defined in the js component to render */ attach_on_render_callback(node, callback) { const callback_handler = { callback: e => { if (e.detail.outputNode === node || e.detail.outputNode.querySelector(`#${node.id}`)) { callback(node); const handler_index = this.on_render_callbacks.indexOf((this.on_render_callbacks.find(cb => cb.node === node))); if (handler_index === -1) { console.warn("A callback was registered for node with id " + node.id + " but callbacck handler is undefined.") } else { window.removeEventListener(this.event_name, this.on_render_callbacks[handler_index].callback) this.on_render_callbacks.splice(handler_index, 1); } } }, node, }; const len = this.on_render_callbacks.push(callback_handler); window.addEventListener(this.event_name, this.on_render_callbacks[len - 1].callback); }, /** * If a main element exists in the html document, it will be used as rendering root. * If not, it will be created and inserted. */ renderCycle: function () { const main_elmt = document.getElementsByTagName("main")[0] || (function () { const created_main = document.createElement("main"); document.body.appendChild(created_main); return created_main; })(); this.subRender(this.renderCycleRoot.render(), main_elmt, { mode: "replace" }); }, /** * This method behaves like the renderCycle() method, but rather that starting the rendering cycle from the root component, * it can start from any component of the tree. The root component must be given as the first argument, the second argument be * be a valid html element in the dom and will be used as the insertion target. * @param {Object} object An object providing a render method returning an object representation of the html to insert * @param {HTMLElement} htmlNode The htlm element to update * @param {Object} options can be used the define the insertion mode, default is set to "append" and can be set to "override", * "insert-before" (must be defined along with an insertIndex key (integer)), * "adjacent" (must be defined along with an insertLocation key (String)), "replace" or "remove". * In case of "remove", the first argument "object" is not used and can be set to null, undefined or {}... */ subRender(object, htmlNode, options = { mode: "append" }) { let outputNode = null; const get_insert = () => { outputNode = this.objectToHtml(object); return outputNode; }; switch (options.mode) { case "append": htmlNode.appendChild(get_insert()); break; case "override": htmlNode.innerHTML = ""; htmlNode.appendChild(get_insert()); break; case "insert-before": htmlNode.insertBefore(get_insert(), htmlNode.childNodes[options.insertIndex]); break; case "adjacent": /** * options.insertLocation must be one of: * * afterbegin * afterend * beforebegin * beforeend */ htmlNode.insertAdjacentHTML(options.insertLocation, get_insert()); break; case "replace": htmlNode.parentNode.replaceChild(get_insert(), htmlNode); break; case "remove": htmlNode.remove(); break; } const evt_name = this.event_name; const event = new CustomEvent(evt_name, { detail: { inputObject: object, outputNode, insertOptions: options, targetNode: htmlNode, } }); window.dispatchEvent(event); }, }; },{}],33:[function(require,module,exports){ "use strict"; class ImageCarousel { constructor(props) { this.props = props; this.id = this.props.images.join("").replace(/\s\./g); this.state = { showImageIndex: 0, }; this.RUN_INTERVAL = 5000; this.props.images.length > 1 && this.run(); } run() { this.runningInterval = setInterval(() => { let { showImageIndex } = this.state; const { images } = this.props; this.state.showImageIndex = showImageIndex < images.length - 1 ? ++showImageIndex : 0; this.refreshImage(); }, this.RUN_INTERVAL); } setImageIndex(i) { clearInterval(this.runningInterval); this.state.showImageIndex = i; this.refreshImage(); } refreshImage() { obj2htm.subRender(this.render(), document.getElementById(this.id), { mode: "replace", }); } render() { const { showImageIndex } = this.state; const { images } = this.props; return { tag: "div", id: this.id, class: "image-carousel", contents: [ { tag: "img", property: "image", alt: `image carousel ${images[showImageIndex].replace(/\.[A-Za-z]+/, "")}`, src: images[showImageIndex], }, images.length > 1 && { tag: "div", class: "carousel-bullets", contents: images.map((_, i) => { const active = showImageIndex === i; return { tag: "span", class: `bullet ${active ? "active" : ""}`, onclick: this.setImageIndex.bind(this, i), }; }), }, ], }; } } module.exports = ImageCarousel; },{}],34:[function(require,module,exports){ "use strict"; const { fetch_json_or_error_text } = require("./fetch"); function getArticleBody(text) { return text.replaceAll("\n", "<br/>"); } function getArticleDate(date) { return `${date.getDate()}-${date.getMonth() + 1}-${date.getFullYear()}`; } function loadArticles(category) { return fetch_json_or_error_text(`/articles/${category}`) } module.exports = { loadArticles, getArticleBody, getArticleDate, }; },{"./fetch":35}],35:[function(require,module,exports){ "use strict"; function fetchjson(url) { return new Promise((resolve, reject) => { fetch(url) .then(r => r.json()) .then(r => resolve(r)) .catch(e => reject(e)); }); } function fetchtext(url) { return new Promise((resolve, reject) => { fetch(url) .then(r => r.text()) .then(r => resolve(r)) .catch(e => reject(e)); }); } async function fetch_json_or_error_text(url, options = {}) { return new Promise((resolve, reject) => { fetch(url, options).then(async res => { if (res.status >= 400 && res.status < 600) { reject(await res.text()); } else { resolve(await res.json()); } }) }) } module.exports = { fetchjson, fetchtext, fetch_json_or_error_text, }; },{}],36:[function(require,module,exports){ "use strict"; class WebPage { constructor(args) { Object.assign(this, args); } } module.exports = WebPage; },{}],37:[function(require,module,exports){ "use strict"; const { images_url } = require("../../../../../admin-frontend/src/constants"); const { data_url } = require("../../../../constants"); const ImageCarousel = require("../../../generic-components/image-carousel"); const { getArticleBody } = require("../../../lib/article-utils"); const { fetch_json_or_error_text } = require("../../../lib/fetch"); const { MentaloEngine } = require("mentalo-engine"); class GameArticle { constructor(props) { this.props = props; this.parse_body(); } parse_body() { let body = getArticleBody(this.props.body); const play_btn_regex = /\[PLAY_BUTTON\s\{.+\}\]/g; const found_play_buttons = body.match(play_btn_regex); if (found_play_buttons) { this.build_play_button(JSON.parse(found_play_buttons[0].replace(/[\[\]PLAY_BUTTON\s]/g, ""))); body = body.replace(play_btn_regex, ""); } this.body = body; } build_play_button(button_data) { this.render_play_button = { tag: "button", class: "play-button", contents: "Jouer", onclick: this.handle_click_play.bind(this, button_data.filename, button_data.engine) }; } load_and_run_mentalo_game(filename) { fetch_json_or_error_text(`${data_url}/${filename}`) .then(game_data => { const container = document.createElement("div"); container.style.position = "fixed"; container.style.top = 0; container.style.left = 0; container.style.right = 0; container.style.bottom = 0; container.style.zIndex = 10; container.style.display = "flex"; container.style.justifyContent = "center"; container.style.alignItems = "center"; container.id = "kuadrado-tmp-game-player-container"; document.body.appendChild(container); document.body.style.overflow = "hidden"; const engine = new MentaloEngine({ game_data, fullscreen: true, frame_rate: 30, container, on_quit_game: () => { container.remove(); document.body.style.overflow = "visible"; } }); engine.init(); engine.run_game(); }) .catch(err => console.log(err)) } handle_click_play(filename, engine) { switch (engine) { case "mentalo": this.load_and_run_mentalo_game(filename); break; default: alert("Error, unkown engine") return; } } render() { const { title, subtitle, images, details, } = this.props; return { tag: "article", typeof: "VideoGame", additionalType: "Article", class: "game-article", contents: [ { tag: "h2", property: "name", class: "game-title", contents: title, }, { tag: "div", class: "game-banner", contents: [ { tag: "img", class: "pixelated", src: `${images_url}/${images[0]}` }, ], }, { tag: "h3", class: "game-subtitle", contents: subtitle, property: "alternativeHeadline", }, { tag: "div", class: "game-description", property: "description", contents: [{ tag: "p", style_rules: { margin: 0 }, contents: this.body }] .concat(this.render_play_button ? [this.render_play_button] : []), }, new ImageCarousel({ images: images.map(img => `${images_url}/${img}`) }).render(), details.length > 0 && { tag: "div", class: "article-details", contents: [ { tag: "h2", contents: "Details", }, { tag: "ul", class: "details-list", contents: details.map(detail => { return { tag: "li", class: "detail", contents: [ { tag: "label", contents: detail.label }, { tag: "div", contents: detail.value }, ], }; }), }, ], }, ], }; } } module.exports = GameArticle; },{"../../../../../admin-frontend/src/constants":1,"../../../../constants":3,"../../../generic-components/image-carousel":33,"../../../lib/article-utils":34,"../../../lib/fetch":35,"mentalo-engine":4}],38:[function(require,module,exports){ "use strict"; const { loadArticles } = require("../../../lib/article-utils"); const GameArticle = require("./game-article"); class GameArticles { constructor(props) { this.props = props; this.state = { articles: [], }; this.id = performance.now(); this.loadArticles(); } loadArticles() { loadArticles("games") .then(articles => { this.state.articles = articles; this.refresh(); }) .catch(e => console.log(e)); } renderPlaceholder() { return { tag: "article", class: "placeholder", contents: [{ tag: "div" }, { tag: "div" }], }; } refresh() { obj2htm.subRender(this.render(), document.getElementById(this.id), { mode: "replace", }); } render() { const { articles } = this.state; return { tag: "section", class: "game-articles page-contents-center", id: this.id, contents: articles.length > 0 ? articles.map(article => new GameArticle({ ...article }).render()) : [this.renderPlaceholder()], }; } } module.exports = GameArticles; },{"../../../lib/article-utils":34,"./game-article":37}],39:[function(require,module,exports){ "use strict"; const { images_url } = require("../../../constants"); const WebPage = require("../../lib/web-page"); const GameArticles = require("./components/game-articles"); class GamesPage extends WebPage { render() { return { tag: "div", id: "games-page", contents: [ { tag: "div", class: "page-header logo-left", contents: [ { tag: "div", class: "page-contents-center grid-wrapper", contents: [ { tag: "div", class: "logo", contents: [ { tag: "img", alt: "image game controller", src: `${images_url}/game_controller.svg`, }, ], }, { tag: "h1", contents: "Jeux" }, { tag: "p", contents: `Création de jeux vidéos indépendants. <br/>Jeux web, PC et projets en cours de développement`, }, ], }, ], }, new GameArticles().render(), ], }; } } module.exports = GamesPage; },{"../../../constants":3,"../../lib/web-page":36,"./components/game-articles":38}],40:[function(require,module,exports){ "use strict"; "use strict"; const runPage = require("../../run-page"); const GamesPage = require("./games"); runPage(GamesPage); },{"../../run-page":41,"./games":39}],41:[function(require,module,exports){ "use strict"; const renderer = require("object-to-html-renderer") const Template = require("./template/template"); module.exports = function runPage(PageComponent) { const template = new Template({ page: new PageComponent() }); renderer.register("obj2htm") obj2htm.setRenderCycleRoot(template); obj2htm.renderCycle(); }; },{"./template/template":43,"object-to-html-renderer":32}],42:[function(require,module,exports){ "use strict"; const { images_url } = require("../../../constants"); const NAV_MENU_ITEMS = [ { url: "/games/", text: "Jeux" }, { url: "/education/", text: "Pédagogie", // submenu: [ // { url: "/gamedev", text: "Création de jeux vidéo" }, // ] }, { url: "/software-development/", text: "Software" } ]; class NavBar { constructor() { this.initEventHandlers(); } handleBurgerClick() { document.getElementById("nav-menu-list").classList.toggle("responsive-show"); } initEventHandlers() { window.addEventListener("click", event => { if ( event.target.id !== "nav-menu-list" && !event.target.classList.contains("burger") && !event.target.parentNode.classList.contains("burger") ) { document.getElementById("nav-menu-list").classList.remove("responsive-show"); } }); } renderHome() { return { tag: "div", class: "home", contents: [ { tag: "a", href: "/", contents: [ { tag: "img", alt: "Logo Kuadrado", src: `${images_url}/logo_kuadrado.svg`, }, { tag: "img", alt: "Kuadrado Software", class: "logo-text", src: `${images_url}/logo_kuadrado_txt.svg`, }, ], }, ], }; } renderMenu(menuItemsArray, isSubmenu = false, parentUrl = "") { return { tag: "ul", id: "nav-menu-list", class: isSubmenu ? "submenu" : "", contents: menuItemsArray.map(item => { const { url, text, submenu } = item; const href = `${parentUrl}${url}`; return { tag: "li", class: !isSubmenu && window.location.pathname === href ? "active" : "", contents: [ { tag: "a", href, contents: text, }, ].concat(submenu ? [this.renderMenu(submenu, true, url)] : []), }; }), }; } renderResponsiveBurger() { return { tag: "div", class: "burger", onclick: this.handleBurgerClick.bind(this), contents: [{ tag: "span", contents: "···" }], }; } render() { return { tag: "nav", contents: [ this.renderHome(), this.renderResponsiveBurger(), this.renderMenu(NAV_MENU_ITEMS), ], }; } } module.exports = NavBar; },{"../../../constants":3}],43:[function(require,module,exports){ "use strict"; const { in_construction } = require("../../config"); const { images_url } = require("../../constants"); const NavBar = require("./components/navbar"); class Template { constructor(props) { this.props = props; } render() { return { tag: "main", contents: [ { tag: "header", contents: [new NavBar().render()], }, in_construction && { tag: "section", class: "warning-banner", contents: [ { tag: "strong", class: "page-contents-center", contents: "Site en construction ...", }, ], }, { tag: "section", id: "page-container", contents: [this.props.page.render()], }, { tag: "footer", contents: [ { tag: "div", class: "logo", contents: [ { tag: "img", alt: `logo Kuadrado`, src: `${images_url}/logo_kuadrado.svg`, }, { tag: "img", class: "text-logo", alt: "Kuadrado Software", src: `${images_url}/logo_kuadrado_txt.svg`, }, ], }, { tag: "span", contents: "32 rue Simon Vialet, 07240 Vernoux en Vivarais. Ardèche, France", }, { tag: "div", contents: [ { tag: "strong", contents: "<blue>Contact : </blue>" }, { tag: "a", href: "mailto:contact@kuadrado-software.fr", contents: "contact@kuadrado-software.fr", }, ], }, { tag: "div", class: "social", contents: [ { tag: "strong", contents: "<blue>Sur les réseaux : </blue>", }, { tag: "a", href: "https://www.linkedin.com/company/kuadrado-software", target: "_blank", contents: "in", title: "Linkedin", }, { tag: "a", href: "https://mastodon.gamedev.place/@kuadrado_software", target: "_blank", contents: "m", title: "Mastodon", } ], }, { tag: "span", contents: `Copyright © ${new Date() .getFullYear()} Kuadrado Software | Toutes les images du site ont été réalisées par mes soins et peuvent être réutilisées pour un usage personnel.`, }, ], }, ], }; } } module.exports = Template; },{"../../config":2,"../../constants":3,"./components/navbar":42}]},{},[40]);