modified plug2in

🧩 Syntax:
/**
 * All plugin parameters and structs for this plugin.
 *
 * @file parameters
 *
 * @author       Qazm
 * @copyright    2022 Qazm
 */

/*:
 * @pluginname Plug2in
 * @plugindesc An Intiface client plugin. (Version 0.1)
 * @modulename
 * @required
 * @external
 *
 * @author Qazm (https://qazm.itch.io/)
 *
 * @param nameForIntiface
 * @type text
 * @desc The application name to show in the intiface server GUI. (Defaults to game title.)
 * @default
 *
 * @param stopOnTitle
 * @type boolean
 * @desc Stop all action when entering the title screen.
 * @default true
 * @on Stop
 * @off Ignore
 *
 * @param stopOnGameover
 * @type boolean
 * @desc Stop all action on game over.
 * @default false
 * @on Stop
 * @off Ignore
 *
 * @help
 *
 * Read https://buttplug-developer-guide.docs.buttplug.io/intro/buttplug-ethics.html
 * before starting and always respect the player's maximum intensity settings.
 *
 * The https://desktop.intiface.com/desktop/ server must be running on port 12345 to connect.
 *
 * Plugin Command:
 *      PlugPlug connect # Connect to all available devices (and pulse each briefly to confirm).
 *      PlugPlug disconnect # Stop all devices and disconnect from Intiface.
 *
 *      PlugPlug config vibe strength <multiplier> # Set global vibe strength multiplier. 0.0 - 1.0. Default: 1.0
 *      PlugPlug config vibe duration <multiplier> # Set global vibe duration multiplier: 0.0 - infinity. Default: 1.0
 *      PlugPlug config vibe pause <multiplier> # Set global vibe pause duration multiplier: 0.0 - infinity. Default: 1.0
 *      # Configuration works offline but is not saved automatically across game restarts.
 *      # It and the device connections should persist across game-overs though.
 *
 *      PlugPlug vibe pulse <on-duration> <pattern> # Vibrate once.
 *      PlugPlug vibe start <on-duration> <off-duration> <pattern> # Set a vibration pattern to repeat until told otherwise.
 *      PlugPlug vibe stop  # Stop vibration.
 *      PlugPlug stop # Stop all motion.
 *
 * * There is currently no difference between `PlugPlug vibe stop` and `PlugPlug stop`,
 *   but this may change with updates (if I get my hands on test hardware).
 *
 * Vibration commands are applied only to currently connected devices.
 *
 * About Parameters:
 *      <multiplier>    # A decimal number, scalar and unit-less.
 *      <on-duration>   # The playback duration of the pattern, in seconds.
 *      <off-duration>  # The pause between patterns.
 *
 *      <pattern>   # A pattern definition.
 *
 *      There are two possible types of patterns:
 *      - Constant strength (decimal numbers from 0 to 1), for example 0.9
 *      - More complex patterns defined using the letters A through Z (case-insensitive)
 *        as well as the . character. 'A' will use the lowest possible non-zero intensity,
 *        'Z' will use the maximum intensity (1.0) and '.' turns off vibration.
 *
 *      A single pattern is applied to all vibration features at once.
 *      If you pass multiple patterns, they are assigned in order to the first features, as available, and run in parallel.
 *
 * Examples:
 *      - PlugPlug vibe start 0.5 # Constant half intensity.
 *      - PlugPlug vibe pulse 1 0.8 0 # 1-second pulse with constant 80% intensity, but using the first motor only.
 *      - PlugPlug vibe start 2.5 0 ZZZ...ZZ....ZZZ... # Strong pulsed vibration.
 *      - PlugPlug vibe start 5 0 Vbvbvbvbvb..  # Alternating strong and weak vibration, with a pause at the end.
 *      - PlugPlug vibe pulse 10 mm...mm # Slow medium strength vibration, with a pause.
 *      - PlugPlug vibe start 1 2 rrrrrrrr # Relatively strong vibration, but it is not recommended to unnecessarily repeat the pattern.
 *      - PlugPlug vibe start 3.0 0 X.X.X.X.X. Y.Y.Y.Y.   # Two patterns in parallel, with mismatched timings. (The total duration will be the same.)
 *      - PlugPlug vibe pulse 0.5 0 0 0 abfjmpsvyz # A curved ramp pattern, but only on the third vibration feature, and only if such a feature is available at all.
 *
 *      The durations likely won't be entirely accurate and timings may drift a bit,
 *      as I tried to make sure the game keeps running no matter what.
 *
 * JS API:
 *
 *      All commands are available through the global `PlugPlug` object.
 *      Just concatenate the command identifiers and pass all parameters in parentheses. For example:
 *      - `PlugPlug.config.vibe.strength(0.8)`  # Set global vibe strength multiplier.
 *      - `PlugPlug.connect()`  # Connect to all devices.
 *      - `PlugPlug.vibe.start(3, 2, 0.2, 'Vb.vb.vb.vb.vb.')`   # Play back the patterns 0.2 and `Vb.vb.vb.vb.vb.` on the first two motors stretched over three seconds, then pause for two seconds.
 *
 *      Device control commands return `true` if they found a matching device.
 *      This means you can use the `stop` commands to probe for device connections,
 *      and fall back to alternative action when using the JS API.
 *
 *      If PlugPlug isn't ready yet, you will instead see `undefined` returned from those functions.
--------------------------------------------------------------------------------
 # TERMS OF USE

 Read https://buttplug-developer-guide.docs.buttplug.io/intro/buttplug-ethics.html
 and always respect the player's maximum intensity settings.

 License for this file:

    The MIT License (MIT)

        Copyright © 2022 Qazm (https://qazm.itch.io/)

        Permission is hereby granted, free of charge, to any person obtaining a copy
        of this software and associated documentation files (the “Software”), to deal
        in the Software without restriction, including without limitation the rights
        to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
        copies of the Software, and to permit persons to whom the Software is
        furnished to do so, subject to the following conditions:

        The above copyright notice and this permission notice shall be included in
        all copies or substantial portions of the Software.

        THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
        IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
        FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
        AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
        LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
        OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
        THE SOFTWARE.

 Included dependencies are provided under their own respective licenses.
 See buttplug.min.js for more information.

 -------------------------------------------------------------------------------
 # INFORMATION

 Have fun ;-)
 Also remember to play safely and stay hydrated. I tried to make this plug₂in rock hard
 to misuse, but there's a limit to what I can do from here.

 Criticism and suggestions welcome, without guarantee I'll implement them here.
*/

(function () {
'use strict';

/**
 * The Core file is responsible for fetching and parsing of all plugin
 * parameters, note-tags and other important information. The core should
 * contain all global variables and base configuration related to the plugin.
 *
 * @file Core
 *
 * @author       Qazm
 * @copyright    2022 Qazm
 */

/**
 * The main file is responsible for importing the modules that make your plugin
 * run.
 *
 * @file main
 *
 * @author       Qazm
 * @copyright    2022 Qazm
 */

(function () {
    // Horrible hack. This makes https://github.com/rust-random/getrandom/blob/157d6f23dc53ea995294646c4f6301116aaa2bfa/src/js.rs
    // think Node isn't available, so it falls back to the browser API.
    try {
        delete process.versions.node;
    }
    catch (e) {
        // Nothing.
    }

    let buttplugScript = document.createElement('script');
    buttplugScript.setAttribute('type', 'text/javascript');
    buttplugScript.setAttribute('src', 'js/plugins/buttplug.min.js');
    buttplugScript.onload = initializePlugPlug;
    document.head.appendChild(buttplugScript);

    let client = null;

    function initializePlugPlug() {
        Buttplug.buttplugInit().then(() => {

            let parameters = PluginManager.parameters('Plug2in');

            let oldTitleStart = Scene_Title.prototype.start;
            Scene_Title.prototype.start = function (...params) {
                if (parameters.stopOnTitle == 'true') {
                    PlugPlug.stop();
                }
                oldTitleStart.call(this, ...params);
            };

            let oldGameoverStart = Scene_Gameover.prototype.start;
            Scene_Gameover.prototype.start = function (...params) {
                if (parameters.stopOnGameover == 'true') {
                    PlugPlug.stop();
                }
                oldGameoverStart.call(this, ...params);
            };

            let title = 'RPG Maker Game with Plug₂in';
            if ($dataSystem && $dataSystem.gameTitle) {
                title = $dataSystem.gameTitle;
            }
            if (parameters.nameForIntiface) {
                title = parameters.nameForIntiface;
            }

            client = new Buttplug.ButtplugClient(title);
            client.addListener('deviceadded', (device) => {
                console.info("PlugPlug: device connected: " + device);

                vibingPromise = new Promise(resolve => vibingPromise.finally(() =>
                    device.vibrate(0.5).catch(reason => console.warn("PlugPlug failed to start device connect pulse: " + reason)).finally(() => {
                        setTimeout(() => device.stop().catch(reason => console.error("PlugPlug failed to stop device connect pulse: " + reason)).finally(resolve), 300);
                    })
                ));
            });

            console.log("PlugPlug Ready!");
        });
    }

    let scanningTimeout = null;
    let vibingTimeouts = [];
    let vibingPromise = Promise.resolve();
    let passiveVibeActive = false;
    let passiveVibeOnDuration = 0;
    let passiveVibeOffDuration = null;
    let passiveVibePatterns = 0;

    let config = {
        vibe: {
            strength: 1,
            duration: 1,
            pause: 1,
        }
    };

    window.PlugPlug = {
        connect() {
            if (!client) return;
            const connector = new Buttplug.ButtplugWebsocketConnectorOptions();
            client.connect(connector).catch(reason => console.error("PlugPlug failed to connect: " + reason)).then(() => {
                client.startScanning().catch(reason => console.error("PlugPlug failed to start scanning: " + reason)).then(() => {

                    scanningTimeout = setTimeout(() => {
                        scanningTimeout = null;
                        client.stopScanning().catch(reason => console.error("PlugPlug failed to stop scanning: " + reason));
                    }, 60000);
                });
            });
        },
        disconnect() {
            if (!client) return;
            if (!client.Connected) return;
            if (scanningTimeout !== null) {
                clearTimeout(scanningTimeout);
                scanningTimeout = null;
            }
            vibingPromise = Promise.all([vibingPromise, client.isScanning && client.stopScanning().catch(reason => console.error("PlugPlug failed to stop scanning: " + reason)) || Promise.resolve()]).catch(reason => console.error('PlugPlug: ignored error: ' + reason)).then(() => {
                return client.stopAllDevices().catch(reason => console.error("PlugPlug failed to stop all devices:" + reason)).then(() => {
                    client.disconnect().catch(reason => console.error("PlugPlug failed to disconnect: " + reason));
                });
            });
        },

        config: {
            vibe: {
                strength(ratio) {
                    if (typeof ratio !== 'number') {
                        console.error('PlugPlug vibe strength ratio must be a number but was: ' + ratio + ', a ' + typeof ratio + '. Ignoring.');
                        return;
                    }
                    if (ratio < 0) {
                        console.warn("PlugPlug vibe strength ratio was <0, clamping.");
                        ratio = 0;
                    } else if (ratio > 1) {
                        console.warn("PlugPlug vibe strength ratio was >1, clamping.");
                        ratio = 1;
                    } else if (!isFinite(ratio)) {
                        console.error('PlugPlug vibe strength ratio was not finite after clamping (likely NaN). Ignoring.');
                        return;
                    }
                    config.vibe.strength = ratio;
                },
                duration(multiplier) {
                    if (typeof multiplier !== 'number') {
                        console.error('PlugPlug vibe duration multiplier must be a number but was: ' + multiplier + ', a ' + typeof multiplier + '. Ignoring.');
                        return;
                    }
                    if (multiplier < 0) {
                        console.warn("PlugPlug vibe duration multiplier was <0, clamping.");
                        multiplier = 0;
                    } else if (!(multiplier >= 0)) {
                        console.error('PlugPlug vibe duration multiplier was not zero or positive after clamping (likely NaN). Ignoring.');
                        return;
                    }
                    config.vibe.duration = multiplier;
                },
                pause(multiplier) {
                    if (typeof multiplier !== 'number') {
                        console.error('PlugPlug vibe pause multiplier must be a number but was: ' + multiplier + ', a ' + typeof multiplier + '. Ignoring.');
                        return;
                    }
                    if (multiplier < 0) {
                        console.warn("PlugPlug vibe pause multiplier was <0, clamping.");
                        multiplier = 0;
                    } else if (!(multiplier >= 0)) {
                        console.error('PlugPlug vibe pause multiplier was not zero or positive after clamping (likely NaN). Ignoring.');
                        return;
                    }
                    config.vibe.pause = multiplier;
                }
            }
        },

        vibe: {
            pulse(onDuration, ...patterns) {
                return vibe_patterns(onDuration, null, ...patterns);
            },
            start(onDuration, offDuration, ...patterns) {
                return vibe_patterns(onDuration, offDuration, ...patterns);
            },
            startPassive(onDuration, offDuration, ...patterns) {
                passiveVibeOnDuration = onDuration;
                passiveVibeOffDuration = offDuration;
                passiveVibePatterns = [...patterns];
                if (passiveVibePatterns.length === 1) {
                    passiveVibePatterns = passiveVibePatterns[0];
                } else if (passiveVibePatterns.length === 0) {
                    passiveVibePatterns = 0;
                }
                if (!passiveVibeActive) {
                    passiveVibeActive = true;
                    return vibe_patterns(passiveVibeOnDuration, passiveVibeOffDuration, passiveVibePatterns);
                }
                return 1;
            },
            stopPassive() {
                if (passiveVibeActive) {
                    passiveVibeActive = false;
                    passiveVibeOnDuration = 0;
                    passiveVibeOffDuration = null;
                    passiveVibePatterns = 0;
                    return vibe_patterns(0, null, 0);
                }
                return 1;
            },
            stop() {
                return vibe_patterns(0, null, 0);
            }
        },

        stop() {
            if (!client) return;
            clearTimeouts(vibingTimeouts);
            vibingPromise = vibingPromise.then(() =>
                client.stopAllDevices().catch(reason => console.error("PlugPlug failed to stop all devices:" + reason)));
            try {
                return client.Devices.length > 0;
            } catch (e) {
                // Not connected.
                console.warn('PlugPlug: stopping cancelled due to error: ' + e);
                return false;
            }
        }
    };

    function vibe_patterns(onDuration, offDuration, ...patterns) {
        if (!client) return;
        clearTimeouts(vibingTimeouts);

        patterns = normalizePatterns(patterns);

        return runPattern();

        function runPattern() {
            let effectiveOnDuration = onDuration * config.vibe.duration;
            if (onDuration == 0 || patterns.length == 0) {
                return stop()
            }

            let effectiveOffDuration = offDuration;
            if (offDuration !== null) {
                effectiveOffDuration *= config.vibe.pause;
            }


            let currentIntensities = [];
            let commonLength = patterns[0].length || 1;
            for (let pattern of patterns) {
                currentIntensities.push(0);
                if (commonLength !== (pattern.length || 1)) {
                    commonLength = null;
                }
            }

            let indices;
            if (commonLength !== null) {
                indices = [null];
            } else {
                indices = [];
                for (let pattern of patterns) {
                    indices.push(indices.length);
                }
            }

            let joinCounter = indices.length;

            let effective = false;
            for (let index of indices) {
                let all = index === null;
                index = index || 0;
                let onTimeout = effectiveOnDuration * 1000 / patterns[index].length;
                let n = 0;


                let vib = () => {
                    if (n < patterns[index].length) {
                        currentIntensities[index] = patterns[index][n] * config.vibe.strength;
                        if (currentIntensities[index] < 0) {
                            console.warn('PlugPlug: clamping current intensity from ' + currentIntensities[index] + ' to 0.');
                            currentIntensities[index] = 0;
                        } else if (currentIntensities[index] > 1) {
                            console.warn('PlugPlug: clamping current intensity from ' + currentIntensities[index] + ' to 1.');
                            currentIntensities[index] = 1;
                        } else if (!isFinite(currentIntensities[index])) {
                            console.error('PlugPlug: current intensity ' + currentIntensities[index] + ' not finite. Setting to 0.');
                            currentIntensities[index] = 0;
                        }
                        let effective = false;

                        let batchPromises = [vibingPromise];
                        try {
                            for (let device of client.Devices) {
                                if (device.AllowedMessages.contains(Buttplug.ButtplugDeviceMessageType.VibrateCmd)) {
                                    effective = true;
                                    if (all) {
                                        batchPromises.push(vibingPromise.finally(() =>
                                            device.vibrate(currentIntensities[index]).catch(reason => "PlugPlug failed to vibrate (all): " + reason)));
                                    } else {
                                        let attributes = device.messageAttributes(Buttplug.ButtplugDeviceMessageType.VibrateCmd);
                                        let featureCount = attributes && attributes.featureCount || 0;
                                        if (featureCount == 0) {
                                            if (index > 0) {
                                                continue;
                                            } else {
                                                batchPromises.push(vibingPromise.finally(() =>
                                                    device.vibrate(currentIntensities[index]).catch(reason => "PlugPlug failed to vibrate (without feature count): " + reason)));
                                            }
                                        } else {
                                            let deviceIntensities = new Array(...currentIntensities);
                                            while (deviceIntensities.length < featureCount) {
                                                deviceIntensities.push(0);
                                            }
                                            while (deviceIntensities.length > featureCount) {
                                                deviceIntensities.pop();
                                            }
                                            batchPromises.push(vibingPromise.finally(() =>
                                                device.vibrate(deviceIntensities).catch(reason => "PlugPlug failed to vibrate (with device intensities): " + reason)));
                                        }
                                    }
                                }
                            }
                        } catch (e) {
                            // Not connected.
                            console.warn('PlugPlug: vibration cancelled due to error: ' + e);
                            return;
                        }
                        vibingPromise = Promise.all(batchPromises).catch(reason => console.error('PlugPlug: ignored error: ' + reason));

                        n++;

                        let timeout;
                        timeout = setTimeout(() => {
                            vibingTimeouts.splice(vibingTimeouts.indexOf(timeout), 1);
                            vib();
                        }, onTimeout);
                        vibingTimeouts.push(timeout);

                        return effective;
                    } else {
                        joinCounter--;
                        if (joinCounter == 0) {
                            if (effectiveOffDuration === null || effectiveOffDuration > 0) {
                                if (passiveVibeActive) {
                                    vibe_patterns(passiveVibeOnDuration, passiveVibeOffDuration, passiveVibePatterns);
                                } else {
                                    stop();
                                }
                            }
                            if (effectiveOffDuration !== null) {
                                if (passiveVibeActive) {
                                    vibe_patterns(passiveVibeOnDuration, passiveVibeOffDuration, passiveVibePatterns);
                                } else {
                                    let timeout;
                                    timeout = setTimeout(() => {
                                        vibingTimeouts.splice(vibingTimeouts.indexOf(timeout), 1);
                                        runPattern();
                                    }, effectiveOffDuration * 1000);
                                    vibingTimeouts.push(timeout);
                                }
                            }
                        }
                    }
                    return effective;
                };
                effective |= vib();
            }
            return effective;
        }

        function stop() {
            let effective = false;

            let stopPromises = [vibingPromise];
            try {
                for (let device of client.Devices) {
                    if (device.AllowedMessages.contains(Buttplug.ButtplugDeviceMessageType.VibrateCmd)) {
                        effective = true;
                        stopPromises.push(vibingPromise.finally(() =>
                            device.vibrate(0).catch(reason => "PlugPlug failed to stop vibration: " + reason)));
                    }
                }
                vibingPromise = Promise.all(stopPromises).catch(reason => console.error('PlugPlug: ignored error: ' + reason));
                return effective;
            } catch (e) {
                // Not connected.
                console.warn('PlugPlug: stopping cancelled due to error: ' + e);
                return false;
            }
        }
    }

    function normalizePatterns(patterns) {
        let result = [];
        for (let pattern of patterns) {
            if (typeof pattern === 'number') {
                if (pattern < 0) {
                    console.warn("PlugPlug vibe strength pattern was <0, clamping.");
                    pattern = 0;
                } else if (pattern > 1) {
                    console.warn("PlugPlug vibe strength pattern was >1, clamping.");
                    pattern = 1;
                } else if (!isFinite(pattern)) {
                    console.error('PlugPlug vibe strength pattern was not finite after clamping (likely NaN). Defaulting to 0.');
                    pattern = 0;
                }
                result.push([pattern]);
            } else {
                let p = [];
                for (let c of pattern) {
                    if (c == '.') {
                        p.push(0);
                        continue;
                    }

                    let char = c;
                    c = c.codePointAt(0);

                    if (c >= 'A'.codePointAt(0) && c <= 'Z'.codePointAt(0)) {
                        c -= 'A'.codePointAt(0);
                    } else if (c >= 'a'.codePointAt(0) && c <= 'z'.codePointAt(0)) {
                        c -= 'a'.codePointAt(0);
                    } else {
                        throw new Error("Unrecognised character in vibration pattern: " + char);
                    }

                    c++;
                    c /= 27;
                    p.push(c);
                }
                result.push(p);
            }
        }
        return result;
    }

    function clearTimeouts(timeouts) {
        for (let timeout of timeouts) {
            clearTimeout(timeout);
        }
        timeouts.length = 0;
    }

    let oldPluginCommand = Game_Interpreter.prototype.pluginCommand;
    Game_Interpreter.prototype.pluginCommand = function (command, args, ...params) {
        if (command == 'PlugPlug') {
            let endpoint = PlugPlug;
            while (args.length > 0 && args[0].charAt(0) >= 'a' && args[0].charAt(0) <= 'z') {
                endpoint = endpoint[args.splice(0, 1)[0]];
            }
            let endpointArgs = [];
            while (args.length > 0) {
                let arg = args.splice(0, 1)[0];
                if (isFinite(+arg)) {
                    endpointArgs.push(+arg);
                } else {
                    endpointArgs.push(arg);
                }
            }
            endpoint(...endpointArgs);
        }

        oldPluginCommand.call(this, command, args, ...params);
    };
})();

}());