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.} 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;