diff --git a/src/components/stage/stage.css b/src/components/stage/stage.css index 9264c094e51bd6e20f897f41be0c4c04c5eac5e6..7e0a59c2e786842feb556423fc0d4c08ffec81f2 100644 --- a/src/components/stage/stage.css +++ b/src/components/stage/stage.css @@ -89,7 +89,10 @@ to adjust for the border using a different method */ padding-bottom: calc($stage-full-screen-stage-padding + $stage-full-screen-border-width); } -.monitor-wrapper, .color-picker-wrapper, .frame-wrapper { +.monitor-wrapper, +.color-picker-wrapper, +.frame-wrapper, +.green-flag-overlay-wrapper { position: absolute; top: 0; left: 0; @@ -138,6 +141,36 @@ to adjust for the border using a different method */ animation-fill-mode: forwards; /* Leave at 0 opacity after animation */ } +.green-flag-overlay-wrapper { + display: flex; + justify-content: center; + align-items: center; + background: rgba(0,0,0,0.25); + border-radius: 0.5rem; +} + +.green-flag-overlay { + padding: 1rem; + border-radius: 100%; + background: rgba(255,255,255,0.75); + border: 3px solid $ui-white; + pointer-events: all; + cursor: pointer; + display: flex; + justify-content: center; + align-items: center; + box-sizing: border-box; + width: 5rem; + height: 5rem; +} + +.green-flag-overlay > img { + width: 100%; + object-fit: contain; +} + + + @keyframes flash { 0% { opacity: 1; } 100% { opacity: 0; } diff --git a/src/components/stage/stage.jsx b/src/components/stage/stage.jsx index ede5048ea8a2c96e7b5535b2b63651d88e16099b..6d81443f893a9c0126d91f950f996c855b6f0b22 100644 --- a/src/components/stage/stage.jsx +++ b/src/components/stage/stage.jsx @@ -7,6 +7,7 @@ import DOMElementRenderer from '../../containers/dom-element-renderer.jsx'; import Loupe from '../loupe/loupe.jsx'; import MonitorList from '../../containers/monitor-list.jsx'; import TargetHighlight from '../../containers/target-highlight.jsx'; +import GreenFlagOverlay from '../../containers/green-flag-overlay.jsx'; import Question from '../../containers/question.jsx'; import MicIndicator from '../mic-indicator/mic-indicator.jsx'; import {STAGE_DISPLAY_SIZES} from '../../lib/layout-constants.js'; @@ -71,6 +72,11 @@ const StageComponent = props => { stageWidth={stageDimensions.width} /> </Box> + <Box className={styles.greenFlagOverlayWrapper}> + <GreenFlagOverlay + className={styles.greenFlagOverlay} + /> + </Box> {isColorPicking && colorInfo ? ( <Box className={styles.colorPickerWrapper}> <Loupe colorInfo={colorInfo} /> diff --git a/src/containers/controls.jsx b/src/containers/controls.jsx index 2869a047a6ba5722762d86f32875419cd14cfb0b..db052b494de54a06fae7c494cab363c143f5cab0 100644 --- a/src/containers/controls.jsx +++ b/src/containers/controls.jsx @@ -20,6 +20,9 @@ class Controls extends React.Component { if (e.shiftKey) { this.props.vm.setTurboMode(!this.props.turbo); } else { + if (!this.props.isStarted) { + this.props.vm.start(); + } this.props.vm.greenFlag(); analytics.event({ category: 'general', @@ -38,6 +41,7 @@ class Controls extends React.Component { render () { const { vm, // eslint-disable-line no-unused-vars + isStarted, // eslint-disable-line no-unused-vars projectRunning, turbo, ...props @@ -55,12 +59,14 @@ class Controls extends React.Component { } Controls.propTypes = { + isStarted: PropTypes.bool.isRequired, projectRunning: PropTypes.bool.isRequired, turbo: PropTypes.bool.isRequired, vm: PropTypes.instanceOf(VM) }; const mapStateToProps = state => ({ + isStarted: state.scratchGui.vmStatus.running, projectRunning: state.scratchGui.vmStatus.running, turbo: state.scratchGui.vmStatus.turbo }); diff --git a/src/containers/green-flag-overlay.jsx b/src/containers/green-flag-overlay.jsx new file mode 100644 index 0000000000000000000000000000000000000000..2d0e4135ff241e30666414e133241261c05fe1d3 --- /dev/null +++ b/src/containers/green-flag-overlay.jsx @@ -0,0 +1,55 @@ +import bindAll from 'lodash.bindall'; +import React from 'react'; +import PropTypes from 'prop-types'; + +import {connect} from 'react-redux'; +import VM from 'scratch-vm'; +import greenFlag from '../components/green-flag/icon--green-flag.svg'; + +class GreenFlagOverlay extends React.Component { + constructor (props) { + super(props); + bindAll(this, [ + 'handleClick' + ]); + } + + handleClick () { + this.props.vm.start(); + this.props.vm.greenFlag(); + } + + render () { + if (this.props.isStarted) return null; + + return ( + <div + className={this.props.className} + onClick={this.handleClick} + > + <img + draggable={false} + src={greenFlag} + /> + </div> + ); + } +} + +GreenFlagOverlay.propTypes = { + className: PropTypes.string, + isStarted: PropTypes.bool, + vm: PropTypes.instanceOf(VM) +}; + +const mapStateToProps = state => ({ + isStarted: state.scratchGui.vmStatus.started, + vm: state.scratchGui.vm +}); + +const mapDispatchToProps = () => ({}); + +export default connect( + mapStateToProps, + mapDispatchToProps +)(GreenFlagOverlay); diff --git a/src/containers/green-flag.jsx b/src/containers/green-flag.jsx deleted file mode 100644 index a725b7e1ba6d198f69cc36da32c676ac9e350d12..0000000000000000000000000000000000000000 --- a/src/containers/green-flag.jsx +++ /dev/null @@ -1,60 +0,0 @@ -import bindAll from 'lodash.bindall'; -import PropTypes from 'prop-types'; -import React from 'react'; - -import VM from 'scratch-vm'; - -import GreenFlagComponent from '../components/green-flag/green-flag.jsx'; - -class GreenFlag extends React.Component { - constructor (props) { - super(props); - bindAll(this, [ - 'handleClick', - 'onProjectRunStart', - 'onProjectRunStop' - ]); - this.state = {projectRunning: false}; - } - componentDidMount () { - this.props.vm.addListener('PROJECT_RUN_START', this.onProjectRunStart); - this.props.vm.addListener('PROJECT_RUN_STOP', this.onProjectRunStop); - } - componentWillUnmount () { - this.props.vm.removeListener('PROJECT_RUN_START', this.onProjectRunStart); - this.props.vm.removeListener('PROJECT_RUN_STOP', this.onProjectRunStop); - } - onProjectRunStart () { - this.setState({projectRunning: true}); - } - onProjectRunStop () { - this.setState({projectRunning: false}); - } - handleClick (e) { - e.preventDefault(); - if (e.shiftKey) { - this.props.vm.setTurboMode(!this.props.vm.runtime.turboMode); - } else { - this.props.vm.greenFlag(); - } - } - render () { - const { - vm, // eslint-disable-line no-unused-vars - ...props - } = this.props; - return ( - <GreenFlagComponent - active={this.state.projectRunning} - onClick={this.handleClick} - {...props} - /> - ); - } -} - -GreenFlag.propTypes = { - vm: PropTypes.instanceOf(VM) -}; - -export default GreenFlag; diff --git a/src/containers/stage.jsx b/src/containers/stage.jsx index 888dfa1c6fa105a4038a4c88f8ff7ce7812f7465..077006fccc29be381fe66b0ae4b224437a9e98e9 100644 --- a/src/containers/stage.jsx +++ b/src/containers/stage.jsx @@ -59,6 +59,12 @@ class Stage extends React.Component { this.canvas = document.createElement('canvas'); this.renderer = new Renderer(this.canvas); this.props.vm.attachRenderer(this.renderer); + + // Calling draw a single time before any project is loaded just makes + // the canvas white instead of solid black–needed because it is not + // possible to use CSS to style the canvas to have a different + // default color + this.props.vm.renderer.draw(); } this.props.vm.attachV2SVGAdapter(new V2SVGAdapter()); this.props.vm.attachV2BitmapAdapter(new V2BitmapAdapter()); diff --git a/src/containers/stop-all.jsx b/src/containers/stop-all.jsx deleted file mode 100644 index 0f7641a20b7f9db5c0769ca89374cb1faeef1b4f..0000000000000000000000000000000000000000 --- a/src/containers/stop-all.jsx +++ /dev/null @@ -1,55 +0,0 @@ -import bindAll from 'lodash.bindall'; -import PropTypes from 'prop-types'; -import React from 'react'; -import VM from 'scratch-vm'; - -import StopAllComponent from '../components/stop-all/stop-all.jsx'; - -class StopAll extends React.Component { - constructor (props) { - super(props); - bindAll(this, [ - 'handleClick', - 'onProjectRunStart', - 'onProjectRunStop' - ]); - this.state = {projectRunning: false}; - } - componentDidMount () { - this.props.vm.addListener('PROJECT_RUN_START', this.onProjectRunStart); - this.props.vm.addListener('PROJECT_RUN_STOP', this.onProjectRunStop); - } - componentWillUnmount () { - this.props.vm.removeListener('PROJECT_RUN_START', this.onProjectRunStart); - this.props.vm.removeListener('PROJECT_RUN_STOP', this.onProjectRunStop); - } - onProjectRunStart () { - this.setState({projectRunning: true}); - } - onProjectRunStop () { - this.setState({projectRunning: false}); - } - handleClick (e) { - e.preventDefault(); - this.props.vm.stopAll(); - } - render () { - const { - vm, // eslint-disable-line no-unused-vars - ...props - } = this.props; - return ( - <StopAllComponent - active={!this.state.projectRunning} - onClick={this.handleClick} - {...props} - /> - ); - } -} - -StopAll.propTypes = { - vm: PropTypes.instanceOf(VM) -}; - -export default StopAll; diff --git a/src/lib/vm-listener-hoc.jsx b/src/lib/vm-listener-hoc.jsx index 9dee35d75b75f581a0683caa8f637e8854a71aad..58f45e79f0496b5bb50cd5b81e0d049477ac16aa 100644 --- a/src/lib/vm-listener-hoc.jsx +++ b/src/lib/vm-listener-hoc.jsx @@ -8,7 +8,7 @@ import {connect} from 'react-redux'; import {updateTargets} from '../reducers/targets'; import {updateBlockDrag} from '../reducers/block-drag'; import {updateMonitors} from '../reducers/monitors'; -import {setRunningState, setTurboState} from '../reducers/vm-status'; +import {setRunningState, setTurboState, setStartedState} from '../reducers/vm-status'; import {showExtensionAlert} from '../reducers/alerts'; import {updateMicIndicator} from '../reducers/mic-indicator'; @@ -39,6 +39,7 @@ const vmListenerHOC = function (WrappedComponent) { this.props.vm.on('TURBO_MODE_OFF', this.props.onTurboModeOff); this.props.vm.on('PROJECT_RUN_START', this.props.onProjectRunStart); this.props.vm.on('PROJECT_RUN_STOP', this.props.onProjectRunStop); + this.props.vm.on('RUNTIME_STARTED', this.props.onRuntimeStarted); this.props.vm.on('PERIPHERAL_DISCONNECT_ERROR', this.props.onShowExtensionAlert); this.props.vm.on('MIC_LISTENING', this.props.onMicListeningUpdate); @@ -110,6 +111,7 @@ const vmListenerHOC = function (WrappedComponent) { onTargetsUpdate, onProjectRunStart, onProjectRunStop, + onRuntimeStarted, onTurboModeOff, onTurboModeOn, onShowExtensionAlert, @@ -128,6 +130,7 @@ const vmListenerHOC = function (WrappedComponent) { onMonitorsUpdate: PropTypes.func.isRequired, onProjectRunStart: PropTypes.func.isRequired, onProjectRunStop: PropTypes.func.isRequired, + onRuntimeStarted: PropTypes.func.isRequired, onShowExtensionAlert: PropTypes.func.isRequired, onTargetsUpdate: PropTypes.func.isRequired, onTurboModeOff: PropTypes.func.isRequired, @@ -158,6 +161,7 @@ const vmListenerHOC = function (WrappedComponent) { }, onProjectRunStart: () => dispatch(setRunningState(true)), onProjectRunStop: () => dispatch(setRunningState(false)), + onRuntimeStarted: () => dispatch(setStartedState(true)), onTurboModeOn: () => dispatch(setTurboState(true)), onTurboModeOff: () => dispatch(setTurboState(false)), onShowExtensionAlert: data => { diff --git a/src/lib/vm-manager-hoc.jsx b/src/lib/vm-manager-hoc.jsx index 404b7e3c84c03102e352035cadbc52c739848628..981fe8326f73baecc3576e24a47e55cc85b61fa2 100644 --- a/src/lib/vm-manager-hoc.jsx +++ b/src/lib/vm-manager-hoc.jsx @@ -27,12 +27,15 @@ const vmManagerHOC = function (WrappedComponent) { ]); } componentDidMount () { - if (this.props.vm.initialized) return; - this.audioEngine = new AudioEngine(); - this.props.vm.attachAudioEngine(this.audioEngine); - this.props.vm.setCompatibilityMode(true); - this.props.vm.start(); - this.props.vm.initialized = true; + if (!this.props.vm.initialized) { + this.audioEngine = new AudioEngine(); + this.props.vm.attachAudioEngine(this.audioEngine); + this.props.vm.setCompatibilityMode(true); + this.props.vm.initialized = true; + } + if (!this.props.isPlayerOnly && !this.props.isStarted) { + this.props.vm.start(); + } } componentDidUpdate (prevProps) { // if project is in loading state, AND fonts are loaded, @@ -41,11 +44,26 @@ const vmManagerHOC = function (WrappedComponent) { (!prevProps.isLoadingWithId || !prevProps.fontsLoaded)) { this.loadProject(); } + // Start the VM if entering editor mode with an unstarted vm + if (!this.props.isPlayerOnly && !this.props.isStarted) { + this.props.vm.start(); + } } loadProject () { return this.props.vm.loadProject(this.props.projectData) .then(() => { this.props.onLoadedProject(this.props.loadingState, this.props.canSave); + + // If the vm is not running, call draw on the renderer manually + // This draws the state of the loaded project with no blocks running + // which closely matches the 2.0 behavior, except for monitors– + // 2.0 runs monitors and shows updates (e.g. timer monitor) + // before the VM starts running other hat blocks. + if (!this.props.isStarted) { + // Wrap in a setTimeout because skin loading in + // the renderer can be async. + setTimeout(() => this.props.vm.renderer.draw()); + } }) .catch(e => { this.props.onError(e); @@ -56,6 +74,7 @@ const vmManagerHOC = function (WrappedComponent) { /* eslint-disable no-unused-vars */ fontsLoaded, loadingState, + isStarted, onError: onErrorProp, onLoadedProject: onLoadedProjectProp, projectData, @@ -79,6 +98,7 @@ const vmManagerHOC = function (WrappedComponent) { cloudHost: PropTypes.string, fontsLoaded: PropTypes.bool, isLoadingWithId: PropTypes.bool, + isPlayerOnly: PropTypes.bool, loadingState: PropTypes.oneOf(LoadingStates), onError: PropTypes.func, onLoadedProject: PropTypes.func, @@ -94,7 +114,9 @@ const vmManagerHOC = function (WrappedComponent) { isLoadingWithId: getIsLoadingWithId(loadingState), projectData: state.scratchGui.projectState.projectData, projectId: state.scratchGui.projectState.projectId, - loadingState: loadingState + loadingState: loadingState, + isPlayerOnly: state.scratchGui.mode.isPlayerOnly, + isStarted: state.scratchGui.vmStatus.started }; }; diff --git a/src/reducers/vm-status.js b/src/reducers/vm-status.js index 8e965199232d322d386ce53c3f57fb243be6151c..e99b80368d6bd72d70820fa106eab5678afd0fc6 100644 --- a/src/reducers/vm-status.js +++ b/src/reducers/vm-status.js @@ -1,14 +1,20 @@ const SET_RUNNING_STATE = 'scratch-gui/vm-status/SET_RUNNING_STATE'; const SET_TURBO_STATE = 'scratch-gui/vm-status/SET_TURBO_STATE'; +const SET_STARTED_STATE = 'scratch-gui/vm-status/SET_STARTED_STATE'; const initialState = { running: false, + started: false, turbo: false }; const reducer = function (state, action) { if (typeof state === 'undefined') state = initialState; switch (action.type) { + case SET_STARTED_STATE: + return Object.assign({}, state, { + started: action.started + }); case SET_RUNNING_STATE: return Object.assign({}, state, { running: action.running @@ -22,6 +28,14 @@ const reducer = function (state, action) { } }; +const setStartedState = function (started) { + return { + type: SET_STARTED_STATE, + started: started + }; +}; + + const setRunningState = function (running) { return { type: SET_RUNNING_STATE, @@ -40,5 +54,6 @@ export { reducer as default, initialState as vmStatusInitialState, setRunningState, + setStartedState, setTurboState }; diff --git a/test/unit/containers/__snapshots__/green-flag.test.jsx.snap b/test/unit/containers/__snapshots__/green-flag.test.jsx.snap deleted file mode 100644 index f8dbdbf82fc3959a5c4254e0c6c519cac54d09e5..0000000000000000000000000000000000000000 --- a/test/unit/containers/__snapshots__/green-flag.test.jsx.snap +++ /dev/null @@ -1,21 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`GreenFlag Container renders active state 1`] = ` -<img - className="undefined" - draggable={false} - onClick={[Function]} - src="test-file-stub" - title="Go" -/> -`; - -exports[`GreenFlag Container renders inactive state 1`] = ` -<img - className="" - draggable={false} - onClick={[Function]} - src="test-file-stub" - title="Go" -/> -`; diff --git a/test/unit/containers/green-flag.test.jsx b/test/unit/containers/green-flag.test.jsx deleted file mode 100644 index 4744ebaabd147318afdf430596f53e243b950700..0000000000000000000000000000000000000000 --- a/test/unit/containers/green-flag.test.jsx +++ /dev/null @@ -1,49 +0,0 @@ -import React from 'react'; -import {shallow} from 'enzyme'; -import GreenFlag from '../../../src/containers/green-flag'; -import renderer from 'react-test-renderer'; -import VM from 'scratch-vm'; - -describe('GreenFlag Container', () => { - let vm; - beforeEach(() => { - vm = new VM(); - }); - - test('renders active state', () => { - const component = renderer.create( - <GreenFlag - active - vm={vm} - /> - ); - expect(component.toJSON()).toMatchSnapshot(); - }); - - test('renders inactive state', () => { - const component = renderer.create( - <GreenFlag - active={false} - vm={vm} - /> - ); - expect(component.toJSON()).toMatchSnapshot(); - }); - - test('triggers onClick when active', () => { - const onClick = jest.fn(); - const componentShallowWrapper = shallow( - <GreenFlag - active - vm={vm} - onClick={onClick} - /> - ); - componentShallowWrapper.simulate('click'); - expect(onClick).toHaveBeenCalled(); - }); - - // @todo: Test for handles key events. - // @todo: Test project run start. - // @todo: Test project run stop. -}); diff --git a/test/unit/util/vm-manager-hoc.test.jsx b/test/unit/util/vm-manager-hoc.test.jsx index 8c065d83d9006b4a4a4553277c45bc07e44b5ffc..798e3a7e4f43cb1598915b2392d9e6a62fe2baa6 100644 --- a/test/unit/util/vm-manager-hoc.test.jsx +++ b/test/unit/util/vm-manager-hoc.test.jsx @@ -16,7 +16,9 @@ describe('VMManagerHOC', () => { beforeEach(() => { store = mockStore({ scratchGui: { - projectState: {} + projectState: {}, + mode: {}, + vmStatus: {} } }); vm = new VM(); @@ -24,34 +26,95 @@ describe('VMManagerHOC', () => { vm.setCompatibilityMode = jest.fn(); vm.start = jest.fn(); }); - test('when it mounts, the vm is initialized', () => { + test('when it mounts in player mode, the vm is initialized but not started', () => { const Component = () => (<div />); const WrappedComponent = vmManagerHOC(Component); mount( <WrappedComponent + isPlayerOnly + isStarted={false} store={store} vm={vm} /> ); expect(vm.attachAudioEngine.mock.calls.length).toBe(1); expect(vm.setCompatibilityMode.mock.calls.length).toBe(1); - expect(vm.start.mock.calls.length).toBe(1); expect(vm.initialized).toBe(true); + + // But vm should not be started automatically + expect(vm.start).not.toHaveBeenCalled(); + }); + test('when it mounts in editor mode, the vm is initialized and started', () => { + const Component = () => (<div />); + const WrappedComponent = vmManagerHOC(Component); + mount( + <WrappedComponent + isPlayerOnly={false} + isStarted={false} + store={store} + vm={vm} + /> + ); + expect(vm.attachAudioEngine.mock.calls.length).toBe(1); + expect(vm.setCompatibilityMode.mock.calls.length).toBe(1); + expect(vm.initialized).toBe(true); + + expect(vm.start).toHaveBeenCalled(); }); - test('if it mounts with an initialized vm, it does not reinitialize the vm', () => { + test('if it mounts with an initialized vm, it does not reinitialize the vm but will start it', () => { const Component = () => <div />; const WrappedComponent = vmManagerHOC(Component); vm.initialized = true; mount( <WrappedComponent + isPlayerOnly={false} + isStarted={false} store={store} vm={vm} /> ); expect(vm.attachAudioEngine.mock.calls.length).toBe(0); expect(vm.setCompatibilityMode.mock.calls.length).toBe(0); - expect(vm.start.mock.calls.length).toBe(0); expect(vm.initialized).toBe(true); + + expect(vm.start).toHaveBeenCalled(); + }); + + test('if it mounts without starting the VM, it can be started by switching to editor mode', () => { + const Component = () => <div />; + const WrappedComponent = vmManagerHOC(Component); + vm.initialized = true; + const mounted = mount( + <WrappedComponent + isPlayerOnly + isStarted={false} + store={store} + vm={vm} + /> + ); + expect(vm.start).not.toHaveBeenCalled(); + mounted.setProps({ + isPlayerOnly: false + }); + expect(vm.start).toHaveBeenCalled(); + }); + test('if it mounts with an initialized and started VM, it does not start again', () => { + const Component = () => <div />; + const WrappedComponent = vmManagerHOC(Component); + vm.initialized = true; + const mounted = mount( + <WrappedComponent + isPlayerOnly + isStarted + store={store} + vm={vm} + /> + ); + expect(vm.start).not.toHaveBeenCalled(); + mounted.setProps({ + isPlayerOnly: false + }); + expect(vm.start).not.toHaveBeenCalled(); }); test('if the isLoadingWithId prop becomes true, it loads project data into the vm', () => { vm.loadProject = jest.fn(() => Promise.resolve());