shithub: puzzles

ref: b5e02b0b9c1b35b0b907bf6d63e62b9fafd9cb7e
dir: /emcclib.js/

View raw version
/*
 * emcclib.js: one of the Javascript components of an Emscripten-based
 * web/Javascript front end for Puzzles.
 *
 * The other parts of this system live in emcc.c and emccpre.js. It
 * also depends on being run in the context of a web page containing
 * an appropriate collection of bits and pieces (a canvas, some
 * buttons and links etc), which is generated for each puzzle by the
 * script html/jspage.pl.
 *
 * This file contains a set of Javascript functions which we insert
 * into Emscripten's library object via the --js-library option; this
 * allows us to provide JS code which can be called from the
 * Emscripten-compiled C, mostly dealing with UI interaction of
 * various kinds.
 */

mergeInto(LibraryManager.library, {
    /*
     * void js_debug(const char *message);
     *
     * A function to write a diagnostic to the Javascript console.
     * Unused in production, but handy in development.
     */
    js_debug: function(ptr) {
        console.log(UTF8ToString(ptr));
    },

    /*
     * void js_error_box(const char *message);
     *
     * A wrapper around Javascript's alert(), so the C code can print
     * simple error message boxes (e.g. when invalid data is entered
     * in a configuration dialog).
     */
    js_error_box: function(ptr) {
        alert(UTF8ToString(ptr));
    },

    /*
     * void js_remove_type_dropdown(void);
     *
     * Get rid of the drop-down list on the web page for selecting
     * game presets. Called at setup time if the game back end
     * provides neither presets nor configurability.
     */
    js_remove_type_dropdown: function() {
        var gametypeitem = gametypelist.closest("li");
        if (gametypeitem === null) return;
        gametypeitem.parentNode.removeChild(gametypeitem);
    },

    /*
     * void js_remove_solve_button(void);
     *
     * Get rid of the Solve button on the web page. Called at setup
     * time if the game doesn't support an in-game solve function.
     */
    js_remove_solve_button: function() {
        var solvebutton = document.getElementById("solve");
        if (solvebutton === null) return;
        var solveitem = solvebutton.closest("li");
        if (solveitem === null) return;
        solveitem.parentNode.removeChild(solveitem);
    },

    /*
     * void js_add_preset(int menuid, const char *name, int value);
     *
     * Add a preset to the drop-down types menu, or to a submenu of
     * it. 'menuid' specifies an index into our array of submenus
     * where the item might be placed; 'value' specifies the number
     * that js_get_selected_preset() will return when this item is
     * clicked.
     */
    js_add_preset: function(menuid, ptr, value) {
        var name = UTF8ToString(ptr);
        var item = document.createElement("li");
        var label = document.createElement("label");
        var tick = document.createElement("input");
        tick.type = "radio";
        tick.className = "tick";
        tick.name = "preset";
        tick.value = value;
        label.appendChild(tick);
        label.appendChild(document.createTextNode(" " + name));
        item.appendChild(label);
        gametypesubmenus[menuid].appendChild(item);

        tick.onclick = function(event) {
            if (dlg_dimmer === null) {
                command(2);
            }
        }
    },

    /*
     * int js_add_preset_submenu(int menuid, const char *name);
     *
     * Add a submenu in the presets menu hierarchy. Returns its index,
     * for passing as the 'menuid' argument in further calls to
     * js_add_preset or this function.
     */
    js_add_preset_submenu: function(menuid, ptr, value) {
        var name = UTF8ToString(ptr);
        var item = document.createElement("li");
        // We still create a transparent tick element, even though it
        // won't ever be selected, to make submenu titles line up
        // nicely with their neighbours.
        var label = document.createElement("div");
        var tick = document.createElement("span");
        tick.className = "tick";
        label.appendChild(tick);
        label.tabIndex = 0;
        label.appendChild(document.createTextNode(" " + name));
        item.appendChild(label);
        var submenu = document.createElement("ul");
        label.appendChild(submenu);
        gametypesubmenus[menuid].appendChild(item);
        var toret = gametypesubmenus.length;
        gametypesubmenus.push(submenu);
        return toret;
    },

    /*
     * int js_get_selected_preset(void);
     *
     * Return the index of the currently selected value in the type
     * dropdown.
     */
    js_get_selected_preset: function() {
        return menuform.elements["preset"].value;
    },

    /*
     * void js_select_preset(int n);
     *
     * Cause a different value to be selected in the type dropdown
     * (for when the user selects values from the Custom configurer
     * which turn out to exactly match a preset).
     */
    js_select_preset: function(n) {
        menuform.elements["preset"].value = n;
    },

    /*
     * void js_default_colour(float *output);
     *
     * Try to extract a default colour from the CSS computed
     * background colour of the canvas element.
     */
    js_default_colour: function(output) {
        var col = window.getComputedStyle(onscreen_canvas).backgroundColor;
        /* We only support opaque sRGB colours. */
        var m = col.match(
            /^rgb\((\d+(?:\.\d+)?), (\d+(?:\.\d+)?), (\d+(?:\.\d+)?)\)$/);
        if (m) {
            setValue(output,     +m[1] / 255, "float");
            setValue(output + 4, +m[2] / 255, "float");
            setValue(output + 8, +m[3] / 255, "float");
        }
    },

    /*
     * void js_set_background_colour(const char *bg);
     *
     * Record the puzzle background colour in a CSS variable so
     * the style sheet can use it if it wants.
     */
    js_set_background_colour: function(bgptr) {
        document.documentElement.style.setProperty("--puzzle-background",
                                                   UTF8ToString(bgptr));
    },

    /*
     * void js_get_date_64(unsigned *p);
     *
     * Return the current date, in milliseconds since the epoch
     * (Javascript's native format), as a 64-bit integer. Used to
     * invent an initial random seed for puzzle generation.
     */
    js_get_date_64: function(ptr) {
        var d = (new Date()).valueOf();
        setValue(ptr, d, 'i64');
    },

    /*
     * void js_update_permalinks(const char *desc, const char *seed);
     *
     * Update the permalinks on the web page for a new game
     * description and optional random seed. desc can never be NULL,
     * but seed might be (if the game was generated by entering a
     * descriptive id by hand), in which case we suppress display of
     * the random seed permalink.
     */
    js_update_permalinks: function(desc, seed) {
        desc = encodeURI(UTF8ToString(desc)).replace(/#/g, "%23");
        if (permalink_desc !== null)
            permalink_desc.href = "#" + desc;

        if (permalink_seed !== null) {
            if (seed == 0) {
                permalink_seed.style.display = "none";
            } else {
                seed = encodeURI(UTF8ToString(seed)).replace(/#/g, "%23");;
                permalink_seed.href = "#" + seed;
                permalink_seed.style.display = "";
            }
        }
    },

    /*
     * void js_enable_undo_redo(int undo, int redo);
     *
     * Set the enabled/disabled states of the undo and redo buttons,
     * after a move.
     */
    js_enable_undo_redo: function(undo, redo) {
        disable_menu_item(undo_button, (undo == 0));
        disable_menu_item(redo_button, (redo == 0));
    },

    /*
     * void js_activate_timer();
     *
     * Start calling the C timer_callback() function every frame.
     * The C code ensures that the activate and deactivate functions
     * are called in a sensible order.
     */
    js_activate_timer: function() {
        timer_reference = performance.now();
        var frame = function(now) {
            timer = window.requestAnimationFrame(frame);
            // The callback might call js_deactivate_timer() below.
            timer_callback((now - timer_reference) / 1000.0);
            timer_reference = now;
        };
        timer = window.requestAnimationFrame(frame);
    },

    /*
     * void js_deactivate_timer();
     *
     * Stop calling the C timer_callback() function every frame.
     */
    js_deactivate_timer: function() {
        window.cancelAnimationFrame(timer);
    },

    /*
     * void js_canvas_start_draw(void);
     *
     * Prepare to do some drawing on the canvas.
     */
    js_canvas_start_draw: function() {
        update_xmin = update_xmax = update_ymin = update_ymax = undefined;
    },

    /*
     * void js_canvas_draw_update(int x, int y, int w, int h);
     *
     * Mark a rectangle of the off-screen canvas as needing to be
     * copied to the on-screen one.
     */
    js_canvas_draw_update: function(x, y, w, h) {
        /*
         * Currently we do this in a really simple way, just by taking
         * the smallest rectangle containing all updates so far. We
         * could instead keep the data in a richer form (e.g. retain
         * multiple smaller rectangles needing update, and only redraw
         * the whole thing beyond a certain threshold) but this will
         * do for now.
         */
        if (update_xmin === undefined || update_xmin > x) update_xmin = x;
        if (update_ymin === undefined || update_ymin > y) update_ymin = y;
        if (update_xmax === undefined || update_xmax < x+w) update_xmax = x+w;
        if (update_ymax === undefined || update_ymax < y+h) update_ymax = y+h;
    },

    /*
     * void js_canvas_end_draw(void);
     *
     * Finish the drawing, by actually copying the newly drawn stuff
     * to the on-screen canvas.
     */
    js_canvas_end_draw: function() {
        if (update_xmin !== undefined) {
            var onscreen_ctx =
                onscreen_canvas.getContext('2d', { alpha: false });
            onscreen_ctx.drawImage(offscreen_canvas,
                                   update_xmin, update_ymin,
                                   update_xmax - update_xmin,
                                   update_ymax - update_ymin,
                                   update_xmin, update_ymin,
                                   update_xmax - update_xmin,
                                   update_ymax - update_ymin);
        }
    },

    /*
     * void js_canvas_draw_rect(int x, int y, int w, int h,
     *                          const char *colour);
     * 
     * Draw a rectangle.
     */
    js_canvas_draw_rect: function(x, y, w, h, colptr) {
        ctx.fillStyle = UTF8ToString(colptr);
        ctx.fillRect(x, y, w, h);
    },

    /*
     * void js_canvas_clip_rect(int x, int y, int w, int h);
     * 
     * Set a clipping rectangle.
     */
    js_canvas_clip_rect: function(x, y, w, h) {
        ctx.save();
        ctx.beginPath();
        ctx.rect(x, y, w, h);
        ctx.clip();
    },

    /*
     * void js_canvas_unclip(void);
     * 
     * Reset to no clipping.
     */
    js_canvas_unclip: function() {
        ctx.restore();
    },

    /*
     * void js_canvas_draw_line(float x1, float y1, float x2, float y2,
     *                          int width, const char *colour);
     * 
     * Draw a line. We must adjust the coordinates by 0.5 because
     * Javascript's canvas coordinates appear to be pixel corners,
     * whereas we want pixel centres. Also, we manually draw the pixel
     * at each end of the line, which our clients will expect but
     * Javascript won't reliably do by default (in common with other
     * Postscriptish drawing frameworks).
     */
    js_canvas_draw_line: function(x1, y1, x2, y2, width, colour) {
        colour = UTF8ToString(colour);

        ctx.beginPath();
        ctx.moveTo(x1 + 0.5, y1 + 0.5);
        ctx.lineTo(x2 + 0.5, y2 + 0.5);
        ctx.lineWidth = width;
        ctx.lineCap = 'round';
        ctx.lineJoin = 'round';
        ctx.strokeStyle = colour;
        ctx.stroke();
        ctx.fillStyle = colour;
        ctx.fillRect(x1, y1, 1, 1);
        ctx.fillRect(x2, y2, 1, 1);
    },

    /*
     * void js_canvas_draw_poly(int *points, int npoints,
     *                          const char *fillcolour,
     *                          const char *outlinecolour);
     * 
     * Draw a polygon.
     */
    js_canvas_draw_poly: function(pointptr, npoints, fill, outline) {
        ctx.beginPath();
        ctx.moveTo(getValue(pointptr  , 'i32') + 0.5,
                   getValue(pointptr+4, 'i32') + 0.5);
        for (var i = 1; i < npoints; i++)
            ctx.lineTo(getValue(pointptr+8*i  , 'i32') + 0.5,
                       getValue(pointptr+8*i+4, 'i32') + 0.5);
        ctx.closePath();
        if (fill != 0) {
            ctx.fillStyle = UTF8ToString(fill);
            ctx.fill();
        }
        ctx.lineWidth = '1';
        ctx.lineCap = 'round';
        ctx.lineJoin = 'round';
        ctx.strokeStyle = UTF8ToString(outline);
        ctx.stroke();
    },

    /*
     * void js_canvas_draw_circle(int x, int y, int r,
     *                            const char *fillcolour,
     *                            const char *outlinecolour);
     * 
     * Draw a circle.
     */
    js_canvas_draw_circle: function(x, y, r, fill, outline) {
        ctx.beginPath();
        ctx.arc(x + 0.5, y + 0.5, r, 0, 2*Math.PI);
        if (fill != 0) {
            ctx.fillStyle = UTF8ToString(fill);
            ctx.fill();
        }
        ctx.lineWidth = '1';
        ctx.lineCap = 'round';
        ctx.lineJoin = 'round';
        ctx.strokeStyle = UTF8ToString(outline);
        ctx.stroke();
    },

    /*
     * int js_canvas_find_font_midpoint(int height, bool monospaced);
     * 
     * Return the adjustment required for text displayed using
     * ALIGN_VCENTRE. We want to place the midpoint between the
     * baseline and the cap-height at the specified position; so this
     * function returns the adjustment which, when added to the
     * desired centre point, returns the y-coordinate at which you
     * should put the baseline.
     *
     * There is no sensible method of querying this kind of font
     * metric in Javascript, so instead we render a piece of test text
     * to a throwaway offscreen canvas and then read the pixel data
     * back out to find the highest and lowest pixels. That's good
     * _enough_ (in that we only needed the answer to the nearest
     * pixel anyway), but rather disgusting!
     *
     * Since this is a very expensive operation, we cache the results
     * per (font,height) pair.
     */
    js_canvas_find_font_midpoint: function(height, monospaced) {

        // Resolve the font into a string.
        var ctx1 = onscreen_canvas.getContext('2d', { alpha: false });
        canvas_set_font(ctx1, height, monospaced);

        // Reuse cached value if possible
        if (midpoint_cache[ctx1.font] !== undefined)
            return midpoint_cache[ctx1.font];

        // Find the width of the string
        var width = (ctx1.measureText(midpoint_test_str).width + 1) | 0;

        // Construct a test canvas of appropriate size, initialise it to
        // black, and draw the string on it in white
        var measure_canvas = document.createElement('canvas');
        var ctx2 = measure_canvas.getContext('2d', { alpha: false });
        ctx2.canvas.width = width;
        ctx2.canvas.height = 2*height;
        ctx2.fillStyle = "#000000";
        ctx2.fillRect(0, 0, width, 2*height);
        var baseline = (1.5*height) | 0;
        ctx2.fillStyle = "#ffffff";
        canvas_set_font(ctx2, height, monospaced);
        ctx2.fillText(midpoint_test_str, 0, baseline);

        // Scan the contents of the test canvas to find the top and bottom
        // set pixels.
        var pixels = ctx2.getImageData(0, 0, width, 2*height).data;
        var ymin = 2*height, ymax = -1;
        for (var y = 0; y < 2*height; y++) {
            for (var x = 0; x < width; x++) {
                if (pixels[4*(y*width+x)] != 0) {
                    if (ymin > y) ymin = y;
                    if (ymax < y) ymax = y;
                    break;
                }
            }
        }

        var ret = (baseline - (ymin + ymax) / 2) | 0;
        midpoint_cache[ctx1.font] = ret;
        return ret;
    },

    /*
     * void js_canvas_draw_text(int x, int y, int halign,
     *                          const char *colptr, int height,
     *                          bool monospaced, const char *text);
     * 
     * Draw text. Vertical alignment has been taken care of on the C
     * side, by optionally calling the above function. Horizontal
     * alignment is handled here, since we can get the canvas draw
     * function to do it for us with almost no extra effort.
     */
    js_canvas_draw_text: function(x, y, halign, colptr, fontsize, monospaced,
                                  text) {
        canvas_set_font(ctx, fontsize, monospaced);
        ctx.fillStyle = UTF8ToString(colptr);
        ctx.textAlign = (halign == 0 ? 'left' :
                         halign == 1 ? 'center' : 'right');
        ctx.textBaseline = 'alphabetic';
        ctx.fillText(UTF8ToString(text), x, y);
    },

    /*
     * int js_canvas_new_blitter(int w, int h);
     * 
     * Create a new blitter object, which is just an offscreen canvas
     * of the specified size.
     */
    js_canvas_new_blitter: function(w, h) {
        var id = blittercount++;
        blitters[id] = document.createElement("canvas");
        blitters[id].width = w;
        blitters[id].height = h;
        return id;
    },

    /*
     * void js_canvas_free_blitter(int id);
     * 
     * Free a blitter (or rather, destroy our reference to it so JS
     * can garbage-collect it, and also enforce that we don't
     * accidentally use it again afterwards).
     */
    js_canvas_free_blitter: function(id) {
        blitters[id] = null;
    },

    /*
     * void js_canvas_copy_to_blitter(int id, int x, int y, int w, int h);
     * 
     * Copy from the puzzle image to a blitter. The size is passed to
     * us, partly so we don't have to remember the size of each
     * blitter, but mostly so that the C side can adjust the copy
     * rectangle in the case where it partially overlaps the edge of
     * the screen.
     */
    js_canvas_copy_to_blitter: function(id, x, y, w, h) {
        var blitter_ctx = blitters[id].getContext('2d', { alpha: false });
        blitter_ctx.drawImage(offscreen_canvas,
                              x, y, w, h,
                              0, 0, w, h);
    },

    /*
     * void js_canvas_copy_from_blitter(int id, int x, int y, int w, int h);
     * 
     * Copy from a blitter back to the puzzle image. As above, the
     * size of the copied rectangle is passed to us from the C side
     * and may already have been modified.
     */
    js_canvas_copy_from_blitter: function(id, x, y, w, h) {
        ctx.drawImage(blitters[id],
                      0, 0, w, h,
                      x, y, w, h);
    },

    /*
     * void js_canvas_remove_statusbar(void);
     *
     * Cause a status bar not to exist. Called at setup time if the
     * puzzle back end turns out not to want one.
     */
    js_canvas_remove_statusbar: function() {
        if (statusbar !== null)
            statusbar.parentNode.removeChild(statusbar);
        statusbar = null;
    },

    /*
     * void js_canvas_set_statusbar(const char *text);
     * 
     * Set the text in the status bar.
     */
    js_canvas_set_statusbar: function(ptr) {
        statusbar.textContent = UTF8ToString(ptr);
    },

    /*
     * bool js_canvas_get_preferred_size(int *wp, int *hp);
     *
     * This is called before calling midend_size() to set a puzzle to
     * the default size.  If the JavaScript layer has an opinion about
     * how big the puzzle should be, it can overwrite *wp and *hp with
     * its preferred size, and return true if the "user" parameter to
     * midend_size() should be true.  Otherwise it should leave them
     * alone and return false.
     */
    js_canvas_get_preferred_size: function(wp, hp) {
        if (document.readyState == "complete" && containing_div !== null) {
            var dpr = window.devicePixelRatio || 1;
            setValue(wp, containing_div.clientWidth * dpr, "i32");
            setValue(hp, containing_div.clientHeight * dpr, "i32");
            return true;
        }
        return false;
    },

    /*
     * void js_canvas_set_size(int w, int h);
     * 
     * Set the size of the puzzle canvas. Called whenever the size of
     * the canvas needs to change.  That might be because of a change
     * of configuration, because the user has resized the puzzle, or
     * because the device pixel ratio has changed.
     */
    js_canvas_set_size: function(w, h) {
        onscreen_canvas.width = w;
        offscreen_canvas.width = w;
        if (resizable_div !== null)
            resizable_div.style.width =
                w / (window.devicePixelRatio || 1) + "px";
        else {
            onscreen_canvas.style.width =
                w / (window.devicePixelRatio || 1) + "px";
            onscreen_canvas.style.height =
                h / (window.devicePixelRatio || 1) + "px";
        }

        onscreen_canvas.height = h;
        offscreen_canvas.height = h;
    },

    /*
     * double js_get_device_pixel_ratio();
     *
     * Return the current device pixel ratio.
     */
    js_get_device_pixel_ratio: function() {
        return window.devicePixelRatio || 1;
    },

    /*
     * void js_dialog_init(const char *title);
     * 
     * Begin constructing a 'dialog box' which will be popped up in an
     * overlay on top of the rest of the puzzle web page.
     */
    js_dialog_init: function(titletext) {
        dialog_init(UTF8ToString(titletext));
    },

    /*
     * void js_dialog_string(int i, const char *title, const char *initvalue);
     * 
     * Add a string control (that is, an edit box) to the dialog under
     * construction.
     */
    js_dialog_string: function(index, title, initialtext) {
        var label = document.createElement("label");
        label.textContent = UTF8ToString(title);
        dlg_form.appendChild(label);
        var editbox = document.createElement("input");
        editbox.type = "text";
        editbox.value = UTF8ToString(initialtext);
        label.appendChild(editbox);
        dlg_form.appendChild(document.createElement("br"));

        dlg_return_funcs.push(function() {
            dlg_return_sval(index, editbox.value);
        });
    },

    /*
     * void js_dialog_choices(int i, const char *title, const char *choicelist,
     *                        int initvalue);
     * 
     * Add a choices control (i.e. a drop-down list) to the dialog
     * under construction. The 'choicelist' parameter is unchanged
     * from the way the puzzle back end will have supplied it: i.e.
     * it's still encoded as a single string whose first character
     * gives the separator.
     */
    js_dialog_choices: function(index, title, choicelist, initvalue) {
        var label = document.createElement("label");
        label.textContent = UTF8ToString(title);
        dlg_form.appendChild(label);
        var dropdown = document.createElement("select");
        var choicestr = UTF8ToString(choicelist);
        var items = choicestr.slice(1).split(choicestr[0]);
        var options = [];
        for (var i in items) {
            var option = document.createElement("option");
            option.value = i;
            option.appendChild(document.createTextNode(items[i]));
            if (i == initvalue) option.selected = true;
            dropdown.appendChild(option);
            options.push(option);
        }
        label.appendChild(dropdown);
        dlg_form.appendChild(document.createElement("br"));

        dlg_return_funcs.push(function() {
            var val = 0;
            for (var i in options) {
                if (options[i].selected) {
                    val = options[i].value;
                    break;
                }
            }
            dlg_return_ival(index, val);
        });
    },

    /*
     * void js_dialog_boolean(int i, const char *title, int initvalue);
     * 
     * Add a boolean control (a checkbox) to the dialog under
     * construction. Checkboxes are generally expected to be sensitive
     * on their label text as well as the box itself, so for this
     * control we create an actual label rather than merely a text
     * node (and hence we must allocate an id to the checkbox so that
     * the label can refer to it).
     */
    js_dialog_boolean: function(index, title, initvalue) {
        var checkbox = document.createElement("input");
        checkbox.type = "checkbox";
        checkbox.checked = (initvalue != 0);
        var checkboxlabel = document.createElement("label");
        checkboxlabel.appendChild(checkbox);
        checkboxlabel.appendChild(document.createTextNode(UTF8ToString(title)));
        dlg_form.appendChild(checkboxlabel);
        dlg_form.appendChild(document.createElement("br"));

        dlg_return_funcs.push(function() {
            dlg_return_ival(index, checkbox.checked ? 1 : 0);
        });
    },

    /*
     * void js_dialog_launch(void);
     * 
     * Finish constructing a dialog, and actually display it, dimming
     * everything else on the page.
     */
    js_dialog_launch: function() {
        dialog_launch(function(event) {
            for (var i in dlg_return_funcs)
                dlg_return_funcs[i]();
            command(3);         // OK
        }, function(event) {
            command(4);         // Cancel
        });
    },

    /*
     * void js_dialog_cleanup(void);
     * 
     * Stop displaying a dialog, and clean up the internal state
     * associated with it.
     */
    js_dialog_cleanup: function() {
        dialog_cleanup();
    },

    /*
     * void js_focus_canvas(void);
     * 
     * Return keyboard focus to the puzzle canvas. Called after a
     * puzzle-control button is pressed, which tends to have the side
     * effect of taking focus away from the canvas.
     */
    js_focus_canvas: function() {
        onscreen_canvas.focus();
    }
});