soiz1's picture
Upload 2891 files
6bcb42f verified
raw
history blame
15.1 kB
import GamepadLib from "./gamepadlib.js";
export default async function ({ addon, console, msg }) {
const vm = addon.tab.traps.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 === "event_whenkeyhit" || 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();
gamepad.getUserHints = () => {
const parsedOptions = parseOptionsComment();
if (parsedOptions) {
return {
importedSettings: parsedOptions,
};
}
return {
usedKeys: getKeysUsedByProject(),
};
};
vm.runtime.on("PROJECT_LOADED", () => {
gamepad.resetControls();
});
if (addon.settings.get("hide")) {
await new Promise((resolve) => {
const end = () => {
addon.settings.removeEventListener("change", listener);
resolve();
};
const listener = () => {
if (!addon.settings.get("hide")) {
end();
}
};
gamepad.gamepadConnected().then(end);
addon.settings.addEventListener("change", listener);
});
}
const renderer = vm.runtime.renderer;
const width = renderer._xRight - renderer._xLeft;
const height = renderer._yTop - renderer._yBottom;
const canvas = renderer.canvas;
const container = document.createElement("div");
container.className = "sa-gamepad-container";
addon.tab.displayNoneWhileDisabled(container, { display: "flex" });
const buttonContainer = document.createElement("span");
buttonContainer.className = addon.tab.scratchClass("button_outlined-button", "stage-header_stage-button");
const buttonContent = document.createElement("div");
buttonContent.className = addon.tab.scratchClass("button_content");
const buttonImage = document.createElement("img");
buttonImage.className = addon.tab.scratchClass("stage-header_stage-button-icon");
buttonImage.draggable = false;
buttonImage.src = addon.self.getResource("/gamepad.svg") /* rewritten by pull.js */;
buttonContent.appendChild(buttonImage);
buttonContainer.appendChild(buttonContent);
container.appendChild(buttonContainer);
let editor;
let shouldStoreSettingsInProject = false;
const didChangeProject = () => {
vm.runtime.emitProjectChanged();
if (vm.editingTarget === vm.runtime.getTargetForStage()) {
vm.emitWorkspaceUpdate();
}
};
const storeMappings = () => {
const exported = editor.export();
if (!exported) {
console.warn("Could not export gamepad settings");
return;
}
const text = `${msg("config-header")}\n${JSON.stringify(exported)}${GAMEPAD_CONFIG_MAGIC}`;
const existingComment = findOptionsComment();
if (existingComment) {
existingComment.text = text;
} else {
const target = vm.runtime.getTargetForStage();
target.createComment(
// comment ID, just has to be a random string
Math.random() + "",
// block ID
null,
// text
text,
// x, y, width, height
50,
50,
350,
150,
// minimized
false
);
}
didChangeProject();
};
const removeStoredMappings = () => {
const comment = findOptionsComment();
if (comment) {
const target = vm.runtime.getTargetForStage();
delete target.comments[comment.id];
didChangeProject();
}
};
const handleGamepadMappingChanged = () => {
if (shouldStoreSettingsInProject) {
storeMappings();
}
};
const handleStoreSettingsCheckboxChanged = (e) => {
shouldStoreSettingsInProject = !!e.target.checked;
if (shouldStoreSettingsInProject) {
storeMappings();
} else {
removeStoredMappings();
}
};
const handleEditorControllerChanged = () => {
document.body.classList.toggle("sa-gamepad-has-controller", editor.hasControllerSelected());
handleGamepadMappingChanged();
};
buttonContainer.addEventListener("click", () => {
if (!editor) {
editor = gamepad.editor();
editor.msg = msg;
editor.addEventListener("mapping-changed", handleGamepadMappingChanged);
editor.addEventListener("gamepad-changed", handleEditorControllerChanged);
}
const editorEl = editor.generateEditor();
handleEditorControllerChanged();
const { backdrop, container, content, closeButton, remove } = addon.tab.createModal(msg("settings"), {
isOpen: true,
useEditorClasses: true,
});
const handleKeyDown = (e) => {
if (e.key === "Escape" && !e.target.closest("[data-accepting-input]")) {
remove();
}
};
backdrop.addEventListener("click", remove);
window.addEventListener("keydown", handleKeyDown);
addon.self.addEventListener("disabled", remove);
backdrop.classList.add("sa-gamepad-popup-outer");
container.classList.add("sa-gamepad-popup");
closeButton.tabIndex = "0";
closeButton.setAttribute("role", "button");
closeButton.addEventListener("click", remove);
closeButton.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
remove();
}
});
content.classList.add("sa-gamepad-popup-content");
if (GamepadLib.browserHasBrokenGamepadAPI()) {
const warning = document.createElement("div");
warning.textContent = msg("browser-support");
warning.className = "sa-gamepad-browser-support-warning";
content.appendChild(warning);
}
content.appendChild(editorEl);
const extraOptionsContainer = document.createElement("div");
extraOptionsContainer.className = "sa-gamepad-extra-options";
content.appendChild(extraOptionsContainer);
const mappingsWereResetOrCleared = () => {
editor.updateAllContent();
storeSettingsCheckbox.checked = false;
shouldStoreSettingsInProject = false;
};
const resetButton = document.createElement("button");
resetButton.className = "sa-gamepad-reset-button";
resetButton.textContent = msg("reset");
resetButton.addEventListener("click", () => {
gamepad.resetControls();
mappingsWereResetOrCleared();
});
extraOptionsContainer.appendChild(resetButton);
const clearButton = document.createElement("button");
clearButton.className = "sa-gamepad-reset-button";
clearButton.textContent = msg("clear");
clearButton.addEventListener("click", () => {
gamepad.clearControls();
mappingsWereResetOrCleared();
});
extraOptionsContainer.appendChild(clearButton);
const storeSettingsLabel = document.createElement("label");
storeSettingsLabel.className = "sa-gamepad-store-settings";
storeSettingsLabel.textContent = msg("store-in-project");
const storeSettingsCheckbox = document.createElement("input");
storeSettingsCheckbox.type = "checkbox";
storeSettingsCheckbox.checked = shouldStoreSettingsInProject;
storeSettingsCheckbox.addEventListener("change", handleStoreSettingsCheckboxChanged);
storeSettingsLabel.prepend(storeSettingsCheckbox);
extraOptionsContainer.appendChild(storeSettingsLabel);
editor.focus();
});
if (addon.tab.redux.state && addon.tab.redux.state.scratchGui.stageSize.stageSize === "small") {
document.body.classList.add("sa-gamepad-small");
}
document.addEventListener(
"click",
(e) => {
if (e.target.closest("[class*='stage-header_stage-button-first']:not(.sa-hide-stage-button)")) {
document.body.classList.add("sa-gamepad-small");
} else if (
e.target.closest("[class*='stage-header_stage-button-last']") ||
e.target.closest(".sa-hide-stage-button")
) {
document.body.classList.remove("sa-gamepad-small");
}
},
{ capture: true }
);
const virtualCursorElement = document.createElement("img");
virtualCursorElement.hidden = true;
virtualCursorElement.className = "sa-gamepad-cursor";
virtualCursorElement.src = addon.self.getResource("/cursor.png") /* rewritten by pull.js */;
addon.self.addEventListener("disabled", () => {
virtualCursorElement.hidden = true;
});
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 Turbo Warp)
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 (addon.self.disabled || !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 (addon.self.disabled || !vmStarted()) return;
vm.postIOData("keyboard", {
key,
isDown,
});
};
const handleGamepadButtonDown = (e) => postKeyboardData(e.detail, true);
const handleGamepadButtonUp = (e) => postKeyboardData(e.detail, false);
const handleGamepadMouseDown = (e) => {
virtualCursorSetDown(true);
postMouseData({
isDown: true,
button: e.detail,
});
};
const handleGamepadMouseUp = (e) => {
virtualCursorSetDown(false);
postMouseData({
isDown: false,
button: e.detail,
});
};
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);
while (true) {
const target = await addon.tab.waitForElement(
'[class^="stage-header_embed-buttons_"], [class^="stage-header_stage-size-row"], [class^="stage-header_stage-menu-wrapper"] > [class^="button_outlined-button"]',
{
markAsSeen: true,
reduxEvents: [
"scratch-gui/mode/SET_PLAYER",
"scratch-gui/mode/SET_FULL_SCREEN",
"fontsLoaded/SET_FONTS_LOADED",
"scratch-gui/locales/SELECT_LOCALE",
],
}
);
container.dataset.editorMode = addon.tab.editorMode;
if (target.className.includes("stage-size-row")) {
addon.tab.appendToSharedSpace({ space: "stageHeader", element: container, order: 1 });
} else {
addon.tab.appendToSharedSpace({ space: "fullscreenStageHeader", element: container, order: 0 });
}
const monitorListScaler = document.querySelector("[class^='monitor-list_monitor-list-scaler']");
monitorListScaler.appendChild(virtualCursorElement);
}
}