You know what? Let's add addons
This commit is contained in:
13 files changed
+1029
-7
No files matched your search
+5
-2
@@ -93,6 +93,7 @@
|
||||
</div>
|
||||
|
||||
<script src="dist/index.js"></script>
|
||||
<script src="dist/addons.js"></script>
|
||||
<script>
|
||||
const appElement = document.getElementById('app');
|
||||
const launchScreen = document.getElementById('launch');
|
||||
@@ -104,7 +105,9 @@
|
||||
scaffolding.setup();
|
||||
scaffolding.appendTo(appElement);
|
||||
|
||||
const {storage} = scaffolding;
|
||||
ScaffoldingAddons.run(scaffolding);
|
||||
|
||||
const {storage, vm} = scaffolding;
|
||||
storage.addWebStore(
|
||||
[storage.AssetType.Project],
|
||||
(asset) => `https://projects.scratch.mit.edu/${asset.assetId}`
|
||||
@@ -122,7 +125,7 @@
|
||||
setProgress(0.1);
|
||||
|
||||
const getProjectJSON = async () => {
|
||||
const PROJECT_ID = '60917032';
|
||||
const PROJECT_ID = '413072152';
|
||||
const asset = await storage.load(Scaffolding.Storage.AssetType.Project, PROJECT_ID);
|
||||
return asset.data;
|
||||
};
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 139 B |
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "Gamepad support",
|
||||
"description": "Interact with projects using a USB or Bluetooth controller/gamepad.",
|
||||
"credits": [
|
||||
{
|
||||
"name": "GarboMuffin"
|
||||
}
|
||||
],
|
||||
"info": [
|
||||
{
|
||||
"type": "notice",
|
||||
"text": "This addon is highly experimental and subject to change. It can be configured with the gamepad button by the green flag.",
|
||||
"id": "info"
|
||||
}
|
||||
],
|
||||
"userscripts": [
|
||||
{
|
||||
"url": "userscript.js",
|
||||
"matches": ["https://scratch.mit.edu/projects/*"]
|
||||
}
|
||||
],
|
||||
"userstyles": [
|
||||
{
|
||||
"url": "style.css",
|
||||
"matches": ["https://scratch.mit.edu/projects/*"]
|
||||
},
|
||||
{
|
||||
"url": "gamepadlib.css",
|
||||
"matches": ["https://scratch.mit.edu/projects/*"]
|
||||
}
|
||||
],
|
||||
"settings": [
|
||||
{
|
||||
"name": "Hide settings button when no controllers detected",
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"id": "hide"
|
||||
}
|
||||
],
|
||||
"tags": ["editor", "projectPlayer", "recommended"],
|
||||
"enabledByDefault": false,
|
||||
"versionAdded": "1.14.0",
|
||||
"dynamicEnable": true,
|
||||
"dynamicDisable": true
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 7.48 7.48"><path d="M3.74 6.48V1M1 3.74h5.48" style="fill:none;stroke:#fff;stroke-linecap:round;stroke-linejoin:round;stroke-width:2px"/></svg>
|
||||
|
After Width: | Height: | Size: 217 B |
Binary file not shown.
|
After Width: | Height: | Size: 128 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8" viewBox="0 0 2.117 2.117"><circle cx="1.058" cy="1.058" r="1.058" fill="red"/></svg>
|
||||
|
After Width: | Height: | Size: 146 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="1em" height="1em" style="-ms-transform:rotate(360deg);-webkit-transform:rotate(360deg);transform:rotate(360deg)" viewBox="0 0 512 512"><path fill="#575e75" d="M368 168h-96v-64h176a24.027 24.027 0 0 0 24-24V16h-32v56H264a24.027 24.027 0 0 0-24 24v72h-96A128.145 128.145 0 0 0 16 296v100.953A91.15 91.15 0 0 0 107.047 488h1.8a90.807 90.807 0 0 0 69.953-32.76L231.5 392h48.628l52.666 68.465A91.046 91.046 0 0 0 496 404.953V296a128.145 128.145 0 0 0-128-128zm96 236.953a59.047 59.047 0 0 1-105.849 36L295.878 360h-79.372l-62.294 74.754A58.893 58.893 0 0 1 108.85 456h-1.8A59.113 59.113 0 0 1 48 396.953V296a96.108 96.108 0 0 1 96-96h224a96.108 96.108 0 0 1 96 96z"/><path fill="#575e75" d="M360 248h32v32h-32zm0 80h32v32h-32zm-40-40h32v32h-32zm80 0h32v32h-32zm-248-40h-32v40H80v32h40v40h32v-40h40v-32h-40v-40z"/></svg>
|
||||
|
After Width: | Height: | Size: 881 B |
@@ -0,0 +1,643 @@
|
||||
let console = window.console;
|
||||
|
||||
/*
|
||||
Mapping types:
|
||||
|
||||
type: "key" maps a button to a keyboard key
|
||||
All key names will be interpreted as a KeyboardEvent.key value (https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key/Key_Values)
|
||||
high: "KeyName" is the name of the key to dispatch when a button reads a HIGH value
|
||||
low: "KeyName" is the name of the key to dispatch when a button reads a LOW value
|
||||
deadZone: 0.5 controls the minimum value necessary to be read in either + or - to trigger either high or low
|
||||
The high/low distinction is necessary for axes. Buttons will only use high
|
||||
|
||||
type: "mousedown" maps a button to control whether the mouse is down or not
|
||||
deadZone: 0.5 controls the minimum value to trigger a mousedown
|
||||
|
||||
type: "virtual_cursor" maps a button to control the "virtual cursor"
|
||||
deadZone: 0.5 again controls the minimum value to trigger a movement
|
||||
sensitivity: 10 controls the speed
|
||||
high: "+y"/"-y"/"+x"/"-x" defines what happens when an axis reads high
|
||||
low: "+y"/"-y"/"+x"/"-x" defines what happens when an axis reads low
|
||||
+y increases y, -y decreases y, +x increases x, -x decreases x.
|
||||
*/
|
||||
|
||||
const defaultAxesMappings = {
|
||||
arrows: [
|
||||
{
|
||||
type: "key",
|
||||
high: "ArrowRight",
|
||||
low: "ArrowLeft",
|
||||
deadZone: 0.5,
|
||||
},
|
||||
{
|
||||
type: "key",
|
||||
high: "ArrowDown",
|
||||
low: "ArrowUp",
|
||||
deadZone: 0.5,
|
||||
},
|
||||
],
|
||||
wasd: [
|
||||
{
|
||||
type: "key",
|
||||
high: "d",
|
||||
low: "a",
|
||||
deadZone: 0.5,
|
||||
},
|
||||
{
|
||||
type: "key",
|
||||
high: "s",
|
||||
low: "w",
|
||||
deadZone: 0.5,
|
||||
},
|
||||
],
|
||||
cursor: [
|
||||
{
|
||||
type: "virtual_cursor",
|
||||
high: "+x",
|
||||
low: "-x",
|
||||
sensitivity: 0.6,
|
||||
deadZone: 0.2,
|
||||
},
|
||||
{
|
||||
type: "virtual_cursor",
|
||||
high: "-y",
|
||||
low: "+y",
|
||||
sensitivity: 0.6,
|
||||
deadZone: 0.2,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const emptyMapping = () => ({
|
||||
type: "key",
|
||||
high: null,
|
||||
low: null,
|
||||
});
|
||||
const transformAndCopyMapping = (mapping) => {
|
||||
if (typeof mapping !== "object" || !mapping) {
|
||||
console.warn("invalid mapping", mapping);
|
||||
return emptyMapping();
|
||||
}
|
||||
const copy = Object.assign({}, mapping);
|
||||
if (copy.type === "key") {
|
||||
if (typeof copy.deadZone === "undefined") {
|
||||
copy.deadZone = 0.5;
|
||||
}
|
||||
if (typeof copy.high === "undefined") {
|
||||
copy.high = "";
|
||||
}
|
||||
if (typeof copy.low === "undefined") {
|
||||
copy.low = "";
|
||||
}
|
||||
} else if (copy.type === "mousedown") {
|
||||
if (typeof copy.deadZone === "undefined") {
|
||||
copy.deadZone = 0.5;
|
||||
}
|
||||
} else if (copy.type === "virtual_cursor") {
|
||||
if (typeof copy.high === "undefined") {
|
||||
copy.high = "";
|
||||
}
|
||||
if (typeof copy.low === "undefined") {
|
||||
copy.low = "";
|
||||
}
|
||||
if (typeof copy.sensitivity === "undefined") {
|
||||
copy.sensitivity = 10;
|
||||
}
|
||||
if (typeof copy.deadZone === "undefined") {
|
||||
copy.deadZone = 0.5;
|
||||
}
|
||||
} else {
|
||||
console.warn("unknown mapping type", copy.type);
|
||||
return emptyMapping();
|
||||
}
|
||||
return copy;
|
||||
};
|
||||
const prepareMappingForExport = (mapping) => Object.assign({}, mapping);
|
||||
const prepareAxisMappingForExport = prepareMappingForExport;
|
||||
const prepareButtonMappingForExport = (mapping) => {
|
||||
const copy = prepareMappingForExport(mapping);
|
||||
delete copy.deadZone;
|
||||
delete copy.low;
|
||||
return copy;
|
||||
};
|
||||
|
||||
const padWithEmptyMappings = (array, length) => {
|
||||
// Keep adding empty mappings until the list is full
|
||||
while (array.length < length) {
|
||||
array.push(emptyMapping());
|
||||
}
|
||||
// In case the input array is longer than the desired length
|
||||
array.length = length;
|
||||
return array;
|
||||
};
|
||||
|
||||
const getMovementConfiguration = (usedKeys) => ({
|
||||
usesArrows:
|
||||
usedKeys.has("ArrowUp") || usedKeys.has("ArrowDown") || usedKeys.has("ArrowRight") || usedKeys.has("ArrowLeft"),
|
||||
usesWASD: (usedKeys.has("w") && usedKeys.has("s")) || (usedKeys.has("a") && usedKeys.has("d")),
|
||||
});
|
||||
|
||||
const getGamepadId = (gamepad) => `${gamepad.id} (${gamepad.index})`;
|
||||
|
||||
class GamepadData {
|
||||
/**
|
||||
* @param {Gamepad} gamepad Source Gamepad
|
||||
* @param {GamepadLib} gamepadLib Parent GamepadLib
|
||||
*/
|
||||
constructor(gamepad, gamepadLib) {
|
||||
this.gamepad = gamepad;
|
||||
this.gamepadLib = gamepadLib;
|
||||
this.buttonMappings = this.getDefaultButtonMappings().map(transformAndCopyMapping);
|
||||
this.axesMappings = this.getDefaultAxisMappings().map(transformAndCopyMapping);
|
||||
}
|
||||
|
||||
getDefaultButtonMappings() {
|
||||
let buttons;
|
||||
if (this.gamepadLib.hints.importedSettings) {
|
||||
buttons = this.gamepadLib.hints.importedSettings.buttons;
|
||||
} else {
|
||||
const usedKeys = this.gamepadLib.hints.usedKeys;
|
||||
const alreadyUsedKeys = new Set();
|
||||
const { usesArrows, usesWASD } = getMovementConfiguration(usedKeys);
|
||||
if (usesWASD) {
|
||||
alreadyUsedKeys.add("w");
|
||||
alreadyUsedKeys.add("a");
|
||||
alreadyUsedKeys.add("s");
|
||||
alreadyUsedKeys.add("d");
|
||||
}
|
||||
const possiblePauseKeys = [
|
||||
// Restart keys, pause keys, other potentially dangerous keys
|
||||
"p",
|
||||
"q",
|
||||
"r",
|
||||
];
|
||||
const possibleActionKeys = [
|
||||
" ",
|
||||
"Enter",
|
||||
"e",
|
||||
"f",
|
||||
"z",
|
||||
"x",
|
||||
"c",
|
||||
...Array.from(usedKeys).filter((i) => i.length === 1 && !possiblePauseKeys.includes(i)),
|
||||
];
|
||||
|
||||
const findKey = (keys) => {
|
||||
for (const key of keys) {
|
||||
if (usedKeys.has(key) && !alreadyUsedKeys.has(key)) {
|
||||
alreadyUsedKeys.add(key);
|
||||
return key;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const getPrimaryAction = () => {
|
||||
if (usesArrows && usedKeys.has("ArrowUp")) {
|
||||
return "ArrowUp";
|
||||
}
|
||||
if (usesWASD && usedKeys.has("w")) {
|
||||
return "w";
|
||||
}
|
||||
return findKey(possibleActionKeys);
|
||||
};
|
||||
const getSecondaryAction = () => findKey(possibleActionKeys);
|
||||
const getPauseKey = () => findKey(possiblePauseKeys);
|
||||
const getUp = () => {
|
||||
if (usesArrows || !usesWASD) return "ArrowUp";
|
||||
return "w";
|
||||
};
|
||||
const getDown = () => {
|
||||
if (usesArrows || !usesWASD) return "ArrowDown";
|
||||
return "s";
|
||||
};
|
||||
const getRight = () => {
|
||||
if (usesArrows || !usesWASD) return "ArrowRight";
|
||||
return "d";
|
||||
};
|
||||
const getLeft = () => {
|
||||
if (usesArrows || !usesWASD) return "ArrowLeft";
|
||||
return "a";
|
||||
};
|
||||
|
||||
const action1 = getPrimaryAction();
|
||||
let action2 = getSecondaryAction();
|
||||
let action3 = getSecondaryAction();
|
||||
let action4 = getSecondaryAction();
|
||||
// When only 1 or 2 action keys are detected, bind the other buttons to the same things.
|
||||
if (action1 && !action2 && !action3 && !action4) {
|
||||
action2 = action1;
|
||||
action3 = action1;
|
||||
action4 = action1;
|
||||
}
|
||||
if (action1 && action2 && !action3 && !action4) {
|
||||
action3 = action1;
|
||||
action4 = action2;
|
||||
}
|
||||
|
||||
// Set indices "manually" because we don't evaluate them in order.
|
||||
buttons = [];
|
||||
buttons[0] = {
|
||||
/*
|
||||
Xbox: A
|
||||
SNES-like: B
|
||||
*/
|
||||
type: "key",
|
||||
high: action1,
|
||||
};
|
||||
buttons[1] = {
|
||||
/*
|
||||
Xbox: B
|
||||
SNES-like: A
|
||||
*/
|
||||
type: "key",
|
||||
high: action2,
|
||||
};
|
||||
buttons[2] = {
|
||||
/*
|
||||
Xbox: X
|
||||
SNES-like: Y
|
||||
*/
|
||||
type: "key",
|
||||
high: action3,
|
||||
};
|
||||
buttons[3] = {
|
||||
/*
|
||||
Xbox: Y
|
||||
SNES-like: X
|
||||
*/
|
||||
type: "key",
|
||||
high: action4,
|
||||
};
|
||||
buttons[4] = {
|
||||
/*
|
||||
Xbox: LB
|
||||
SNES-like: Left trigger
|
||||
*/
|
||||
type: "mousedown",
|
||||
};
|
||||
buttons[5] = {
|
||||
/*
|
||||
Xbox: RB
|
||||
*/
|
||||
type: "mousedown",
|
||||
};
|
||||
buttons[6] = {
|
||||
/*
|
||||
Xbox: LT
|
||||
*/
|
||||
type: "mousedown",
|
||||
};
|
||||
buttons[7] = {
|
||||
/*
|
||||
Xbox: RT
|
||||
SNES-like: Right trigger
|
||||
*/
|
||||
type: "mousedown",
|
||||
};
|
||||
buttons[9] = {
|
||||
/*
|
||||
Xbox: Menu
|
||||
SNES-like: Start
|
||||
*/
|
||||
type: "key",
|
||||
high: getPauseKey(),
|
||||
};
|
||||
buttons[8] = {
|
||||
/*
|
||||
Xbox: Change view
|
||||
SNES-like: Select
|
||||
*/
|
||||
type: "key",
|
||||
high: getPauseKey(),
|
||||
};
|
||||
// Xbox: Left analog press
|
||||
buttons[10] = emptyMapping();
|
||||
// Xbox: Right analog press
|
||||
buttons[11] = emptyMapping();
|
||||
buttons[12] = {
|
||||
/*
|
||||
Xbox: D-pad up
|
||||
*/
|
||||
type: "key",
|
||||
high: getUp(),
|
||||
};
|
||||
buttons[13] = {
|
||||
/*
|
||||
Xbox: D-pad down
|
||||
*/
|
||||
type: "key",
|
||||
high: getDown(),
|
||||
};
|
||||
buttons[14] = {
|
||||
/*
|
||||
Xbox: D-pad left
|
||||
*/
|
||||
type: "key",
|
||||
high: getLeft(),
|
||||
};
|
||||
buttons[15] = {
|
||||
/*
|
||||
Xbox: D-pad right
|
||||
*/
|
||||
type: "key",
|
||||
high: getRight(),
|
||||
};
|
||||
}
|
||||
return padWithEmptyMappings(buttons, this.gamepad.buttons.length);
|
||||
}
|
||||
|
||||
getDefaultAxisMappings() {
|
||||
let axes = [];
|
||||
if (this.gamepadLib.hints.importedSettings) {
|
||||
axes = this.gamepadLib.hints.importedSettings.axes;
|
||||
} else {
|
||||
// Only return default axis mappings when there are 4 axes, like an xbox controller
|
||||
// If there isn't exactly 4, we can't really predict what the axes mean
|
||||
// Some controllers map the dpad to *both* buttons and axes at the same time, which would cause conflicts.
|
||||
if (this.gamepad.axes.length === 4) {
|
||||
const usedKeys = this.gamepadLib.hints.usedKeys;
|
||||
const { usesArrows, usesWASD } = getMovementConfiguration(usedKeys);
|
||||
if (usesWASD) {
|
||||
axes.push(defaultAxesMappings.wasd[0]);
|
||||
axes.push(defaultAxesMappings.wasd[1]);
|
||||
} else if (usesArrows) {
|
||||
axes.push(defaultAxesMappings.arrows[0]);
|
||||
axes.push(defaultAxesMappings.arrows[1]);
|
||||
} else {
|
||||
axes.push(defaultAxesMappings.cursor[0]);
|
||||
axes.push(defaultAxesMappings.cursor[1]);
|
||||
}
|
||||
axes.push(defaultAxesMappings.cursor[0]);
|
||||
axes.push(defaultAxesMappings.cursor[1]);
|
||||
}
|
||||
}
|
||||
return padWithEmptyMappings(axes, this.gamepad.axes.length);
|
||||
}
|
||||
}
|
||||
|
||||
class GamepadLib extends EventTarget {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
/** @type {Map<string, GamepadData>} */
|
||||
this.gamepads = new Map();
|
||||
|
||||
this.handleConnect = this.handleConnect.bind(this);
|
||||
this.handleDisconnect = this.handleDisconnect.bind(this);
|
||||
this.update = this.update.bind(this);
|
||||
|
||||
this.animationFrame = null;
|
||||
this.currentTime = null;
|
||||
this.deltaTime = 0;
|
||||
|
||||
this.virtualCursor = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
maxX: Infinity,
|
||||
minX: -Infinity,
|
||||
maxY: Infinity,
|
||||
minY: -Infinity,
|
||||
modified: false,
|
||||
};
|
||||
|
||||
this._editor = null;
|
||||
|
||||
this.connectCallbacks = [];
|
||||
|
||||
this.hints = {
|
||||
usedKeys: new Set(),
|
||||
importedSettings: null,
|
||||
generated: false,
|
||||
};
|
||||
|
||||
this.keysPressedThisFrame = new Set();
|
||||
this.oldKeysPressed = new Set();
|
||||
|
||||
this.mouseDownThisFrame = false;
|
||||
this.oldMouseDown = false;
|
||||
|
||||
this.addEventHandlers();
|
||||
}
|
||||
|
||||
addEventHandlers() {
|
||||
window.addEventListener("gamepadconnected", this.handleConnect);
|
||||
window.addEventListener("gamepaddisconnected", this.handleDisconnect);
|
||||
}
|
||||
|
||||
removeEventHandlers() {
|
||||
window.removeEventListener("gamepadconnected", this.handleConnect);
|
||||
window.removeEventListener("gamepaddisconnected", this.handleDisconnect);
|
||||
}
|
||||
|
||||
gamepadConnected() {
|
||||
if (this.gamepads.size > 0) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
this.connectCallbacks.push(resolve);
|
||||
});
|
||||
}
|
||||
|
||||
ensureHintsGenerated() {
|
||||
if (this.hints.generated) {
|
||||
return;
|
||||
}
|
||||
if (this.getHintsLazily) {
|
||||
Object.assign(this.hints, this.getHintsLazily());
|
||||
}
|
||||
this.hints.generated = true;
|
||||
}
|
||||
|
||||
handleConnect(e) {
|
||||
this.ensureHintsGenerated();
|
||||
for (const callback of this.connectCallbacks) {
|
||||
callback();
|
||||
}
|
||||
this.connectCallbacks = [];
|
||||
const gamepad = e.gamepad;
|
||||
const id = getGamepadId(gamepad);
|
||||
console.log("connected", gamepad);
|
||||
const gamepadData = new GamepadData(gamepad, this);
|
||||
this.gamepads.set(id, gamepadData);
|
||||
if (this.animationFrame === null) {
|
||||
this.animationFrame = requestAnimationFrame(this.update);
|
||||
}
|
||||
this.dispatchEvent(new CustomEvent("gamepadconnected", { detail: gamepadData }));
|
||||
}
|
||||
|
||||
handleDisconnect(e) {
|
||||
const gamepad = e.gamepad;
|
||||
const id = getGamepadId(gamepad);
|
||||
console.log("disconnected", gamepad);
|
||||
const gamepadData = this.gamepads.get(id);
|
||||
this.gamepads.delete(id);
|
||||
this.dispatchEvent(new CustomEvent("gamepaddisconnected", { detail: gamepadData }));
|
||||
if (this.gamepads.size === 0) {
|
||||
cancelAnimationFrame(this.animationFrame);
|
||||
this.animationFrame = null;
|
||||
this.currentTime = null;
|
||||
}
|
||||
}
|
||||
|
||||
dispatchKey(key, pressed) {
|
||||
if (pressed) {
|
||||
this.dispatchEvent(new CustomEvent("keydown", { detail: key }));
|
||||
} else {
|
||||
this.dispatchEvent(new CustomEvent("keyup", { detail: key }));
|
||||
}
|
||||
}
|
||||
|
||||
dispatchMouseDown(down) {
|
||||
if (down) {
|
||||
this.dispatchEvent(new CustomEvent("mousedown"));
|
||||
} else {
|
||||
this.dispatchEvent(new CustomEvent("mouseup"));
|
||||
}
|
||||
}
|
||||
|
||||
dispatchMouseMove(x, y) {
|
||||
this.dispatchEvent(new CustomEvent("mousemove", { detail: { x, y } }));
|
||||
}
|
||||
|
||||
updateButton(value, mapping) {
|
||||
if (mapping.type === "key") {
|
||||
if (value >= mapping.deadZone) {
|
||||
if (mapping.high) {
|
||||
this.keysPressedThisFrame.add(mapping.high);
|
||||
}
|
||||
} else if (value <= -mapping.deadZone) {
|
||||
if (mapping.low) {
|
||||
this.keysPressedThisFrame.add(mapping.low);
|
||||
}
|
||||
}
|
||||
} else if (mapping.type === "mousedown") {
|
||||
const isDown = Math.abs(value) >= mapping.deadZone;
|
||||
if (isDown) {
|
||||
this.mouseDownThisFrame = true;
|
||||
}
|
||||
} else if (mapping.type === "virtual_cursor") {
|
||||
const deadZone = mapping.deadZone;
|
||||
let action;
|
||||
if (value >= deadZone) action = mapping.high;
|
||||
if (value <= -deadZone) action = mapping.low;
|
||||
if (action) {
|
||||
// an axis value just beyond the deadzone should have a multiplier near 0, a high value should have a multiplier of 1
|
||||
const multiplier = (Math.abs(value) - deadZone) / (1 - deadZone);
|
||||
const speed = multiplier * multiplier * mapping.sensitivity * this.deltaTime;
|
||||
if (action === "+x") {
|
||||
this.virtualCursor.x += speed;
|
||||
} else if (action === "-x") {
|
||||
this.virtualCursor.x -= speed;
|
||||
} else if (action === "+y") {
|
||||
this.virtualCursor.y += speed;
|
||||
} else if (action === "-y") {
|
||||
this.virtualCursor.y -= speed;
|
||||
}
|
||||
this.virtualCursor.modified = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
update(time) {
|
||||
this.oldKeysPressed = this.keysPressedThisFrame;
|
||||
this.oldMouseDown = this.mouseDownThisFrame;
|
||||
this.keysPressedThisFrame = new Set();
|
||||
this.mouseDownThisFrame = false;
|
||||
|
||||
if (this.currentTime === null) {
|
||||
this.deltaTime = 0; // doesn't matter what this is, it's just the first frame
|
||||
} else {
|
||||
this.deltaTime = time - this.currentTime;
|
||||
}
|
||||
this.deltaTime = Math.max(Math.min(this.deltaTime, 1000), 0);
|
||||
this.currentTime = time;
|
||||
|
||||
this.animationFrame = requestAnimationFrame(this.update);
|
||||
|
||||
const gamepads = navigator.getGamepads();
|
||||
for (const gamepad of gamepads) {
|
||||
if (gamepad === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const id = getGamepadId(gamepad);
|
||||
const data = this.gamepads.get(id);
|
||||
|
||||
for (let i = 0; i < gamepad.buttons.length; i++) {
|
||||
const button = gamepad.buttons[i];
|
||||
const value = button.value;
|
||||
const mapping = data.buttonMappings[i];
|
||||
this.updateButton(value, mapping);
|
||||
}
|
||||
|
||||
for (let i = 0; i < gamepad.axes.length; i++) {
|
||||
const axis = gamepad.axes[i];
|
||||
const mapping = data.axesMappings[i];
|
||||
this.updateButton(axis, mapping);
|
||||
}
|
||||
}
|
||||
|
||||
if (this._editor) {
|
||||
this._editor.update(gamepads);
|
||||
}
|
||||
|
||||
for (const key of this.keysPressedThisFrame) {
|
||||
if (!this.oldKeysPressed.has(key)) {
|
||||
this.dispatchKey(key, true);
|
||||
}
|
||||
}
|
||||
for (const key of this.oldKeysPressed) {
|
||||
if (!this.keysPressedThisFrame.has(key)) {
|
||||
this.dispatchKey(key, false);
|
||||
}
|
||||
}
|
||||
if (this.mouseDownThisFrame && !this.oldMouseDown) {
|
||||
this.dispatchMouseDown(true);
|
||||
} else if (!this.mouseDownThisFrame && this.oldMouseDown) {
|
||||
this.dispatchMouseDown(false);
|
||||
}
|
||||
if (this.virtualCursor.modified) {
|
||||
this.virtualCursor.modified = false;
|
||||
if (this.virtualCursor.x > this.virtualCursor.maxX) {
|
||||
this.virtualCursor.x = this.virtualCursor.maxX;
|
||||
}
|
||||
if (this.virtualCursor.x < this.virtualCursor.minX) {
|
||||
this.virtualCursor.x = this.virtualCursor.minX;
|
||||
}
|
||||
if (this.virtualCursor.y > this.virtualCursor.maxY) {
|
||||
this.virtualCursor.y = this.virtualCursor.maxY;
|
||||
}
|
||||
if (this.virtualCursor.y < this.virtualCursor.minY) {
|
||||
this.virtualCursor.y = this.virtualCursor.minY;
|
||||
}
|
||||
this.dispatchMouseMove(this.virtualCursor.x, this.virtualCursor.y);
|
||||
}
|
||||
}
|
||||
|
||||
editor() {
|
||||
throw new Error('removed');
|
||||
}
|
||||
}
|
||||
|
||||
GamepadLib.browserHasBrokenGamepadAPI = () => {
|
||||
// Check that the gamepad API is supported at all
|
||||
if (!navigator.getGamepads) {
|
||||
return true;
|
||||
}
|
||||
// Firefox on Linux has a broken gamepad API implementation that results in strange and sometimes unusable mappings
|
||||
// https://bugzilla.mozilla.org/show_bug.cgi?id=1643358
|
||||
// https://bugzilla.mozilla.org/show_bug.cgi?id=1643835
|
||||
if (navigator.userAgent.includes("Firefox") && navigator.userAgent.includes("Linux")) {
|
||||
return true;
|
||||
}
|
||||
// Firefox on macOS has other bugs that result in strange and unusable mappings
|
||||
// eg. https://bugzilla.mozilla.org/show_bug.cgi?id=1434408
|
||||
if (navigator.userAgent.includes("Firefox") && navigator.userAgent.includes("Mac OS")) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
GamepadLib.setConsole = (n) => (console = n);
|
||||
|
||||
export default GamepadLib;
|
||||
@@ -0,0 +1,73 @@
|
||||
.sa-gamepad-container {
|
||||
margin-right: 0.2rem;
|
||||
}
|
||||
|
||||
.sa-gamepad-spacer {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.sa-gamepad-popup-outer {
|
||||
/* above fullscreen */
|
||||
z-index: 99999;
|
||||
}
|
||||
.sa-gamepad-popup {
|
||||
box-sizing: border-box;
|
||||
width: 700px;
|
||||
max-height: min(800px, 85vh);
|
||||
height: 100%;
|
||||
max-width: 85%;
|
||||
margin: 50px auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.sa-gamepad-popup-content {
|
||||
background-color: white;
|
||||
padding: 1.5rem 2.25rem;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sa-gamepad-popup [class*="modal_header-item-title"] {
|
||||
margin: 0 -20rem 0 0;
|
||||
}
|
||||
|
||||
.sa-gamepad-cursor {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 9999;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
will-change: transform;
|
||||
image-rendering: optimizeSpeed;
|
||||
image-rendering: crisp-edges;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
.sa-gamepad-cursor-down {
|
||||
filter: invert(100%);
|
||||
}
|
||||
|
||||
.sa-gamepad-small .sa-gamepad-container[data-editor-mode="editor"] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.sa-gamepad-hide-cursor [class^="stage_stage_"] {
|
||||
cursor: none;
|
||||
}
|
||||
|
||||
.sa-gamepad-browser-support-warning {
|
||||
font-weight: bold;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.sa-gamepad-store-settings {
|
||||
display: none;
|
||||
}
|
||||
.sa-gamepad-store-settings > input {
|
||||
margin-right: 4px;
|
||||
}
|
||||
.sa-gamepad-has-controller .sa-gamepad-store-settings {
|
||||
display: block;
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
/* inserted by pull.js */
|
||||
import _twAsset0 from "./active.png";
|
||||
import _twAsset1 from "./close.svg";
|
||||
import _twAsset2 from "./cursor.png";
|
||||
import _twAsset3 from "./dot.svg";
|
||||
const _twGetAsset = (path) => {
|
||||
if (path === "/active.png") return _twAsset0;
|
||||
if (path === "/close.svg") return _twAsset1;
|
||||
if (path === "/cursor.png") return _twAsset2;
|
||||
if (path === "/dot.svg") return _twAsset3;
|
||||
throw new Error(`Unknown asset: ${path}`);
|
||||
};
|
||||
|
||||
import GamepadLib from "./gamepadlib.js";
|
||||
|
||||
export default async function (scaffolding) {
|
||||
const vm = scaffolding.vm;
|
||||
|
||||
// Wait for the project to finish loading. Renderer and scripts will not be fully available until this happens.
|
||||
await new Promise((resolve) => {
|
||||
if (vm.editingTarget) return resolve();
|
||||
vm.runtime.once("PROJECT_LOADED", resolve);
|
||||
});
|
||||
|
||||
const vmStarted = () => vm.runtime._steppingInterval !== null;
|
||||
|
||||
const scratchKeyToKey = (key) => {
|
||||
switch (key) {
|
||||
case "right arrow":
|
||||
return "ArrowRight";
|
||||
case "up arrow":
|
||||
return "ArrowUp";
|
||||
case "left arrow":
|
||||
return "ArrowLeft";
|
||||
case "down arrow":
|
||||
return "ArrowDown";
|
||||
case "enter":
|
||||
return "Enter";
|
||||
case "space":
|
||||
return " ";
|
||||
}
|
||||
return key.toLowerCase().charAt(0);
|
||||
};
|
||||
const getKeysUsedByProject = () => {
|
||||
const allBlocks = [vm.runtime.getTargetForStage(), ...vm.runtime.targets]
|
||||
.filter((i) => i.isOriginal)
|
||||
.map((i) => i.blocks);
|
||||
const result = new Set();
|
||||
for (const blocks of allBlocks) {
|
||||
for (const block of Object.values(blocks._blocks)) {
|
||||
if (block.opcode === "event_whenkeypressed" || block.opcode === "sensing_keyoptions") {
|
||||
// For blocks like "key (my variable) pressed?", the sensing_keyoptions still exists but has a null parent.
|
||||
if (block.opcode === "sensing_keyoptions" && !block.parent) {
|
||||
continue;
|
||||
}
|
||||
const key = block.fields.KEY_OPTION.value;
|
||||
result.add(scratchKeyToKey(key));
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const GAMEPAD_CONFIG_MAGIC = " // _gamepad_";
|
||||
const findOptionsComment = () => {
|
||||
const target = vm.runtime.getTargetForStage();
|
||||
const comments = target.comments;
|
||||
for (const comment of Object.values(comments)) {
|
||||
if (comment.text.includes(GAMEPAD_CONFIG_MAGIC)) {
|
||||
return comment;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const parseOptionsComment = () => {
|
||||
const comment = findOptionsComment();
|
||||
if (!comment) {
|
||||
return null;
|
||||
}
|
||||
const lineWithMagic = comment.text.split("\n").find((i) => i.endsWith(GAMEPAD_CONFIG_MAGIC));
|
||||
if (!lineWithMagic) {
|
||||
console.warn("Gamepad comment does not contain valid line");
|
||||
return null;
|
||||
}
|
||||
const jsonText = lineWithMagic.substr(0, lineWithMagic.length - GAMEPAD_CONFIG_MAGIC.length);
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(jsonText);
|
||||
if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.buttons) || !Array.isArray(parsed.axes)) {
|
||||
throw new Error("Invalid data");
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Gamepad comment has invalid JSON", e);
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
};
|
||||
|
||||
GamepadLib.setConsole(console);
|
||||
const gamepad = new GamepadLib();
|
||||
|
||||
const parsedOptions = parseOptionsComment();
|
||||
gamepad.getHintsLazily = () => {
|
||||
if (parsedOptions) {
|
||||
return {
|
||||
importedSettings: parsedOptions,
|
||||
};
|
||||
}
|
||||
return {
|
||||
usedKeys: getKeysUsedByProject(),
|
||||
};
|
||||
};
|
||||
|
||||
const renderer = vm.runtime.renderer;
|
||||
const width = renderer._xRight - renderer._xLeft;
|
||||
const height = renderer._yTop - renderer._yBottom;
|
||||
const canvas = renderer.canvas;
|
||||
|
||||
const virtualCursorElement = document.createElement("img");
|
||||
virtualCursorElement.hidden = true;
|
||||
virtualCursorElement.className = "sa-gamepad-cursor";
|
||||
virtualCursorElement.src = _twGetAsset("/cursor.png");
|
||||
|
||||
let hideCursorTimeout;
|
||||
|
||||
const hideRealCursor = () => {
|
||||
document.body.classList.add("sa-gamepad-hide-cursor");
|
||||
};
|
||||
const showRealCursor = () => {
|
||||
document.body.classList.remove("sa-gamepad-hide-cursor");
|
||||
};
|
||||
const virtualCursorSetVisible = (visible) => {
|
||||
virtualCursorElement.hidden = !visible;
|
||||
clearTimeout(hideCursorTimeout);
|
||||
if (visible) {
|
||||
hideRealCursor();
|
||||
hideCursorTimeout = setTimeout(virtualCursorHide, 8000);
|
||||
}
|
||||
};
|
||||
const virtualCursorHide = () => {
|
||||
virtualCursorSetVisible(false);
|
||||
};
|
||||
const virtualCursorSetDown = (down) => {
|
||||
virtualCursorSetVisible(true);
|
||||
virtualCursorElement.classList.toggle("sa-gamepad-cursor-down", down);
|
||||
};
|
||||
const virtualCursorSetPosition = (x, y) => {
|
||||
virtualCursorSetVisible(true);
|
||||
const CURSOR_SIZE = 6;
|
||||
const stageX = width / 2 + x - CURSOR_SIZE / 2;
|
||||
const stageY = height / 2 - y - CURSOR_SIZE / 2;
|
||||
virtualCursorElement.style.transform = `translate(${stageX}px, ${stageY}px)`;
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", () => {
|
||||
virtualCursorSetVisible(false);
|
||||
showRealCursor();
|
||||
});
|
||||
|
||||
let getCanvasSize;
|
||||
// Support modern ResizeObserver and slow getBoundingClientRect version for improved browser support (matters for TurboWarp)
|
||||
if (window.ResizeObserver) {
|
||||
let canvasWidth = width;
|
||||
let canvasHeight = height;
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
canvasWidth = entry.contentRect.width;
|
||||
canvasHeight = entry.contentRect.height;
|
||||
}
|
||||
});
|
||||
resizeObserver.observe(canvas);
|
||||
getCanvasSize = () => [canvasWidth, canvasHeight];
|
||||
} else {
|
||||
getCanvasSize = () => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
return [rect.width, rect.height];
|
||||
};
|
||||
}
|
||||
|
||||
// Both in Scratch space
|
||||
let virtualX = 0;
|
||||
let virtualY = 0;
|
||||
const postMouseData = (data) => {
|
||||
if (!vmStarted()) return;
|
||||
const [rectWidth, rectHeight] = getCanvasSize();
|
||||
vm.postIOData("mouse", {
|
||||
...data,
|
||||
canvasWidth: rectWidth,
|
||||
canvasHeight: rectHeight,
|
||||
x: (virtualX + width / 2) * (rectWidth / width),
|
||||
y: (height / 2 - virtualY) * (rectHeight / height),
|
||||
});
|
||||
};
|
||||
const postKeyboardData = (key, isDown) => {
|
||||
if (!vmStarted()) return;
|
||||
vm.postIOData("keyboard", {
|
||||
key,
|
||||
isDown,
|
||||
});
|
||||
};
|
||||
const handleGamepadButtonDown = (e) => postKeyboardData(e.detail, true);
|
||||
const handleGamepadButtonUp = (e) => postKeyboardData(e.detail, false);
|
||||
const handleGamepadMouseDown = () => {
|
||||
virtualCursorSetDown(true);
|
||||
postMouseData({
|
||||
isDown: true,
|
||||
});
|
||||
};
|
||||
const handleGamepadMouseUp = () => {
|
||||
virtualCursorSetDown(false);
|
||||
postMouseData({
|
||||
isDown: false,
|
||||
});
|
||||
};
|
||||
const handleGamepadMouseMove = (e) => {
|
||||
virtualX = e.detail.x;
|
||||
virtualY = e.detail.y;
|
||||
virtualCursorSetPosition(virtualX, virtualY);
|
||||
postMouseData({});
|
||||
};
|
||||
|
||||
gamepad.virtualCursor.maxX = renderer._xRight;
|
||||
gamepad.virtualCursor.minX = renderer._xLeft;
|
||||
gamepad.virtualCursor.maxY = renderer._yTop;
|
||||
gamepad.virtualCursor.minY = renderer._yBottom;
|
||||
gamepad.addEventListener("keydown", handleGamepadButtonDown);
|
||||
gamepad.addEventListener("keyup", handleGamepadButtonUp);
|
||||
gamepad.addEventListener("mousedown", handleGamepadMouseDown);
|
||||
gamepad.addEventListener("mouseup", handleGamepadMouseUp);
|
||||
gamepad.addEventListener("mousemove", handleGamepadMouseMove);
|
||||
|
||||
scaffolding._overlays.appendChild(virtualCursorElement);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import gamepadUserscript from './gamepad/userscript.js';
|
||||
import gamepadUserstyle from '!raw-loader!./gamepad/style.css'
|
||||
|
||||
const addStylesheet = (css) => {
|
||||
const stylesheet = document.createElement('style');
|
||||
stylesheet.textContent = css;
|
||||
document.head.appendChild(stylesheet);
|
||||
};
|
||||
|
||||
const run = (scaffolding) => {
|
||||
gamepadUserscript(scaffolding);
|
||||
addStylesheet(gamepadUserstyle);
|
||||
};
|
||||
|
||||
window.ScaffoldingAddons = {
|
||||
run
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
import * as Scaffolding from './index';
|
||||
|
||||
window.Scaffolding = Scaffolding;
|
||||
+7
-5
@@ -3,15 +3,17 @@ const path = require('path');
|
||||
module.exports = {
|
||||
mode: process.env.NODE_ENV || 'development',
|
||||
output: {
|
||||
filename: 'index.js',
|
||||
path: path.resolve(__dirname, 'dist'),
|
||||
libraryTarget: 'var',
|
||||
library: 'Scaffolding',
|
||||
filename: '[name].js',
|
||||
path: path.resolve(__dirname, 'dist')
|
||||
},
|
||||
entry: {
|
||||
index: './src/export.js',
|
||||
addons: './src/addons/index.js'
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.mp3$/i,
|
||||
test: /\.(mp3|svg|png)$/i,
|
||||
use: [
|
||||
{
|
||||
loader: 'url-loader'
|
||||
|
||||
Reference in new issue
Block a user