From a7f40593111bb7e170d4e116fda5f417be13d63b Mon Sep 17 00:00:00 2001 From: Bart Riemens Date: Mon, 2 Sep 2019 11:37:41 +0200 Subject: [PATCH] Implemented theming and game resuming via local storage --- package.json | 1 + src/components/Apple.js | 1 - src/components/Banner.js | 25 ++++++ src/components/Button.js | 52 +++++------- src/components/ControlPanel.js | 49 ++++++++---- src/components/HeaderMenu.js | 8 +- src/components/Scoreboard.js | 6 +- src/components/SnakePart.js | 27 +++---- src/components/Stage.js | 2 +- src/components/Tooltip.js | 26 ++++++ src/containers/Document.js | 34 ++++---- src/containers/Snake.js | 118 +++++++++++++--------------- src/index.js | 28 ++++++- src/redux/game.js | 39 ++++++--- src/redux/localStorage.js | 23 ++++++ src/redux/snake.js | 35 +++++++-- src/redux/store.js | 27 +++++-- src/redux/ui.js | 67 +++++++++------- src/theming/GlobalStyle.js | 4 +- src/theming/theme.js | 44 +++-------- src/theming/themes/dark.js | 47 +++++++++++ src/theming/themes/darkOcean.js | 47 +++++++++++ src/theming/themes/default.js | 135 ++++++++++++++++++++++++++++++++ src/theming/themes/light.js | 42 ++++++++++ src/utils/throttle.js | 34 ++++++++ tsconfig.json | 21 +++++ 26 files changed, 698 insertions(+), 244 deletions(-) create mode 100644 src/components/Banner.js create mode 100644 src/components/Tooltip.js create mode 100644 src/redux/localStorage.js create mode 100644 src/theming/themes/dark.js create mode 100644 src/theming/themes/darkOcean.js create mode 100644 src/theming/themes/default.js create mode 100644 src/theming/themes/light.js create mode 100644 src/utils/throttle.js create mode 100644 tsconfig.json diff --git a/package.json b/package.json index 9e7eef2..8bdaaad 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ }, "dependencies": { "connected-react-router": "^6.5.2", + "deepmerge": "^4.0.0", "history": "^4.9.0", "react": "^16.9.0", "react-dom": "^16.9.0", diff --git a/src/components/Apple.js b/src/components/Apple.js index 5d4e882..fcc90ec 100644 --- a/src/components/Apple.js +++ b/src/components/Apple.js @@ -19,7 +19,6 @@ const Apple = styled.div.attrs(({ theme, zoom }) => ({ &:after { content: "${props => (!props.died ? "🍎" : "🐛")}"; - // content: "🍎"; display: inline-block; animation: ${fade} 1s ease-out alternate infinite; } diff --git a/src/components/Banner.js b/src/components/Banner.js new file mode 100644 index 0000000..e6894f6 --- /dev/null +++ b/src/components/Banner.js @@ -0,0 +1,25 @@ +import React from "react"; +import styled from "styled-components"; + +const StyledBanner = styled.div` + border-width: ${({ theme }) => theme.banner.borderWidth}; + border-style: solid; + border-color: ${({ theme }) => theme.banner.borderColor}; + background-color: ${({ theme }) => theme.banner.background}; + color: ${({ theme }) => theme.banner.color}; + padding: ${({ theme }) => theme.banner.padding}; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + transition: opacity 100ms ease-in; + border-radius: 2px; + box-shadow: 1px 1px 5px ${({ theme }) => theme.banner.shadowColor}; + &:hover { + opacity: 0.5; + } +`; + +export const Banner = ({ children }) => {children}; + +export default Banner; diff --git a/src/components/Button.js b/src/components/Button.js index 4d19697..5915bd7 100644 --- a/src/components/Button.js +++ b/src/components/Button.js @@ -1,12 +1,15 @@ import React from "react"; import { NavLink } from "react-router-dom"; -import styled, { css } from "styled-components"; +import styled from "styled-components"; +import Tooltip from "./Tooltip.js"; const SharedStyle = () => ` - border: 1px solid gray; - border-radius: .1875rem; - background-color: palevioletred; - color: papayawhip; + border-width: ${({ theme }) => theme.button.borderWidth}; + border-style: solid; + border-color: ${({ theme }) => theme.button.borderColor}; + border-radius: ${({ theme }) => theme.button.borderRadius}; + background-color: ${({ theme }) => theme.button.background}; + color: ${({ theme }) => theme.button.color}; text-align: center; padding: .3125rem; font-size: 1rem; @@ -17,7 +20,14 @@ const SharedStyle = () => ` cursor: pointer; &:hover { - background-color: blue; + background-color: ${({ theme }) => theme.button.hover.background}; + } + + &:active, + &.active { + border-color: ${({ theme }) => theme.button.active.borderColor}; + color: ${({ theme }) => theme.button.active.color}; + background-color: ${({ theme }) => theme.button.active.background}; } `; @@ -37,31 +47,6 @@ const StyledAnchor = styled.a` const StyledNavLink = styled(NavLink)` ${SharedStyle} - &.active { - border-color: silver; - color: #828282; - background-color: #ecb1c5; - } -`; - -const Tooltip = styled.div` - position: absolute; - margin-top: 0.5rem; - border: 1px solid gray; - background-color: palevioletred; - color: papayawhip; - padding: 5px; - box-shadow: 1px 1px 3px #d0bfa3; - ${props => - props.hover - ? css` - transition: opacity 400ms 200ms ease-in; - ` - : css` - transition: opacity 100ms ease-out; - `} - white-space: nowrap; - opacity: ${props => (props.hover ? 1 : 0)}; `; export const Button = (props, refs) => { @@ -102,9 +87,10 @@ export const Button = (props, refs) => { }; export const ToggleButton = styled(Button)` - background-color: ${props => (props.toggle ? "silver" : null)}; + background-color: ${({ theme, toggle }) => (toggle ? theme.button.toggled.background : null)}; `; +// TODO: extract text shadow colors export const IconButton = styled(Button)` margin: 0 0.2rem; display: inline-block; @@ -116,7 +102,7 @@ export const IconButton = styled(Button)` `; export const ToggleIconButton = styled(IconButton)` - color: ${props => (props.toggle ? "#e6b4c4" : null)}; + color: ${({ theme, toggle }) => (toggle ? theme.button.toggled.color : null)}; &:before { text-shadow: 0 0 1px gray, 0 0 1px #d0bfa3; } diff --git a/src/components/ControlPanel.js b/src/components/ControlPanel.js index 77e5a38..954e1df 100644 --- a/src/components/ControlPanel.js +++ b/src/components/ControlPanel.js @@ -1,6 +1,6 @@ import React from "react"; import styled from "styled-components"; -import Button, { IconButton, ToggleIconButton } from "../components/Button.js"; +import { IconButton, ToggleIconButton } from "../components/Button.js"; const buttonSize = 2.5; @@ -23,20 +23,40 @@ const HorizontalStack = styled.div` place-content: space-between; `; -const VerticalStack = styled.div` - display: flex; - flex-direction: column; - place-content: center; - align-content: space-around; +const ThemeSelector = styled.select` + appearance: none; + border: 1px solid transparent; + background: none; + border-radius: 3px; + // padding: 5px; + line-height: 1.5em; + color: ${({ theme }) => theme.body.color}; + font-size: ${({ theme }) => theme.body.fontSize}; + font-family: ${({ theme }) => theme.body.fontFamily}; + height: 100%; + width: 100%; + cursor: pointer; + background-image: url('data:image/svg+xml;utf8, + + + '); + background-repeat: no-repeat; + background-position: right 0.625rem center; + background-size: 1rem 1rem; + outline-offset: 3px; + &:hover { + outline: 1px solid silver; + } `; export const ControlPanel = ({ - updateFrameSnake, paused, started, pauseSnake, stopSnake, startSnake, + theme, + changeTheme, fps, zoom, setFps, @@ -44,13 +64,14 @@ export const ControlPanel = ({ zoomOut }) => ( - { - //

Control Panel

- // - // {paused ? "Resume" : "Pause"} - // - } - + + changeTheme(e.target.value)} value={theme}> + + + + + + Zoom: {zoom}
diff --git a/src/components/HeaderMenu.js b/src/components/HeaderMenu.js index b37f070..879e97b 100644 --- a/src/components/HeaderMenu.js +++ b/src/components/HeaderMenu.js @@ -30,12 +30,12 @@ const MenuButton = styled(NavLink)` const HeaderMenu = () => ( - - Home - + Snake - Snake + + About + ); diff --git a/src/components/Scoreboard.js b/src/components/Scoreboard.js index 7487561..2b5caa0 100644 --- a/src/components/Scoreboard.js +++ b/src/components/Scoreboard.js @@ -22,7 +22,7 @@ const Card = styled.div` font-size: ${props => (props.zoom || 1) * 1.5}rem; font-family: Rubik,monospace; font-weight: 500; - box-shadow: 1px 1px 3px #d0bfa3; + box-shadow: 1px 1px 3px ${({ theme }) => theme.card.shadow}; text-shadow: 0 0 1px black; overflow: hidden; @@ -34,7 +34,7 @@ const Card = styled.div` height: 50%; width: 100%; left: 0; - border-bottom: solid 2px #bdb19a; + border-bottom: solid 2px ${({ theme }) => theme.card.fold.highlight}; } &::after { @@ -44,7 +44,7 @@ const Card = styled.div` top: 0; height: 50%; width: 100%; - border-bottom: dotted 2px #ad5a75; + border-bottom: dotted 2px ${({ theme }) => theme.card.fold.shadow}; z-index:1; } `; diff --git a/src/components/SnakePart.js b/src/components/SnakePart.js index 3ca3651..274563c 100644 --- a/src/components/SnakePart.js +++ b/src/components/SnakePart.js @@ -1,32 +1,23 @@ -import styled, { css } from "styled-components"; +import styled from "styled-components"; -export const SnakePart = styled.div.attrs(({ theme, zoom }) => ({ +export const SnakePart = styled.div.attrs(({ theme, zoom, died, value: { index, length } }) => ({ style: { height: theme.snake.cell.size * (zoom || 1) + "rem", width: theme.snake.cell.size * (zoom || 1) + "rem", fontSize: (zoom || 1) * 1.3 + "rem", lineHeight: (zoom || 1) * 1.7 + "rem", - paddingLeft: (zoom || 1) * 0.0625 + "rem" + paddingLeft: (zoom || 1) * 0.0625 + "rem", + backgroundColor: `${theme.snakePart.getColor(length, index, died)}` } }))` display: inline-block; vertical-align: top; + box-shadow: ${({ theme }) => theme.snakePart.boxShadow}; - ${props => - !props.died - ? css` - background-color: ${props => props.value && `hsl(340, ${props.value.brightness}%, 65%)`}; - box-shadow: 1px 1px 2px #ddd inset, -1px -1px 2px gray inset; - ` - : css` - background-color: ${props => props.value && `hsl(0, 0%, ${100 - props.value.brightness}% )`}; - box-shadow: 1px 1px 2px #ddd inset, -1px -1px 2px gray inset; - - &:after { - content: "${props => props.value && props.value.index === 0 && "💀"}"; - display: inline-block; - } - `} + &:after { + content: "${({ value: { index }, died }) => index === 0 && died && "💀"}"; + display: inline-block; + } `; export default SnakePart; diff --git a/src/components/Stage.js b/src/components/Stage.js index 85b80c0..ef57f56 100644 --- a/src/components/Stage.js +++ b/src/components/Stage.js @@ -19,7 +19,7 @@ const Cell = styled.div.attrs(({ theme, zoom }) => ({ display: inline-block; vertical-align: top; text-align: center; - box-shadow: 1px 1px 4px gray inset, -1px -1px 4px #fff inset; + box-shadow: ${({ theme }) => theme.stage.cell.boxShadow}; `; export const Stage = ({ data, children, zoom = 1 }) => ( diff --git a/src/components/Tooltip.js b/src/components/Tooltip.js new file mode 100644 index 0000000..2fb2bfd --- /dev/null +++ b/src/components/Tooltip.js @@ -0,0 +1,26 @@ +import styled, { css } from "styled-components"; + +export const Tooltip = styled.div` + position: absolute; + border-width: ${({ theme }) => theme.tooltip.borderWidth}; + border-style: solid; + border-color: ${({ theme }) => theme.tooltip.borderColor}; + border-radius: ${({ theme }) => theme.tooltip.borderRadius}; + background-color: ${({ theme }) => theme.tooltip.background}; + color: ${({ theme }) => theme.tooltip.color}; + margin-top: 0.5rem; + padding: ${({ theme }) => theme.tooltip.padding}; + box-shadow: 1px 1px 3px ${({ theme }) => theme.tooltip.shadowColor}; + ${props => + props.hover + ? css` + transition: opacity 400ms 200ms ease-in; + ` + : css` + transition: opacity 100ms ease-out; + `} + white-space: nowrap; + opacity: ${props => (props.hover ? 1 : 0)}; +`; + +export default Tooltip; diff --git a/src/containers/Document.js b/src/containers/Document.js index 1499c60..a74d317 100644 --- a/src/containers/Document.js +++ b/src/containers/Document.js @@ -1,25 +1,27 @@ import React from "react"; -import { Provider } from "react-redux"; +import { connect } from "react-redux"; import { hot } from "react-hot-loader/root"; -import { ConnectedRouter } from "connected-react-router"; - -import { createStore } from "../redux/store"; +import { MODULE_NAME as UI_MODULE_NAME } from "../redux/ui.js"; import { ThemeProvider } from "styled-components"; import { themes } from "../theming/theme.js"; import GlobalStyle from "../theming/GlobalStyle.js"; -const store = createStore(); - -const Document = ({ children }) => ( - - - - - {children} - - - +const Document = ({ theme, children }) => ( + + + + {children} + + ); -export default (process.env.NODE_ENV === "development" ? hot(Document) : Document); +const ConnectedDocument = connect( + state => ({ + theme: state[UI_MODULE_NAME].theme || "light" + }), + {} +)(Document); + +export default (process.env.NODE_ENV === "development" ? hot(ConnectedDocument) : ConnectedDocument); +// export default (process.env.NODE_ENV === "development" ? hot(Document) : Document); diff --git a/src/containers/Snake.js b/src/containers/Snake.js index 4a00475..885f1b3 100644 --- a/src/containers/Snake.js +++ b/src/containers/Snake.js @@ -3,6 +3,7 @@ import { connect } from "react-redux"; import styled from "styled-components"; /* Redux Modules */ +import { module as uiModule, changeTheme } from "../redux/ui.js"; import { module as gameModule, keyPress, setFps } from "../redux/game.js"; import { module as snakeModule, @@ -21,6 +22,7 @@ import Scoreboard from "../components/Scoreboard.js"; import Stage from "../components/Stage.js"; import SnakePart from "../components/SnakePart.js"; import Apple from "../components/Apple.js"; +import Banner from "../components/Banner.js"; const Layout = styled.div` display: flex; @@ -29,6 +31,10 @@ const Layout = styled.div` margin-top: 2rem; `; +const StageContainer = styled.div` + position: relative; +`; + const SidePanel = styled.div` display: flex; flex-direction: column; @@ -36,76 +42,61 @@ const SidePanel = styled.div` place-content: space-between; `; -class Snake extends React.Component { - constructor(props) { - super(props); - this.handleKeyDown = this.handleKeyDown.bind(this); - window.me = this; - } - - handleKeyDown(e) { - this.props.keyPress(e.key); - } - - componentDidMount() { - window.addEventListener("keydown", this.handleKeyDown, false); - !this.props.started && this.props.startSnake(); - } - - render() { - const { - startSnake, - stopSnake, - pauseSnake, - updateFrameSnake, - grid, - started, - paused, - died, - fps, - setFps, - score, - zoom, - zoomIn, - zoomOut - } = this.props; - - return ( - - +const Snake = ({ + startSnake, + stopSnake, + pauseSnake, + updateFrameSnake, + changeTheme, + theme, + grid, + started, + paused, + died, + fps, + setFps, + score, + zoom, + zoomIn, + zoomOut +}) => { + return ( + + + {cell => (cell.type === "apple" && ) || (cell.type === "snake" && ) } - - - - - - - ); - } - - componentWillUnmount() { - window.removeEventListener("keydown", this.handleKeyDown); - } -} + {!started && Press 'r' to start the game} + + + + + + + + ); +}; const mapStateToProps = state => ({ + ...state[uiModule.name], ...state[gameModule.name], ...state[snakeModule.name] }); @@ -119,7 +110,8 @@ const mapActionsToProps = { keyPress, setFps, zoomIn, - zoomOut + zoomOut, + changeTheme }; export default connect( diff --git a/src/index.js b/src/index.js index 3a43438..4e92832 100644 --- a/src/index.js +++ b/src/index.js @@ -1,12 +1,34 @@ import React from "react"; import ReactDOM from "react-dom"; +import { Provider } from "react-redux"; +import { ConnectedRouter } from "connected-react-router"; + +import { createStore } from "./redux/store"; +import { initSnake } from "./redux/snake"; +import { keyPress } from "./redux/game"; import Document from "./containers/Document.js"; import App from "./containers/App.js"; +const store = createStore(); +window.store = store; + ReactDOM.render( - - - , + + + + + + + , document.getElementById("root") ); + +store.dispatch(initSnake()); + +/* Register global key down handler */ +if (window.__KEY_DOWN_LISTENER__) { + window.removeEventListener("keydown", window.__KEY_DOWN_LISTENER__); +} +window.__KEY_DOWN_LISTENER__ = e => store.dispatch(keyPress(e)); +window.addEventListener("keydown", window.__KEY_DOWN_LISTENER__, false); diff --git a/src/redux/game.js b/src/redux/game.js index 5b67826..0c7e221 100644 --- a/src/redux/game.js +++ b/src/redux/game.js @@ -1,4 +1,4 @@ -import Module from "./module.js"; +import Module from "./Module.js"; import keys from "../enums/keys.js"; export const MODULE_NAME = "GAME"; @@ -7,18 +7,25 @@ export const module = new Module(MODULE_NAME, { loopId: null, callers: [], keyPressSubscribers: [], - fps: 8 + fps: 6 }); export const [START_LOOP, startLoop] = module.action("START_LOOP"); export const [STOP_LOOP, stopLoop] = module.action("STOP_LOOP"); +export const [INIT, initGame] = module.action("INIT"); export const [LOOP, loop] = module.action("LOOP", ({ recur, skip } = {}) => ({ recur, skip })); export const [UPDATE_LOOP_ID, updateLoopId] = module.action("UPDATE_LOOP_ID", loopId => ({ loopId })); export const [SET_FPS, setFps] = module.action("SET_FPS", fps => ({ fps })); export const [INVOKE_CALLERS, invokeCallers] = module.action("INVOKE_CALLERS"); export const [REGISTER_CALLER, registerCaller] = module.action("REGISTER_CALLER", name => ({ name })); export const [UNREGISTER_CALLER, unregisterCaller] = module.action("UNREGISTER_CALLER", name => ({ name })); -export const [KEY_PRESS, keyPress] = module.action("KEY_PRESS", key => ({ key })); +export const [KEY_PRESS, keyPress] = module.action("KEY_PRESS", ({ key, altKey, ctrlKey, metaKey, shiftKey }) => ({ + key, + altKey, + ctrlKey, + metaKey, + shiftKey +})); export const [SUBSCRIBE_KEY_PRESSED, subscribeKeyPressed] = module.action("SUBSCRIBE_KEY_PRESSED", name => ({ name })); export const [UNSUBSCRIBE_KEY_PRESSED, unsubscribeKeyPressed] = module.action("UNSUBSCRIBE_KEY_PRESSED", name => ({ name @@ -84,16 +91,28 @@ module.reducer(UNSUBSCRIBE_KEY_PRESSED, (state, { name }) => { return { ...state, keyPressSubscribers: state.keyPressSubscribers.filter(subscriber => subscriber !== name) }; }); -module.middleware(KEY_PRESS, (dispatch, { keyPressSubscribers = [], fps }, { key }) => { - keyPressSubscribers.forEach(subscriber => dispatch({ type: subscriber, key })); - if (key === keys.PLUS || key === keys.EQUAL) { - dispatch(setFps(fps + 1)); - } - if (key === keys.MINUS) { - dispatch(setFps(fps - 1)); +module.middleware(INIT, (dispatch, { loopId }) => { + dispatch(updateLoopId(null)); + if (loopId) { + dispatch(startLoop()); } }); +module.middleware( + KEY_PRESS, + (dispatch, { keyPressSubscribers = [], fps }, { key, altKey, ctrlKey, metaKey, shiftKey }) => { + keyPressSubscribers.forEach(subscriber => dispatch({ type: subscriber, key, altKey, ctrlKey, metaKey, shiftKey })); + + if (key === keys.PLUS || key === keys.EQUAL) { + dispatch(setFps(fps + 1)); + } + + if (key === keys.MINUS) { + dispatch(setFps(fps - 1)); + } + } +); + module.middleware(INVOKE_CALLERS, (dispatch, { callers = [] }) => { callers.forEach(caller => dispatch({ type: caller })); }); diff --git a/src/redux/localStorage.js b/src/redux/localStorage.js new file mode 100644 index 0000000..5a32972 --- /dev/null +++ b/src/redux/localStorage.js @@ -0,0 +1,23 @@ +export const loadState = () => { + try { + const serializedState = localStorage.getItem("state"); + if (serializedState === null) { + return undefined; + } + return JSON.parse(serializedState) || {}; + } catch (err) { + return undefined; + } +}; + +export const saveState = state => { + if (!state) { + return; + } + try { + const serializedState = JSON.stringify(state); + localStorage.setItem("state", serializedState); + } catch { + // ignore write errors + } +}; diff --git a/src/redux/snake.js b/src/redux/snake.js index 2dfaa36..57fe9ee 100644 --- a/src/redux/snake.js +++ b/src/redux/snake.js @@ -1,7 +1,7 @@ -import Module from "./module.js"; +import Module from "./Module.js"; import keys from "../enums/keys.js"; import directions from "../enums/directions.js"; -import { registerCaller, unregisterCaller, startLoop, stopLoop, subscribeKeyPressed } from "./game.js"; +import { registerCaller, unregisterCaller, startLoop, stopLoop, subscribeKeyPressed, initGame } from "./game.js"; const MODULE_NAME = "SNAKE"; const DEFAULT_GRID_SIZE = 16; @@ -28,8 +28,6 @@ function randomPosition(size) { } const comparePositions = (pos1, pos2) => pos1[0] === pos2[0] && pos1[1] === pos2[1]; -const brightness = (l, i) => 20 + Math.round(0.8 * ((i + 1) / l) * 100); - function createGrid(size) { const rows = []; for (let y = 0; y < size; y += 1) { @@ -76,6 +74,7 @@ export const module = new Module(MODULE_NAME, initialState); /* === Actions ================================================================================== */ +export const [INIT, initSnake] = module.action("INIT"); export const [START_SNAKE, startSnake] = module.action("START_SNAKE"); export const [RESET_SNAKE, resetSnake] = module.action("RESET_SNAKE", options => ({ ...options })); export const [STOP_SNAKE, stopSnake] = module.action("STOP_SNAKE"); @@ -99,7 +98,7 @@ module.reducer(ZOOM_IN, (state, { step = DEFAUL_ZOOM_STEP }) => ({ ...state, zoom: Math.round((state.zoom + step) * 10) / 10 })); -module.reducer(SET_ZOOM_LEVEL, (state, { level }) => ({ ...state, zoom: level })); +module.reducer(SET_ZOOM_LEVEL, (state, { level }) => ({ ...state, zoom: Math.round(level * 10) / 10 })); module.reducer(ZOOM_OUT, (state, { step = DEFAUL_ZOOM_STEP }) => state.zoom - step >= 0.5 ? { ...state, zoom: Math.round((state.zoom - step) * 10) / 10 } : { ...state, zoom: 1 } ); @@ -111,7 +110,11 @@ module.reducer(RESET_SNAKE, (state, options) => { markCell(grid, apple, { type: "apple" }); snake.forEach((snakePart, i) => - markCell(grid, snakePart, { type: "snake", index: snake.length - 1 - i, brightness: brightness(snake.length, i) }) + markCell(grid, snakePart, { + type: "snake", + index: snake.length - 1 - i, + length: snake.length + }) ); return { ...state, ...initialState, grid, zoom, snake, apple, ...options }; }); @@ -173,7 +176,7 @@ module.reducer(UPDATE_FRAME_SNAKE, state => { markCell(grid, snakePart, { type: "snake", index: newSnake.length - 1 - i, - brightness: brightness(newSnake.length, i) + length: snake.length }) ); score = newSnake.length; @@ -201,6 +204,19 @@ module.after(UPDATE_FRAME_SNAKE, (dispatch, { died }) => { } }); +module.middleware(INIT, (dispatch, { started, paused, died }) => { + if (!started && !died) { + dispatch(resetSnake({ started: false, died: false })); + } + // dispatch({ type: "[GAME] UPDATE_LOOP_ID", loopId: undefined }); + if (!paused) { + dispatch(registerCaller(UPDATE_FRAME_SNAKE)); + dispatch(subscribeKeyPressed(KEY_PRESSED_SNAKE)); + } + // dispatch(startLoop()); + dispatch(initGame()); +}); + module.middleware(START_SNAKE, dispatch => { dispatch(resetSnake({ started: true, died: false })); dispatch(registerCaller(UPDATE_FRAME_SNAKE)); @@ -224,7 +240,10 @@ module.middleware(PAUSE_SNAKE, (dispatch, { paused }) => { } }); -module.middleware(KEY_PRESSED_SNAKE, (dispatch, _, { key }) => { +module.middleware(KEY_PRESSED_SNAKE, (dispatch, _, { key, altKey, ctrlKey, metaKey, shiftKey }) => { + if (altKey || ctrlKey || metaKey) { + return; + } if (key === keys.UP || key === keys.k) { dispatch(changeDirection(directions.UP)); } diff --git a/src/redux/store.js b/src/redux/store.js index 9b32efc..60a903c 100644 --- a/src/redux/store.js +++ b/src/redux/store.js @@ -2,23 +2,34 @@ import { createStore as createReduxStore, combineReducers, compose, applyMiddlew import { createBrowserHistory } from "history"; import { connectRouter, routerMiddleware } from "connected-react-router"; -import { reducers as uiReducers } from "./ui.js"; +import { loadState, saveState } from "./localStorage.js"; +import { throttle } from "../utils/throttle.js"; + import { module as snakeModule } from "./snake.js"; import { module as gameModule } from "./game.js"; +import { module as uiModule } from "./ui.js"; export const createStore = () => { const history = createBrowserHistory({ basename: process.env.NODE_ENV === "production" ? "/redux/" : "/" }); + console.log("loadState", loadState(), { + router: connectRouter(history), + ...uiModule.reducers, + ...snakeModule.reducers, + ...gameModule.reducers + }); + const store = createReduxStore( combineReducers({ router: connectRouter(history), - ui: uiReducers, ...snakeModule.reducers, + ...uiModule.reducers, ...gameModule.reducers }), - {}, + loadState(), + // {}, compose.apply( this, [ @@ -31,11 +42,11 @@ export const createStore = () => { ); if (module.hot) { - module.hot.accept(["./ui.js", "./snake.js", "./game.js", "./module.js"], () => { + module.hot.accept(["./ui.js", "./snake.js", "./game.js", "./Module.js"], () => { store.replaceReducer( combineReducers({ - ui: uiReducers, router: connectRouter(history), + ...uiModule.reducers, ...snakeModule.reducers, ...gameModule.reducers }) @@ -45,6 +56,12 @@ export const createStore = () => { store.history = history; + store.subscribe( + throttle(() => { + saveState(store.getState()); + }, 500) + ); + return store; }; diff --git a/src/redux/ui.js b/src/redux/ui.js index e576149..5104565 100644 --- a/src/redux/ui.js +++ b/src/redux/ui.js @@ -1,30 +1,41 @@ -const SHOW_SPINNER = "[UI] SHOW_SPINNER"; -const HIDE_SPINNER = "[UI] HIDE_SPINNER"; -const TOGGLE_SPINNER = "[UI] TOGGLE_SPINNER"; +import Module from "./Module.js"; -export const showSpinner = () => ({ type: SHOW_SPINNER }); -export const hideSpinner = () => ({ type: HIDE_SPINNER }); -export const toggleSpinner = () => ({ type: TOGGLE_SPINNER }); - -export const reducers = (state = {}, action) => { - if (action.type === SHOW_SPINNER) { - return { - ...state, - spinner: true - }; - } - if (action.type === HIDE_SPINNER) { - return { - ...state, - spinner: false - }; - } - if (action.type === TOGGLE_SPINNER) { - return { - ...state, - spinner: !state.spinner - }; - } - - return state; +export const MODULE_NAME = "UI"; +export const initialState = { + theme: "light" }; + +export const module = new Module(MODULE_NAME, initialState); + +export const [SHOW_SPINNER, showSpinner] = module.action("SHOW_SPINNER"); +export const [HIDE_SPINNER, hideSpinner] = module.action("HIDE_SPINNER"); +export const [TOGGLE_SPINNER, toggleSpinner] = module.action("TOGGLE_SPINNER"); +export const [CHANGE_THEME, changeTheme] = module.action("CHANGE_THEME", theme => ({ theme })); + +module.reducer(SHOW_SPINNER, state => { + return { + ...state, + spinner: true + }; +}); +module.reducer(HIDE_SPINNER, state => { + return { + ...state, + spinner: false + }; +}); +module.reducer(TOGGLE_SPINNER, state => { + return { + ...state, + spinner: !state.spinner + }; +}); + +module.reducer(CHANGE_THEME, (state, { theme }) => { + return { + ...state, + theme + }; +}); + +export default module; diff --git a/src/theming/GlobalStyle.js b/src/theming/GlobalStyle.js index a5fa87e..190b802 100644 --- a/src/theming/GlobalStyle.js +++ b/src/theming/GlobalStyle.js @@ -8,8 +8,8 @@ const GlobalStyle = createGlobalStyle` margin: 0; background: ${props => props.theme.body.background}; color: ${props => props.theme.body.color}; - font-family: Raleway; - font-size: 1rem; + font-family: ${({ theme }) => theme.body.fontFamily}; + font-size: ${({ theme }) => theme.body.fontSize}; min-height: 100vh; } h1, h2 { diff --git a/src/theming/theme.js b/src/theming/theme.js index fcc2c00..f323323 100644 --- a/src/theming/theme.js +++ b/src/theming/theme.js @@ -1,39 +1,13 @@ -const themes = { - main: { - body: { - background: "papayawhip", - color: "palevioletred" - }, - header: { - background: "#ffe6bd", - color: "palevioletred", +import defaultTheme from "./themes/default.js"; +import lightTheme from "./themes/light.js"; +import darkTheme from "./themes/dark.js"; +import darkOceanTheme from "./themes/darkOcean.js"; - menuButton: { - background: "#ffe6bd", - color: "palevioletred", - active: { - background: "palevioletred", - color: "#ffe6bd" - } - } - }, - spinner: { - shadow: "#eeeeee", - highlight: "#db7093" - }, - snake: { - cell: { - border: "0.0625rem solid papayawhip", - size: 1.5 - }, - cellColors: { - " ": "", - a: "", - ab: "#90dc90", - s: "#f12f00" - } - } - } +const themes = { + default: defaultTheme, + light: lightTheme, + dark: darkTheme, + darkOcean: darkOceanTheme }; export { themes as default, themes }; diff --git a/src/theming/themes/dark.js b/src/theming/themes/dark.js new file mode 100644 index 0000000..2c4fb0c --- /dev/null +++ b/src/theming/themes/dark.js @@ -0,0 +1,47 @@ +import merge from "deepmerge"; +import { renderTheme } from "./default.js"; + +export const colors = { + background: "#333", + backgroundActive: "#828282", + backgroundInactive: "silver", + backgroundAlternate: "#555", + color: "palevioletred", + colorActive: "#ecb1c5", + colorInactive: "#e6b4c4", + colorAlternate: "#ecb1c5", + shadowColor: "#222", + borderColor: "#222", + borderColorActive: "silver", + spinnerShadow: "#444", + spinnerHighlight: "#db7093", + cardFoldHighlight: "#ad5a75", + cardFoldShadow: "#bdb19a", + selectColor: "#db7093" +}; + +const mapRange = (l, i, min, max) => Math.round((max - min) * ((l - i) / l) + min); + +export const darkTheme = merge(renderTheme(colors), { + snakePart: { + boxShadow: "1px 1px 2px #888 inset, -1px -1px 2px #222 inset", + getColor(length, index, died) { + const hue = 340; + if (!died) { + const saturation = mapRange(length, index, 20, 65); + const brightness = 65; + return `hsl(${hue}, ${saturation}%, ${brightness}%)`; + } + const saturation = 0; + const brightness = mapRange(length, index, 70, 0); + return `hsl(${hue}, ${saturation}%, ${brightness}%)`; + } + }, + stage: { + cell: { + boxShadow: "1px 1px 3px #191919 inset, -1px -1px 1px #777777 inset" + } + } +}); + +export default darkTheme; diff --git a/src/theming/themes/darkOcean.js b/src/theming/themes/darkOcean.js new file mode 100644 index 0000000..65856ea --- /dev/null +++ b/src/theming/themes/darkOcean.js @@ -0,0 +1,47 @@ +import merge from "deepmerge"; +import { renderTheme } from "./default.js"; + +export const colors = { + background: "#333", + backgroundActive: "#828282", + backgroundInactive: "silver", + backgroundAlternate: "#555", + color: "#7094db", + colorActive: "#ecb1c5", + colorInactive: "#b3c3e6", + colorAlternate: "#ecb1c5", + shadowColor: "#222", + borderColor: "#222", + borderColorActive: "silver", + spinnerShadow: "#444", + spinnerHighlight: "#db7093", + cardFoldHighlight: "#091225", + cardFoldShadow: "#bdb19a", + selectColor: "#7094db" +}; + +const mapRange = (l, i, min, max) => Math.round((max - min) * ((l - i) / l) + min); + +export const darkTheme = merge(renderTheme(colors), { + snakePart: { + boxShadow: "1px 1px 2px #888 inset, -1px -1px 2px #222 inset", + getColor(length, index, died) { + const hue = 220; + if (!died) { + const saturation = mapRange(length, index, 20, 65); + const brightness = 65; + return `hsl(${hue}, ${saturation}%, ${brightness}%)`; + } + const saturation = 0; + const brightness = mapRange(length, index, 70, 0); + return `hsl(${hue}, ${saturation}%, ${brightness}%)`; + } + }, + stage: { + cell: { + boxShadow: "1px 1px 3px #191919 inset, -1px -1px 1px #777777 inset" + } + } +}); + +export default darkTheme; diff --git a/src/theming/themes/default.js b/src/theming/themes/default.js new file mode 100644 index 0000000..5ff08a0 --- /dev/null +++ b/src/theming/themes/default.js @@ -0,0 +1,135 @@ +import merge from "deepmerge"; + +export const defaultColors = { + background: "white", + backgroundAlternate: "#ddd", + backgroundInactive: "silver", + backgroundActive: "#ccc", + color: "black", + colorActive: "#444", + colorAlternate: "#222", + colorInactive: "silver", + shadowColor: "gray", + borderColor: "black", + borderColorActive: "silver", + spinnerShadow: "#eee", + spinnerHighlight: "black", + cardFoldHighlight: "#888", + cardFoldShadow: "#bbb", + snakePartHueAlive: "340", + snakePartHueDied: "0", + snakePartLightness: "0%", + selectColor: "black" +}; + +const mapRange = (l, i, min, max) => Math.round((max - min) * ((l - i) / l) + min); + +export const renderTheme = themeColors => { + const colors = merge(defaultColors, themeColors); + return { + body: { + background: colors.background, + color: colors.color, + fontSize: "1rem", + fontFamily: "Raleway" + }, + banner: { + background: colors.color, + color: colors.background, + borderColor: colors.borderColor, + shadowColor: colors.shadowColor, + padding: `2rem`, + borderWidth: `${1 / 16}rem` + }, + select: { + color: colors.selectColor + }, + header: { + background: colors.backgroundAlternate, + color: colors.color, + + menuButton: { + background: colors.backgroundAlternate, + color: colors.color, + active: { + background: colors.color, + color: colors.backgroundAlternate + } + } + }, + button: { + borderColor: colors.borderColor, + background: colors.color, + color: colors.background, + borderWidth: `${1 / 16}rem`, + borderRadius: `${3 / 16}rem`, + toggled: { + background: colors.backgroundInactive, + color: colors.colorInactive + }, + hover: { + background: colors.colorAlternate + }, + active: { + background: colors.backgroundActive, + color: colors.colorActive, + borderColor: colors.borderColorActive + } + }, + tooltip: { + borderColor: colors.borderColor, + borderWidth: `${1 / 16}rem`, + borderRadius: `${3 / 16}rem`, + padding: `${5 / 16}rem`, + background: colors.color, + color: colors.background, + shadowColor: colors.shadowColor + }, + card: { + shadow: colors.shadowColor, + fold: { + highlight: colors.cardFoldHighlight, + shadow: colors.cardFoldShadow + } + }, + apple: { + effect: "none" + }, + snakePart: { + boxShadow: "1px 1px 2px #ddd inset, -1px -1px 2px gray inset", + getColor(length, index, died) { + const hue = 0; + if (!died) { + const saturation = 0; + const brightness = mapRange(length, index, 70, 0); + return `hsl(${hue}, ${saturation}%, ${brightness}%)`; + } + const saturation = mapRange(length, index, 70, 100); + const brightness = 65; + return `hsl(${hue}, ${saturation}%, ${brightness}%)`; + }, + hueAlive: colors.snakePartHueAlive, + hueDied: colors.snakePartHueDied, + lightness: colors.snakePartLightness + }, + stage: { + cell: { + boxShadow: "1px 1px 4px grey inset, -1px -1px 4px #fff inset" + } + }, + spinner: { + shadow: colors.spinnerShadow, + highlight: colors.spinnerHighlight + }, + snake: { + cell: { + border: `0.0625rem solid ${colors.background}`, + size: 1.5 + } + } + }; +}; + +export const defaultTheme = renderTheme(defaultColors); + +export default defaultTheme; diff --git a/src/theming/themes/light.js b/src/theming/themes/light.js new file mode 100644 index 0000000..52e9ede --- /dev/null +++ b/src/theming/themes/light.js @@ -0,0 +1,42 @@ +import merge from "deepmerge"; +import { renderTheme } from "./default.js"; + +export const colors = { + background: "papayawhip", + backgroundActive: "#828282", + backgroundInactive: "silver", + backgroundAlternate: "#ffe6bd", + color: "palevioletred", + colorActive: "#ecb1c5", + colorInactive: "#e6b4c4", + colorAlternate: "#ecb1c5", + shadowColor: "#d0bfa3", + shadowColorx: "gray", + borderColor: "gray", + borderColorActive: "silver", + spinnerShadow: "#eee", + spinnerHighlight: "#db7093", + cardFoldHighlight: "#ad5a75", + cardFoldShadow: "#bdb19a", + selectColor: "#db7093" +}; + +const mapRange = (l, i, min, max) => Math.round((max - min) * ((l - i) / l) + min); + +export const lightTheme = merge(renderTheme(colors), { + snakePart: { + getColor(length, index, died) { + const hue = 340; + if (!died) { + const saturation = mapRange(length, index, 20, 65); + const brightness = 65; + return `hsl(${hue}, ${saturation}%, ${brightness}%)`; + } + const saturation = 0; + const brightness = mapRange(length, index, 70, 0); + return `hsl(${hue}, ${saturation}%, ${brightness}%)`; + } + } +}); + +export default lightTheme; diff --git a/src/utils/throttle.js b/src/utils/throttle.js new file mode 100644 index 0000000..8e2d651 --- /dev/null +++ b/src/utils/throttle.js @@ -0,0 +1,34 @@ +export const throttle = (fn, wait = 0) => { + let timeoutId = null; + + const throttleFn = () => { + if (timeoutId) { + return; + } + timeoutId = setTimeout(() => { + fn(); + timeoutId = null; + }, wait); + }; + + throttleFn.cancel = () => { + if (!timeoutId) { + return; + } + clearTimeout(timeoutId); + timeoutId = null; + }; + + throttleFn.flush = () => { + if (!timeoutId) { + return; + } + clearTimeout(timeoutId); + timeoutId = null; + fn(); + }; + + return throttleFn; +}; + +export default throttle; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..93c33c8 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "es5", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "checkJs": false, + "skipLibCheck": true, + "strictNullChecks": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react" + }, + "include": ["src"] +}