You know what? Let's add addons

This commit is contained in:
Thomas Weber committed 2021-06-24 14:08:48 -05:00
1 parent 6115f3aad1
commit d2fd86ce58
13 files changed
+1029 -7

No files matched your search

+5 -2
View File
@@ -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

+45
View File
@@ -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
}
+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+643
View File
@@ -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;
+73
View File
@@ -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;
}
+233
View File
@@ -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);
}
+17
View File
@@ -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
};
+3
View File
@@ -0,0 +1,3 @@
import * as Scaffolding from './index';
window.Scaffolding = Scaffolding;
+7 -5
View File
@@ -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'