const EventEmitter = require('events'); const Blocks = require('./blocks'); const Variable = require('../engine/variable'); const Comment = require('../engine/comment'); const uid = require('../util/uid'); const {Map} = require('immutable'); const log = require('../util/log'); const StringUtil = require('../util/string-util'); const VariableUtil = require('../util/variable-util'); /** * @fileoverview * A Target is an abstract "code-running" object for the Scratch VM. * Examples include sprites/clones or potentially physical-world devices. */ class Target extends EventEmitter { /** * @param {Runtime} runtime Reference to the runtime. * @param {?Blocks} blocks Blocks instance for the blocks owned by this target. * @constructor */ constructor (runtime, blocks) { super(); if (!blocks) { blocks = new Blocks(runtime); } /** * Reference to the runtime. * @type {Runtime} */ this.runtime = runtime; /** * A unique ID for this target. * @type {string} */ this.id = uid(); /** * Blocks run as code for this target. * @type {!Blocks} */ this.blocks = blocks; /** * Dictionary of variables and their values for this target. * Key is the variable id. * @type {Object.} */ this.variables = {}; /** * Dictionary of comments for this target. * Key is the comment id. * @type {Object.} */ this.comments = {}; /** * Dictionary of custom state for this target. * This can be used to store target-specific custom state for blocks which need it. * TODO: do we want to persist this in SB3 files? * @type {Object.} */ this._customState = {}; /** * Currently known values for edge-activated hats. * Keys are block ID for the hat; values are the currently known values. * @type {Object.} */ this._edgeActivatedHatValues = {}; } /** * Called when the project receives a "green flag." * @abstract */ onGreenFlag () {} /** * Return a human-readable name for this target. * Target implementations should override this. * @abstract * @returns {string} Human-readable name for the target. */ getName () { return this.id; } /** * Update an edge-activated hat block value. * @param {!string} blockId ID of hat to store value for. * @param {*} newValue Value to store for edge-activated hat. * @return {*} The old value for the edge-activated hat. */ updateEdgeActivatedValue (blockId, newValue) { const oldValue = this._edgeActivatedHatValues[blockId]; this._edgeActivatedHatValues[blockId] = newValue; return oldValue; } hasEdgeActivatedValue (blockId) { return this._edgeActivatedHatValues.hasOwnProperty(blockId); } /** * Clear all edge-activaed hat values. */ clearEdgeActivatedValues () { this._edgeActivatedHatValues = {}; } /** * Look up a variable object, first by id, and then by name if the id is not found. * Create a new variable if both lookups fail. * @param {string} id Id of the variable. * @param {string} name Name of the variable. * @return {!Variable} Variable object. */ lookupOrCreateVariable (id, name) { let variable = this.lookupVariableById(id); if (variable) return variable; variable = this.lookupVariableByNameAndType(name, Variable.SCALAR_TYPE); if (variable) return variable; // No variable with this name exists - create it locally. const newVariable = new Variable(id, name, Variable.SCALAR_TYPE, false); this.variables[id] = newVariable; return newVariable; } /** * Look up a broadcast message object with the given id and return it * if it exists. * @param {string} id Id of the variable. * @param {string} name Name of the variable. * @return {?Variable} Variable object. */ lookupBroadcastMsg (id, name) { let broadcastMsg; if (id) { broadcastMsg = this.lookupVariableById(id); } else if (name) { broadcastMsg = this.lookupBroadcastByInputValue(name); } else { log.error('Cannot find broadcast message if neither id nor name are provided.'); } if (broadcastMsg) { if (name && (broadcastMsg.name.toLowerCase() !== name.toLowerCase())) { log.error(`Found broadcast message with id: ${id}, but` + `its name, ${broadcastMsg.name} did not match expected name ${name}.`); } if (broadcastMsg.type !== Variable.BROADCAST_MESSAGE_TYPE) { log.error(`Found variable with id: ${id}, but its type ${broadcastMsg.type}` + `did not match expected type ${Variable.BROADCAST_MESSAGE_TYPE}`); } return broadcastMsg; } } /** * Look up a broadcast message with the given name and return the variable * if it exists. Does not create a new broadcast message variable if * it doesn't exist. * @param {string} name Name of the variable. * @return {?Variable} Variable object. */ lookupBroadcastByInputValue(name) { const variables = Object.values(this.variables); return variables.find(varData => { return ( varData.type === Variable.BROADCAST_MESSAGE_TYPE && varData.name.toLowerCase() === name.toLowerCase() ); }); } /** * Look up a variable object. * Search begins for local variables; then look for globals. * @param {string} id Id of the variable. * @param {string} name Name of the variable. * @return {!Variable} Variable object. */ lookupVariableById (id) { // If we have a local copy, return it. if (this.variables.hasOwnProperty(id)) { return this.variables[id]; } // If the stage has a global copy, return it. if (this.runtime && !this.isStage) { const stage = this.runtime.getTargetForStage(); if (stage && stage.variables.hasOwnProperty(id)) { return stage.variables[id]; } } } /** * Look up a variable object by its name and variable type. * Search begins with local variables; then global variables if a local one * was not found. * @param {string} name Name of the variable. * @param {string} type Type of the variable. Defaults to Variable.SCALAR_TYPE. * @param {?bool} skipStage Optional flag to skip checking the stage * @return {?Variable} Variable object if found, or null if not. */ lookupVariableByNameAndType(name, type, skipStage) { if (typeof name !== 'string') return; if (typeof type !== 'string') type = Variable.SCALAR_TYPE; skipStage = skipStage || false; // Search variables in the current target const variables = Object.values(this.variables); const foundInCurrent = variables.find(varData => varData.name === name && varData.type === type); if (foundInCurrent) return foundInCurrent; // Search variables in the stage if applicable if (!skipStage && this.runtime && !this.isStage) { const stage = this.runtime.getTargetForStage(); if (stage) { const stageVariables = Object.values(stage.variables); const foundInStage = stageVariables.find(varData => varData.name === name && varData.type === type); if (foundInStage) return foundInStage; } } return null; } /** * Look up a list object for this target, and create it if one doesn't exist. * Search begins for local lists; then look for globals. * @param {!string} id Id of the list. * @param {!string} name Name of the list. * @return {!Varible} Variable object representing the found/created list. */ lookupOrCreateList (id, name) { let list = this.lookupVariableById(id); if (list) return list; list = this.lookupVariableByNameAndType(name, Variable.LIST_TYPE); if (list) return list; // No variable with this name exists - create it locally. const newList = new Variable(id, name, Variable.LIST_TYPE, false); this.variables[id] = newList; return newList; } /** * Creates a variable with the given id and name and adds it to the * dictionary of variables. * @param {string} id Id of variable * @param {string} name Name of variable. * @param {string} type Type of variable, '', 'broadcast_msg', or 'list' * @param {boolean} isCloud Whether the variable to create has the isCloud flag set. * Additional checks are made that the variable can be created as a cloud variable. */ createVariable (id, name, type, isCloud) { if (!this.variables.hasOwnProperty(id)) { const newVariable = this.runtime.newVariableInstance(type, id, name, false); if (isCloud && this.isStage && this.runtime.canAddCloudVariable()) { newVariable.isCloud = true; this.runtime.addCloudVariable(); this.runtime.ioDevices.cloud.requestCreateVariable(newVariable); } this.variables[id] = newVariable; } } /** * Creates a comment with the given properties. * @param {string} id Id of the comment. * @param {string} blockId Optional id of the block the comment is attached * to if it is a block comment. * @param {string} text The text the comment contains. * @param {number} x The x coordinate of the comment on the workspace. * @param {number} y The y coordinate of the comment on the workspace. * @param {number} width The width of the comment when it is full size * @param {number} height The height of the comment when it is full size * @param {boolean} minimized Whether the comment is minimized. */ createComment (id, blockId, text, x, y, width, height, minimized) { if (!this.comments.hasOwnProperty(id)) { const newComment = new Comment(id, text, x, y, width, height, minimized); if (blockId) { newComment.blockId = blockId; const blockWithComment = this.blocks.getBlock(blockId); if (blockWithComment) { blockWithComment.comment = id; } else { log.warn(`Could not find block with id ${blockId } associated with commentId: ${id}`); } } this.comments[id] = newComment; } } /** * Renames the variable with the given id to newName. * @param {string} id Id of variable to rename. * @param {string} newName New name for the variable. */ renameVariable (id, newName) { if (this.variables.hasOwnProperty(id)) { const variable = this.variables[id]; if (variable.id === id) { const oldName = variable.name; variable.name = newName; if (this.runtime) { if (variable.isCloud && this.isStage) { this.runtime.ioDevices.cloud.requestRenameVariable(oldName, newName); } if (variable.type === Variable.SCALAR_TYPE) { // sensing__of may be referencing to this variable. // Change the reference. let blockUpdated = false; this.runtime.targets.forEach(t => { blockUpdated = t.blocks.updateSensingOfReference( oldName, newName, this.isStage ? '_stage_' : this.getName() ) || blockUpdated; }); // Request workspace change only if sensing_of blocks were actually updated. if (blockUpdated) this.runtime.requestBlocksUpdate(); } const blocks = this.runtime.monitorBlocks; blocks.changeBlock({ id: id, element: 'field', name: variable.type === Variable.LIST_TYPE ? 'LIST' : 'VARIABLE', value: id }, this.runtime); const monitorBlock = blocks.getBlock(variable.id); if (monitorBlock) { this.runtime.requestUpdateMonitor(Map({ id: id, params: blocks._getBlockParams(monitorBlock) })); } } } } } /** * Removes the variable with the given id from the dictionary of variables. * @param {string} id Id of variable to delete. */ deleteVariable (id) { if (this.variables.hasOwnProperty(id)) { // Get info about the variable before deleting it const deletedVariableName = this.variables[id].name; const deletedVariableWasCloud = this.variables[id].isCloud; delete this.variables[id]; if (this.runtime) { if (deletedVariableWasCloud && this.isStage) { this.runtime.ioDevices.cloud.requestDeleteVariable(deletedVariableName); this.runtime.removeCloudVariable(); } this.runtime.monitorBlocks.deleteBlock(id); this.runtime.requestRemoveMonitor(id); } } } /** * Remove this target's monitors from the runtime state and remove the * target-specific monitored blocks (e.g. local variables, global variables for the stage, x-position). * NOTE: This does not delete any of the stage monitors like backdrop name. */ deleteMonitors () { this.runtime.requestRemoveMonitorByTargetId(this.id); let targetSpecificMonitorBlockIds; if (this.isStage) { // This only deletes global variables and not other stage monitors like backdrop number. targetSpecificMonitorBlockIds = Object.keys(this.variables); } else { targetSpecificMonitorBlockIds = Object.keys(this.runtime.monitorBlocks._blocks) .filter(key => this.runtime.monitorBlocks._blocks[key].targetId === this.id); } for (const blockId of targetSpecificMonitorBlockIds) { this.runtime.monitorBlocks.deleteBlock(blockId); } } /** * Create a clone of the variable with the given id from the dictionary of * this target's variables. * @param {string} id Id of variable to duplicate. * @param {boolean=} optKeepOriginalId Optional flag to keep the original variable ID * for the duplicate variable. This is necessary when cloning a sprite, for example. * @return {?Variable} The duplicated variable, or null if * the original variable was not found. */ duplicateVariable (id, optKeepOriginalId) { if (this.variables.hasOwnProperty(id)) { const originalVariable = this.variables[id]; const newVariable = this.runtime.newVariableInstance( originalVariable.type, optKeepOriginalId ? id : null, // conditionally keep original id or generate a new one originalVariable.name, originalVariable.isCloud ); if (newVariable.type === Variable.LIST_TYPE) { newVariable.value = originalVariable.value.slice(0); } else { newVariable.value = originalVariable.value; } return newVariable; } return null; } /** * Duplicate the dictionary of this target's variables as part of duplicating. * this target or making a clone. * @param {object=} optBlocks Optional block container for the target being duplicated. * If provided, new variables will be generated with new UIDs and any variable references * in this blocks container will be updated to refer to the corresponding new IDs. * @return {object} The duplicated dictionary of variables */ duplicateVariables (optBlocks) { let allVarRefs; if (optBlocks) { allVarRefs = optBlocks.getAllVariableAndListReferences(); } return Object.keys(this.variables).reduce((accum, varId) => { const newVariable = this.duplicateVariable(varId, !optBlocks); accum[newVariable.id] = newVariable; if (optBlocks && allVarRefs) { const currVarRefs = allVarRefs[varId]; if (currVarRefs) { this.mergeVariables(varId, newVariable.id, currVarRefs); } } return accum; }, {}); } /** * Post/edit sprite info. * @param {object} data An object with sprite info data to set. * @abstract */ postSpriteInfo () {} /** * Retrieve custom state associated with this target and the provided state ID. * @param {string} stateId - specify which piece of state to retrieve. * @returns {*} the associated state, if any was found. */ getCustomState (stateId) { return this._customState[stateId]; } /** * Store custom state associated with this target and the provided state ID. * @param {string} stateId - specify which piece of state to store on this target. * @param {*} newValue - the state value to store. */ setCustomState (stateId, newValue) { this._customState[stateId] = newValue; } /** * Call to destroy a target. * @abstract */ dispose () { this._customState = {}; if (this.runtime) { this.runtime.removeExecutable(this); } } // Variable Conflict Resolution Helpers /** * Get the names of all the variables of the given type that are in scope for this target. * For targets that are not the stage, this includes any target-specific * variables as well as any stage variables unless the skipStage flag is true. * For the stage, this is all stage variables. * @param {string} type The variable type to search for; defaults to Variable.SCALAR_TYPE * @param {?bool} skipStage Optional flag to skip the stage. * @return {Array} A list of variable names */ getAllVariableNamesInScopeByType (type, skipStage) { if (typeof type !== 'string') type = Variable.SCALAR_TYPE; skipStage = skipStage || false; const targetVariables = Object.values(this.variables) .filter(v => v.type === type) .map(variable => variable.name); if (skipStage || this.isStage || !this.runtime) { return targetVariables; } const stage = this.runtime.getTargetForStage(); const stageVariables = stage.getAllVariableNamesInScopeByType(type); return targetVariables.concat(stageVariables); } /** * Merge variable references with another variable. * @param {string} idToBeMerged ID of the variable whose references need to be updated * @param {string} idToMergeWith ID of the variable that the old references should be replaced with * @param {?Array} optReferencesToUpdate Optional context of the change. * Defaults to all the blocks in this target. * @param {?string} optNewName New variable name to merge with. The old * variable name in the references being updated should be replaced with this new name. * If this parameter is not provided or is '', no name change occurs. */ mergeVariables (idToBeMerged, idToMergeWith, optReferencesToUpdate, optNewName) { const referencesToChange = optReferencesToUpdate || // TODO should there be a separate helper function that traverses the blocks // for all references for a given ID instead of doing the below..? this.blocks.getAllVariableAndListReferences()[idToBeMerged]; VariableUtil.updateVariableIdentifiers(referencesToChange, idToMergeWith, optNewName); } /** * Share a local variable (and given references for that variable) to the stage. * @param {string} varId The ID of the variable to share. * @param {Array} varRefs The list of variable references being shared, * that reference the given variable ID. The names and IDs of these variable * references will be updated to refer to the new (or pre-existing) global variable. */ shareLocalVariableToStage (varId, varRefs) { if (!this.runtime) return; const variable = this.variables[varId]; if (!variable) { log.warn(`Cannot share a local variable to the stage if it's not local.`); return; } const stage = this.runtime.getTargetForStage(); // If a local var is being shared with the stage, // sharing will make the variable global, resulting in a conflict // with the existing local variable. Preemptively Resolve this conflict // by renaming the new global variable. // First check if we've already done the local to global transition for this // variable. If we have, merge it with the global variable we've already created. const varIdForStage = `StageVarFromLocal_${varId}`; let stageVar = stage.lookupVariableById(varIdForStage); // If a global var doesn't already exist, create a new one with a fresh name. // Use the ID we created above so that we can lookup this new variable in the // future if we decide to share this same variable again. if (!stageVar) { const varName = variable.name; const varType = variable.type; const newStageName = `Stage: ${varName}`; stageVar = this.runtime.createNewGlobalVariable(newStageName, varIdForStage, varType); } // Update all variable references to use the new name and ID this.mergeVariables(varId, stageVar.id, varRefs, stageVar.name); } /** * Share a local variable with a sprite, merging with one of the same name and * type if it already exists on the sprite, or create a new one. * @param {string} varId Id of the variable to share * @param {Target} sprite The sprite to share the variable with * @param {Array} varRefs A list of all the variable references currently being shared. */ shareLocalVariableToSprite (varId, sprite, varRefs) { if (!this.runtime) return; if (this.isStage) return; const variable = this.variables[varId]; if (!variable) { log.warn(`Tried to call 'shareLocalVariableToSprite' with a non-local variable.`); return; } const varName = variable.name; const varType = variable.type; // Check if the receiving sprite already has a variable of the same name and type // and use the existing variable, otherwise create a new one. const existingLocalVar = sprite.lookupVariableByNameAndType(varName, varType); let newVarId; if (existingLocalVar) { newVarId = existingLocalVar.id; } else { const newVar = this.runtime.newVariableInstance(varType, null, varName); newVarId = newVar.id; sprite.variables[newVarId] = newVar; } // Merge with the local variable on the new sprite. this.mergeVariables(varId, newVarId, varRefs); } /** * Given a list of variable referencing fields, shares those variables with * the target with the provided id, resolving any variable conflicts that arise * using the following rules: * * If this target is the stage, exit. There are no conflicts that arise * from sharing variables from the stage to another sprite. The variables * already exist globally, so no further action is needed. * * If a variable being referenced is a global variable, do nothing. The * global variable already exists so no further action is needed. * * If a variable being referenced is local, and * 1) The receiving target is a sprite: * create a new local variable or merge with an existing local variable * of the same name and type. Update all the referencing fields * for the original variable to reference the new variable. * 2) The receiving target is the stage: * Create a new global variable with a fresh name and update all the referencing * fields to reference the new variable. * * @param {Array} blocks The blocks containing * potential conflicting references to variables. * @param {Target} receivingTarget The target receiving the variables */ resolveVariableSharingConflictsWithTarget (blocks, receivingTarget) { if (this.isStage) return; // Get all the variable references in the given list of blocks const allVarListRefs = this.blocks.getAllVariableAndListReferences(blocks); // For all the variables being referenced, check for which ones are local // to this target, and resolve conflicts based on whether the receiving target // is a sprite (with a conflicting local variable) or whether it is // the stage (which cannot have local variables) for (const varId in allVarListRefs) { const currVar = this.variables[varId]; if (!currVar) continue; // The current variable is global, there shouldn't be any conflicts here, skip it. // Get the list of references for the current variable id const currVarListRefs = allVarListRefs[varId]; if (receivingTarget.isStage) { this.shareLocalVariableToStage(varId, currVarListRefs); } else { this.shareLocalVariableToSprite(varId, receivingTarget, currVarListRefs); } } } /** * Fixes up variable references in this target avoiding conflicts with * pre-existing variables in the same scope. * This is used when uploading this target as a new sprite into an existing * project, where the new sprite may contain references * to variable names that already exist as global variables in the project * (and thus are in scope for variable references in the given sprite). * * If this target has a block that references an existing global variable and that * variable *does not* exist in this target (e.g. it was a global variable in the * project the sprite was originally exported from), merge the variables. This entails * fixing the variable references in this sprite to reference the id of the pre-existing global variable. * * If this target has a block that references an existing global variable and that * variable does exist in the target itself (e.g. it's a local variable in the sprite being uploaded), * then the local variable is renamed to distinguish itself from the pre-existing variable. * All blocks that reference the local variable will be updated to use the new name. */ // TODO (#1360) This function is too long, add some helpers for the different chunks and cases... fixUpVariableReferences () { if (!this.runtime) return; // There's no runtime context to conflict with if (this.isStage) return; // Stage can't have variable conflicts with itself (and also can't be uploaded) const stage = this.runtime.getTargetForStage(); if (!stage || !stage.variables) return; const renameConflictingLocalVar = (id, name, type) => { const conflict = stage.lookupVariableByNameAndType(name, type); if (conflict) { const newName = StringUtil.unusedName( `${this.getName()}: ${name}`, this.getAllVariableNamesInScopeByType(type)); this.renameVariable(id, newName); return newName; } return null; }; const allReferences = this.blocks.getAllVariableAndListReferences(); const unreferencedLocalVarIds = []; if (Object.keys(this.variables).length > 0) { for (const localVarId in this.variables) { if (!this.variables.hasOwnProperty(localVarId)) continue; if (!allReferences[localVarId]) unreferencedLocalVarIds.push(localVarId); } } const conflictIdsToReplace = Object.create(null); const conflictNamesToReplace = Object.create(null); // Cache the list of all variable names by type so that we don't need to // re-calculate this in every iteration of the following loop. const varNamesByType = {}; const allVarNames = type => { const namesOfType = varNamesByType[type]; if (namesOfType) return namesOfType; varNamesByType[type] = this.runtime.getAllVarNamesOfType(type); return varNamesByType[type]; }; for (const varId in allReferences) { // We don't care about which var ref we get, they should all have the same var info const varRef = allReferences[varId][0]; const varName = varRef.referencingField.value; const varType = varRef.type; if (this.lookupVariableById(varId)) { // Found a variable with the id in either the target or the stage, // figure out which one. if (this.variables.hasOwnProperty(varId)) { // If the target has the variable, then check whether the stage // has one with the same name and type. If it does, then rename // this target specific variable so that there is a distinction. const newVarName = renameConflictingLocalVar(varId, varName, varType); if (newVarName) { // We are not calling this.blocks.updateBlocksAfterVarRename // here because it will search through all the blocks. We already // have access to all the references for this var id. allReferences[varId].map(ref => { ref.referencingField.value = newVarName; return ref; }); } } } else { // We didn't find the referenced variable id anywhere, // Treat it as a reference to a global variable (from the original // project this sprite was exported from). // Check for whether a global variable of the same name and type exists, // and if so, track it to merge with the existing global in a second pass of the blocks. const existingVar = stage.lookupVariableByNameAndType(varName, varType); if (existingVar) { if (!conflictIdsToReplace[varId]) { conflictIdsToReplace[varId] = existingVar.id; } } else { // A global variable with the same name did not already exist, // create a new one such that it does not conflict with any // names of local variables of the same type. const allNames = allVarNames(varType); const freshName = StringUtil.unusedName(varName, allNames); stage.createVariable(varId, freshName, varType); if (!conflictNamesToReplace[varId]) { conflictNamesToReplace[varId] = freshName; } } } } // Rename any local variables that were missed above because they aren't // referenced by any blocks for (const id in unreferencedLocalVarIds) { const varId = unreferencedLocalVarIds[id]; const name = this.variables[varId].name; const type = this.variables[varId].type; renameConflictingLocalVar(varId, name, type); } // Handle global var conflicts with existing global vars (e.g. a sprite is uploaded, and has // blocks referencing some variable that the sprite does not own, and this // variable conflicts with a global var) // In this case, we want to merge the new variable referenes with the // existing global variable for (const conflictId in conflictIdsToReplace) { const existingId = conflictIdsToReplace[conflictId]; const referencesToUpdate = allReferences[conflictId]; this.mergeVariables(conflictId, existingId, referencesToUpdate); } // Handle global var conflicts existing local vars (e.g a sprite is uploaded, // and has blocks referencing some variable that the sprite does not own, and this // variable conflcits with another sprite's local var). // In this case, we want to go through the variable references and update // the name of the variable in that reference. for (const conflictId in conflictNamesToReplace) { const newName = conflictNamesToReplace[conflictId]; const referencesToUpdate = allReferences[conflictId]; referencesToUpdate.map(ref => { ref.referencingField.value = newName; return ref; }); } } } module.exports = Target;