Spaces:
Build error
Build error
const Cast = require('../util/cast'); | |
const Timer = require('../util/timer'); | |
const MathUtil = require('../util/math-util'); | |
const getMonitorIdForBlockWithArgs = require('../util/get-monitor-id'); | |
const { validateRegex } = require('../util/json-block-utilities'); | |
class Scratch3SensingBlocks { | |
constructor (runtime) { | |
/** | |
* The runtime instantiating this block package. | |
* @type {Runtime} | |
*/ | |
this.runtime = runtime; | |
/** | |
* The "answer" block value. | |
* @type {string} | |
*/ | |
this._answer = ''; // used by compiler | |
/** | |
* The timer utility. | |
* @type {Timer} | |
*/ | |
this._timer = new Timer(); | |
/** | |
* The stored microphone loudness measurement. | |
* @type {number} | |
*/ | |
this._cachedLoudness = -1; | |
/** | |
* The time of the most recent microphone loudness measurement. | |
* @type {number} | |
*/ | |
this._cachedLoudnessTimestamp = 0; | |
/** | |
* The list of loudness values to determine the average. | |
* @type {!Array} | |
*/ | |
this._loudnessList = []; | |
/** | |
* The list of queued questions and respective `resolve` callbacks. | |
* @type {!Array} | |
*/ | |
this._questionList = []; | |
this.runtime.on('ANSWER', this._onAnswer.bind(this)); | |
this.runtime.on('PROJECT_START', this._resetAnswer.bind(this)); | |
this.runtime.on('PROJECT_STOP_ALL', this._clearAllQuestions.bind(this)); | |
this.runtime.on('STOP_FOR_TARGET', this._clearTargetQuestions.bind(this)); | |
this.runtime.on('RUNTIME_DISPOSED', this._resetAnswer.bind(this)); | |
} | |
/** | |
* Retrieve the block primitives implemented by this package. | |
* @return {object.<string, Function>} Mapping of opcode to Function. | |
*/ | |
getPrimitives () { | |
return { | |
sensing_objecttouchingobject: this.objectTouchingObject, | |
sensing_objecttouchingclonesprite: this.objectTouchingCloneOfSprite, | |
sensing_touchingobject: this.touchingObject, | |
sensing_touchingcolor: this.touchingColor, | |
sensing_coloristouchingcolor: this.colorTouchingColor, | |
sensing_distanceto: this.distanceTo, | |
sensing_timer: this.getTimer, | |
sensing_resettimer: this.resetTimer, | |
sensing_of: this.getAttributeOf, | |
sensing_mousex: this.getMouseX, | |
sensing_mousey: this.getMouseY, | |
sensing_setdragmode: this.setDragMode, | |
sensing_mousedown: this.getMouseDown, | |
sensing_keypressed: this.getKeyPressed, | |
sensing_current: this.current, | |
sensing_dayssince2000: this.daysSince2000, | |
sensing_loudness: this.getLoudness, | |
sensing_loud: this.isLoud, | |
sensing_askandwait: this.askAndWait, | |
sensing_answer: this.getAnswer, | |
sensing_username: this.getUsername, | |
sensing_loggedin: this.getLoggedIn, | |
sensing_userid: () => {}, // legacy no-op block | |
sensing_regextest: this.regextest, | |
sensing_thing_is_number: this.thing_is_number, | |
sensing_thing_has_number: this.thing_has_number, | |
sensing_mobile: this.mobile, | |
sensing_thing_is_text: this.thing_is_text, | |
sensing_getspritewithattrib: this.getspritewithattrib, | |
sensing_directionTo: this.getDirectionToFrom, | |
sensing_distanceTo: this.getDistanceToFrom, | |
sensing_isUpperCase: this.isCharecterUppercase, | |
sensing_mouseclicked: this.mouseClicked, | |
sensing_keyhit: this.keyHit, | |
sensing_mousescrolling: this.mouseScrolling, | |
sensing_fingerdown: this.fingerDown, | |
sensing_fingertapped: this.fingerTapped, | |
sensing_fingerx: this.getFingerX, | |
sensing_fingery: this.getFingerY, | |
sensing_setclipboard: this.setClipboard, | |
sensing_getclipboard: this.getClipboard, | |
sensing_getdragmode: this.getDragMode, | |
sensing_getoperatingsystem: this.getOS, | |
sensing_getbrowser: this.getBrowser, | |
sensing_geturl: this.getUrl, | |
sensing_getxyoftouchingsprite: this.getXYOfTouchingSprite | |
}; | |
} | |
getOS () { | |
if (!('userAgent' in navigator)) return 'Unknown'; | |
const agent = navigator.userAgent; | |
if (agent.includes('Windows')) { | |
return 'Windows'; | |
} | |
if (agent.includes('Android')) { | |
return 'Android'; | |
} | |
if (agent.includes('iPad') || agent.includes('iPod') || agent.includes('iPhone')) { | |
return 'iOS'; | |
} | |
if (agent.includes('Linux')) { | |
return 'Linux'; | |
} | |
if (agent.includes('CrOS')) { | |
return 'ChromeOS'; | |
} | |
if (agent.includes('Mac OS')) { | |
return 'MacOS'; | |
} | |
return 'Unknown'; | |
} | |
getBrowser () { | |
if (!('userAgent' in navigator)) return 'Unknown'; | |
const agent = navigator.userAgent; | |
if ('userAgentData' in navigator) { | |
const agentData = JSON.stringify(navigator.userAgentData.brands); | |
if (agentData.includes('Google Chrome')) { | |
return 'Chrome'; | |
} | |
if (agentData.includes('Opera')) { | |
return 'Opera'; | |
} | |
if (agentData.includes('Microsoft Edge')) { | |
return 'Edge'; | |
} | |
} | |
if (agent.includes('Chrome')) { | |
return 'Chrome'; | |
} | |
if (agent.includes('Firefox')) { | |
return 'Firefox'; | |
} | |
// PenguinMod cannot be loaded in IE 11 (the last supported version) | |
// if (agent.includes('MSIE') || agent.includes('rv:')) { | |
// return 'Internet Explorer'; | |
// } | |
if (agent.includes('Safari')) { | |
return 'Safari'; | |
} | |
return 'Unknown'; | |
} | |
getUrl () { | |
if (!('href' in location)) return ''; | |
return location.href; | |
} | |
setClipboard (args) { | |
const text = Cast.toString(args.ITEM); | |
if (!navigator) return; | |
if (('clipboard' in navigator) && ('writeText' in navigator.clipboard)) { | |
navigator.clipboard.writeText(text); | |
} | |
} | |
getClipboard () { | |
if (!navigator) return ''; | |
if (('clipboard' in navigator) && ('readText' in navigator.clipboard)) { | |
return navigator.clipboard.readText(); | |
} else { | |
return ''; | |
} | |
} | |
getDragMode (_, util) { | |
return util.target.draggable; | |
} | |
mouseClicked (_, util) { | |
return util.ioQuery('mouse', 'getIsClicked'); | |
} | |
keyHit (args, util) { | |
return util.ioQuery('keyboard', 'getKeyIsHit', [args.KEY_OPTION]); | |
} | |
mouseScrolling (args, util) { | |
const delta = util.ioQuery('mouseWheel', 'getScrollDelta'); | |
const option = args.SCROLL_OPTION; | |
switch (option) { | |
case "up": | |
return delta < 0; | |
case "down": | |
return delta > 0; | |
default: | |
return false; | |
} | |
} | |
isCharecterUppercase (args) { | |
return (/[A-Z]/g).test(args.text); | |
} | |
getDirectionToFrom (args) { | |
const dx = args.x2 - args.x1; | |
const dy = args.y2 - args.y1; | |
const direction = MathUtil.wrapClamp(90 - MathUtil.radToDeg(Math.atan2(dy, dx)), -179, 180); | |
return direction; | |
} | |
getDistanceToFrom (args) { | |
const dx = args.x2 - args.x1; | |
const dy = args.y2 - args.y1; | |
return Math.sqrt((dx * dx) + (dy * dy)); | |
} | |
getspritewithattrib (args, util) { | |
// strip out usless data | |
const sprites = util.runtime.targets.map(x => ({ | |
id: x.id, | |
name: x.sprite ? x.sprite.name : "Unknown", | |
variables: Object.values(x.variables).reduce((obj, value) => { | |
if (!value.name) return obj; | |
obj[value.name] = String(value.value); | |
return obj; | |
}, {}) | |
})); | |
// get the target with variable x set to y | |
let res = "No sprites found"; | |
for ( | |
// define the index and the sprite | |
let idx = 1, sprite = sprites[0]; | |
// standard for loop thing | |
idx < sprites.length; | |
// set sprite to a new item | |
sprite = sprites[idx++] | |
) { | |
if (sprite.variables[args.var] === args.val) { | |
res = `{"id": "${sprite.id}", "name": "${sprite.name}"}`; | |
break; | |
} | |
} | |
return res; | |
} | |
thing_is_number (args) { | |
// i hate js | |
// i also hate regex | |
// so im gonna do this the lazy way | |
// no. String(Number(value)) === value does infact do the job X) | |
// also what was originaly here was inificiant as hell | |
// jg: why dont you literally just do what "is text" did but the opposite | |
// except also account for numbers that end with . (that aint a number) | |
if (Cast.toString(args.TEXT1).trim().endsWith(".")) { | |
return false; | |
} | |
return !this.thing_is_text(args); | |
} | |
thing_is_text (args) { | |
// WHY IS NAN NOT EQUAL TO ITSELF | |
// HOW IS NAN A NUMBER | |
// because nan is how numbers say the value put into me is not a number | |
return isNaN(Number(args.TEXT1)); | |
} | |
thing_has_number(args) { | |
return /\d/.test(Cast.toString(args.TEXT1)); | |
} | |
mobile () { | |
return typeof window !== 'undefined' && 'ontouchstart' in window; | |
} | |
regextest (args) { | |
if (!validateRegex(args.reg, args.regrule)) return false; | |
const regex = new RegExp(args.reg, args.regrule); | |
return regex.test(args.text); | |
} | |
getMonitored () { | |
return { | |
sensing_answer: { | |
getId: () => 'answer' | |
}, | |
sensing_mousedown: { | |
getId: () => 'mousedown' | |
}, | |
sensing_mouseclicked: { | |
getId: () => 'mouseclicked' | |
}, | |
sensing_mousex: { | |
getId: () => 'mousex' | |
}, | |
sensing_mousey: { | |
getId: () => 'mousey' | |
}, | |
sensing_getclipboard: { | |
getId: () => 'getclipboard' | |
}, | |
sensing_getdragmode: { | |
isSpriteSpecific: true, | |
getId: targetId => `${targetId}_getdragmode` | |
}, | |
sensing_loudness: { | |
getId: () => 'loudness' | |
}, | |
sensing_loud: { | |
getId: () => 'loud' | |
}, | |
sensing_timer: { | |
getId: () => 'timer' | |
}, | |
sensing_dayssince2000: { | |
getId: () => 'dayssince2000' | |
}, | |
sensing_current: { | |
// This is different from the default toolbox xml id in order to support | |
// importing multiple monitors from the same opcode from sb2 files, | |
// something that is not currently supported in scratch 3. | |
getId: (_, fields) => getMonitorIdForBlockWithArgs('current', fields) // _${param}` | |
}, | |
sensing_loggedin: { | |
getId: () => 'loggedin' | |
}, | |
}; | |
} | |
_onAnswer (answer) { | |
this._answer = answer; | |
const questionObj = this._questionList.shift(); | |
if (questionObj) { | |
const [_question, resolve, target, wasVisible, wasStage] = questionObj; | |
// If the target was visible when asked, hide the say bubble unless the target was the stage. | |
if (wasVisible && !wasStage) { | |
this.runtime.emit('SAY', target, 'say', ''); | |
} | |
resolve(); | |
this._askNextQuestion(); | |
} | |
} | |
_resetAnswer () { | |
this._answer = ''; | |
} | |
_enqueueAsk (question, resolve, target, wasVisible, wasStage) { | |
this._questionList.push([question, resolve, target, wasVisible, wasStage]); | |
} | |
_askNextQuestion () { | |
if (this._questionList.length > 0) { | |
const [question, _resolve, target, wasVisible, wasStage] = this._questionList[0]; | |
// If the target is visible, emit a blank question and use the | |
// say event to trigger a bubble unless the target was the stage. | |
if (wasVisible && !wasStage) { | |
this.runtime.emit('SAY', target, 'say', question); | |
this.runtime.emit('QUESTION', ''); | |
} else { | |
this.runtime.emit('QUESTION', question); | |
} | |
} | |
} | |
_clearAllQuestions () { | |
this._questionList = []; | |
this.runtime.emit('QUESTION', null); | |
} | |
_clearTargetQuestions (stopTarget) { | |
const currentlyAsking = this._questionList.length > 0 && this._questionList[0][2] === stopTarget; | |
this._questionList = this._questionList.filter(question => ( | |
question[2] !== stopTarget | |
)); | |
if (currentlyAsking) { | |
this.runtime.emit('SAY', stopTarget, 'say', ''); | |
if (this._questionList.length > 0) { | |
this._askNextQuestion(); | |
} else { | |
this.runtime.emit('QUESTION', null); | |
} | |
} | |
} | |
askAndWait (args, util) { | |
const _target = util.target; | |
return new Promise(resolve => { | |
const isQuestionAsked = this._questionList.length > 0; | |
this._enqueueAsk(String(args.QUESTION), resolve, _target, _target.visible, _target.isStage); | |
if (!isQuestionAsked) { | |
this._askNextQuestion(); | |
} | |
}); | |
} | |
getAnswer () { | |
return this._answer; | |
} | |
objectTouchingObject (args, util) { | |
const object1 = (args.FULLTOUCHINGOBJECTMENU) === "_myself_" ? util.target.getName() : args.FULLTOUCHINGOBJECTMENU; | |
const object2 = args.SPRITETOUCHINGOBJECTMENU; | |
if (object2 === "_myself_") { | |
return util.target.isTouchingObject(object1); | |
} | |
const target = this.runtime.getSpriteTargetByName(object2); | |
if (!target) return false; | |
return target.isTouchingObject(object1); | |
} | |
objectTouchingCloneOfSprite (args, util) { | |
const object1 = args.FULLTOUCHINGOBJECTMENU; | |
let object2 = args.SPRITETOUCHINGOBJECTMENU; | |
if (object2 === "_myself_") { | |
object2 = util.target.getName(); | |
} | |
if (object1 === "_myself_") { | |
return util.target.isTouchingObject(object2, true); | |
} | |
const target = this.runtime.getSpriteTargetByName(object2); | |
if (!target) return false; | |
if (object1 === "_mouse_") { | |
if (!this.runtime.ioDevices.mouse) return false; | |
const mouseX = this.runtime.ioDevices.mouse.getClientX(); | |
const mouseY = this.runtime.ioDevices.mouse.getClientY(); | |
const clones = target.sprite.clones.filter(clone => !clone.isOriginal && clone.isTouchingPoint(mouseX, mouseY)); | |
return clones.length > 0; | |
} else if (object1 === '_edge_') { | |
const clones = target.sprite.clones.filter(clone => !clone.isOriginal && clone.isTouchingEdge()); | |
return clones.length > 0; | |
} | |
const originalSprite = this.runtime.getSpriteTargetByName(object1); | |
if (!originalSprite) return false; | |
return originalSprite.isTouchingObject(object2, true); | |
} | |
touchingObject (args, util) { | |
return util.target.isTouchingObject(args.TOUCHINGOBJECTMENU); | |
} | |
getXYOfTouchingSprite (args, util) { | |
const object = args.SPRITE; | |
if (object === '_mouse_') { | |
// we can just return mouse pos | |
// if mouse is touching us, the mouse size is practically 1x1 anyways | |
const x = util.ioQuery('mouse', 'getScratchX'); | |
const y = util.ioQuery('mouse', 'getScratchY'); | |
if (args.XY === 'y') return y; | |
return x; | |
} | |
const point = util.target.spriteTouchingPoint(object); | |
if (!point) return ''; | |
if (args.XY === 'y') return point[1]; | |
return point[0]; | |
} | |
touchingColor (args, util) { | |
const color = Cast.toRgbColorList(args.COLOR); | |
return util.target.isTouchingColor(color); | |
} | |
colorTouchingColor (args, util) { | |
const maskColor = Cast.toRgbColorList(args.COLOR); | |
const targetColor = Cast.toRgbColorList(args.COLOR2); | |
return util.target.colorIsTouchingColor(targetColor, maskColor); | |
} | |
distanceTo (args, util) { | |
if (util.target.isStage) return 10000; | |
let targetX = 0; | |
let targetY = 0; | |
if (args.DISTANCETOMENU === '_mouse_') { | |
targetX = util.ioQuery('mouse', 'getScratchX'); | |
targetY = util.ioQuery('mouse', 'getScratchY'); | |
} else { | |
args.DISTANCETOMENU = Cast.toString(args.DISTANCETOMENU); | |
const distTarget = this.runtime.getSpriteTargetByName( | |
args.DISTANCETOMENU | |
); | |
if (!distTarget) return 10000; | |
targetX = distTarget.x; | |
targetY = distTarget.y; | |
} | |
const dx = util.target.x - targetX; | |
const dy = util.target.y - targetY; | |
return Math.sqrt((dx * dx) + (dy * dy)); | |
} | |
setDragMode (args, util) { | |
util.target.setDraggable(args.DRAG_MODE === 'draggable'); | |
} | |
getTimer (args, util) { | |
return util.ioQuery('clock', 'projectTimer'); | |
} | |
resetTimer (args, util) { | |
util.ioQuery('clock', 'resetProjectTimer'); | |
} | |
getMouseX (args, util) { | |
return util.ioQuery('mouse', 'getScratchX'); | |
} | |
getMouseY (args, util) { | |
return util.ioQuery('mouse', 'getScratchY'); | |
} | |
getMouseDown (args, util) { | |
return util.ioQuery('mouse', 'getIsDown'); | |
} | |
getFingerX (args, util) { | |
return util.ioQuery('touch', 'getScratchX', [Cast.toNumber(args.FINGER_OPTION) - 1]); | |
} | |
getFingerY (args, util) { | |
return util.ioQuery('touch', 'getScratchY', [Cast.toNumber(args.FINGER_OPTION) - 1]); | |
} | |
fingerDown (args, util) { | |
return util.ioQuery('touch', 'getIsDown', [Cast.toNumber(args.FINGER_OPTION) - 1]); | |
} | |
fingerTapped (args, util) { | |
return util.ioQuery('touch', 'getIsTapped', [Cast.toNumber(args.FINGER_OPTION) - 1]); | |
} | |
current (args) { | |
const menuOption = Cast.toString(args.CURRENTMENU).toLowerCase(); | |
const date = new Date(); | |
switch (menuOption) { | |
case 'year': return date.getFullYear(); | |
case 'month': return date.getMonth() + 1; // getMonth is zero-based | |
case 'date': return date.getDate(); | |
case 'dayofweek': return date.getDay() + 1; // getDay is zero-based, Sun=0 | |
case 'hour': return date.getHours(); | |
case 'minute': return date.getMinutes(); | |
case 'second': return date.getSeconds(); | |
} | |
return 0; | |
} | |
getKeyPressed (args, util) { | |
return util.ioQuery('keyboard', 'getKeyIsDown', [args.KEY_OPTION]); | |
} | |
daysSince2000 () { | |
const msPerDay = 24 * 60 * 60 * 1000; | |
const start = new Date(2000, 0, 1); // Months are 0-indexed. | |
const today = new Date(); | |
const dstAdjust = today.getTimezoneOffset() - start.getTimezoneOffset(); | |
let mSecsSinceStart = today.valueOf() - start.valueOf(); | |
mSecsSinceStart += ((today.getTimezoneOffset() - dstAdjust) * 60 * 1000); | |
return mSecsSinceStart / msPerDay; | |
} | |
getLoudness () { | |
if (typeof this.runtime.audioEngine === 'undefined') return -1; | |
if (this.runtime.currentStepTime === null) return -1; | |
// Only measure loudness once per step | |
const timeSinceLoudness = this._timer.time() - this._cachedLoudnessTimestamp; | |
if (timeSinceLoudness < this.runtime.currentStepTime) { | |
return this._cachedLoudness; | |
} | |
this._cachedLoudnessTimestamp = this._timer.time(); | |
this._cachedLoudness = this.runtime.audioEngine.getLoudness(); | |
this.pushLoudness(this._cachedLoudness); | |
return this._cachedLoudness; | |
} | |
isLoud () { | |
this.pushLoudness(); | |
let sum = this._loudnessList.reduce((accumulator, currentValue) => accumulator + currentValue, 0); | |
sum /= this._loudnessList.length; | |
return this.getLoudness() > sum + 15; | |
} | |
pushLoudness (value) { | |
if (this._loudnessList.length >= 30) this._loudnessList.shift(); // remove first item | |
this._loudnessList.push(value ?? this.getLoudness()); | |
} | |
getAttributeOf (args) { | |
let attrTarget; | |
if (args.OBJECT === '_stage_') { | |
attrTarget = this.runtime.getTargetForStage(); | |
} else { | |
args.OBJECT = Cast.toString(args.OBJECT); | |
attrTarget = this.runtime.getSpriteTargetByName(args.OBJECT); | |
} | |
// attrTarget can be undefined if the target does not exist | |
// (e.g. single sprite uploaded from larger project referencing | |
// another sprite that wasn't uploaded) | |
if (!attrTarget) return 0; | |
// Generic attributes | |
if (attrTarget.isStage) { | |
switch (args.PROPERTY) { | |
// Scratch 1.4 support | |
case 'background #': return attrTarget.currentCostume + 1; | |
case 'backdrop #': return attrTarget.currentCostume + 1; | |
case 'backdrop name': | |
return attrTarget.getCostumes()[attrTarget.currentCostume].name; | |
case 'volume': return attrTarget.volume; | |
} | |
} else { | |
switch (args.PROPERTY) { | |
case 'x position': return attrTarget.x; | |
case 'y position': return attrTarget.y; | |
case 'direction': return attrTarget.direction; | |
case 'costume #': return attrTarget.currentCostume + 1; | |
case 'costume name': | |
return attrTarget.getCostumes()[attrTarget.currentCostume].name; | |
case 'layer': return attrTarget.getLayerOrder(); | |
case 'size': return attrTarget.size; | |
case 'volume': return attrTarget.volume; | |
} | |
} | |
// Target variables. | |
const varName = args.PROPERTY; | |
const variable = attrTarget.lookupVariableByNameAndType(varName, '', true); | |
if (variable) { | |
return variable.value; | |
} | |
// Otherwise, 0 | |
return 0; | |
} | |
getUsername (args, util) { | |
return util.ioQuery('userData', 'getUsername'); | |
} | |
getLoggedIn(args, util) { | |
return util.ioQuery('userData', 'getLoggedIn'); | |
} | |
} | |
module.exports = Scratch3SensingBlocks; | |