From fd0289ad553aea084cd1f47b83ff1c9e2f382241 Mon Sep 17 00:00:00 2001 From: chrisklus Date: Wed, 20 Nov 2019 01:08:45 -0500 Subject: [PATCH] Add the number pieces carousel, see #2 --- js/lab/model/LabModel.js | 89 ++++++++++++++++++++++++- js/lab/view/LabNumberCarousel.js | 53 +++++++++++++++ js/lab/view/LabScreenView.js | 109 +++++++++++++++++++++++++++++-- js/number-play-config.js | 1 + package.json | 1 + 5 files changed, 246 insertions(+), 7 deletions(-) create mode 100644 js/lab/view/LabNumberCarousel.js diff --git a/js/lab/model/LabModel.js b/js/lab/model/LabModel.js index 12ca2781..3857bb6d 100644 --- a/js/lab/model/LabModel.js +++ b/js/lab/model/LabModel.js @@ -11,18 +11,24 @@ define( require => { // modules const BooleanProperty = require( 'AXON/BooleanProperty' ); const EnumerationProperty = require( 'AXON/EnumerationProperty' ); + const FractionsCommonConstants = require( 'FRACTIONS_COMMON/common/FractionsCommonConstants' ); const numberPlay = require( 'NUMBER_PLAY/numberPlay' ); + const NumberPiece = require( 'FRACTIONS_COMMON/building/model/NumberPiece' ); const NumberProperty = require( 'AXON/NumberProperty' ); + const NumberStack = require( 'FRACTIONS_COMMON/building/model/NumberStack' ); const ObjectsPlayArea = require( 'NUMBER_PLAY/common/model/ObjectsPlayArea' ); + const ObservableArray = require( 'AXON/ObservableArray' ); const OnesPlayArea = require( 'NUMBER_PLAY/common/model/OnesPlayArea' ); const PlayObjectType = require( 'NUMBER_PLAY/common/model/PlayObjectType' ); const Range = require( 'DOT/Range' ); const Vector2 = require( 'DOT/Vector2' ); + const NUMBER_PIECE_RETURN_THRESHOLD = 92; + /** * @constructor */ - class LabModel { + class LabModel { /** * @param {number} highestCount - the highest integer number that can be counted to @@ -32,6 +38,20 @@ define( require => { */ constructor( highestCount, paperNumberOrigin, objectMaxScale, tandem ) { + // @public {Array.} + this.numberStacks = []; + + // @public {ObservableArray.} - Number pieces in the play area (controlled or animating) + this.activeNumberPieces = new ObservableArray(); + + // Number stacks + _.range( 1, 21 ).map( number => { + const stack = new NumberStack( number, 2, false ); + stack.numberPieces.push( new NumberPiece( number ) ); + stack.numberPieces.push( new NumberPiece( number ) ); + this.numberStacks.push( stack ); + } ); + const bucketOffsetX = 140; // @public (read-only) - the model for managing paper ones in the playArea @@ -61,6 +81,71 @@ define( require => { ); } + /** + * Called when the user drags a number piece from a stack. + * + * @param {NumberPiece} numberPiece + * @public + */ + dragNumberPieceFromStack( numberPiece ) { + this.activeNumberPieces.push( numberPiece ); + } + + /** + * Returns a corresponding NumberStack that should be used as the "home" of a given NumberPiece (if it's returned from + * the play area with an animation, etc.) + * @public + * + * @param {NumberPiece} numberPiece + * @returns {NumberStack|null} + */ + findMatchingNumberStack( numberPiece ) { + return _.find( this.numberStacks, stack => stack.number === numberPiece.number ) || null; + } + + /** + * Animates a piece back to its "home" stack. + * @public + * + * @param {NumberPiece} numberPiece + */ + returnActiveNumberPiece( numberPiece ) { + const numberStack = this.findMatchingNumberStack( numberPiece ); + const offset = NumberStack.getOffset( numberStack.numberPieces.length ); + numberPiece.animator.animateTo( { + position: numberStack.positionProperty.value.plus( offset.timesScalar( FractionsCommonConstants.NUMBER_BUILD_SCALE ) ), + scale: 1, + animationInvalidationProperty: numberStack.positionProperty, + endAnimationCallback: () => { + this.activeNumberPieces.remove( numberPiece ); + if ( numberStack.isMutable ) { + numberStack.numberPieces.push( numberPiece ); + } + } + } ); + } + + /** + * Called when a NumberPiece is dropped by the user. + * @public + * + * @param {NumberPiece} numberPiece + * @param {number} threshold - How much distance to allow between the piece and a container/group for it to be + * dropped inside. + * @param {boolean} animateReturn + */ + numberPieceDropped( numberPiece, threshold, animateReturn ) { + const numberPiecePosition = numberPiece.positionProperty.value; + const sortedNumberStacks = _.sortBy( this.numberStacks, numberStack => { + return numberStack.positionProperty.value.distance( numberPiecePosition ); + } ); + const closestNumberStack = sortedNumberStacks.shift(); + + if ( numberPiecePosition.distance( closestNumberStack.positionProperty.value ) < NUMBER_PIECE_RETURN_THRESHOLD ) { + animateReturn ? this.returnActiveNumberPiece( numberPiece ) : this.activeNumberPieces.remove( numberPiece ); + } + } + /** * Resets the model. * @public @@ -69,6 +154,7 @@ define( require => { this.onesPlayArea.reset(); this.leftObjectsPlayArea.reset(); this.rightObjectsPlayArea.reset(); + this.activeNumberPieces.reset(); } /** @@ -78,6 +164,7 @@ define( require => { */ step( dt ) { this.onesPlayArea.step( dt ); + this.activeNumberPieces.forEach( numberPiece => numberPiece.step( dt ) ); } } diff --git a/js/lab/view/LabNumberCarousel.js b/js/lab/view/LabNumberCarousel.js new file mode 100644 index 00000000..c7b2980f --- /dev/null +++ b/js/lab/view/LabNumberCarousel.js @@ -0,0 +1,53 @@ +// Copyright 2019, University of Colorado Boulder + +/** + * The top carousel with number pieces for the Lab screen. + * + * @author Chris Klusendorf (PhET Interactive Simulations) + */ +define( require => { + 'use strict'; + + // modules + const numberPlay = require( 'NUMBER_PLAY/numberPlay' ); + const Carousel = require( 'SUN/Carousel' ); + const Node = require( 'SCENERY/nodes/Node' ); + const StackNodesBox = require( 'FRACTIONS_COMMON/building/view/StackNodesBox' ); + + class LabNumberCarousel extends Carousel { + /** + * @param {Array.} numberStacks + * @param {number} animationDuration + * @param {function} pressCallback - function( {Event}, {Stack} ) - Called when a press is started. + */ + constructor( numberStacks, animationDuration, pressCallback ) { + const box = new StackNodesBox( [ + ...numberStacks + ], pressCallback ); + + super( box.children.map( stack => { + return new Node().addChild( stack ); + } ), { + itemsPerPage: 10, + margin: 14, + spacing: 8, + animationDuration: animationDuration + } ); + + // @private {StackNodesBox} + this.box = box; + } + + /** + * Sets the model positions of our model objects corresponding to their displayed (view) positions. + * @public + * + * @param {ModelViewTransform2} modelViewTransform + */ + updateModelLocations( modelViewTransform ) { + this.box.updateModelLocations( modelViewTransform, this ); + } + } + + return numberPlay.register( 'LabNumberCarousel', LabNumberCarousel ); +} ); diff --git a/js/lab/view/LabScreenView.js b/js/lab/view/LabScreenView.js index 43b47cfc..435a2a43 100644 --- a/js/lab/view/LabScreenView.js +++ b/js/lab/view/LabScreenView.js @@ -10,9 +10,14 @@ define( require => { // modules const Bounds2 = require( 'DOT/Bounds2' ); + const DragListener = require( 'SCENERY/listeners/DragListener' ); + const LabNumberCarousel = require( 'NUMBER_PLAY/lab/view/LabNumberCarousel' ); const ModelViewTransform2 = require( 'PHETCOMMON/view/ModelViewTransform2' ); + const Node = require( 'SCENERY/nodes/Node' ); const numberPlay = require( 'NUMBER_PLAY/numberPlay' ); const NumberPlayConstants = require( 'NUMBER_PLAY/common/NumberPlayConstants' ); + const NumberPiece = require( 'FRACTIONS_COMMON/building/model/NumberPiece' ); + const NumberPieceNode = require( 'FRACTIONS_COMMON/building/view/NumberPieceNode' ); const ObjectsPlayAreaNode = require( 'NUMBER_PLAY/common/view/ObjectsPlayAreaNode' ); const OnesPlayAreaNode = require( 'NUMBER_PLAY/common/view/OnesPlayAreaNode' ); const ResetAllButton = require( 'SCENERY_PHET/buttons/ResetAllButton' ); @@ -31,28 +36,63 @@ define( require => { tandem: tandem } ); + // @private + this.model = model; + const playAreaViewBounds = new Bounds2( this.layoutBounds.left, this.layoutBounds.top + NumberPlayConstants.SCREEN_VIEW_Y_PADDING, this.layoutBounds.right, this.layoutBounds.bottom - NumberPlayConstants.SCREEN_VIEW_Y_PADDING ); - const translateMVT = ModelViewTransform2.createSinglePointScaleInvertedYMapping( + this.modelViewTransform = ModelViewTransform2.createSinglePointScaleInvertedYMapping( Vector2.ZERO, playAreaViewBounds.centerBottom, 1 ); + // @private {Array.} + this.numberPieceNodes = []; + + const animationDuration = 0.4; // in seconds + + // @private {Node} + this.numberPanel = new LabNumberCarousel( model.numberStacks, animationDuration, ( event, stack ) => { + const modelPoint = this.modelViewTransform.viewToModelPosition( this.globalToLocalPoint( event.pointer.point ) ); + const numberPiece = new NumberPiece( stack.number ); + numberPiece.positionProperty.value = modelPoint; + model.dragNumberPieceFromStack( numberPiece ); + const numberPieceNode = this.getNumberPieceNode( numberPiece ); + numberPieceNode.dragListener.press( event, numberPieceNode ); + } ); + this.numberPanel.centerX = this.layoutBounds.centerX; + this.numberPanel.top = playAreaViewBounds.top; + + this.numberPanel.pageNumberProperty.link( pageNumber => { + + // TODO: The following code needs to run when the Carousel is actually at the page that this Property says it's + // at, but this is not an acceptable solution. + setTimeout( () => { + this.numberPanel.updateModelLocations( this.modelViewTransform ); + }, animationDuration * 1000 ); + } ); + + model.activeNumberPieces.addItemAddedListener( this.addNumberPiece.bind( this ) ); + model.activeNumberPieces.addItemRemovedListener( this.removeNumberPiece.bind( this ) ); + // create and add the OnesPlayAreaNode - const onesPlayAreaNode = new OnesPlayAreaNode( model.onesPlayArea, playAreaViewBounds, translateMVT ); + const onesPlayAreaNode = new OnesPlayAreaNode( model.onesPlayArea, playAreaViewBounds, this.modelViewTransform ); this.addChild( onesPlayAreaNode ); + // the one's play area covers other nodes, so add the numberPanel after + this.addChild( this.numberPanel ); + // create and add the left ObjectsPlayAreaNode - const playAreaModelBounds = translateMVT.viewToModelBounds( playAreaViewBounds ).dilatedX( -20 ).dilatedY( -19 ); + const playAreaModelBounds = this.modelViewTransform.viewToModelBounds( playAreaViewBounds ).dilatedX( -20 ).dilatedY( -19 ); const leftObjectsPlayAreaNode = new ObjectsPlayAreaNode( model.leftObjectsPlayArea, playAreaModelBounds, - translateMVT + this.modelViewTransform ); this.addChild( leftObjectsPlayAreaNode ); @@ -60,16 +100,19 @@ define( require => { const rightObjectsPlayAreaNode = new ObjectsPlayAreaNode( model.rightObjectsPlayArea, playAreaModelBounds, - translateMVT + this.modelViewTransform ); this.addChild( rightObjectsPlayAreaNode ); + // TODO: all pieces in this ScreenView need to use this layer, not just the number pieces + this.pieceLayer = new Node(); + this.addChild( this.pieceLayer ); + // create and add the ResetAllButton const resetAllButton = new ResetAllButton( { listener: () => { this.interruptSubtreeInput(); // cancel interactions that may be in progress model.reset(); - this.reset(); }, right: this.layoutBounds.maxX - NumberPlayConstants.SCREEN_VIEW_X_PADDING, bottom: this.layoutBounds.maxY - NumberPlayConstants.SCREEN_VIEW_Y_PADDING, @@ -77,6 +120,60 @@ define( require => { } ); this.addChild( resetAllButton ); } + + /** + * Returns the corresponding NumberPieceNode for a given NumberPiece. + * @public + * + * @param {NumberPiece} numberPiece + * @returns {NumberPieceNode} + */ + getNumberPieceNode( numberPiece ) { + return _.find( this.numberPieceNodes, numberPieceNode => numberPieceNode.numberPiece === numberPiece ); + } + + /** + * Called when a new NumberPiece is added to the model (we'll create the view). + * @private + * + * @param {NumberPiece} numberPiece + */ + addNumberPiece( numberPiece ) { + const numberPieceNode = new NumberPieceNode( numberPiece, { + positioned: true, + modelViewTransform: this.modelViewTransform, + dropListener: wasTouch => { + this.model.numberPieceDropped( + numberPiece, + wasTouch ? 50 : 20, + ( numberPiece.number < 11 && this.numberPanel.pageNumberProperty.value === 0 ) || + ( numberPiece.number > 10 && this.numberPanel.pageNumberProperty.value === 1 ) + ); + } + } ); + + numberPieceNode.cursor = 'pointer'; + numberPieceNode.inputListeners = [ DragListener.createForwardingListener( event => { + numberPieceNode.dragListener.press( event, numberPieceNode ); + } ) ]; + + this.numberPieceNodes.push( numberPieceNode ); + this.pieceLayer.addChild( numberPieceNode ); + } + + /** + * Called when a NumberPiece is removed from the model (we'll remove the view). + * @private + * + * @param {NumberPiece} numberPiece + */ + removeNumberPiece( numberPiece ) { + const numberPieceNode = _.find( this.numberPieceNodes, numberPieceNode => numberPieceNode.numberPiece === numberPiece ); + + _.pull( this.numberPieceNodes, numberPieceNode ); + this.pieceLayer.removeChild( numberPieceNode ); + numberPieceNode.dispose(); + } } return numberPlay.register( 'LabScreenView', LabScreenView ); diff --git a/js/number-play-config.js b/js/number-play-config.js index cb3f0687..e70b8489 100644 --- a/js/number-play-config.js +++ b/js/number-play-config.js @@ -29,6 +29,7 @@ require.config( { AXON: '../../axon/js', BRAND: '../../brand/' + phet.chipper.brand + '/js', DOT: '../../dot/js', + FRACTIONS_COMMON: '../../fractions-common/js', JOIST: '../../joist/js', KITE: '../../kite/js', MAKE_A_TEN: '../../make-a-ten/js', diff --git a/package.json b/package.json index 45bbfb4d..9b309390 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "phet": { "requirejsNamespace": "NUMBER_PLAY", "phetLibs": [ + "fractions-common", "make-a-ten", "twixt" ],