(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]);