diff --git a/js/common/model/ExpressionManipulationModel.js b/js/common/model/ExpressionManipulationModel.js index b55bcf61..0c7cfa0b 100644 --- a/js/common/model/ExpressionManipulationModel.js +++ b/js/common/model/ExpressionManipulationModel.js @@ -6,8 +6,6 @@ * explore screens and for each of the game challenges. Options are used to support the different restrictions for * each screen. * - * REVIEW: Large constructor, recommend extracting listeners if possible. - * * @author John Blanco */ define( function( require ) { @@ -130,6 +128,9 @@ define( function( require ) { // coin term because the user has essentially put it away this.creatorBoxBounds = Bounds2.NOTHING; + // @private {boolean} - make this option available to methods + this.partialCancellationEnabled = options.partialCancellationEnabled; + // add a listener that resets the coin term values when the view mode switches from variables to coins this.viewModeProperty.link( function( newViewMode, oldViewMode ) { if ( newViewMode === ViewMode.COINS && oldViewMode === ViewMode.VARIABLES ) { @@ -139,643 +140,296 @@ define( function( require ) { } } ); - // function to update the total value of the coin terms - function updateTotal() { - var total = 0; - self.coinTerms.forEach( function( coinTerm ) { - total += coinTerm.valueProperty.value * coinTerm.totalCountProperty.get(); - } ); - self.totalValueProperty.set( total ); - } - // add a listener that updates the total whenever one of the term value properties change Property.multilink( [ this.xTermValueProperty, this.yTermValueProperty, this.zTermValueProperty, this.coinTerms.lengthProperty ], - updateTotal + function() { + var total = 0; + self.coinTerms.forEach( function( coinTerm ) { + total += coinTerm.valueProperty.value * coinTerm.totalCountProperty.get(); + } ); + self.totalValueProperty.set( total ); + } ); - // when a coin term is added, add listeners to handle the things about it that are dynamic and can affect the model - this.coinTerms.addItemAddedListener( function( addedCoinTerm ) { - //REVIEW ~200 line item-added-listener is a lot. Can things be broken up into functions? + // add a listener that handles the addition of coin terms + this.coinTerms.addItemAddedListener( this.coinTermAddedListener.bind( this ) ); - // Add a listener that will potentially combine this coin term with expressions or other coin terms based on - // where it is released. - function coinTermUserControlledListener( userControlled ) { + // add a listener that handles the addition of an expression + this.expressions.addItemAddedListener( this.expressionAddedListener.bind( this ) ); + } - if ( userControlled === false ) { + expressionExchange.register( 'ExpressionManipulationModel', ExpressionManipulationModel ); - // Set a bunch of variables related to the current state of this coin term. It's not really necessary to set - // them all every time, but it avoids a deeply nested if-else structure. - var releasedOverCreatorBox = addedCoinTerm.getViewBounds().intersectsBounds( self.creatorBoxBounds ); - var expressionBeingEdited = self.expressionBeingEditedProperty.get(); - var mostOverlappingCollectionArea = self.getMostOverlappingCollectionAreaForCoinTerm( addedCoinTerm ); - var mostOverlappingExpression = self.getExpressionMostOverlappingWithCoinTerm( addedCoinTerm ); - var mostOverlappingLikeCoinTerm = self.getMostOverlappingLikeCoinTerm( addedCoinTerm ); - var joinableFreeCoinTerm = self.checkForJoinableFreeCoinTerm( addedCoinTerm ); + return inherit( Object, ExpressionManipulationModel, { - if ( expressionBeingEdited ) { + /** + * main step function for this model, should only be called by the framework + * @param {number} dt + * @public + */ + step: function( dt ) { - // An expression is being edited, so a released coin term could be either moved to a new location within an - // expression or combined with another coin term in the expression. + var self = this; + var userControlledCoinTerms; + var coinTermsWithHalos = []; - // state checking - assert && assert( - expressionBeingEdited.coinTerms.contains( addedCoinTerm ), - 'coin term being released is not in expression being edited, this should not occur' - ); + // step all the coin terms + this.coinTerms.forEach( function( coinTerm ) { coinTerm.step( dt ); } ); - // determine if the coin term was dropped while overlapping a coin term of the same type - var overlappingLikeCoinTerm = self.getOverlappingLikeCoinTermWithinExpression( - addedCoinTerm, - expressionBeingEdited - ); + // Update the state of the hints and halos. This has to be done in the step function rather than in the + // event listeners, where much of the other action occurs, because the code needs to figure out which hints and + // halos should be activated and deactivated based on the positions of all coin terms and expressions. + if ( !this.expressionBeingEditedProperty.get() ) { - if ( overlappingLikeCoinTerm ) { + // get a list of user controlled expressions, max of one on mouse based systems, any number on touch devices + var userControlledExpressions = _.filter( this.expressions.getArray(), function( expression ) { + return expression.userControlledProperty.get(); + } ); - // combine the dropped coin term with the one with which it overlaps - overlappingLikeCoinTerm.absorb( addedCoinTerm, options.partialCancellationEnabled ); - expressionExchange.log && expressionExchange.log( - overlappingLikeCoinTerm.id + ' absorbed ' + addedCoinTerm.id + ', ' + overlappingLikeCoinTerm.id + - ' composition = ' + '[' + overlappingLikeCoinTerm.composition + ']' ); - self.removeCoinTerm( addedCoinTerm, false ); - } - else { + var collectionAreasWhoseHalosShouldBeActive = []; - // the coin term has been dropped at some potentially new location withing the expression - expressionBeingEdited.reintegrateCoinTerm( addedCoinTerm ); - } - } - else if ( releasedOverCreatorBox ) { + // Update hints for expressions and collection areas. + userControlledExpressions.forEach( function( userControlledExpression ) { - // the user has put this coin term back in the creator box, so remove it - self.removeCoinTerm( addedCoinTerm, true ); + var expressionIsOverCreatorBox = userControlledExpression.getBounds().intersectsBounds( self.creatorBoxBounds ); + var mostOverlappingCollectionArea = self.getMostOverlappingCollectionAreaForExpression( userControlledExpression ); + var mostOverlappingExpression = self.getExpressionMostOverlappingWithExpression( userControlledExpression ); + var mostOverlappingCoinTerm = self.getFreeCoinTermMostOverlappingWithExpression( userControlledExpression ); + var expressionOverWhichThisExpressionIsHovering = null; + var coinTermOverWhichThisExpressionIsHovering = null; + + if ( expressionIsOverCreatorBox ) { + // The expression is at least partially over the creator box, which takes precedence over everything else, + // so don't activate any hints or halos. } else if ( mostOverlappingCollectionArea ) { - - // The coin term was released over a collection area (this only occurs on game screens). Notify the - // collection area so that it can either collect or reject it. - mostOverlappingCollectionArea.collectOrRejectCoinTerm( addedCoinTerm ); + collectionAreasWhoseHalosShouldBeActive.push( mostOverlappingCollectionArea ); } else if ( mostOverlappingExpression ) { - - // the user is adding the coin term to an expression - mostOverlappingExpression.addCoinTerm( addedCoinTerm ); - expressionExchange.log && expressionExchange.log( 'added ' + addedCoinTerm.id + ' to ' + - mostOverlappingExpression.id ); - } - else if ( mostOverlappingLikeCoinTerm ) { - - // The coin term was released over a coin term of the same type, so combine the two coin terms into a single - // one with a higher count value. - addedCoinTerm.destinationReachedEmitter.addListener( function destinationReachedListener() { - mostOverlappingLikeCoinTerm.absorb( addedCoinTerm, options.partialCancellationEnabled ); - expressionExchange.log && expressionExchange.log( - mostOverlappingLikeCoinTerm.id + ' absorbed ' + addedCoinTerm.id + ', ' + - mostOverlappingLikeCoinTerm.id + ' composition = ' + '[' + - mostOverlappingLikeCoinTerm.composition + ']' ); - self.removeCoinTerm( addedCoinTerm, false ); - addedCoinTerm.destinationReachedEmitter.removeListener( destinationReachedListener ); - } ); - addedCoinTerm.travelToDestination( mostOverlappingLikeCoinTerm.positionProperty.get() ); + expressionOverWhichThisExpressionIsHovering = mostOverlappingExpression; } - else if ( joinableFreeCoinTerm ) { - - // The coin term was released in a place where it could join another free coin term. - var expressionHintToRemove; - self.expressionHints.forEach( function( expressionHint ) { - if ( expressionHint.containsCoinTerm( addedCoinTerm ) && expressionHint.containsCoinTerm( joinableFreeCoinTerm ) ) { - expressionHintToRemove = expressionHint; - } - } ); - if ( expressionHintToRemove ) { - self.removeExpressionHint( expressionHintToRemove ); - } - - // create the next expression with these coin terms - self.expressions.push( new Expression( - joinableFreeCoinTerm, - addedCoinTerm, - self.simplifyNegativesProperty - ) ); + else if ( mostOverlappingCoinTerm ) { + coinTermOverWhichThisExpressionIsHovering = mostOverlappingCoinTerm; } - } - } - addedCoinTerm.userControlledProperty.lazyLink( coinTermUserControlledListener ); + // update hover info for each of the other expressions with respect to this one + self.expressions.forEach( function( expression ) { - // add a listener that will handle requests to break apart the coin term - function coinTermBreakApartListener() { + if ( expression === userControlledExpression ) { + // skip self + return; + } - if ( addedCoinTerm.composition.length < 2 ) { - // bail if the coin term can't be decomposed - return; - } - var extractedCoinTerms = addedCoinTerm.extractConstituentCoinTerms(); - var relativeViewBounds = addedCoinTerm.localViewBoundsProperty.get(); + if ( expression === expressionOverWhichThisExpressionIsHovering ) { + expression.addHoveringExpression( userControlledExpression ); + } + else { - // If the total combined coin count was even, shift the 'parent coin' a bit so that the coins end up being - // distributed around the centerX position. - if ( extractedCoinTerms.length % 2 === 1 ) { - addedCoinTerm.travelToDestination( - new Vector2( - addedCoinTerm.positionProperty.get().x - relativeViewBounds.width / 2 - BREAK_APART_SPACING / 2, - addedCoinTerm.positionProperty.get().y - ) - ); - } + // removes it if there, no-op if not + expression.removeHoveringExpression( userControlledExpression ); + } + } ); - // add the extracted coin terms to the model - var interCoinTermDistance = relativeViewBounds.width + BREAK_APART_SPACING; - var nextLeftX = addedCoinTerm.destinationProperty.get().x - interCoinTermDistance; - var nextRightX = addedCoinTerm.destinationProperty.get().x + interCoinTermDistance; - extractedCoinTerms.forEach( function( extractedCoinTerm, index ) { - var destination; - self.addCoinTerm( extractedCoinTerm ); - if ( index % 2 === 0 ) { - destination = new Vector2( nextRightX, addedCoinTerm.positionProperty.get().y ); - nextRightX += interCoinTermDistance; - } - else { - destination = new Vector2( nextLeftX, addedCoinTerm.positionProperty.get().y ); - nextLeftX -= interCoinTermDistance; - } + // update overlap info with respect to free coin terms + userControlledExpression.clearHoveringCoinTerms(); + if ( coinTermOverWhichThisExpressionIsHovering ) { - // if the destination is outside of the allowed bounds, change it to be in bounds - if ( !self.coinTermRetrievalBounds.containsPoint( destination ) ) { - destination = self.getNextOpenRetrievalSpot(); + // there can only be one most overlapping coin term, so out with the old, in with the new + userControlledExpression.addHoveringCoinTerm( mostOverlappingCoinTerm ); } + } ); - // initiate the animation - extractedCoinTerm.travelToDestination( destination ); + // get a list of all user controlled coin terms, max of one coin on mouse-based systems, any number on touch devices + userControlledCoinTerms = _.filter( this.coinTerms.getArray(), function( coin ) { + return coin.userControlledProperty.get(); } ); - } - addedCoinTerm.breakApartEmitter.addListener( coinTermBreakApartListener ); + // check each user-controlled coin term to see if it's in a position to combine with an expression or another + // coin term + var neededExpressionHints = []; + userControlledCoinTerms.forEach( function( userControlledCoinTerm ) { - // add a listener that will remove this coin if and when it returns to its original position - function coinTermReturnedToOriginListener() { - self.removeCoinTerm( addedCoinTerm, false ); - } + var coinTermIsOverCreatorBox = userControlledCoinTerm.getViewBounds().intersectsBounds( self.creatorBoxBounds ); + var mostOverlappingCollectionArea = self.getMostOverlappingCollectionAreaForCoinTerm( userControlledCoinTerm ); + var mostOverlappingExpression = self.getExpressionMostOverlappingWithCoinTerm( userControlledCoinTerm ); + var mostOverlappingLikeCoinTerm = self.getMostOverlappingLikeCoinTerm( userControlledCoinTerm ); + var joinableFreeCoinTerm = self.checkForJoinableFreeCoinTerm( userControlledCoinTerm ); + var expressionOverWhichCoinTermIsHovering = null; - addedCoinTerm.returnedToOriginEmitter.addListener( coinTermReturnedToOriginListener ); + if ( coinTermIsOverCreatorBox ) { + // The coin term is over the creator box, which takes precedence over everything else, so don't activate any + // hints or halos. + } + else if ( mostOverlappingCollectionArea ) { - // monitor the existence strength of this coin term - function coinTermExistenceStrengthListener( existenceStrength ) { + // the coin term is over a collection area, so activate that collection area's hint + collectionAreasWhoseHalosShouldBeActive.push( mostOverlappingCollectionArea ); + } + else if ( mostOverlappingExpression ) { - if ( existenceStrength <= 0 ) { + // the coin term is over an expression, so add this coin term to the list of those hovering + expressionOverWhichCoinTermIsHovering = mostOverlappingExpression; + } + else if ( mostOverlappingLikeCoinTerm ) { - // the existence strength has gone to zero, remove this from the model - self.removeCoinTerm( addedCoinTerm, false ); + // activate halos for overlapping coin terms + coinTermsWithHalos.push( userControlledCoinTerm ); + coinTermsWithHalos.push( mostOverlappingLikeCoinTerm ); + } + else if ( joinableFreeCoinTerm ) { - if ( self.expressionBeingEditedProperty.get() ) { - if ( self.expressionBeingEditedProperty.get().coinTerms.length === 0 ) { + // this coin term is positioned such that it could join a free coin term, so add a hint + neededExpressionHints.push( new ExpressionHint( joinableFreeCoinTerm, userControlledCoinTerm ) ); + } - // the removal of the coin term caused the expression being edited to be empty, so drop out of edit mode - self.stopEditingExpression(); + // update hover info for each expression with respect to this coin term + self.expressions.forEach( function( expression ) { + if ( expression === expressionOverWhichCoinTermIsHovering ) { + expression.addHoveringCoinTerm( userControlledCoinTerm ); } - } - } - } + else { + expression.removeHoveringCoinTerm( userControlledCoinTerm ); + } + } ); + } ); - addedCoinTerm.existenceStrengthProperty.link( coinTermExistenceStrengthListener ); + // update the expression hints for single coins that could combine into expressions + if ( neededExpressionHints.length > 0 ) { - // clean up the listeners added above if and when this coin term is removed from the model - self.coinTerms.addItemRemovedListener( function coinTermRemovalListener( removedCoinTerm ) { - if ( removedCoinTerm === addedCoinTerm ) { - addedCoinTerm.userControlledProperty.unlink( coinTermUserControlledListener ); - addedCoinTerm.breakApartEmitter.removeListener( coinTermBreakApartListener ); - addedCoinTerm.returnedToOriginEmitter.removeListener( coinTermReturnedToOriginListener ); - addedCoinTerm.existenceStrengthProperty.unlink( coinTermExistenceStrengthListener ); - self.coinTerms.removeItemRemovedListener( coinTermRemovalListener ); - } - } ); - } ); - - this.expressions.addItemAddedListener( function( addedExpression ) { - //REVIEW: Also a very large listener, can this be broken up into more manageable pieces? - - // add a listener for when the expression is released, which may cause it to be combined with another expression - function expressionUserControlledListener( userControlled ) { - - if ( !userControlled ) { - - // Set a bunch of variables related to the current state of this expression. It's not really necessary to set - // them all every time, but it avoids a deeply nested if-else structure. - var releasedOverCreatorBox = addedExpression.getBounds().intersectsBounds( self.creatorBoxBounds ); - var mostOverlappingCollectionArea = self.getMostOverlappingCollectionAreaForExpression( addedExpression ); - var mostOverlappingExpression = self.getExpressionMostOverlappingWithExpression( addedExpression ); - var numOverlappingCoinTerms = addedExpression.hoveringCoinTerms.length; - - // state checking - assert && assert( - numOverlappingCoinTerms === 0 || numOverlappingCoinTerms === 1, - 'max of one overlapping free coin term when expression is released, seeing ' + numOverlappingCoinTerms - ); - - if ( releasedOverCreatorBox ) { - - // the expression was released over the creator box, so it and the coin terms should be "put away" - self.removeExpression( addedExpression ); - } - else if ( mostOverlappingCollectionArea ) { - - // The expression was released in a location that at least partially overlaps a collection area. The - // collection area must decide whether to collect or reject the expression. - mostOverlappingCollectionArea.collectOrRejectExpression( addedExpression ); - } - else if ( mostOverlappingExpression ) { - - // The expression was released in a place where it at least partially overlaps another expression, the the - // two expressions should be joined into one. The first step is to remove the expression from the list of - // those hovering. - mostOverlappingExpression.removeHoveringExpression( addedExpression ); - - // send the combining expression to the right side of receiving expression - var destinationForCombine = mostOverlappingExpression.getUpperRightCorner(); - addedExpression.travelToDestination( destinationForCombine ); - - // Listen for when the expression is in place and, when it is, transfer its coin terms to the receiving - // expression. - addedExpression.destinationReachedEmitter.addListener( function destinationReachedListener() { - - // destination reached, combine with other expression, but ONLY if it hasn't moved - if ( mostOverlappingExpression.getUpperRightCorner().equals( destinationForCombine ) ) { - var coinTermsToBeMoved = addedExpression.removeAllCoinTerms(); - self.expressions.remove( addedExpression ); - coinTermsToBeMoved.forEach( function( coinTerm ) { - expressionExchange.log && expressionExchange.log( 'moving ' + coinTerm.id + ' from ' + - addedExpression.id + ' to ' + - mostOverlappingExpression.id ); - mostOverlappingExpression.addCoinTerm( coinTerm ); - } ); - addedExpression.destinationReachedEmitter.removeListener( destinationReachedListener ); + // remove any expression hints that are no longer needed + this.expressionHints.forEach( function( existingExpressionHint ) { + var matchFound = false; + neededExpressionHints.forEach( function( neededExpressionHint ) { + if ( neededExpressionHint.equals( existingExpressionHint ) ) { + matchFound = true; } } ); - } - else if ( numOverlappingCoinTerms === 1 ) { - - // the expression was released over a free coin term, so have that free coin term join the expression - var coinTermToAddToExpression = addedExpression.hoveringCoinTerms[ 0 ]; - if ( addedExpression.rightHintActiveProperty.get() ) { - - // move to the left side of the coin term - addedExpression.travelToDestination( - coinTermToAddToExpression.positionProperty.get().plusXY( - -addedExpression.widthProperty.get() - addedExpression.rightHintWidthProperty.get() / 2, - -addedExpression.heightProperty.get() / 2 - ) - ); - } - else { - - assert && assert( - addedExpression.leftHintActiveProperty.get(), - 'at least one hint should be active if there is a hovering coin term' - ); - - // move to the right side of the coin term - addedExpression.travelToDestination( - coinTermToAddToExpression.positionProperty.get().plusXY( - addedExpression.leftHintWidthProperty.get() / 2, - -addedExpression.heightProperty.get() / 2 - ) - ); + if ( !matchFound ) { + self.removeExpressionHint( existingExpressionHint ); } + } ); - addedExpression.destinationReachedEmitter.addListener( function addCoinTermAfterAnimation() { - addedExpression.addCoinTerm( coinTermToAddToExpression ); - addedExpression.destinationReachedEmitter.removeListener( addCoinTermAfterAnimation ); + // add any needed expression hints that are not yet on the list + neededExpressionHints.forEach( function( neededExpressionHint ) { + var matchFound = false; + self.expressionHints.forEach( function( existingExpressionHint ) { + if ( existingExpressionHint.equals( neededExpressionHint ) ) { + matchFound = true; + } } ); - } + if ( !matchFound ) { + self.expressionHints.add( neededExpressionHint ); + } + } ); + } + else { + self.expressionHints.forEach( function( existingExpressionHint ) { + self.removeExpressionHint( existingExpressionHint ); + } ); } - } - - addedExpression.userControlledProperty.lazyLink( expressionUserControlledListener ); - // add a listener that will handle requests to break apart this expression - function expressionBreakApartListener() { + // update hover info for each collection area + self.collectionAreas.forEach( function( collectionArea ) { + collectionArea.haloActiveProperty.set( + collectionAreasWhoseHalosShouldBeActive.indexOf( collectionArea ) >= 0 + ); + } ); - // keep a reference to the center for when we spread out the coin terms - var expressionCenterX = addedExpression.getBounds().centerX; + // step the expressions + this.expressions.forEach( function( expression ) { + expression.step( dt ); + } ); + } + else { + // The stepping behavior is significantly different - basically much simpler - when an expression is being + // edited. The individual expressions are not stepped at all to avoid activating halos, updating layouts, and + // so forth. Interaction between coin terms and expressions is not tested. Only overlap between two like + // coins is tested so that their halos can be activated. - // remove the coin terms from the expression and the expression from the model - var newlyFreedCoinTerms = addedExpression.removeAllCoinTerms(); - self.expressions.remove( addedExpression ); + // get a list of all user controlled coins, max of one coin on mouse-based systems, any number on touch devices + userControlledCoinTerms = _.filter( this.coinTerms.getArray(), function( coinTerm ) { + return coinTerm.userControlledProperty.get(); + } ); - // spread the released coin terms out horizontally - //var numRetrievedCoinTerms = 0; - newlyFreedCoinTerms.forEach( function( newlyFreedCoinTerm ) { + // check for overlap between coins that can combine + userControlledCoinTerms.forEach( function( userControlledCoinTerm ) { - // calculate a destination that will cause the coin terms to spread out from the expression center - var horizontalDistanceFromExpressionCenter = newlyFreedCoinTerm.positionProperty.get().x - expressionCenterX; - var coinTermDestination = new Vector2( - newlyFreedCoinTerm.positionProperty.get().x + horizontalDistanceFromExpressionCenter * 0.15, // spread factor empirically determined - newlyFreedCoinTerm.positionProperty.get().y + var overlappingCoinTerm = self.getOverlappingLikeCoinTermWithinExpression( + userControlledCoinTerm, + self.expressionBeingEditedProperty.get() ); - // if the destination is outside of the allowed bounds, change it to be in bounds - if ( !self.coinTermRetrievalBounds.containsPoint( coinTermDestination ) ) { - coinTermDestination = self.getNextOpenRetrievalSpot(); - } + if ( overlappingCoinTerm ) { - // initiate the animation - newlyFreedCoinTerm.travelToDestination( coinTermDestination ); + // these coin terms can be combined, so they should have their halos activated + coinTermsWithHalos.push( userControlledCoinTerm ); + coinTermsWithHalos.push( overlappingCoinTerm ); + } } ); } - addedExpression.breakApartEmitter.addListener( expressionBreakApartListener ); + // go through all coin terms and update the state of their combine halos + this.coinTerms.forEach( function( coinTerm ) { + coinTerm.combineHaloActiveProperty.set( coinTermsWithHalos.indexOf( coinTerm ) !== -1 ); + } ); + }, - // add a listener that will handle requests to edit this expression - function editExpressionListener() { - self.expressionBeingEditedProperty.set( addedExpression ); - } + // @public + addCoinTerm: function( coinTerm ) { + this.coinTerms.add( coinTerm ); + this.updateCoinTermCounts( coinTerm.typeID ); + expressionExchange.log && expressionExchange.log( + 'added ' + coinTerm.id + ', composition = [' + coinTerm.composition + ']' + ); + }, - addedExpression.selectedForEditEmitter.addListener( editExpressionListener ); + // @public + removeCoinTerm: function( coinTerm, animate ) { - // remove the listeners when this expression is removed - self.expressions.addItemRemovedListener( function expressionRemovedListener( removedExpression ) { - if ( removedExpression === addedExpression ) { - addedExpression.dispose(); - addedExpression.userControlledProperty.unlink( expressionUserControlledListener ); - addedExpression.breakApartEmitter.removeListener( expressionBreakApartListener ); - addedExpression.selectedForEditEmitter.removeListener( editExpressionListener ); - self.expressions.removeItemRemovedListener( expressionRemovedListener ); + // remove the coin term from any expressions + this.expressions.forEach( function( expression ) { + if ( expression.containsCoinTerm( coinTerm ) ) { + expression.removeCoinTerm( coinTerm ); } } ); - } ); - } - expressionExchange.register( 'ExpressionManipulationModel', ExpressionManipulationModel ); + if ( animate ) { + // send the coin term back to its origin - the final steps of its removal will take place when it gets there + coinTerm.returnToOrigin(); + } + else { - return inherit( Object, ExpressionManipulationModel, { + expressionExchange.log && expressionExchange.log( 'removed ' + coinTerm.id ); + this.coinTerms.remove( coinTerm ); + this.updateCoinTermCounts( coinTerm.typeID ); + } + }, /** - * main step function for this model, should only be called by the framework - * @param {number} dt + * get a property that represents the count in the model of coin terms of the given type and min decomposition + * @param {CoinTermTypeID} coinTermTypeID + * @param {number} minimumDecomposition - miniumum amount into which the coin term can be decomposed + * @param {boolean} createIfUndefined * @public */ - step: function( dt ) { + getCoinTermCountProperty: function( coinTermTypeID, minimumDecomposition, createIfUndefined ) { + assert && assert( this.coinTermCounts.hasOwnProperty( coinTermTypeID ), 'unrecognized coin term type ID' ); + assert && assert( minimumDecomposition !== 0, 'minimumDecomposition cannot be 0' ); - var self = this; - var userControlledCoinTerms; - var coinTermsWithHalos = []; + // Calculate the corresponding index into the data structure - this is necessary in order to support negative + // minimum decomposition values, e.g. -3X. + var countPropertyIndex = minimumDecomposition + EESharedConstants.MAX_NON_DECOMPOSABLE_AMOUNT; - // step all the coin terms - this.coinTerms.forEach( function( coinTerm ) { coinTerm.step( dt ); } ); + // get the property or, if specified, create it + var coinTermCountProperty = this.coinTermCounts[ coinTermTypeID ][ countPropertyIndex ].countProperty; + if ( coinTermCountProperty === null && createIfUndefined ) { - // Update the state of the hints and halos. This has to be done in the step function rather than in the - // event listeners, where much of the other action occurs, because the code needs to figure out which hints and - // halos should be activated and deactivated based on the positions of all coin terms and expressions. - if ( !this.expressionBeingEditedProperty.get() ) { - - // get a list of user controlled expressions, max of one on mouse based systems, any number on touch devices - var userControlledExpressions = _.filter( this.expressions.getArray(), function( expression ) { - return expression.userControlledProperty.get(); - } ); - - var collectionAreasWhoseHalosShouldBeActive = []; - - // Update hints for expressions and collection areas. - userControlledExpressions.forEach( function( userControlledExpression ) { - - var expressionIsOverCreatorBox = userControlledExpression.getBounds().intersectsBounds( self.creatorBoxBounds ); - var mostOverlappingCollectionArea = self.getMostOverlappingCollectionAreaForExpression( userControlledExpression ); - var mostOverlappingExpression = self.getExpressionMostOverlappingWithExpression( userControlledExpression ); - var mostOverlappingCoinTerm = self.getFreeCoinTermMostOverlappingWithExpression( userControlledExpression ); - var expressionOverWhichThisExpressionIsHovering = null; - var coinTermOverWhichThisExpressionIsHovering = null; - - if ( expressionIsOverCreatorBox ) { - // The expression is at least partially over the creator box, which takes precedence over everything else, - // so don't activate any hints or halos. - } - else if ( mostOverlappingCollectionArea ) { - collectionAreasWhoseHalosShouldBeActive.push( mostOverlappingCollectionArea ); - } - else if ( mostOverlappingExpression ) { - expressionOverWhichThisExpressionIsHovering = mostOverlappingExpression; - } - else if ( mostOverlappingCoinTerm ) { - coinTermOverWhichThisExpressionIsHovering = mostOverlappingCoinTerm; - } - - // update hover info for each of the other expressions with respect to this one - self.expressions.forEach( function( expression ) { - - if ( expression === userControlledExpression ) { - // skip self - return; - } - - if ( expression === expressionOverWhichThisExpressionIsHovering ) { - expression.addHoveringExpression( userControlledExpression ); - } - else { - - // removes it if there, no-op if not - expression.removeHoveringExpression( userControlledExpression ); - } - } ); - - // update overlap info with respect to free coin terms - userControlledExpression.clearHoveringCoinTerms(); - if ( coinTermOverWhichThisExpressionIsHovering ) { - - // there can only be one most overlapping coin term, so out with the old, in with the new - userControlledExpression.addHoveringCoinTerm( mostOverlappingCoinTerm ); - } - } ); - - // get a list of all user controlled coin terms, max of one coin on mouse-based systems, any number on touch devices - userControlledCoinTerms = _.filter( this.coinTerms.getArray(), function( coin ) { - return coin.userControlledProperty.get(); - } ); - - // check each user-controlled coin term to see if it's in a position to combine with an expression or another - // coin term - var neededExpressionHints = []; - userControlledCoinTerms.forEach( function( userControlledCoinTerm ) { - - var coinTermIsOverCreatorBox = userControlledCoinTerm.getViewBounds().intersectsBounds( self.creatorBoxBounds ); - var mostOverlappingCollectionArea = self.getMostOverlappingCollectionAreaForCoinTerm( userControlledCoinTerm ); - var mostOverlappingExpression = self.getExpressionMostOverlappingWithCoinTerm( userControlledCoinTerm ); - var mostOverlappingLikeCoinTerm = self.getMostOverlappingLikeCoinTerm( userControlledCoinTerm ); - var joinableFreeCoinTerm = self.checkForJoinableFreeCoinTerm( userControlledCoinTerm ); - var expressionOverWhichCoinTermIsHovering = null; - - if ( coinTermIsOverCreatorBox ) { - // The coin term is over the creator box, which takes precedence over everything else, so don't activate any - // hints or halos. - } - else if ( mostOverlappingCollectionArea ) { - - // the coin term is over a collection area, so activate that collection area's hint - collectionAreasWhoseHalosShouldBeActive.push( mostOverlappingCollectionArea ); - } - else if ( mostOverlappingExpression ) { - - // the coin term is over an expression, so add this coin term to the list of those hovering - expressionOverWhichCoinTermIsHovering = mostOverlappingExpression; - } - else if ( mostOverlappingLikeCoinTerm ) { - - // activate halos for overlapping coin terms - coinTermsWithHalos.push( userControlledCoinTerm ); - coinTermsWithHalos.push( mostOverlappingLikeCoinTerm ); - } - else if ( joinableFreeCoinTerm ) { - - // this coin term is positioned such that it could join a free coin term, so add a hint - neededExpressionHints.push( new ExpressionHint( joinableFreeCoinTerm, userControlledCoinTerm ) ); - } - - // update hover info for each expression with respect to this coin term - self.expressions.forEach( function( expression ) { - if ( expression === expressionOverWhichCoinTermIsHovering ) { - expression.addHoveringCoinTerm( userControlledCoinTerm ); - } - else { - expression.removeHoveringCoinTerm( userControlledCoinTerm ); - } - } ); - } ); - - // update the expression hints for single coins that could combine into expressions - if ( neededExpressionHints.length > 0 ) { - - // remove any expression hints that are no longer needed - this.expressionHints.forEach( function( existingExpressionHint ) { - var matchFound = false; - neededExpressionHints.forEach( function( neededExpressionHint ) { - if ( neededExpressionHint.equals( existingExpressionHint ) ) { - matchFound = true; - } - } ); - if ( !matchFound ) { - self.removeExpressionHint( existingExpressionHint ); - } - } ); - - // add any needed expression hints that are not yet on the list - neededExpressionHints.forEach( function( neededExpressionHint ) { - var matchFound = false; - self.expressionHints.forEach( function( existingExpressionHint ) { - if ( existingExpressionHint.equals( neededExpressionHint ) ) { - matchFound = true; - } - } ); - if ( !matchFound ) { - self.expressionHints.add( neededExpressionHint ); - } - } ); - } - else { - self.expressionHints.forEach( function( existingExpressionHint ) { - self.removeExpressionHint( existingExpressionHint ); - } ); - } - - // update hover info for each collection area - self.collectionAreas.forEach( function( collectionArea ) { - collectionArea.haloActiveProperty.set( - collectionAreasWhoseHalosShouldBeActive.indexOf( collectionArea ) >= 0 - ); - } ); - - // step the expressions - this.expressions.forEach( function( expression ) { - expression.step( dt ); - } ); - } - else { - // The stepping behavior is significantly different - basically much simpler - when an expression is being - // edited. The individual expressions are not stepped at all to avoid activating halos, updating layouts, and - // so forth. Interaction between coin terms and expressions is not tested. Only overlap between two like - // coins is tested so that their halos can be activated. - - // get a list of all user controlled coins, max of one coin on mouse-based systems, any number on touch devices - userControlledCoinTerms = _.filter( this.coinTerms.getArray(), function( coinTerm ) { - return coinTerm.userControlledProperty.get(); - } ); - - // check for overlap between coins that can combine - userControlledCoinTerms.forEach( function( userControlledCoinTerm ) { - - var overlappingCoinTerm = self.getOverlappingLikeCoinTermWithinExpression( - userControlledCoinTerm, - self.expressionBeingEditedProperty.get() - ); - - if ( overlappingCoinTerm ) { - - // these coin terms can be combined, so they should have their halos activated - coinTermsWithHalos.push( userControlledCoinTerm ); - coinTermsWithHalos.push( overlappingCoinTerm ); - } - } ); - } - - // go through all coin terms and update the state of their combine halos - this.coinTerms.forEach( function( coinTerm ) { - coinTerm.combineHaloActiveProperty.set( coinTermsWithHalos.indexOf( coinTerm ) !== -1 ); - } ); - }, - - // @public - addCoinTerm: function( coinTerm ) { - this.coinTerms.add( coinTerm ); - this.updateCoinTermCounts( coinTerm.typeID ); - expressionExchange.log && expressionExchange.log( - 'added ' + coinTerm.id + ', composition = [' + coinTerm.composition + ']' - ); - }, - - // @public - removeCoinTerm: function( coinTerm, animate ) { - - // remove the coin term from any expressions - this.expressions.forEach( function( expression ) { - if ( expression.containsCoinTerm( coinTerm ) ) { - expression.removeCoinTerm( coinTerm ); - } - } ); - - if ( animate ) { - // send the coin term back to its origin - the final steps of its removal will take place when it gets there - coinTerm.returnToOrigin(); - } - else { - - expressionExchange.log && expressionExchange.log( 'removed ' + coinTerm.id ); - this.coinTerms.remove( coinTerm ); - this.updateCoinTermCounts( coinTerm.typeID ); - } - }, - - /** - * get a property that represents the count in the model of coin terms of the given type and min decomposition - * @param {CoinTermTypeID} coinTermTypeID - * @param {number} minimumDecomposition - miniumum amount into which the coin term can be decomposed - * @param {boolean} createIfUndefined - * @public - */ - getCoinTermCountProperty: function( coinTermTypeID, minimumDecomposition, createIfUndefined ) { - assert && assert( this.coinTermCounts.hasOwnProperty( coinTermTypeID ), 'unrecognized coin term type ID' ); - assert && assert( minimumDecomposition !== 0, 'minimumDecomposition cannot be 0' ); - - // Calculate the corresponding index into the data structure - this is necessary in order to support negative - // minimum decomposition values, e.g. -3X. - var countPropertyIndex = minimumDecomposition + EESharedConstants.MAX_NON_DECOMPOSABLE_AMOUNT; - - // get the property or, if specified, create it - var coinTermCountProperty = this.coinTermCounts[ coinTermTypeID ][ countPropertyIndex ].countProperty; - if ( coinTermCountProperty === null && createIfUndefined ) { - - // the requested count property does not yet exist - create and add it - coinTermCountProperty = new Property( 0 ); - coinTermCountProperty.set( this.coinTermCounts[ coinTermTypeID ][ countPropertyIndex ].count ); - this.coinTermCounts[ coinTermTypeID ][ countPropertyIndex ].countProperty = coinTermCountProperty; - } + // the requested count property does not yet exist - create and add it + coinTermCountProperty = new Property( 0 ); + coinTermCountProperty.set( this.coinTermCounts[ coinTermTypeID ][ countPropertyIndex ].count ); + this.coinTermCounts[ coinTermTypeID ][ countPropertyIndex ].countProperty = coinTermCountProperty; + } return coinTermCountProperty; }, @@ -995,6 +649,366 @@ define( function( require ) { return mostOverlappingCollectionArea; }, + /** + * handler for when a coin term is added to the model, hooks up a bunch of listeners + * @param addedCoinTerm + * @private + */ + coinTermAddedListener: function( addedCoinTerm ) { + + var self = this; + + // Add a listener that will potentially combine this coin term with expressions or other coin terms based on + // where it is released. + function coinTermUserControlledListener( userControlled ) { + + if ( userControlled === false ) { + + // Set a bunch of variables related to the current state of this coin term. It's not really necessary to set + // them all every time, but it avoids a deeply nested if-else structure. + var releasedOverCreatorBox = addedCoinTerm.getViewBounds().intersectsBounds( self.creatorBoxBounds ); + var expressionBeingEdited = self.expressionBeingEditedProperty.get(); + var mostOverlappingCollectionArea = self.getMostOverlappingCollectionAreaForCoinTerm( addedCoinTerm ); + var mostOverlappingExpression = self.getExpressionMostOverlappingWithCoinTerm( addedCoinTerm ); + var mostOverlappingLikeCoinTerm = self.getMostOverlappingLikeCoinTerm( addedCoinTerm ); + var joinableFreeCoinTerm = self.checkForJoinableFreeCoinTerm( addedCoinTerm ); + + if ( expressionBeingEdited ) { + + // An expression is being edited, so a released coin term could be either moved to a new location within an + // expression or combined with another coin term in the expression. + + // state checking + assert && assert( + expressionBeingEdited.coinTerms.contains( addedCoinTerm ), + 'coin term being released is not in expression being edited, this should not occur' + ); + + // determine if the coin term was dropped while overlapping a coin term of the same type + var overlappingLikeCoinTerm = self.getOverlappingLikeCoinTermWithinExpression( + addedCoinTerm, + expressionBeingEdited + ); + + if ( overlappingLikeCoinTerm ) { + + // combine the dropped coin term with the one with which it overlaps + overlappingLikeCoinTerm.absorb( addedCoinTerm, self.partialCancellationEnabled ); + expressionExchange.log && expressionExchange.log( + overlappingLikeCoinTerm.id + ' absorbed ' + addedCoinTerm.id + ', ' + overlappingLikeCoinTerm.id + + ' composition = ' + '[' + overlappingLikeCoinTerm.composition + ']' ); + self.removeCoinTerm( addedCoinTerm, false ); + } + else { + + // the coin term has been dropped at some potentially new location withing the expression + expressionBeingEdited.reintegrateCoinTerm( addedCoinTerm ); + } + } + else if ( releasedOverCreatorBox ) { + + // the user has put this coin term back in the creator box, so remove it + self.removeCoinTerm( addedCoinTerm, true ); + } + else if ( mostOverlappingCollectionArea ) { + + // The coin term was released over a collection area (this only occurs on game screens). Notify the + // collection area so that it can either collect or reject it. + mostOverlappingCollectionArea.collectOrRejectCoinTerm( addedCoinTerm ); + } + else if ( mostOverlappingExpression ) { + + // the user is adding the coin term to an expression + mostOverlappingExpression.addCoinTerm( addedCoinTerm ); + expressionExchange.log && expressionExchange.log( 'added ' + addedCoinTerm.id + ' to ' + + mostOverlappingExpression.id ); + } + else if ( mostOverlappingLikeCoinTerm ) { + + // The coin term was released over a coin term of the same type, so combine the two coin terms into a single + // one with a higher count value. + addedCoinTerm.destinationReachedEmitter.addListener( function destinationReachedListener() { + mostOverlappingLikeCoinTerm.absorb( addedCoinTerm, self.partialCancellationEnabled ); + expressionExchange.log && expressionExchange.log( + mostOverlappingLikeCoinTerm.id + ' absorbed ' + addedCoinTerm.id + ', ' + + mostOverlappingLikeCoinTerm.id + ' composition = ' + '[' + + mostOverlappingLikeCoinTerm.composition + ']' ); + self.removeCoinTerm( addedCoinTerm, false ); + addedCoinTerm.destinationReachedEmitter.removeListener( destinationReachedListener ); + } ); + addedCoinTerm.travelToDestination( mostOverlappingLikeCoinTerm.positionProperty.get() ); + } + else if ( joinableFreeCoinTerm ) { + + // The coin term was released in a place where it could join another free coin term. + var expressionHintToRemove; + self.expressionHints.forEach( function( expressionHint ) { + if ( expressionHint.containsCoinTerm( addedCoinTerm ) && expressionHint.containsCoinTerm( joinableFreeCoinTerm ) ) { + expressionHintToRemove = expressionHint; + } + } ); + if ( expressionHintToRemove ) { + self.removeExpressionHint( expressionHintToRemove ); + } + + // create the next expression with these coin terms + self.expressions.push( new Expression( + joinableFreeCoinTerm, + addedCoinTerm, + self.simplifyNegativesProperty + ) ); + } + } + } + + addedCoinTerm.userControlledProperty.lazyLink( coinTermUserControlledListener ); + + // add a listener that will handle requests to break apart the coin term + function coinTermBreakApartListener() { + + if ( addedCoinTerm.composition.length < 2 ) { + // bail if the coin term can't be decomposed + return; + } + var extractedCoinTerms = addedCoinTerm.extractConstituentCoinTerms(); + var relativeViewBounds = addedCoinTerm.localViewBoundsProperty.get(); + + // If the total combined coin count was even, shift the 'parent coin' a bit so that the coins end up being + // distributed around the centerX position. + if ( extractedCoinTerms.length % 2 === 1 ) { + addedCoinTerm.travelToDestination( + new Vector2( + addedCoinTerm.positionProperty.get().x - relativeViewBounds.width / 2 - BREAK_APART_SPACING / 2, + addedCoinTerm.positionProperty.get().y + ) + ); + } + + // add the extracted coin terms to the model + var interCoinTermDistance = relativeViewBounds.width + BREAK_APART_SPACING; + var nextLeftX = addedCoinTerm.destinationProperty.get().x - interCoinTermDistance; + var nextRightX = addedCoinTerm.destinationProperty.get().x + interCoinTermDistance; + extractedCoinTerms.forEach( function( extractedCoinTerm, index ) { + var destination; + self.addCoinTerm( extractedCoinTerm ); + if ( index % 2 === 0 ) { + destination = new Vector2( nextRightX, addedCoinTerm.positionProperty.get().y ); + nextRightX += interCoinTermDistance; + } + else { + destination = new Vector2( nextLeftX, addedCoinTerm.positionProperty.get().y ); + nextLeftX -= interCoinTermDistance; + } + + // if the destination is outside of the allowed bounds, change it to be in bounds + if ( !self.coinTermRetrievalBounds.containsPoint( destination ) ) { + destination = self.getNextOpenRetrievalSpot(); + } + + // initiate the animation + extractedCoinTerm.travelToDestination( destination ); + } ); + } + + addedCoinTerm.breakApartEmitter.addListener( coinTermBreakApartListener ); + + // add a listener that will remove this coin if and when it returns to its original position + function coinTermReturnedToOriginListener() { + self.removeCoinTerm( addedCoinTerm, false ); + } + + addedCoinTerm.returnedToOriginEmitter.addListener( coinTermReturnedToOriginListener ); + + // monitor the existence strength of this coin term + function coinTermExistenceStrengthListener( existenceStrength ) { + + if ( existenceStrength <= 0 ) { + + // the existence strength has gone to zero, remove this from the model + self.removeCoinTerm( addedCoinTerm, false ); + + if ( self.expressionBeingEditedProperty.get() ) { + if ( self.expressionBeingEditedProperty.get().coinTerms.length === 0 ) { + + // the removal of the coin term caused the expression being edited to be empty, so drop out of edit mode + self.stopEditingExpression(); + } + } + } + } + + addedCoinTerm.existenceStrengthProperty.link( coinTermExistenceStrengthListener ); + + // clean up the listeners added above if and when this coin term is removed from the model + self.coinTerms.addItemRemovedListener( function coinTermRemovalListener( removedCoinTerm ) { + if ( removedCoinTerm === addedCoinTerm ) { + addedCoinTerm.userControlledProperty.unlink( coinTermUserControlledListener ); + addedCoinTerm.breakApartEmitter.removeListener( coinTermBreakApartListener ); + addedCoinTerm.returnedToOriginEmitter.removeListener( coinTermReturnedToOriginListener ); + addedCoinTerm.existenceStrengthProperty.unlink( coinTermExistenceStrengthListener ); + self.coinTerms.removeItemRemovedListener( coinTermRemovalListener ); + } + } ); + }, + + /** + * handle the addition of an expresion to the model + * @param {Expression} addedExpression + * @private + */ + expressionAddedListener: function( addedExpression ) { + var self = this; + + // add a listener for when the expression is released, which may cause it to be combined with another expression + function expressionUserControlledListener( userControlled ) { + + if ( !userControlled ) { + + // Set a bunch of variables related to the current state of this expression. It's not really necessary to set + // them all every time, but it avoids a deeply nested if-else structure. + var releasedOverCreatorBox = addedExpression.getBounds().intersectsBounds( self.creatorBoxBounds ); + var mostOverlappingCollectionArea = self.getMostOverlappingCollectionAreaForExpression( addedExpression ); + var mostOverlappingExpression = self.getExpressionMostOverlappingWithExpression( addedExpression ); + var numOverlappingCoinTerms = addedExpression.hoveringCoinTerms.length; + + // state checking + assert && assert( + numOverlappingCoinTerms === 0 || numOverlappingCoinTerms === 1, + 'max of one overlapping free coin term when expression is released, seeing ' + numOverlappingCoinTerms + ); + + if ( releasedOverCreatorBox ) { + + // the expression was released over the creator box, so it and the coin terms should be "put away" + self.removeExpression( addedExpression ); + } + else if ( mostOverlappingCollectionArea ) { + + // The expression was released in a location that at least partially overlaps a collection area. The + // collection area must decide whether to collect or reject the expression. + mostOverlappingCollectionArea.collectOrRejectExpression( addedExpression ); + } + else if ( mostOverlappingExpression ) { + + // The expression was released in a place where it at least partially overlaps another expression, the the + // two expressions should be joined into one. The first step is to remove the expression from the list of + // those hovering. + mostOverlappingExpression.removeHoveringExpression( addedExpression ); + + // send the combining expression to the right side of receiving expression + var destinationForCombine = mostOverlappingExpression.getUpperRightCorner(); + addedExpression.travelToDestination( destinationForCombine ); + + // Listen for when the expression is in place and, when it is, transfer its coin terms to the receiving + // expression. + addedExpression.destinationReachedEmitter.addListener( function destinationReachedListener() { + + // destination reached, combine with other expression, but ONLY if it hasn't moved + if ( mostOverlappingExpression.getUpperRightCorner().equals( destinationForCombine ) ) { + var coinTermsToBeMoved = addedExpression.removeAllCoinTerms(); + self.expressions.remove( addedExpression ); + coinTermsToBeMoved.forEach( function( coinTerm ) { + expressionExchange.log && expressionExchange.log( 'moving ' + coinTerm.id + ' from ' + + addedExpression.id + ' to ' + + mostOverlappingExpression.id ); + mostOverlappingExpression.addCoinTerm( coinTerm ); + } ); + addedExpression.destinationReachedEmitter.removeListener( destinationReachedListener ); + } + } ); + } + else if ( numOverlappingCoinTerms === 1 ) { + + // the expression was released over a free coin term, so have that free coin term join the expression + var coinTermToAddToExpression = addedExpression.hoveringCoinTerms[ 0 ]; + if ( addedExpression.rightHintActiveProperty.get() ) { + + // move to the left side of the coin term + addedExpression.travelToDestination( + coinTermToAddToExpression.positionProperty.get().plusXY( + -addedExpression.widthProperty.get() - addedExpression.rightHintWidthProperty.get() / 2, + -addedExpression.heightProperty.get() / 2 + ) + ); + } + else { + + assert && assert( + addedExpression.leftHintActiveProperty.get(), + 'at least one hint should be active if there is a hovering coin term' + ); + + // move to the right side of the coin term + addedExpression.travelToDestination( + coinTermToAddToExpression.positionProperty.get().plusXY( + addedExpression.leftHintWidthProperty.get() / 2, + -addedExpression.heightProperty.get() / 2 + ) + ); + } + + addedExpression.destinationReachedEmitter.addListener( function addCoinTermAfterAnimation() { + addedExpression.addCoinTerm( coinTermToAddToExpression ); + addedExpression.destinationReachedEmitter.removeListener( addCoinTermAfterAnimation ); + } ); + } + } + } + + addedExpression.userControlledProperty.lazyLink( expressionUserControlledListener ); + + // add a listener that will handle requests to break apart this expression + function expressionBreakApartListener() { + + // keep a reference to the center for when we spread out the coin terms + var expressionCenterX = addedExpression.getBounds().centerX; + + // remove the coin terms from the expression and the expression from the model + var newlyFreedCoinTerms = addedExpression.removeAllCoinTerms(); + self.expressions.remove( addedExpression ); + + // spread the released coin terms out horizontally + //var numRetrievedCoinTerms = 0; + newlyFreedCoinTerms.forEach( function( newlyFreedCoinTerm ) { + + // calculate a destination that will cause the coin terms to spread out from the expression center + var horizontalDistanceFromExpressionCenter = newlyFreedCoinTerm.positionProperty.get().x - expressionCenterX; + var coinTermDestination = new Vector2( + newlyFreedCoinTerm.positionProperty.get().x + horizontalDistanceFromExpressionCenter * 0.15, // spread factor empirically determined + newlyFreedCoinTerm.positionProperty.get().y + ); + + // if the destination is outside of the allowed bounds, change it to be in bounds + if ( !self.coinTermRetrievalBounds.containsPoint( coinTermDestination ) ) { + coinTermDestination = self.getNextOpenRetrievalSpot(); + } + + // initiate the animation + newlyFreedCoinTerm.travelToDestination( coinTermDestination ); + } ); + } + + addedExpression.breakApartEmitter.addListener( expressionBreakApartListener ); + + // add a listener that will handle requests to edit this expression + function editExpressionListener() { + self.expressionBeingEditedProperty.set( addedExpression ); + } + + addedExpression.selectedForEditEmitter.addListener( editExpressionListener ); + + // remove the listeners when this expression is removed + self.expressions.addItemRemovedListener( function expressionRemovedListener( removedExpression ) { + if ( removedExpression === addedExpression ) { + addedExpression.dispose(); + addedExpression.userControlledProperty.unlink( expressionUserControlledListener ); + addedExpression.breakApartEmitter.removeListener( expressionBreakApartListener ); + addedExpression.selectedForEditEmitter.removeListener( editExpressionListener ); + self.expressions.removeItemRemovedListener( expressionRemovedListener ); + } + } ); + }, + /** * @public */ diff --git a/js/common/view/CoinTermCreatorBox.js b/js/common/view/CoinTermCreatorBox.js index 947a8ab4..205571a1 100644 --- a/js/common/view/CoinTermCreatorBox.js +++ b/js/common/view/CoinTermCreatorBox.js @@ -71,9 +71,23 @@ define( function( require ) { this.addChild( this.coinTermCreatorBox ); this.mutate( options ); + + // add a dispose function + this.disposeCoinTermCreatorBox = function() { + creatorNodes.forEach( function( creatorNode ) { creatorNode.dispose(); } ); + }; } expressionExchange.register( 'CoinTermCreatorBox', CoinTermCreatorBox ); - return inherit( Node, CoinTermCreatorBox ); + return inherit( Node, CoinTermCreatorBox, { + + /** + * @public + */ + dispose: function() { + this.disposeCoinTermCreatorBox(); + Node.prototype.dispose.call( this ); + } + } ); } ); \ No newline at end of file diff --git a/js/common/view/CoinTermCreatorBoxFactory.js b/js/common/view/CoinTermCreatorBoxFactory.js index a564b72e..7bffb290 100644 --- a/js/common/view/CoinTermCreatorBoxFactory.js +++ b/js/common/view/CoinTermCreatorBoxFactory.js @@ -17,6 +17,7 @@ define( function( require ) { var CoinTermCreatorSetID = require( 'EXPRESSION_EXCHANGE/common/enum/CoinTermCreatorSetID' ); var CoinTermCreatorBox = require( 'EXPRESSION_EXCHANGE/common/view/CoinTermCreatorBox' ); var CoinTermCreatorNode = require( 'EXPRESSION_EXCHANGE/common/view/CoinTermCreatorNode' ); + var DerivedProperty = require( 'AXON/DerivedProperty' ); var EESharedConstants = require( 'EXPRESSION_EXCHANGE/common/EESharedConstants' ); var expressionExchange = require( 'EXPRESSION_EXCHANGE/expressionExchange' ); var Property = require( 'AXON/Property' ); @@ -103,18 +104,14 @@ define( function( require ) { function makeGameScreenCreatorNode( typeID, createdCoinTermInitialCount, numInstancesAllowed, model, view ) { // Create a property that will control number of coin terms shown in this creator node. For the game screen, - // multiple creator nodes are shown and a staggered arrangement. - var numberToShowProperty = new Property( numInstancesAllowed ); - var instanceCount = model.getCoinTermCountProperty( typeID, createdCoinTermInitialCount, true ); - - //REVIEW: Potential memory leak here, since it seems like this is called whenever there is a new challenge? - //TODO: The review comment is correct, but it's not immediately obvious to me how to fix it, so revisit later - instanceCount.link( function( count ) { - numberToShowProperty.set( numInstancesAllowed - count ); - } ); + // multiple creator nodes are shown in a staggered arrangement. + var numberToShowProperty = new DerivedProperty( + [ model.getCoinTermCountProperty( typeID, createdCoinTermInitialCount, true ) ], + function( instanceCount ) { return numInstancesAllowed - instanceCount; } + ); // create the "creator node" for the specified coin term type - return new CoinTermCreatorNode( + var coinTermCreatorNode = new CoinTermCreatorNode( model, view, typeID, @@ -128,6 +125,13 @@ define( function( require ) { onCard: true } ); + + // dispose of the derived property in order to avoid memory leaks + coinTermCreatorNode.disposeEmitter.addListener( function() { + numberToShowProperty.dispose(); + } ); + + return coinTermCreatorNode; } diff --git a/js/common/view/CoinTermCreatorNode.js b/js/common/view/CoinTermCreatorNode.js index 69956723..2538dfa3 100644 --- a/js/common/view/CoinTermCreatorNode.js +++ b/js/common/view/CoinTermCreatorNode.js @@ -16,6 +16,7 @@ define( function( require ) { var Bounds2 = require( 'DOT/Bounds2' ); var CoinTermTypeID = require( 'EXPRESSION_EXCHANGE/common/enum/CoinTermTypeID' ); var ConstantCoinTermNode = require( 'EXPRESSION_EXCHANGE/common/view/ConstantCoinTermNode' ); + var Emitter = require( 'AXON/Emitter' ); var expressionExchange = require( 'EXPRESSION_EXCHANGE/expressionExchange' ); var inherit = require( 'PHET_CORE/inherit' ); var Node = require( 'SCENERY/nodes/Node' ); @@ -67,7 +68,9 @@ define( function( require ) { // @public {number} (read only) - initial count of the coin term created by this creator node, a.k.a. the coefficient this.createdCoinTermInitialCount = options.createdCoinTermInitialCount; - this.typeID = typeID; // @public, read only + + this.typeID = typeID; // @public {CoinTermID} (read only) + this.disposeEmitter = new Emitter(); // @public, listen only // add the individual coin term node(s) var coinTermNodes = []; @@ -104,8 +107,8 @@ define( function( require ) { coinTermNodes.push( coinTermNode ); } ); - // control the visibility of the individual coin term nodes - options.numberToShowProperty.link( function( numberToShow ) { + // create a listener that changes the visibility of individual nodes as the number to show changes + function numberToShowListener( numberToShow ) { self.pickable = numberToShow > 0; @@ -122,7 +125,10 @@ define( function( require ) { else { coinTermNodes[ 0 ].opacity = 1; } - } ); + } + + // control the visibility of the individual coin term nodes + options.numberToShowProperty.link( numberToShowListener ); // Add the listener that will allow the user to click on this node and create a new coin term, and then position it // in the model. This works by forwarding the events it receives to the node that gets created in the view. @@ -162,9 +168,28 @@ define( function( require ) { } } } ); + + // dispose function + this.disposeCoinTermCreatorNode = function() { + options.numberToShowProperty.unlink( numberToShowListener ); + + // this type emits an event upon disposal because it was needed to avoid memory leaks + this.disposeEmitter.emit(); + this.disposeEmitter.removeAllListeners(); + //this.disposeEmitter.dispose(); + }; } expressionExchange.register( 'CoinTermCreatorNode', CoinTermCreatorNode ); - return inherit( Node, CoinTermCreatorNode ); + return inherit( Node, CoinTermCreatorNode, { + + /** + * @public + */ + dispose: function() { + this.disposeCoinTermCreatorNode(); + Node.prototype.dispose.call( this ); + } + } ); } ); \ No newline at end of file diff --git a/js/game/view/EEGameLevelView.js b/js/game/view/EEGameLevelView.js index 8c87e677..147c1f40 100644 --- a/js/game/view/EEGameLevelView.js +++ b/js/game/view/EEGameLevelView.js @@ -115,6 +115,7 @@ define( function( require ) { levelModel.currentChallengeProperty.link( function( currentChallenge ) { if ( coinTermCreatorBox ) { middleLayer.removeChild( coinTermCreatorBox ); + coinTermCreatorBox.dispose(); } coinTermCreatorBox = CoinTermCreatorBoxFactory.createGameScreenCreatorBox( currentChallenge,