UNPKG

@bitsy/hecks

Version:

a collection of re-usable scripts for bitsy game maker

648 lines (559 loc) • 18.3 kB
/** šŸŽ® @file gamepad input @summary HTML5 gamepad support @license MIT @version 2.1.2 @requires Bitsy Version: 5.1 @author Sean S. LeBlanc @description Adds support for gamepad input. Directional input is mapped to the left and right analog sticks, the dpad, and the face buttons (e.g. ABXY). The same hold-to-move logic used for keyboard input is shared with the gamepad input. HOW TO USE: Copy-paste this script into a script tag after the bitsy source */ this.hacks = this.hacks || {}; (function (bitsy) { 'use strict'; bitsy = bitsy && bitsy.hasOwnProperty('default') ? bitsy['default'] : bitsy; const nullGamepad = { connected: false, disabled: true, down: [], justDown: [], justUp: [], axes: [], axesPrev: [], buttons: [] }; const gamepads = { // XBOX360 wired controller configuration // buttons A: 0, X: 2, B: 1, Y: 3, LB: 4, RB: 5, LT: 6, RT: 7, BACK: 8, START: 9, LHAT: 10, RHAT: 11, DPAD_UP: 12, DPAD_DOWN: 13, DPAD_LEFT: 14, DPAD_RIGHT: 15, // axes LSTICK_H: 0, LSTICK_V: 1, RSTICK_H: 2, RSTICK_V: 3, // settings /** if `abs(an axis value)` is < `deadZone`, returns 0 instead */ deadZone: 0.25, // /** if `abs(1-an axis value)` is < `snapZone`, returns 1 instead */ snapZone: 0.25, /** axis values between `deadZone` and `snapZone` will be run through this function * * defaults to normalizing between the two thresholds */ interpolate: function (value) { const v = Math.max(0, Math.min(1, (value - this.deadZone) / (1.0 - this.snapZone - this.deadZone))); return v; }, // internal vars players: [], available: false, pollEveryFrame: false, connected: false, /** * initialize gamepads * (only call once per session) */ init: function () { if (navigator.getGamepads) { this.available = true; } else if (navigator.webkitGetGamepads) { navigator.getGamepads = navigator.webkitGetGamepads; this.available = true; } if (this.available) { console.log("Gamepad API available"); if (navigator.userAgent.includes('Firefox')) { // listen to connection events for firefox window.addEventListener("gamepadconnected", this.pollconnections.bind(this)); window.addEventListener("gamepaddisconnected", this.pollconnections.bind(this)); } else { this.pollEveryFrame = true; } } else { console.error("Gamepad API not available"); } }, /** * update gamepads (clears arrays, polls connections, etc.) */ pollconnections: function () { this.connected = false; // assume existing players' gamepads aren't enabled until they're found for (let i = 0; i < this.players.length; ++i) { if (this.players[i]) { this.players[i].disabled = true; } } const gps = navigator.getGamepads(); for (let i = 0; i < gps.length; ++i) { const gp = gps[i]; if (gp) { if (gp.connected) { if (this.players[gp.index] == null) { // new player gp.down = []; gp.justDown = []; gp.justUp = []; gp.axesPrev = []; this.players[gp.index] = gp; } else { // returning player, copy old button states before replacing gp.down = this.players[gp.index].down; gp.justDown = this.players[gp.index].justDown; gp.justUp = this.players[gp.index].justUp; gp.axesPrev = this.players[gp.index].axesPrev; this.players[gp.index] = gp; } this.connected = true; this.players[gp.index].disabled = false; } else { this.players[gp.index] = null; } } } }, /** * update gamepads (clears arrays, polls connections, etc.) */ update: function () { // store the previous axis values // has to be done before pollConnections since that will get the new axis values for (let i = 0; i < this.players.length; ++i) { const p = this.getPlayer(i); p.axesPrev = p.axes.slice(); } // poll connections and update gamepad states every frame because chrome's a lazy bum if (this.pollEveryFrame) { this.pollconnections(); } for (let i = 0; i < this.players.length; ++i) { const p = this.getPlayer(i); if (p && p != null) { for (let j = 0; j < p.buttons.length; ++j) { if (p.buttons[j].pressed) { p.justDown[j] = !(p.down[j] === true); p.down[j] = true; p.justUp[j] = false; } else { p.justUp[j] = p.down[j] === true; p.down[j] = false; p.justDown[j] = false; } } } } }, /** * @returns `player`'s gamepad * * if one doesn't exist, returns an object with gamepad properties reflecting a null state */ getPlayer: function (player) { if (this.players[player] && this.players[player].connected && !this.players[player].disabled) { return this.players[player]; } else { return nullGamepad; } }, /** * @returns an array representing `length` axes for `player` at `offset` * * if `abs(an axis value)` is < `deadZone`, returns 0 instead * if `abs(1-an axis value)` is < `snapZone`, returns 1/-1 instead * otherwise, returns the axis value normalized between `deadZone` and `(1-snapZone)` * @param {Number} offset axis index * @param {Number} length number of axes to return * @param {Number} player player index (`undefined` for "sum of all") * @param {boolean} prev if `true` uses axis values from previous update */ getAxes: function (offset = 0, length = 2, player, prev = false) { const axes = []; for (let i = 0; i < length; ++i) { axes[i] = 0; } if (player === undefined) { for (let i = 0; i < this.players.length; ++i) { const a = this.getAxes(offset, length, i, prev); for (let j = 0; j < a.length; ++j) { axes[j] += a[j]; } } } else { const p = this.getPlayer(player); let a = prev ? p.axesPrev : p.axes; a = a.slice(offset, offset + length); for (let i = 0; i < a.length; ++i) { if (Math.abs(a[i]) < this.deadZone) { axes[i] += 0; } else if (Math.abs(1.0 - a[i]) < this.snapZone) { axes[i] += 1; } else if (Math.abs(-1.0 - a[i]) < this.snapZone) { axes[i] -= 1; } else { axes[i] += Math.sign(a[i]) * this.interpolate(Math.abs(a[i])); } } } return axes; }, /** * @returns equivalent to `getAxes(axis, 1, player, prev)[0]` */ getAxis: function (axis, player, prev) { return this.getAxes(axis, 1, player, prev)[0]; }, /** * @returns `true` if `axis` is past `threshold` in `direction` * @param {Number} axis axis index * @param {Number} threshold threshold (-1 to 1) * @param {Number} direction direction (-1|1) (if `undefined`, assumes the sign of `theshold` is the direction (e.g. if `theshold` is -0.5, it will check if the axis is < -0.5)) * @param {Number} player player index (`undefined` for "any") * @param {boolean} prev if `true` uses axis values from previous update */ axisPast: function (axis, threshold, direction, player, prev) { if (!threshold) { throw new Error('must specify a non-zero threshold'); } if (!direction) { direction = Math.sign(threshold); } const a = this.getAxis(axis, player, prev); return direction < 0 ? a < threshold : a > threshold; }, /** * @returns `true` if `axis` is past `threshold` in `direction` and WAS NOT in previous update * @param {Number} axis axis index * @param {Number} threshold threshold (-1 to 1) * @param {Number} direction direction (-1|1) (if `undefined`, assumes the sign of `theshold` is the direction (e.g. if `theshold` is -0.5, it will check if the axis is < -0.5)) * @param {Number} player player index (`undefined` for "any") */ axisJustPast: function (axis, threshold, direction, player) { return this.axisPast(axis, threshold, direction, player, false) && !this.axisPast(axis, threshold, direction, player, true); }, /** * @returns `[x,y]` representing the dpad for `player` * @param {Number} player player index (`undefined` for "sum of all") */ getDpad: function (player) { let x = 0; let y = 0; if (player === undefined) { for (let i = 0; i < this.players.length; ++i) { const [ix, iy] = this.getDpad(i); x += ix; y += iy; } } else { if (this.isDown(this.DPAD_RIGHT, player)) { x += 1; } if (this.isDown(this.DPAD_LEFT, player)) { x -= 1; } if (this.isDown(this.DPAD_UP, player)) { y += 1; } if (this.isDown(this.DPAD_DOWN, player)) { y -= 1; } } return [x, y]; }, /** * @returns `true` if `player`'s `btn` is currently down * @param {Number} btn button index * @param {Number} player player index (`undefined` for "any") */ isDown: function (btn, player) { if (btn === undefined) { throw new Error('must specify a button'); } if (player === undefined) { for (let i = 0; i < this.players.length; ++i) { if (this.isDown(btn, i)) { return true; } } return false; } else { return this.getPlayer(player).down[btn] === true; } }, /** * @returns equivalent to `!isDown(btn, player)` * @param {Number} btn button index * @param {Number} player player index (`undefined` for "any") */ isUp: function (btn, player) { return !this.isDown(btn, player); }, /** * @returns `true` if `player`'s `btn` is currently down and WAS NOT in previous update * @param {Number} btn button index * @param {Number} player player index (`undefined` for "any") */ isJustDown: function (btn, player) { if (btn === undefined) { throw new Error('must specify a button'); } if (player === undefined) { for (var i = 0; i < this.players.length; ++i) { if (this.isJustDown(btn, i)) { return true; } } return false; } else { return this.getPlayer(player).justDown[btn] === true; } }, /** * @returns `true` if `player`'s `btn` is currently NOT down and WAS down in previous update * @param {Number} btn button index * @param {Number} player player index (`undefined` for "any") */ isJustUp: function (btn, player) { if (btn === undefined) { throw new Error('must specify a button'); } if (player === undefined) { for (let i = 0; i < this.players.length; ++i) { if (this.isJustUp(btn, i)) { return true; } } return false; } return this.getPlayer(player).justUp[btn] === true; } }; /** @file utils @summary miscellaneous bitsy utilities @author Sean S. LeBlanc */ /* Helper used to replace code in a script tag based on a search regex To inject code without erasing original string, using capturing groups; e.g. inject(/(some string)/,'injected before $1 injected after') */ function inject(searchRegex, replaceString) { // find the relevant script tag var scriptTags = document.getElementsByTagName('script'); var scriptTag; var code; for (var i = 0; i < scriptTags.length; ++i) { scriptTag = scriptTags[i]; var matchesSearch = scriptTag.textContent.search(searchRegex) !== -1; var isCurrentScript = scriptTag === document.currentScript; if (matchesSearch && !isCurrentScript) { code = scriptTag.textContent; break; } } // error-handling if (!code) { throw 'Couldn\'t find "' + searchRegex + '" in script tags'; } // modify the content code = code.replace(searchRegex, replaceString); // replace the old script tag with a new one using our modified code var newScriptTag = document.createElement('script'); newScriptTag.textContent = code; scriptTag.insertAdjacentElement('afterend', newScriptTag); scriptTag.remove(); } /** * Helper for getting an array with unique elements * @param {Array} array Original array * @return {Array} Copy of array, excluding duplicates */ function unique(array) { return array.filter(function (item, idx) { return array.indexOf(item) === idx; }); } /** @file kitsy-script-toolkit @summary makes it easier and cleaner to run code before and after Bitsy functions or to inject new code into Bitsy script tags @license WTFPL (do WTF you want) @version 4.0.0 @requires Bitsy Version: 4.5, 4.6 @author @mildmojo @description HOW TO USE: import {before, after, inject, addDialogTag, addDeferredDialogTag} from "./helpers/kitsy-script-toolkit"; before(targetFuncName, beforeFn); after(targetFuncName, afterFn); inject(searchRegex, replaceString); addDialogTag(tagName, dialogFn); addDeferredDialogTag(tagName, dialogFn); For more info, see the documentation at: https://github.com/seleb/bitsy-hacks/wiki/Coding-with-kitsy */ // Ex: before('load_game', function run() { alert('Loading!'); }); // before('show_text', function run(text) { return text.toUpperCase(); }); // before('show_text', function run(text, done) { done(text.toUpperCase()); }); function before(targetFuncName, beforeFn) { var kitsy = kitsyInit(); kitsy.queuedBeforeScripts[targetFuncName] = kitsy.queuedBeforeScripts[targetFuncName] || []; kitsy.queuedBeforeScripts[targetFuncName].push(beforeFn); } // Ex: after('load_game', function run() { alert('Loaded!'); }); function after(targetFuncName, afterFn) { var kitsy = kitsyInit(); kitsy.queuedAfterScripts[targetFuncName] = kitsy.queuedAfterScripts[targetFuncName] || []; kitsy.queuedAfterScripts[targetFuncName].push(afterFn); } function kitsyInit() { // return already-initialized kitsy if (bitsy.kitsy) { return bitsy.kitsy; } // Initialize kitsy bitsy.kitsy = { queuedInjectScripts: [], queuedBeforeScripts: {}, queuedAfterScripts: {} }; var oldStartFunc = bitsy.startExportedGame; bitsy.startExportedGame = function doAllInjections() { // Only do this once. bitsy.startExportedGame = oldStartFunc; // Rewrite scripts and hook everything up. doInjects(); applyAllHooks(); // Start the game bitsy.startExportedGame.apply(this, arguments); }; return bitsy.kitsy; } function doInjects() { bitsy.kitsy.queuedInjectScripts.forEach(function (injectScript) { inject(injectScript.searchRegex, injectScript.replaceString); }); _reinitEngine(); } function applyAllHooks() { var allHooks = unique(Object.keys(bitsy.kitsy.queuedBeforeScripts).concat(Object.keys(bitsy.kitsy.queuedAfterScripts))); allHooks.forEach(applyHook); } function applyHook(functionName) { var functionNameSegments = functionName.split('.'); var obj = bitsy; while (functionNameSegments.length > 1) { obj = obj[functionNameSegments.shift()]; } var lastSegment = functionNameSegments[0]; var superFn = obj[lastSegment]; var superFnLength = superFn ? superFn.length : 0; var functions = []; // start with befores functions = functions.concat(bitsy.kitsy.queuedBeforeScripts[functionName] || []); // then original if (superFn) { functions.push(superFn); } // then afters functions = functions.concat(bitsy.kitsy.queuedAfterScripts[functionName] || []); // overwrite original with one which will call each in order obj[lastSegment] = function () { var returnVal; var args; var i = 0; function runBefore() { // All outta functions? Finish if (i === functions.length) { return returnVal; } // Update args if provided. if (arguments.length > 0) { args = [].slice.call(arguments); } if (functions[i].length > superFnLength) { // Assume funcs that accept more args than the original are // async and accept a callback as an additional argument. return functions[i++].apply(this, args.concat(runBefore.bind(this))); } else { // run synchronously returnVal = functions[i++].apply(this, args); if (returnVal && returnVal.length) { args = returnVal; } return runBefore.apply(this, args); } } return runBefore.apply(this, arguments); }; } function _reinitEngine() { // recreate the script and dialog objects so that they'll be // referencing the code with injections instead of the original bitsy.scriptModule = new bitsy.Script(); bitsy.scriptInterpreter = bitsy.scriptModule.CreateInterpreter(); bitsy.dialogModule = new bitsy.Dialog(); bitsy.dialogRenderer = bitsy.dialogModule.CreateRenderer(); bitsy.dialogBuffer = bitsy.dialogModule.CreateBuffer(); } before('startExportedGame', gamepads.init.bind(gamepads)); var empty = function () {}; var move = function (dpad, face, axis, axis2, axispast, axisdir, key) { // keydown if ( gamepads.isJustDown(dpad) || gamepads.isJustDown(face) || gamepads.axisJustPast(axis, axispast, axisdir) || ( bitsy.playerHoldToMoveTimer <= 0 && ( gamepads.isDown(dpad) || gamepads.isDown(face) || gamepads.axisPast(axis, axispast, axisdir) ) ) ) { bitsy.curPlayerDirection = bitsy.Direction.None; bitsy.input.onkeydown({ keyCode: key, preventDefault: empty }); } // keyup if ( gamepads.isJustUp(dpad) || gamepads.isJustUp(face) || gamepads.axisJustPast(axis, axispast, -axisdir) ) { bitsy.input.onkeyup({ keyCode: key, preventDefault: empty }); } }; before('update', function(){ move(gamepads.DPAD_LEFT, gamepads.X, gamepads.LSTICK_H, gamepads.RSTICK_H, -0.5, -1, bitsy.key.left); move(gamepads.DPAD_RIGHT, gamepads.B, gamepads.LSTICK_H, gamepads.RSTICK_H, 0.5, 1, bitsy.key.right); move(gamepads.DPAD_UP, gamepads.Y, gamepads.LSTICK_V, gamepads.RSTICK_V, -0.5, -1, bitsy.key.up); move(gamepads.DPAD_DOWN, gamepads.A, gamepads.LSTICK_V, gamepads.RSTICK_V, 0.5, 1, bitsy.key.down); }); after('update', function(){ gamepads.update(); }); }(window));