Skip to content

Add Sprite.dragging ("set drag mode to") + reflect Scratch "click" behavior #201

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 10 commits into
base: master
Choose a base branch
from
15 changes: 13 additions & 2 deletions src/Input.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import type { Stage } from "./Sprite";

type Mouse = { x: number; y: number; down: boolean };
type Mouse = {
x: number;
y: number;

down: boolean;
downAt: { x: number; y: number } | null;
};

export default class Input {
private _stage;
Expand All @@ -23,7 +29,7 @@ export default class Input {
this._canvas.tabIndex = 0;
}

this.mouse = { x: 0, y: 0, down: false };
this.mouse = { x: 0, y: 0, down: false, downAt: null };
this._canvas.addEventListener("mousemove", this._mouseMove.bind(this));
this._canvas.addEventListener("mousedown", this._mouseDown.bind(this));
this._canvas.addEventListener("mouseup", this._mouseUp.bind(this));
Expand Down Expand Up @@ -55,13 +61,18 @@ export default class Input {
this.mouse = {
...this.mouse,
down: true,
downAt: {
x: this.mouse.x,
y: this.mouse.y,
},
};
}

private _mouseUp(): void {
this.mouse = {
...this.mouse,
down: false,
downAt: null,
};
}

Expand Down
225 changes: 200 additions & 25 deletions src/Project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,22 @@ export default class Project {
public answer: string | null;
private timerStart!: Date;

public draggingSprite: Sprite | null;
public dragThreshold: number;
private _dragOffsetX: number;
private _dragOffsetY: number;
private _dragQualified: boolean;
private _idleDragTimeout: number | null;

/**
* Used to keep track of what edge-activated trigger predicates evaluted to
* on the previous step.
*/
private _prevStepTriggerPredicates: WeakMap<Trigger, boolean>;

public constructor(stage: Stage, sprites = {}, { frameRate = 30 } = {}) {
this._bindListenerFunctions();

this.stage = stage;
this.sprites = sprites;

Expand All @@ -56,6 +65,14 @@ export default class Project {
this.restartTimer();

this.answer = null;
this.draggingSprite = null;
this._dragOffsetX = 0;
this._dragOffsetY = 0;
this._dragQualified = false;
this._idleDragTimeout = null;

// TODO: Enable customizing, like frameRate
this.dragThreshold = 3;

// Run project code at specified framerate
setInterval(() => {
Expand All @@ -66,34 +83,33 @@ export default class Project {
this._renderLoop();
}

public attach(renderTarget: string | HTMLElement): void {
this.renderer.setRenderTarget(renderTarget);
this.renderer.stage.addEventListener("click", () => {
// Chrome requires a user gesture on the page before we can start the
// audio context.
// When we click the stage, that counts as a user gesture, so try
// resuming the audio context.
if (Sound.audioContext.state === "suspended") {
void Sound.audioContext.resume();
}
private _bindListenerFunctions(): void {
this._onStageClick = this._onStageClick.bind(this);
this._onStagePointerPress = this._onStagePointerPress.bind(this);
this._onStagePointerMove = this._onStagePointerMove.bind(this);
this._onStagePointerRelease = this._onStagePointerRelease.bind(this);
this._onPagePointerRelease = this._onPagePointerRelease.bind(this);
}

let clickedSprite = this.renderer.pick(this.spritesAndClones, {
x: this.input.mouse.x,
y: this.input.mouse.y,
});
if (!clickedSprite) {
clickedSprite = this.stage;
}
public attach(renderTarget: string | HTMLElement): void {
/* eslint-disable @typescript-eslint/unbound-method */

const matchingTriggers: TriggerWithTarget[] = [];
for (const trigger of clickedSprite.triggers) {
if (trigger.matches(Trigger.CLICKED, {}, clickedSprite)) {
matchingTriggers.push({ trigger, target: clickedSprite });
}
}
this.renderer.setRenderTarget(renderTarget);

void this._startTriggers(matchingTriggers);
});
const { stage } = this.renderer;
stage.addEventListener("click", this._onStageClick);
stage.addEventListener("mousedown", this._onStagePointerPress);
stage.addEventListener("mousemove", this._onStagePointerMove);
stage.addEventListener("mouseup", this._onStagePointerRelease);
stage.addEventListener("touchstart", this._onStagePointerMove);
stage.addEventListener("touchmove", this._onStagePointerMove);
stage.addEventListener("touchend", this._onStagePointerRelease);

const { ownerDocument: stageDocument } = stage;
if (stageDocument) {
stageDocument.addEventListener("mouseup", this._onPagePointerRelease);
stageDocument.addEventListener("touchend", this._onPagePointerRelease);
}
}

public greenFlag(): void {
Expand Down Expand Up @@ -155,6 +171,165 @@ export default class Project {
void this._startTriggers(triggersToStart);
}

private _startClickTriggersFor(target: Sprite | Stage): void {
const matchingTriggers: TriggerWithTarget[] = [];
for (const trigger of target.triggers) {
if (trigger.matches(Trigger.CLICKED, {}, target)) {
matchingTriggers.push({ trigger, target });
}
}

void this._startTriggers(matchingTriggers);
}

private _onStageClick(): void {
// Chrome requires a user gesture on the page before we can start the audio context.
// When we click the stage, that counts as a user gesture, so try resuming the audio context.
if (Sound.audioContext.state === "suspended") {
void Sound.audioContext.resume();
}
}

private _onStagePointerPress(event: PointerEvent | TouchEvent): void {
if (
(event instanceof PointerEvent && event.button === 0) ||
(window.TouchEvent && event instanceof TouchEvent)
) {
// Qualifying a drag doesn't mean we actually are dragging anything, it just indicates that
// the current pointer movement - starting from this mousedown / touchstart event - is suitable
// for beginning a drag, provided the conditions line up right.
this._dragQualified = true;
this._startIdleDragTimeout();
}

const spriteUnderMouse = this.renderer.pick(this.spritesAndClones, {
x: this.input.mouse.x,
y: this.input.mouse.y,
});

if (spriteUnderMouse) {
// Draggable sprites' click triggers are started when the mouse is released
// (provided no drag has started by that point). However, they still occlude
// a click on the stage.
if (!spriteUnderMouse.draggable) {
this._startClickTriggersFor(spriteUnderMouse);
}
} else {
// If there's no sprite under the mouse at all, the stage was clicked.
this._startClickTriggersFor(this.stage);
}
}

private _onStagePointerMove(): void {
if (this.input.mouse.down && this._dragQualified) {
if (!this.draggingSprite) {
// Consider dragging based on if the mouse has traveled far from where it was pressed down.
const distanceX = this.input.mouse.x - this.input.mouse.downAt!.x;
const distanceY = this.input.mouse.y - this.input.mouse.downAt!.y;
const distanceFromMouseDown = Math.sqrt(
distanceX ** 2 + distanceY ** 2
);
if (distanceFromMouseDown > this.dragThreshold) {
// Try starting dragging from where the mouse was pressed down. Yes, this means we're
// checking for the presence of a draggable sprite *where the mouse was pressed down,
// no matter where it is now.* This makes for subtly predictable and hilarious hijinks:
// https://github.com/scratchfoundation/scratch-gui/pull/1434#issuecomment-2207679144
this._tryStartingDraggingFrom(
this.input.mouse.downAt!.x,
this.input.mouse.downAt!.y
);
}
}

if (this.draggingSprite) {
const gotoX = this.input.mouse.x + this._dragOffsetX;
const gotoY = this.input.mouse.y + this._dragOffsetY;

// TODO: This is applied immediately. Do we want to buffer it til the start of the next tick?
this.draggingSprite.goto(gotoX, gotoY, true);
}
}
}

private _onStagePointerRelease(): void {
// Releasing the mouse terminates a drag. If one was terminated, don't start click triggers.
if (this._clearDragging()) {
return;
}

const spriteUnderMouse = this.renderer.pick(this.spritesAndClones, {
x: this.input.mouse.x,
y: this.input.mouse.y,
});

// Only draggable sprites start click triggers when the mouse is released.
// Non-draggable sprites' click triggers are started when the mouse is pressed.
if (spriteUnderMouse && spriteUnderMouse.draggable) {
this._startClickTriggersFor(spriteUnderMouse);
}
}

private _onPagePointerRelease(): void {
// Releasing the mouse outside of the stage canvas should never start click triggers,
// so we don't care if a drag was actually terminated or not.
void this._clearDragging();
}

private _tryStartingDraggingFrom(x: number, y: number): void {
const spriteUnderMouse = this.renderer.pick(this.spritesAndClones, {
x,
y,
});

if (spriteUnderMouse && spriteUnderMouse.draggable) {
this.draggingSprite = spriteUnderMouse;
this._clearIdleDragTimeout();

// Note the drag offset is in terms of where the drag is starting from, not where the mouse is now.
// This has the apparent effect of teleporting the sprite a significant distance, if you moved your
// mouse far away from where you pressed it down.
this._dragOffsetX = this.draggingSprite.x - x;
this._dragOffsetY = this.draggingSprite.y - y;

// TODO: This is applied immediately. Do we want to buffer it til the start of the next tick?
this.draggingSprite.moveAhead();
}
}

private _clearDragging(): boolean {
const wasDragging = !!this.draggingSprite;
this.draggingSprite = null;
this._dragOffsetX = 0;
this._dragOffsetY = 0;
this._clearIdleDragTimeout();
this._dragQualified = false;
return wasDragging;
}

private _startIdleDragTimeout(): void {
// We call this the "idle drag timeout" because it's only relevant if you haven't moved the mouse
// past the drag threshold, so that you'd just call _tryStartDraggingFrom normally. (Or you *have*
// moved it past the threshold, but are not currently moving it on the frame when this timeout
// activates.) Note that the bind is to the position of the mouse when the mouse is pressed down,
// i.e. it will start dragging regardless where the mouse actually is when this timeout activates -
// although usually, it's in the same place, because you just pressed it down and held it still.
this._idleDragTimeout = window.setTimeout(
this._tryStartingDraggingFrom.bind(
this,
this.input.mouse.x,
this.input.mouse.y
),
400
);
}

private _clearIdleDragTimeout(): void {
if (typeof this._idleDragTimeout === "number") {
clearTimeout(this._idleDragTimeout);
this._idleDragTimeout = null;
}
}

private step(): void {
this._cachedLoudness = null;
this._stepEdgeActivatedTriggers();
Expand Down
12 changes: 9 additions & 3 deletions src/Renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -611,6 +611,12 @@ export default class Renderer {
}
}

// Filter out the sprite that is being dragged, if any.
// A sprite that is being dragged can detect other sprites, but other sprites can't detect it.
if (this.project.draggingSprite) {
targets.delete(this.project.draggingSprite);
}

const sprBox = Rectangle.copy(
this.getBoundingBox(spr),
__collisionBox
Expand Down Expand Up @@ -740,10 +746,10 @@ export default class Renderer {
}

// Pick the topmost sprite at the given point (if one exists).
public pick(
sprites: (Sprite | Stage)[],
public pick<T extends Sprite | Stage>(
sprites: T[],
point: { x: number; y: number }
): Sprite | Stage | null {
): T | null {
this._setFramebuffer(this._collisionBuffer);
const gl = this.gl;
gl.clearColor(0, 0, 0, 0);
Expand Down
11 changes: 10 additions & 1 deletion src/Sprite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -508,6 +508,7 @@ type SpriteInitialConditions = {
costumeNumber: number;
size: number;
visible: boolean;
draggable?: boolean;
penDown?: boolean;
penSize?: number;
penColor?: Color;
Expand All @@ -520,6 +521,7 @@ export class Sprite extends SpriteBase {
public rotationStyle: RotationStyle;
public size: number;
public visible: boolean;
public draggable: boolean;

private parent: this | null;
public clones: this[];
Expand All @@ -540,6 +542,7 @@ export class Sprite extends SpriteBase {
costumeNumber,
size,
visible,
draggable,
penDown,
penSize,
penColor,
Expand All @@ -552,6 +555,7 @@ export class Sprite extends SpriteBase {
this._costumeNumber = costumeNumber;
this.size = size;
this.visible = visible;
this.draggable = draggable || false;

this.parent = null;
this.clones = [];
Expand Down Expand Up @@ -630,6 +634,10 @@ export class Sprite extends SpriteBase {
this._project.runningTriggers = this._project.runningTriggers.filter(
({ target }) => target !== this
);

if (this._project.draggingSprite === this) {
this._project.draggingSprite = null;
}
}

public andClones(): this[] {
Expand All @@ -644,7 +652,8 @@ export class Sprite extends SpriteBase {
this._direction = this.normalizeDeg(dir);
}

public goto(x: number, y: number): void {
public goto(x: number, y: number, fromDrag?: boolean): void {
if (this._project.draggingSprite === this && !fromDrag) return;
if (x === this.x && y === this.y) return;

if (this.penDown) {
Expand Down