const Cast = require('../util/cast'); const MathUtil = require('../util/math-util'); const Timer = require('../util/timer'); const STAGE_ALIGNMENT = { TOP_LEFT: 'top-left', TOP_RIGHT: 'top-right', BOTTOM_LEFT: 'bottom-left', BOTTOM_RIGHT: 'bottom-right', TOP: 'top', LEFT: 'left', RIGHT: 'right', MIDDLE: 'middle', BOTTOM: 'bottom' }; class Scratch3MotionBlocks { constructor (runtime) { /** * The runtime instantiating this block package. * @type {Runtime} */ this.runtime = runtime; } /** * Retrieve the block primitives implemented by this package. * @return {object.} Mapping of opcode to Function. */ getPrimitives () { return { motion_movesteps: this.moveSteps, motion_movebacksteps: this.moveStepsBack, motion_moveupdownsteps: this.moveStepsUpDown, motion_gotoxy: this.goToXY, motion_goto: this.goTo, motion_turnright: this.turnRight, motion_turnleft: this.turnLeft, motion_turnrightaroundxy: this.turnRightAround, motion_turnleftaroundxy: this.turnLeftAround, motion_turnaround: this.turnAround, motion_pointinrandomdirection: this.pointInDirectionRandom, motion_pointtowardsxy: this.pointTowardsXY, motion_pointindirection: this.pointInDirection, motion_pointtowards: this.pointTowards, motion_glidesecstoxy: this.glide, motion_glideto: this.glideTo, motion_ifonedgebounce: this.ifOnEdgeBounce, motion_ifonxybounce: this.ifOnXYBounce, motion_ifonspritebounce: this.ifOnSpriteBounce, motion_setrotationstyle: this.setRotationStyle, motion_changexby: this.changeX, motion_setx: this.setX, motion_changeyby: this.changeY, motion_sety: this.setY, motion_changebyxy: this.changeXY, motion_xposition: this.getX, motion_yposition: this.getY, motion_direction: this.getDirection, motion_move_sprite_to_scene_side: this.moveToStageSide, // Legacy no-op blocks: motion_scroll_right: () => {}, motion_scroll_up: () => {}, motion_align_scene: () => {}, motion_xscroll: () => {}, motion_yscroll: () => {} }; } moveToStageSide(args, util) { if (!this.runtime.renderer) return; const side = Cast.toString(args.ALIGNMENT); const stageWidth = this.runtime.stageWidth / 2; const stageHeight = this.runtime.stageHeight / 2; const snap = []; switch (side) { case STAGE_ALIGNMENT.TOP: util.target.setXY(0, stageHeight); snap.push('top'); break; case STAGE_ALIGNMENT.LEFT: util.target.setXY(0 - stageWidth, 0); snap.push('left'); break; case STAGE_ALIGNMENT.MIDDLE: util.target.setXY(0, 0); break; case STAGE_ALIGNMENT.RIGHT: util.target.setXY(stageWidth, 0); snap.push('right'); break; case STAGE_ALIGNMENT.BOTTOM: util.target.setXY(0, 0 - stageHeight); snap.push('bottom'); break; case STAGE_ALIGNMENT.TOP_LEFT: util.target.setXY(0 - stageWidth, stageHeight); snap.push('top'); snap.push('left'); break; case STAGE_ALIGNMENT.TOP_RIGHT: util.target.setXY(stageWidth, stageHeight); snap.push('top'); snap.push('right'); break; case STAGE_ALIGNMENT.BOTTOM_LEFT: util.target.setXY(0 - stageWidth, 0 - stageHeight); snap.push('bottom'); snap.push('left'); break; case STAGE_ALIGNMENT.BOTTOM_RIGHT: util.target.setXY(stageWidth, 0 - stageHeight); snap.push('bottom'); snap.push('right'); break; } const drawableID = util.target.drawableID; const drawable = this.runtime.renderer._allDrawables[drawableID]; const boundingBox = drawable._skin.getFenceBounds(drawable); snap.forEach(side => { switch (side) { case 'top': util.target.setXY(util.target.x, boundingBox.bottom); break; case 'bottom': util.target.setXY(util.target.x, boundingBox.top); break; case 'left': util.target.setXY(boundingBox.right, util.target.y); break; case 'right': util.target.setXY(boundingBox.left, util.target.y); break; } }); } getMonitored () { return { motion_xposition: { isSpriteSpecific: true, getId: targetId => `${targetId}_xposition` }, motion_yposition: { isSpriteSpecific: true, getId: targetId => `${targetId}_yposition` }, motion_direction: { isSpriteSpecific: true, getId: targetId => `${targetId}_direction` } }; } moveSteps (args, util) { const steps = Cast.toNumber(args.STEPS); this._moveSteps(steps, util.target); } moveStepsBack (args, util) { const steps = Cast.toNumber(args.STEPS); this._moveSteps(0 - steps, util.target); } moveStepsUpDown (args, util) { const direction = Cast.toString(args.DIRECTION); const steps = Cast.toNumber(args.STEPS); this.turnLeft({ DEGREES: 90 }, util); if (direction === 'up') { this._moveSteps(steps, util.target); } else if (direction === 'down') { this._moveSteps(0 - steps, util.target); } this.turnRight({ DEGREES: 90 }, util); } _moveSteps (steps, target) { // used by compiler const radians = MathUtil.degToRad(90 - target.direction); const dx = steps * Math.cos(radians); const dy = steps * Math.sin(radians); target.setXY(target.x + dx, target.y + dy); } goToXY (args, util) { const x = Cast.toNumber(args.X); const y = Cast.toNumber(args.Y); util.target.setXY(x, y); } getTargetXY (targetName, util) { let targetX = 0; let targetY = 0; if (targetName === '_mouse_') { targetX = util.ioQuery('mouse', 'getScratchX'); targetY = util.ioQuery('mouse', 'getScratchY'); } else if (targetName === '_random_') { const stageWidth = this.runtime.stageWidth; const stageHeight = this.runtime.stageHeight; targetX = Math.round(stageWidth * (Math.random() - 0.5)); targetY = Math.round(stageHeight * (Math.random() - 0.5)); } else { targetName = Cast.toString(targetName); const goToTarget = this.runtime.getSpriteTargetByName(targetName); if (!goToTarget) return; targetX = goToTarget.x; targetY = goToTarget.y; } return [targetX, targetY]; } goTo (args, util) { const targetXY = this.getTargetXY(args.TO, util); if (targetXY) { util.target.setXY(targetXY[0], targetXY[1]); } } turnRight (args, util) { const degrees = Cast.toNumber(args.DEGREES); util.target.setDirection(util.target.direction + degrees); } turnLeft (args, util) { const degrees = Cast.toNumber(args.DEGREES); util.target.setDirection(util.target.direction - degrees); } turnRightAround (args, util) { this.turnLeftAround({ DEGREES: -Cast.toNumber(args.DEGREES), X: Cast.toNumber(args.X), Y: Cast.toNumber(args.Y) }, util); } turnLeftAround (args, util) { const degrees = Cast.toNumber(args.DEGREES); const center = { x: Cast.toNumber(args.X), y: Cast.toNumber(args.Y) }; const radians = (Math.PI * degrees) / 180; const cos = Math.cos(radians); const sin = Math.sin(radians); const dx = util.target.x - center.x; const dy = util.target.y - center.y; const newPosition = { x: (cos * dx) - (sin * dy) + center.x, y: (cos * dy) + (sin * dx) + center.y }; util.target.setXY(newPosition.x, newPosition.y); } pointInDirection (args, util) { const direction = Cast.toNumber(args.DIRECTION); util.target.setDirection(direction); } turnAround (_, util) { this.turnRight({ DEGREES: 180 }, util); } pointInDirectionRandom (_, util) { this.pointTowards({ TOWARDS: '_random_' }, util); } pointTowardsXY (args, util) { const targetX = Cast.toNumber(args.X); const targetY = Cast.toNumber(args.Y); const dx = targetX - util.target.x; const dy = targetY - util.target.y; const direction = 90 - MathUtil.radToDeg(Math.atan2(dy, dx)); util.target.setDirection(direction); } pointTowards (args, util) { let targetX = 0; let targetY = 0; if (args.TOWARDS === '_mouse_') { targetX = util.ioQuery('mouse', 'getScratchX'); targetY = util.ioQuery('mouse', 'getScratchY'); } else if (args.TOWARDS === '_random_') { util.target.setDirection(Math.round(Math.random() * 360) - 180); return; } else { args.TOWARDS = Cast.toString(args.TOWARDS); const pointTarget = this.runtime.getSpriteTargetByName(args.TOWARDS); if (!pointTarget) return; targetX = pointTarget.x; targetY = pointTarget.y; } const dx = targetX - util.target.x; const dy = targetY - util.target.y; const direction = 90 - MathUtil.radToDeg(Math.atan2(dy, dx)); util.target.setDirection(direction); } glide (args, util) { if (util.stackFrame.timer) { const timeElapsed = util.stackFrame.timer.timeElapsed(); if (timeElapsed < util.stackFrame.duration * 1000) { // In progress: move to intermediate position. const frac = timeElapsed / (util.stackFrame.duration * 1000); const dx = frac * (util.stackFrame.endX - util.stackFrame.startX); const dy = frac * (util.stackFrame.endY - util.stackFrame.startY); util.target.setXY( util.stackFrame.startX + dx, util.stackFrame.startY + dy ); util.yield(); } else { // Finished: move to final position. util.target.setXY(util.stackFrame.endX, util.stackFrame.endY); } } else { // First time: save data for future use. util.stackFrame.timer = new Timer(); util.stackFrame.timer.start(); util.stackFrame.duration = Cast.toNumber(args.SECS); util.stackFrame.startX = util.target.x; util.stackFrame.startY = util.target.y; util.stackFrame.endX = Cast.toNumber(args.X); util.stackFrame.endY = Cast.toNumber(args.Y); if (util.stackFrame.duration <= 0) { // Duration too short to glide. util.target.setXY(util.stackFrame.endX, util.stackFrame.endY); return; } util.yield(); } } glideTo (args, util) { const targetXY = this.getTargetXY(args.TO, util); if (targetXY) { this.glide({SECS: args.SECS, X: targetXY[0], Y: targetXY[1]}, util); } } ifOnEdgeBounce (args, util) { this._ifOnEdgeBounce(util.target); } _ifOnEdgeBounce (target) { // used by compiler const bounds = target.getBounds(); if (!bounds) { return; } // Measure distance to edges. // Values are positive when the sprite is far away, // and clamped to zero when the sprite is beyond. const stageWidth = this.runtime.stageWidth; const stageHeight = this.runtime.stageHeight; const distLeft = Math.max(0, (stageWidth / 2) + bounds.left); const distTop = Math.max(0, (stageHeight / 2) - bounds.top); const distRight = Math.max(0, (stageWidth / 2) - bounds.right); const distBottom = Math.max(0, (stageHeight / 2) + bounds.bottom); // Find the nearest edge. let nearestEdge = ''; let minDist = Infinity; if (distLeft < minDist) { minDist = distLeft; nearestEdge = 'left'; } if (distTop < minDist) { minDist = distTop; nearestEdge = 'top'; } if (distRight < minDist) { minDist = distRight; nearestEdge = 'right'; } if (distBottom < minDist) { minDist = distBottom; nearestEdge = 'bottom'; } if (minDist > 0) { return; // Not touching any edge. } // Point away from the nearest edge. const radians = MathUtil.degToRad(90 - target.direction); let dx = Math.cos(radians); let dy = -Math.sin(radians); if (nearestEdge === 'left') { dx = Math.max(0.2, Math.abs(dx)); } else if (nearestEdge === 'top') { dy = Math.max(0.2, Math.abs(dy)); } else if (nearestEdge === 'right') { dx = 0 - Math.max(0.2, Math.abs(dx)); } else if (nearestEdge === 'bottom') { dy = 0 - Math.max(0.2, Math.abs(dy)); } const newDirection = MathUtil.radToDeg(Math.atan2(dy, dx)) + 90; target.setDirection(newDirection); // Keep within the stage. const fencedPosition = target.keepInFence(target.x, target.y); target.setXY(fencedPosition[0], fencedPosition[1]); } ifOnXYBounce(args, util, _, __, ___, touchingCondition) { const x = Cast.toNumber(args.X); const y = Cast.toNumber(args.Y); const target = util.target; const bounds = target.getBounds(); if (!bounds) { return; } // Check to see if the point is inside the bounding box. const xInBounds = (x >= bounds.left) && (x <= bounds.right); const yInBounds = (y >= bounds.bottom) && (y <= bounds.top); if (touchingCondition !== true) { if (!(xInBounds && yInBounds)) { return; // Not inside the bounding box. } } // Find the distance to the point for all sides. // We use this to figure out which side to bounce on. let nearestEdge = ''; let minDist = Infinity; for (let i = 0; i < 4; i++) { const sides = ['left', 'top', 'right', 'bottom']; let distx; let disty; switch (sides[i]) { case 'left': case 'right': distx = x - bounds[sides[i]]; disty = y - target.y; break; case 'top': case 'bottom': distx = x - target.x; disty = y - bounds[sides[i]]; break; } const distance = Math.sqrt((distx * distx) + (disty * disty)); if (distance < minDist) { minDist = distance; nearestEdge = sides[i]; } } // Point away from the nearest edge. const radians = MathUtil.degToRad(90 - target.direction); let dx = Math.cos(radians); let dy = -Math.sin(radians); if (nearestEdge === 'left') { dx = Math.max(0.2, Math.abs(dx)); } else if (nearestEdge === 'top') { dy = Math.max(0.2, Math.abs(dy)); } else if (nearestEdge === 'right') { dx = 0 - Math.max(0.2, Math.abs(dx)); } else if (nearestEdge === 'bottom') { dy = 0 - Math.max(0.2, Math.abs(dy)); } const newDirection = MathUtil.radToDeg(Math.atan2(dy, dx)) + 90; target.setDirection(newDirection); // Keep within the stage. const fencedPosition = target.keepInFence(target.x, target.y); target.setXY(fencedPosition[0], fencedPosition[1]); } ifOnSpriteBounce (args, util) { if (args.SPRITE === '_mouse_') { const x = util.ioQuery('mouse', 'getScratchX'); const y = util.ioQuery('mouse', 'getScratchY'); return this.ifOnXYBounce({ X: x, Y: y }, util); } else if (args.SPRITE === '_random_') { const stageWidth = this.runtime.stageWidth; const stageHeight = this.runtime.stageHeight; const x = Math.round(stageWidth * (Math.random() - 0.5)); const y = Math.round(stageHeight * (Math.random() - 0.5)); return this.ifOnXYBounce({ X: x, Y: y }, util); } const spriteName = Cast.toString(args.SPRITE); const bounceTarget = this.runtime.getSpriteTargetByName(spriteName); if (!bounceTarget) return; const point = util.target.spriteTouchingPoint(spriteName); if (!point) return; return this.ifOnXYBounce({ X: point[0], Y: point[1] }, util); } setRotationStyle (args, util) { util.target.setRotationStyle(args.STYLE); } changeX (args, util) { const dx = Cast.toNumber(args.DX); util.target.setXY(util.target.x + dx, util.target.y); } setX (args, util) { const x = Cast.toNumber(args.X); util.target.setXY(x, util.target.y); } changeY (args, util) { const dy = Cast.toNumber(args.DY); util.target.setXY(util.target.x, util.target.y + dy); } setY (args, util) { const y = Cast.toNumber(args.Y); util.target.setXY(util.target.x, y); } changeXY (args, util) { const dx = Cast.toNumber(args.DX); const dy = Cast.toNumber(args.DY); util.target.setXY(util.target.x + dx, util.target.y + dy); } getX (args, util) { return this.limitPrecision(util.target.x); } getY (args, util) { return this.limitPrecision(util.target.y); } getDirection (args, util) { return util.target.direction; } // This corresponds to snapToInteger in Scratch 2 limitPrecision (coordinate) { const rounded = Math.round(coordinate); const delta = coordinate - rounded; const limitedCoord = (Math.abs(delta) < 1e-9) ? rounded : coordinate; return limitedCoord; } } module.exports = Scratch3MotionBlocks;