plugins_tweakables.js

/**
 * LittleJS Tweakables Plugin
 * - Change values live from a panel while the game runs, for tuning without a reload
 * - tweak('name') adds a global, or a field of an object with the object option, a number gets a slider when it
 *   has a range, a boolean a checkbox, a Color a color picker with an alpha slider, a Vector2 or Vector3 a number
 *   for each axis
 * - tweakButton adds a button that calls a function, like one to restart the level
 * - Press 9 while the debug overlay is open to show the panel, or set debugTweakables to show it from the start
 * - Changes are saved and come back after a refresh, until the value in the code itself changes
 * - Copy puts the changed values on the clipboard as lines of code, to paste over the values in the code
 * - Debug builds only, the release build has none of this code, its functions are stubs that do nothing, so the
 *   code's values are used as they are
 * @namespace Tweakables
 */

'use strict';

///////////////////////////////////////////////////////////////////////////////

// the tweaks and dividers in the order they were added
const tweakList = [];

// the saved values by name, each with the code value it was saved over, read when the first tweak is added
let tweakSaved;

// the panel, made the first time it is shown, and if its rows need to be made again
let tweakPanel, tweakRows, tweakRowsDirty = true;

// a global's name, or a dotted path to a field on one
const tweakPathRegex = /^[A-Za-z_$][\w$]*(\.[A-Za-z_$][\w$]*)*$/;

/** Options for a tweak
 *  @typedef {Object} TweakOptions
 *  @property {number} [min] - Lowest value on the slider, the slider shows when min and max are both set
 *  @property {number} [max] - Highest value on the slider
 *  @property {number} [step] - Step for the slider and number box, a thousandth of the range by default
 *  @property {string} [label] - Name to show in place of the path, and the name it is saved by with an object
 *  @property {Object} [object] - Object the path is a field of, in place of a global, for an ES module game
 *  @memberof Tweakables */

/** Add a value to the tweakables panel, so it can be changed while the game runs
 *  - The path is the name of a global, or a dotted path to a field on one like 'player.speed'
 *  - Only globals of a script can be found by name, an ES module game passes the object its values are on
 *  - Its type comes from the value it has now: a number, boolean, Color, Vector2 or Vector3
 *  - Call it after the value is set, at the end of gameInit
 *  - Adding the same path again keeps its row and brings its tweaked value back, for an object made again;
 *    with the object option it is known by its label, so two objects with the same field need their own labels
 *  - Does nothing in release builds
 *  @param {string} path - Name of a global, or a dotted path to a field on one or on the object option
 *  @param {TweakOptions} [options]
 *  @memberof Tweakables
 *  @example
 *  tweak('jumpSpeed', {min: 0, max: 1});
 *  tweak('skyColor');
 *  tweak('player.size', {label: 'Player Size'});
 *  tweak('speed', {object: settings}); // settings.speed, in an ES module */
function tweak(path, options={})
{
    if (!debug) return;
    ASSERT(isStringLike(path) && tweakPathRegex.test(path), 'tweak path must be a name like speed or player.speed');

    const {object} = options;
    let name = path, get, set, toCode;
    if (object)
    {
        // a field of the object, the object at each step is looked up again so one made again is found
        ASSERT(typeof object === 'object', 'tweak object must be an object');
        const keys = path.split('.'), last = keys.pop();
        const parent = ()=> keys.reduce((o, key)=> o?.[key], object);
        name = options.label ?? path;
        get = ()=> parent()?.[last];
        set = (v)=> { parent()[last] = v; };
        toCode = (v)=> `${path}: ${v},`; // as it is written in the object
    }
    else
    {
        // a global, compiled once, a missing object on the path reads as undefined
        get = new Function(`try { return ${path} } catch {}`);
        set = new Function('v', `'use strict'; ${path} = v`);
        toCode = (v)=> `${path} = ${v};`;
    }
    const value = get();
    ASSERT(value !== undefined, object ? `tweak could not find ${path} on the object` :
        `tweak could not find ${path}, it must be a global of a script, an ES module game passes the object option`);
    try { set(value); }
    catch { ASSERT(false, `tweak can not change ${path}, it must not be a const or frozen`); }
    tweakAdd(name, get, set, toCode, options);
}

/** Add a button to the tweakables panel that calls a function, like one to restart the level
 *  - Adding the same label again replaces its function
 *  - Does nothing in release builds
 *  @param {string} label
 *  @param {function():void} callback
 *  @memberof Tweakables
 *  @example
 *  tweakButton('Restart', restartLevel); */
function tweakButton(label, callback)
{
    if (!debug) return;
    ASSERT(isStringLike(label), 'tweakButton label must be a string');
    ASSERT(typeof callback === 'function', 'tweakButton callback must be a function');
    const t = tweakList.find(t=> t.type === 'button' && t.label === label);
    if (t)
        t.callback = callback;
    else
    {
        tweakList.push({type: 'button', label, callback});
        tweakRowsDirty = true;
    }
}

/** Add a divider to the tweakables panel, with a label for the tweaks after it
 *  - Does nothing in release builds
 *  @param {string} [label]
 *  @memberof Tweakables */
function tweakDivider(label='')
{
    if (!debug) return;
    if (label && tweakList.find(t=> t.type === 'divider' && t.label === label))
        return; // added again, as a game that calls its setup again does
    tweakList.push({type: 'divider', label});
    tweakRowsDirty = true;
}

/** Add the engine settings a game most often tunes to the tweakables panel:
 *  gravity, timeScale, cameraScale and soundVolume, under an Engine divider
 *  - They are changed through their setters, so this works in the ES module build too
 *  - Does nothing in release builds
 *  @memberof Tweakables */
function tweakEngineDefaults()
{
    if (!debug) return;
    tweakDivider('Engine');
    tweakAdd('gravity', ()=> gravity, setGravity, (v)=> `setGravity(${v});`, {min: -.05, max: .05});
    tweakAdd('timeScale', ()=> timeScale, setTimeScale, (v)=> `setTimeScale(${v});`, {min: 0, max: 2});
    tweakAdd('cameraScale', ()=> cameraScale, setCameraScale, (v)=> `setCameraScale(${v});`,
        {min: 1, max: 128, step: 1});
    tweakAdd('soundVolume', ()=> soundVolume, setSoundVolume, (v)=> `setSoundVolume(${v});`, {min: 0, max: 1});
}

///////////////////////////////////////////////////////////////////////////////
// tweaks

function tweakAdd(name, get, set, toCode, options)
{
    const value = get();
    const type = isNumber(value) ? 'number' : typeof value === 'boolean' ? 'boolean' :
        isColor(value) ? 'color' : isVector2(value) ? 'vector2' : tweakIsVector3(value) ? 'vector3' : undefined;
    ASSERT(type, `tweak ${name} must be a number, boolean, Color, Vector2 or Vector3`);
    if (!type) return;

    // added again, the code value stays the one it had first, the value now may be a tweaked one
    let t = tweakList.find(t=> t.name === name);
    if (!t)
    {
        tweakList.push(t = {name, codeValue: tweakCopy(value)});
        tweakRowsDirty = true;
    }
    Object.assign(t, {type, get, set, toCode, options});

    // a saved value comes back while the code value is the one it was saved over, after an edit it is dropped
    tweakSaved ||= readSaveData(tweakSaveName(), {});
    const saved = tweakSaved[name];
    if (!saved) return;
    if (tweakSaveText(saved.code) === tweakSaveText(tweakToSave(t.codeValue)))
        set(tweakFromSave(type, saved.value));
    else
    {
        delete tweakSaved[name];
        writeSaveData(tweakSaveName(), tweakSaved);
    }
}

// every page keeps its own tweaks
function tweakSaveName() { return 'LittleJS tweaks ' + (globalThis.location?.pathname ?? ''); }

function tweakCopy(v) { return v?.copy ? v.copy() : v; }

// a Vector3, a build may leave out the 3D math
function tweakIsVector3(v) { return typeof Vector3 !== 'undefined' && isVector3(v); }

// the value as json can hold it, and back
function tweakToSave(v)
{
    return isColor(v) ? [v.r, v.g, v.b, v.a] : isVector2(v) ? [v.x, v.y] :
        tweakIsVector3(v) ? [v.x, v.y, v.z] : v;
}
function tweakFromSave(type, v)
{
    return type === 'color' ? rgb(...v) : type === 'vector2' ? vec2(...v) : type === 'vector3' ? vec3(...v) : v;
}
function tweakSaveText(v) { return JSON.stringify(v); }

// remember a tweak's value, or forget it when it is back to the code value
function tweakSave(t)
{
    const value = tweakToSave(t.get()), code = tweakToSave(t.codeValue);
    if (tweakSaveText(value) === tweakSaveText(code))
        delete tweakSaved[t.name];
    else
        tweakSaved[t.name] = {value, code};
    writeSaveData(tweakSaveName(), tweakSaved);
}

// put every tweak back to its code value and forget the saved ones
function tweakReset()
{
    for (const t of tweakList)
        t.set?.(tweakCopy(t.codeValue));
    tweakSaved = {};
    writeSaveData(tweakSaveName(), tweakSaved);
}

// a line of code for each tweak changed from its code value
function tweakChangedCode()
{
    // numbers as the code writes them, without float noise or a leading zero,
    // colors as hsl rounded more, which changes nothing that can be seen
    const number = (n, digits=6)=> String(+n.toFixed(digits)).replace(/^(-?)0\./, '$1.');
    const code = (v)=>
    {
        if (isColor(v))
        {
            const [h, s, l, a] = v.HSLA();
            return `hsl(${[h, s, l, ...(a < 1 ? [a] : [])].map((n)=> number(n, 3)).join(', ')})`;
        }
        if (isVector2(v))
            return `vec2(${number(v.x)}, ${number(v.y)})`;
        if (tweakIsVector3(v))
            return `vec3(${number(v.x)}, ${number(v.y)}, ${number(v.z)})`;
        return isNumber(v) ? number(v) : String(v);
    };
    return tweakList.filter(t=> t.get && tweakSaveText(tweakToSave(t.get())) !==
        tweakSaveText(tweakToSave(t.codeValue))).map(t=> t.toCode(code(t.get()))).join('\n');
}

///////////////////////////////////////////////////////////////////////////////
// panel

// shows or hides the panel each frame, and shows values the game changed itself
function tweakRender()
{
    if (headlessMode) return;
    if (!debugTweakables)
    {
        if (tweakPanel && tweakPanel.style.display !== 'none')
        {
            // hidden, a box it had focus in lets go of the keys
            const active = /** @type {HTMLElement} */ (document.activeElement);
            tweakPanel.contains(active) && active.blur();
            tweakPanel.style.display = 'none';
        }
        return;
    }

    tweakPanel || tweakPanelInit();
    tweakPanel.style.display = '';
    if (tweakRowsDirty)
    {
        tweakRowsDirty = false;
        tweakRows.replaceChildren();
        for (const t of tweakList)
            t.refresh = tweakRow(t);
        tweakList.length || tweakElement('div', tweakRows, 'color:#888', 'Nothing to tweak, add values with tweak()');
    }
    for (const t of tweakList)
        t.refresh?.();
}

function tweakPanelInit()
{
    tweakPanel = tweakElement('div', document.body,
        'position:fixed;top:8px;right:8px;width:260px;max-height:calc(100% - 16px);overflow-y:auto;' +
        'box-sizing:border-box;padding:8px;background:#111d;color:#eee;font:12px monospace;' +
        'border-radius:4px;z-index:9999');

    // a click or touch on the panel is not the game's, a mouse up still goes on so a button can let go
    for (const type of ['mousedown','wheel','touchstart','touchmove','touchend','touchcancel'])
        tweakPanel.addEventListener(type, (e)=> e.stopPropagation());

    tweakElement('div', tweakPanel, 'font-weight:bold;margin-bottom:4px', 'Tweakables');
    tweakRows = tweakElement('div', tweakPanel);
    const buttons = tweakElement('div', tweakPanel, 'display:flex;gap:6px;margin-top:8px');
    const button = (text, onclick)=>
        tweakElement('button', buttons, 'flex:1;padding:4px;cursor:pointer', text).onclick = onclick;
    button('Copy', (e)=>
    {
        // lines for the changed values, to paste over the values in the code
        const code = tweakChangedCode(), target = /** @type {HTMLElement} */ (e.target);
        code && navigator.clipboard?.writeText(code).catch(()=> console.log(code));
        target.textContent = code ? 'Copied' : 'No changes';
        setTimeout(()=> target.textContent = 'Copy', 1e3);
    });
    button('Reset', tweakReset);
}

// the row for a tweak, returns what shows its value
function tweakRow(t)
{
    const row = tweakElement('div', tweakRows, 'margin:6px 0');
    if (t.type === 'divider')
    {
        row.style.cssText = 'margin:10px 0 4px;padding-top:4px;border-top:1px solid #555;color:#aaa';
        row.textContent = t.label;
        return;
    }
    if (t.type === 'button')
    {
        // the function is looked up on click, so one added again replaces it
        const button = tweakElement('button', row, 'width:100%;padding:4px;cursor:pointer', t.label);
        button.onclick = ()=> t.callback();
        return;
    }

    const label = t.options.label ?? t.name;
    const save = ()=> tweakSave(t);
    if (t.type === 'boolean')
    {
        const labelElement = tweakElement('label', row, 'display:flex;gap:6px;align-items:center;cursor:pointer');
        const box = tweakElement('input', labelElement);
        box.type = 'checkbox';
        tweakElement('span', labelElement, '', label);
        box.onchange = ()=> { t.set(box.checked); save(); };
        return ()=> { box.checked = !!t.get(); };
    }

    tweakElement('div', row, '', label);
    if (t.type === 'number')
        return tweakNumber(row, '', t.get, t.set, t.options, save);

    // a part of a vector or color is a number of its own, set as a new copy with that part changed
    const part = (key, options)=> tweakNumber(row, key, ()=> t.get()?.[key],
        (n)=> { const v = t.get().copy(); v[key] = n; t.set(v); }, options, save);
    if (t.type === 'vector2' || t.type === 'vector3')
    {
        const axes = (t.type === 'vector2' ? ['x', 'y'] : ['x', 'y', 'z']).map((key)=> part(key, t.options));
        return ()=> axes.forEach((refresh)=> refresh());
    }

    // a color picker has no alpha, a slider below it has
    const picker = tweakElement('input', row, 'width:100%;height:24px;padding:0;border:0;background:none');
    picker.type = 'color';
    picker.oninput = ()=>
    {
        const color = rgb().setHex(picker.value);
        color.a = t.get().a;
        t.set(color);
        save();
    };
    const alpha = part('a', {min: 0, max: 1});
    return ()=>
    {
        const color = t.get();
        const hex = isColor(color) && color.toString(false);
        hex && picker !== document.activeElement && picker.value !== hex && (picker.value = hex);
        alpha();
    };
}

// a slider when there is a range and a box to type in, returns what shows the value
function tweakNumber(parent, axis, get, set, options, save)
{
    const {min, max} = options;
    const hasRange = isNumber(min) && isNumber(max);
    const value = get(), size = abs(value);
    const step = options.step ?? (hasRange ? (max - min) / 1e3 : size >= 10 ? 1 : size >= 1 ? .1 : .001);

    const row = tweakElement('div', parent, 'display:flex;gap:4px;align-items:center');
    axis && tweakElement('span', row, 'color:#888', axis);
    const slider = hasRange ? tweakElement('input', row, 'flex:1;min-width:0') : undefined;
    const box = tweakElement('input', row,
        'width:80px;box-sizing:border-box;background:#222;color:#eee;border:1px solid #555' +
        (hasRange ? '' : ';flex:1'));
    if (slider)
        Object.assign(slider, {type: 'range', min, max, step});
    Object.assign(box, {type: 'number', step});

    const change = (input)=>
    {
        const v = parseFloat(input.value);
        if (!isNumber(v)) return; // a box part way through typing
        set(v);
        save();
    };
    slider && (slider.oninput = ()=> change(slider));
    box.oninput = ()=> change(box);
    return ()=>
    {
        // the one being used keeps what it has, so typing is not undone
        const v = get();
        if (!isNumber(v)) return;
        for (const input of [slider, box])
            input && input !== document.activeElement && (input.value === '' || +input.value !== v) &&
                (input.value = String(v));
    };
}

function tweakElement(tag, parent, style='', text='')
{
    const element = document.createElement(tag);
    element.style.cssText = style;
    element.textContent = text;
    parent?.appendChild(element);
    return element;
}

///////////////////////////////////////////////////////////////////////////////
// plugin

debug && engineAddPlugin(undefined, tweakRender);