diff --git a/extensions/DNin/touches.js b/extensions/DNin/touches.js
new file mode 100644
index 0000000000..39c99cfd61
--- /dev/null
+++ b/extensions/DNin/touches.js
@@ -0,0 +1,535 @@
+// Name: Touches
+// ID: dnintouches
+// Description: Handle multiple simultaneous touch events. Also detects mouse input.
+// By: D-ScratchNinja
+// License: MPL-2.0
+
+(function (Scratch) {
+ "use strict";
+
+ if (!Scratch.extensions.unsandboxed) {
+ throw new Error("Touches extension must run unsandboxed");
+ }
+
+ const clamp = (n, min, max) => Math.max(Math.min(n, max), min);
+
+ class TouchesExtension {
+ constructor() {
+ this.ongoingTouches = Array(64); // We don't really know how many we'll need so...
+ this.lastPointerType = "";
+
+ this.canvas = Scratch.renderer.canvas;
+
+ const nextFreeTouchID = () => this.ongoingTouches.findIndex((i) => !i);
+ const toStageX = (x, rect) => {
+ const relX = x - rect.x;
+ const stageWidth = Scratch.vm.runtime.stageWidth;
+ const stageX = clamp(
+ (relX / rect.width - 0.5) * stageWidth,
+ -stageWidth / 2,
+ stageWidth / 2
+ );
+ if (Scratch.vm.runtime.runtimeOptions.miscLimits) {
+ return Math.round(stageX);
+ } else {
+ return stageX;
+ }
+ };
+ const toStageY = (y, rect) => {
+ const relY = y - rect.y;
+ const stageHeight = Scratch.vm.runtime.stageHeight;
+ const stageY = clamp(
+ (relY / rect.height - 0.5) * -stageHeight,
+ -stageHeight / 2,
+ stageHeight / 2
+ );
+ if (Scratch.vm.runtime.runtimeOptions.miscLimits) {
+ return Math.round(stageY);
+ } else {
+ return stageY;
+ }
+ };
+
+ this.canvas.addEventListener("pointerdown", (e) => {
+ const existingTouch = this.ongoingTouches.findIndex(
+ (i) => i?.identifier === e.pointerId
+ ); // Depending on the user agent and pointer, events may not always have unique identifiers
+ const i = existingTouch > 0 ? existingTouch : nextFreeTouchID();
+ const canvasRect = this.canvas.getBoundingClientRect();
+ const pressedTarget = Scratch.vm.runtime.ioDevices.mouse._pickTarget(
+ e.offsetX,
+ e.offsetY
+ );
+ Scratch.vm.runtime.startHats(
+ "dnintouches_whenSpritePressed",
+ null,
+ pressedTarget
+ );
+ this.ongoingTouches[i] = {
+ active: true,
+ canceled: false,
+ identifier: e.pointerId,
+ willRemove: false,
+ x: toStageX(e.clientX, canvasRect),
+ y: toStageY(e.clientY, canvasRect),
+ canvasX: e.offsetX,
+ canvasY: e.offsetY,
+ pressedTarget: pressedTarget,
+ };
+ this.lastPointerType = e.pointerType;
+ });
+ document.addEventListener("pointermove", (e) => {
+ const touch = this.ongoingTouches.find(
+ (i) => i?.identifier === e.pointerId
+ );
+ if (!touch) return;
+ const canvasRect = this.canvas.getBoundingClientRect();
+ touch.x = toStageX(e.clientX, canvasRect);
+ touch.y = toStageY(e.clientY, canvasRect);
+ touch.canvasX = e.clientX - canvasRect.x;
+ touch.canvasY = e.clientY - canvasRect.y;
+ });
+ document.addEventListener("pointerup", (e) => {
+ const slot = this.ongoingTouches.find(
+ (i) => i?.identifier === e.pointerId
+ );
+ if (!slot) return;
+ slot.active = false;
+ });
+ document.addEventListener("pointercancel", (e) => {
+ const slot = this.ongoingTouches.find(
+ (i) => i?.identifier === e.pointerId
+ );
+ if (!slot) return;
+ slot.active = false;
+ slot.canceled = true;
+ });
+
+ const preClearInactiveTouches = () => {
+ this.ongoingTouches.forEach((touch) => {
+ if (!touch.active) {
+ touch.willRemove = true;
+ }
+ });
+ };
+ const clearInactiveTouches = () => {
+ this.ongoingTouches.forEach((touch, index) => {
+ if (!touch.active) {
+ if (touch.willRemove) {
+ delete this.ongoingTouches[index]; // Don't splice, the index of each item must be preserved
+ }
+ }
+ });
+ };
+
+ Scratch.vm.runtime.on("BEFORE_EXECUTE", () => {
+ preClearInactiveTouches();
+ });
+ Scratch.vm.runtime.on("AFTER_EXECUTE", () => {
+ clearInactiveTouches();
+ });
+ }
+
+ getInfo() {
+ return {
+ id: "dnintouches",
+ name: "Touches",
+ color1: "#5cb1d6",
+ color2: "#3ba2ce",
+ color3: "#2e8eb8",
+ blocks: [
+ {
+ opcode: "isTouchScreen",
+ blockType: Scratch.BlockType.BOOLEAN,
+ text: Scratch.translate("is touch screen?"),
+ extensions: ["colours_sensing"],
+ },
+ {
+ opcode: "maxTouchPoints",
+ blockType: Scratch.BlockType.REPORTER,
+ text: Scratch.translate("max touch points"),
+ extensions: ["colours_sensing"],
+ },
+ {
+ opcode: "inputMethod",
+ blockType: Scratch.BlockType.REPORTER,
+ text: Scratch.translate("input method"),
+ extensions: ["colours_sensing"],
+ },
+
+ "---",
+
+ {
+ opcode: "countTouches",
+ blockType: Scratch.BlockType.REPORTER,
+ text: Scratch.translate("number of fingers down"),
+ extensions: ["colours_sensing"],
+ },
+ {
+ opcode: "touchProperty",
+ blockType: Scratch.BlockType.REPORTER,
+ text: Scratch.translate("[PROPERTY] of touch [TOUCH]"),
+ arguments: {
+ PROPERTY: {
+ type: Scratch.ArgumentType.STRING,
+ menu: "touchProperty",
+ defaultValue: "x position",
+ },
+ TOUCH: {
+ type: Scratch.ArgumentType.STRING,
+ menu: "touchIDs",
+ defaultValue: "1",
+ },
+ },
+ extensions: ["colours_sensing"],
+ },
+ {
+ opcode: "isTouchActive",
+ blockType: Scratch.BlockType.BOOLEAN,
+ text: Scratch.translate("is touch [TOUCH] down?"),
+ arguments: {
+ TOUCH: {
+ type: Scratch.ArgumentType.STRING,
+ menu: "touchIDs",
+ defaultValue: "1",
+ },
+ },
+ extensions: ["colours_sensing"],
+ },
+ {
+ opcode: "wasTouchCanceled",
+ blockType: Scratch.BlockType.BOOLEAN,
+ text: Scratch.translate("was touch [TOUCH] canceled?"),
+ arguments: {
+ TOUCH: {
+ type: Scratch.ArgumentType.STRING,
+ menu: "touchIDs",
+ defaultValue: "1",
+ },
+ },
+ extensions: ["colours_sensing"],
+ },
+
+ "---",
+
+ {
+ opcode: "whenSpritePressed",
+ blockType: Scratch.BlockType.EVENT,
+ text: Scratch.translate("when this sprite pressed"),
+ isEdgeActivated: false,
+ shouldRestartExistingThreads: true,
+ extensions: ["colours_event"],
+ },
+ {
+ opcode: "isPressed",
+ blockType: Scratch.BlockType.BOOLEAN,
+ text: Scratch.translate("is this sprite being pressed?"),
+ extensions: ["colours_sensing"],
+ },
+ {
+ opcode: "isTouched",
+ blockType: Scratch.BlockType.BOOLEAN,
+ text: Scratch.translate("is this sprite being touched?"),
+ extensions: ["colours_sensing"],
+ },
+ {
+ opcode: "isIdTouchingMe",
+ blockType: Scratch.BlockType.BOOLEAN,
+ text: Scratch.translate("is touch [TOUCH] touching me?"),
+ arguments: {
+ TOUCH: {
+ type: Scratch.ArgumentType.STRING,
+ menu: "touchIDs",
+ defaultValue: "1",
+ },
+ },
+ extensions: ["colours_sensing"],
+ },
+ {
+ opcode: "relatedTouchIndex",
+ blockType: Scratch.BlockType.REPORTER,
+ text: Scratch.translate("# of [SELECTOR] touch"),
+ arguments: {
+ SELECTOR: {
+ type: Scratch.ArgumentType.STRING,
+ menu: "touchSelector",
+ defaultValue: "pressed",
+ },
+ },
+ extensions: ["colours_sensing"],
+ },
+ {
+ opcode: "relatedTouchProperty",
+ blockType: Scratch.BlockType.REPORTER,
+ text: Scratch.translate("[PROPERTY] of [SELECTOR] touch"),
+ arguments: {
+ PROPERTY: {
+ type: Scratch.ArgumentType.STRING,
+ menu: "touchProperty",
+ defaultValue: "x position",
+ },
+ SELECTOR: {
+ type: Scratch.ArgumentType.STRING,
+ menu: "touchSelector",
+ defaultValue: "pressed",
+ },
+ },
+ extensions: ["colours_sensing"],
+ },
+
+ "---",
+
+ {
+ opcode: "goToTouch",
+ blockType: Scratch.BlockType.COMMAND,
+ text: Scratch.translate("go to touch [TOUCH]"),
+ arguments: {
+ TOUCH: {
+ type: Scratch.ArgumentType.STRING,
+ menu: "touchIDs",
+ defaultValue: "1",
+ },
+ },
+ extensions: ["colours_motion"],
+ },
+ {
+ opcode: "pointTowardsTouch",
+ blockType: Scratch.BlockType.COMMAND,
+ text: Scratch.translate("point towards touch [TOUCH]"),
+ arguments: {
+ TOUCH: {
+ type: Scratch.ArgumentType.STRING,
+ menu: "touchIDs",
+ defaultValue: "1",
+ },
+ },
+ extensions: ["colours_motion"],
+ },
+ ],
+ menus: {
+ touchProperty: {
+ acceptReporters: true,
+ items: [
+ {
+ text: Scratch.translate("x position"),
+ value: "x position",
+ },
+ {
+ text: Scratch.translate("y position"),
+ value: "y position",
+ },
+ {
+ text: Scratch.translate({
+ default: "distance (from)",
+ description:
+ "Returns how far away the touch is from the target. This is an argument in a reporter block labeled '[parameter] of touch', so this would end up saying 'distance (from) of touch'",
+ }),
+ value: "distance",
+ },
+ {
+ text: Scratch.translate({
+ default: "initial target",
+ description:
+ "Returns the sprite that the finger was touching when it made contact with the screen",
+ }),
+ value: "initial target",
+ },
+ {
+ text: Scratch.translate({
+ default: "touched target",
+ description:
+ "Returns the sprite that the finger is currently touching",
+ }),
+ value: "touched target",
+ },
+ ],
+ },
+ touchIDs: {
+ acceptReporters: true,
+ items: ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"],
+ },
+ touchSelector: {
+ acceptReporters: true,
+ items: [
+ {
+ text: Scratch.translate("pressed"),
+ value: "pressed",
+ },
+ {
+ text: Scratch.translate("contacted"),
+ value: "contacted",
+ },
+ ],
+ },
+ },
+ };
+ }
+
+ isTouchScreen() {
+ return navigator.maxTouchPoints > 0;
+ }
+
+ maxTouchPoints() {
+ return Math.max(
+ navigator.maxTouchPoints,
+ window.matchMedia("(any-pointer)").matches // Returns true (1) if a pointing device is connected
+ );
+ }
+
+ inputMethod() {
+ return this.lastPointerType;
+ }
+
+ isPressed(_, util) {
+ for (let i = 0; i < this.ongoingTouches.length; i++) {
+ const touch = this.ongoingTouches[i];
+ if (touch?.active && touch?.pressedTarget.id === util.target.id)
+ return true;
+ }
+ return false;
+ }
+
+ relatedTouchIndex({ SELECTOR }, util) {
+ // Iterate through active touches until a match is found
+ for (let i = 0; i < this.ongoingTouches.length; i++) {
+ const touch = this.ongoingTouches[i];
+ if (!touch) continue;
+ // Then return its property
+ switch (SELECTOR) {
+ case "pressed": {
+ if (touch?.pressedTarget.id === util.target.id) {
+ return i + 1;
+ } else break;
+ }
+ case "contacted": {
+ if (util.target.isTouchingPoint(touch.canvasX, touch.canvasY)) {
+ return i + 1;
+ } else break;
+ }
+ default:
+ return 0;
+ }
+ }
+ return 0;
+ }
+
+ relatedTouchProperty({ PROPERTY, SELECTOR }, util) {
+ // Iterate through active touches until a match is found
+ for (let i = 0; i < this.ongoingTouches.length; i++) {
+ const touch = this.ongoingTouches[i];
+ if (!touch) continue;
+ const returnValue = () =>
+ this.touchProperty({ PROPERTY: PROPERTY, TOUCH: i + 1 }, util);
+ // Then return its property
+ switch (SELECTOR) {
+ case "pressed": {
+ if (touch?.pressedTarget.id === util.target.id) {
+ return returnValue();
+ } else break;
+ }
+ case "contacted": {
+ if (util.target.isTouchingPoint(touch.canvasX, touch.canvasY)) {
+ return returnValue();
+ } else break;
+ }
+ default:
+ return "";
+ }
+ }
+ return "";
+ }
+
+ isTouched(_, util) {
+ for (const touch of this.ongoingTouches.filter((i) => i?.active)) {
+ if (util.target.isTouchingPoint(touch.canvasX, touch.canvasY)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ isTouchingId({ TOUCH }, util) {
+ const index = Scratch.Cast.toNumber(TOUCH) - 1;
+ const touch = this.ongoingTouches[index];
+ if (!touch) return false;
+ if (util.target.isTouchingPoint(touch.canvasX, touch.canvasY)) {
+ return true;
+ } else {
+ return false;
+ }
+ }
+
+ touchProperty({ PROPERTY, TOUCH }, util) {
+ const index = Scratch.Cast.toNumber(TOUCH) - 1;
+ const touch = this.ongoingTouches[index];
+ switch (PROPERTY) {
+ case "x position":
+ return touch?.x ?? "";
+ case "y position":
+ return touch?.y ?? "";
+ case "distance": {
+ if (!touch) return NaN;
+ const dx = touch.x - util.target.x;
+ const dy = touch.y - util.target.y;
+ return Math.sqrt(dx ** 2 + dy ** 2);
+ }
+ case "initial target":
+ return touch?.pressedTarget?.sprite?.name ?? "";
+ case "touched target":
+ return this.touchedTarget({ TOUCH: TOUCH }, util)?.sprite?.name ?? "";
+ default:
+ return "";
+ }
+ }
+
+ isIdTouchingMe({ TOUCH }, util) {
+ return this.isTouchingId({ TOUCH: TOUCH }, util);
+ }
+
+ touchedTarget({ TOUCH }, util) {
+ const index = Scratch.Cast.toNumber(TOUCH) - 1;
+ const touch = this.ongoingTouches[index];
+ if (!touch) return "";
+ return Scratch.vm.runtime.ioDevices.mouse._pickTarget(
+ touch.canvasX,
+ touch.canvasY
+ );
+ }
+
+ isTouchActive({ TOUCH }, util) {
+ const index = Scratch.Cast.toNumber(TOUCH) - 1;
+ const touch = this.ongoingTouches[index];
+ return !!touch?.active;
+ }
+
+ wasTouchCanceled({ TOUCH }, util) {
+ const index = Scratch.Cast.toNumber(TOUCH) - 1;
+ const touch = this.ongoingTouches[index];
+ return !!touch?.canceled;
+ }
+
+ countTouches() {
+ return this.ongoingTouches.filter((i) => i?.active).length;
+ }
+
+ goToTouch({ TOUCH }, util) {
+ const index = Scratch.Cast.toNumber(TOUCH) - 1;
+ const touch = this.ongoingTouches[index];
+ if (touch) {
+ util.target.setXY(touch.x, touch.y);
+ }
+ }
+
+ pointTowardsTouch({ TOUCH }, util) {
+ const index = Scratch.Cast.toNumber(TOUCH) - 1;
+ const touch = this.ongoingTouches[index];
+ if (touch) {
+ const dx = touch.x - util.target.x;
+ const dy = touch.y - util.target.y;
+ util.target.setDirection((Math.atan2(dx, dy) * 180) / Math.PI);
+ }
+ }
+ }
+
+ Scratch.extensions.register(new TouchesExtension());
+})(Scratch);
diff --git a/extensions/extensions.json b/extensions/extensions.json
index 044cf1114f..5400353048 100644
--- a/extensions/extensions.json
+++ b/extensions/extensions.json
@@ -26,6 +26,7 @@
"Xeltalliv/simple3D",
"Lily/Skins",
"obviousAlexC/SensingPlus",
+ "DNin/touches",
"CubesterYT/KeySimulation",
"Lily/ClonesPlus",
"Lily/LooksPlus",
diff --git a/images/DNin/touches.svg b/images/DNin/touches.svg
new file mode 100644
index 0000000000..888b2f52be
--- /dev/null
+++ b/images/DNin/touches.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/images/README.md b/images/README.md
index f8f3eb6136..bc42333aa6 100644
--- a/images/README.md
+++ b/images/README.md
@@ -319,3 +319,6 @@ All images in this folder are licensed under the [GNU General Public License ver
## CubesterYT/KeySimulation.svg
- Created by [@SharkPool-SP](https://github.com/SharkPool-SP/)
+
+## DNin/touches.svg
+ - Created by [DNin01](https://github.com/DNin01)