From 918e5fee45fe5eae77f8799a7b44999555a850be Mon Sep 17 00:00:00 2001 From: Bart Riemens Date: Mon, 26 Aug 2019 18:46:19 +0200 Subject: [PATCH] Refactored redux code and snake game --- .babelrc | 3 +- package.json | 2 + src/containers/Document.js | 96 ++++++++++++ src/containers/Home.js | 51 ++++++- src/containers/Snake.js | 256 +++++++++++++++++++++++++++++--- src/helpers/GameLoop.js | 19 +++ src/index.js | 48 +----- src/redux/game.backup.js | 44 ++++++ src/redux/game.js | 152 +++++++++++++++++++ src/redux/home.js | 20 +++ src/redux/module.js | 244 +++++++++++++++++++++++++++++++ src/redux/snake.backup.js | 215 +++++++++++++++++++++++++++ src/redux/snake.js | 290 +++++++++++++++++++++---------------- src/theming/theme.js | 7 +- 14 files changed, 1251 insertions(+), 196 deletions(-) create mode 100644 src/containers/Document.js create mode 100644 src/helpers/GameLoop.js create mode 100644 src/redux/game.backup.js create mode 100644 src/redux/game.js create mode 100644 src/redux/home.js create mode 100644 src/redux/module.js create mode 100644 src/redux/snake.backup.js diff --git a/.babelrc b/.babelrc index 2b7bafa..474780d 100644 --- a/.babelrc +++ b/.babelrc @@ -1,3 +1,4 @@ { - "presets": ["@babel/preset-env", "@babel/preset-react"] + "presets": ["@babel/preset-env", "@babel/preset-react"], + "plugins": ["@babel/plugin-proposal-private-methods", "@babel/plugin-proposal-class-properties"] } diff --git a/package.json b/package.json index 4ac12ae..8818c68 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,8 @@ "license": "ISC", "devDependencies": { "@babel/core": "^7.5.5", + "@babel/plugin-proposal-class-properties": "^7.5.5", + "@babel/plugin-proposal-private-methods": "^7.4.4", "@babel/preset-env": "^7.5.5", "@babel/preset-react": "^7.0.0", "babel-loader": "^8.0.6", diff --git a/src/containers/Document.js b/src/containers/Document.js new file mode 100644 index 0000000..cdf578d --- /dev/null +++ b/src/containers/Document.js @@ -0,0 +1,96 @@ +import React from "react"; +import { Provider } from "react-redux"; +import { hot } from "react-hot-loader/root"; + +import { createStore, combineReducers, compose, applyMiddleware } from "redux"; +import { createBrowserHistory } from "history"; + +import { connectRouter, routerMiddleware, ConnectedRouter } from "connected-react-router"; +import { reducers as keyboardReducers, middleware as keyboardMiddleware } from "../redux/keyboards.js"; +import { middleware as apiMiddleware } from "../redux/api.js"; +import { reducers as uiReducers } from "../redux/ui.js"; +import { module as snakeModule } from "../redux/snake.js"; +// import { reducers as gameReducers, middleware as gameMiddleware } from "../redux/game.js"; +import { module as gameModule } from "../redux/game.js"; +import { module as homeModule } from "../redux/home.js"; + +import { ThemeProvider } from "styled-components"; +import { themes } from "../theming/theme.js"; +import GlobalStyle from "../theming/GlobalStyle.js"; + +window.module = gameModule; + +const history = createBrowserHistory({ + basename: process.env.NODE_ENV === "production" ? "/redux/" : "/" +}); + +const store = createStore( + combineReducers({ + keyboards: keyboardReducers, + ui: uiReducers, + router: connectRouter(history), + ...snakeModule.reducers, + ...gameModule.reducers, + ...homeModule.reducers + }), + {}, + compose.apply( + this, + [ + applyMiddleware( + routerMiddleware(history), + apiMiddleware, + keyboardMiddleware, + snakeModule.middlewares, + gameModule.middlewares, + homeModule.middlewares + ), + process.env.NODE_ENV !== "production" && + window.__REDUX_DEVTOOLS_EXTENSION__ && + window.__REDUX_DEVTOOLS_EXTENSION__() + ].filter(m => m) + ) +); +window.st = store; + +const Document = ({ children }) => ( + + + + + {children} + + + +); + +console.log("env", process.env.NODE_ENV); +process.env.NODE_ENV === "development" ? console.log("development") : console.log("production"); +export default (process.env.NODE_ENV === "development" ? hot(Document) : Document); +// export default Document; + +if (module.hot) { + module.hot.accept( + [ + "../redux/snake.js", + "./Snake.js", + "./App.js", + "./Document.js", + "../redux/game.js", + "../redux/module.js", + "../redux/home.js" + ], + () => { + store.replaceReducer( + combineReducers({ + keyboards: keyboardReducers, + ui: uiReducers, + router: connectRouter(history), + ...snakeModule.reducers, + ...gameModule.reducers, + ...homeModule.reducers + }) + ); + } + ); +} diff --git a/src/containers/Home.js b/src/containers/Home.js index ce16183..3878e8f 100644 --- a/src/containers/Home.js +++ b/src/containers/Home.js @@ -1,6 +1,53 @@ import React from "react"; import Title from "../components/Title.js"; +import Button from "../components/Button.js"; +import Link from "../components/Link.js"; -const Home = () => Home Page; +import { connect } from "react-redux"; +import { module as game, startLoop, stopLoop, registerCaller, unregisterCaller, setFps } from "../redux/game.js"; +import { module as home, INCREMENT_COUNTER, resetCounter } from "../redux/home.js"; -export default Home; +const Home = ({ + startLoop, + stopLoop, + loopId, + registerCaller, + counter, + unregisterCaller, + setFps, + fps, + callers, + resetCounter +}) => ( + + Home Page +

+ fps: {fps} +

+

+ loopId: {loopId} +

+

+ counter: {counter} Reset +

+

+ callers: {callers} +

+ + + + + + +
+); + +export default connect( + state => ({ + loopId: state[game.name].loopId, + fps: state[game.name].fps, + callers: state[game.name].callers, + counter: state[home.name] && state[home.name].counter + }), + { startLoop, stopLoop, registerCaller, unregisterCaller, setFps, resetCounter } +)(Home); diff --git a/src/containers/Snake.js b/src/containers/Snake.js index 92efded..af5d6fb 100644 --- a/src/containers/Snake.js +++ b/src/containers/Snake.js @@ -1,7 +1,16 @@ import React from "react"; import { connect } from "react-redux"; -import styled from "styled-components"; -import { startSnake, stopSnake, pauseSnake, updateFrameSnake, keyPressedSnake, keys } from "../redux/snake.js"; +import styled, { keyframes, css } from "styled-components"; +import { module as gameModule, keyPress } from "../redux/game.js"; +import { + module as snakeModule, + startSnake, + stopSnake, + pauseSnake, + updateFrameSnake, + keyPressedSnake, + keys +} from "../redux/snake.js"; import Title from "../components/Title.js"; import Button from "../components/Button.js"; @@ -18,24 +27,209 @@ const Cell = styled.div` display: inline-block; vertical-align: top; text-align: center; - background-color: ${props => props.value && props.theme.snake.cellColors[props.value]}; + background-color: ${props => props.theme.snake.cellColors[" "]}; + box-shadow: 1px 1px 4px gray inset, -1px -1px 4px #fff inset; `; -const Grid = ({ data }) => +const SnakePart = styled.div` + width: ${({ theme }) => theme.snake.cell.size}; + height: ${({ theme }) => theme.snake.cell.size}; + background-color: ${props => props.value && `hsl(340, ${props.value.slice(1)}%, 65%)`}; + box-shadow: 1px 1px 2px #ddd inset, -1px -1px 2px gray inset; +`; + +const Apple = styled.div.attrs({ role: "img", "aria-label": "apple" })` + width: ${({ theme }) => theme.snake.cell.size}; + height: ${({ theme }) => theme.snake.cell.size}; + display: inline-block; + font-size: 1.3rem; + vertical-align: top; + line-height: 1.8rem; + padding-left: 0.0625rem; + &:after { + content: "${props => (!props.died ? "🍎" : "")}"; + display: inline-block; + } +`; + +const Skull = styled.div.attrs({ role: "img", "aria-label": "skull" })` + width: ${({ theme }) => theme.snake.cell.size}; + height: ${({ theme }) => theme.snake.cell.size}; + display: inline-block; + font-size: 1.3rem; + vertical-align: top; + line-height: 1.8rem; + background-color: ${props => props.value && `hsl(0, 0%, ${100 - props.value.slice(1)}% )`}; + padding-left: 0.0625rem; + box-shadow: 1px 1px 2px #ddd inset, -1px -1px 2px gray inset; + &:after { + content: "💀"; + display: inline-block; + } +`; + +const flipIn = keyframes` + 50% { + transform: rotateX(90deg); + } + 100% { + transform: rotateX(0deg); + } +`; + +const flipOut = keyframes` + 0 { + transform: rotateX(0deg); + } + 50% { + transform: rotateX(90deg); + } +`; + +const Card = styled.div` + display: inline-block; + position: relative; + margin-right: 0.2em; + width: 1rem; + height: 2rem; + // perspective: -1rem; + + &:before { + content: "${props => props.oldValue}"; + position: absolute; + display: inline-block; + // display: none; + border: 1px solid gray; + width: 1rem; + height: 2rem; + left: 0; + top: 0; + text-align: center; + line-height: 2rem; + vertical-align: middle; + background-color: brown; + color: white; + transition: transform ease-out 100ms; + // animation: ${flipIn} 1s infinite; + ${props => + props.value !== props.oldValue && + css` + animation: ${flipIn} 1s forward; + `}; + } + + &:after { + content: "${props => props.value}"; + position: absolute; + display: inline-block; + border: 1px solid gray; + width: 1rem; + height: 2rem; + left: 0; + top: 0; + text-align: center; + line-height: 2rem; + vertical-align: middle; + background-color: brown; + color: white; + transition: transform ease-out 100ms; + // animation: ${flipOut} 1s infinite; + ${props => + props.value !== props.oldValue && + css` + animation: ${flipOut} 1s forward; + `}; + } + + +`; + +const Grid = ({ data, died }) => data.map((r, y) => ( {r.map((c, x) => ( - - {c === "a" && ( - - 🍏 - - )} + + {c === "a" && } + {!died && c[0] === "s" && } + {died && c[0] === "s" && } ))} )); +const Score = ({ score, previousScore }) => { + const now = Date.now(); + const [lastChange, changeLastChange] = React.useState({ timestamp: Date.now(), score, previousScore }); + + if (score === lastChange.score) { + const scoreCards = Array.from(`${score}`.padStart(3, " ")); + return ( +
+ {scoreCards.map((score, index) => ( + + ))} +
+ ); + } + + if (score !== lastChange.score && now - lastChange.timestamp > 1200) { + const scoreCards = Array.from(`${score}`.padStart(3, " ")); + const previousScoreCards = Array.from(`${lastChange.score}`.padStart(3, " ")); + console.log("scoreCards, previousScoreCards", scoreCards, previousScoreCards); + return ( +
+ {scoreCards.map((score, index) => ( + + ))} +
+ ); + } + + if (score !== lastChange.score && now - lastChange.timestamp > 1200) { + changeLastChange({ timestamp: now, score, previousScore: lastChange.score }); + } + + const scoreCards = Array.from(`${lastChange.previousScore}`.padStart(3, " ")); + + // console.log("now - lastChange.timestamp", now - lastChange.timestamp); + if (score !== lastChange.previousScore) { + const newScoreCards = Array.from(`${score}`.padStart(3, " ")); + console.log("updating"); + return ( +
+ {newScoreCards.map( + (score, index) => + console.log(score, scoreCards[index]) || ( + + ) + )} +
+ ); + } + return ( +
+ {scoreCards.map((score, index) => ( + + ))} +
+ ); + + // const scoreCards = Array.from(`${score}`.padStart(3, " ")); + // const previousScoreCards = Array.from(`${lastScore}`.padStart(3, " ")); + + // if (lastScore < score - 1) { + // updateScore(score); + // } + // console.log("score, lastScore", score, lastScore); + // return ( + //
+ // {scoreCards.map((score, index) => ( + // + // ))} + //
+ // ); +}; + // const ArrowButton = styled(Button)` // min-width: 5rem; // `; @@ -58,25 +252,36 @@ class Snake extends React.Component { super(props); this.handleKeyDown = this.handleKeyDown.bind(this); this.supportedKeys = Object.values(keys); + window.me = this; + this.state = { previousPoints: 0 }; } handleKeyDown(e) { - if (!this.supportedKeys.includes(e.key)) { - return; - } - this.props.keyPressedSnake(e.key); + this.props.keyPress(e.key); } componentDidMount() { window.addEventListener("keydown", this.handleKeyDown, false); - this.props.startSnake(); + !this.props.started && this.props.startSnake(); } render() { - const { startSnake, stopSnake, pauseSnake, updateFrameSnake, snakeGame } = this.props; - const { grid, started, paused, gameId, snake, died, fps } = snakeGame; + const { + startSnake, + stopSnake, + pauseSnake, + updateFrameSnake, + grid, + started, + paused, + snake, + died, + fps, + score, + previousScore + } = this.props; - if (died) { + if (false && died) { return ( Snake @@ -96,6 +301,7 @@ class Snake extends React.Component { return ( Snake + ); @@ -103,9 +309,9 @@ class Snake extends React.Component { return ( - Snake: Attempt {gameId} -

Points: {snake.length}

- + Snake + + {paused ? "Resume" : "Pause"} @@ -122,14 +328,18 @@ class Snake extends React.Component { } } -const mapStateToProps = ({ snakeGame }) => ({ snakeGame: snakeGame || {} }); +const mapStateToProps = state => ({ + ...state[gameModule.name], + ...state[snakeModule.name] +}); const mapActionsToProps = { startSnake, stopSnake, pauseSnake, updateFrameSnake, - keyPressedSnake + keyPressedSnake, + keyPress }; export default connect( diff --git a/src/helpers/GameLoop.js b/src/helpers/GameLoop.js new file mode 100644 index 0000000..4d48150 --- /dev/null +++ b/src/helpers/GameLoop.js @@ -0,0 +1,19 @@ +export default class GameLoop { + -_isRunning = false; + -_handler = null; + + constructor(handler) { + this._handler = handler; + } + + get isRunning() { + return this._isRunning; + } + + start() { + this._isRunning = true; + } + stop() { + this._isRunning = false; + } +} diff --git a/src/index.js b/src/index.js index a33863c..3a43438 100644 --- a/src/index.js +++ b/src/index.js @@ -1,52 +1,12 @@ import React from "react"; import ReactDOM from "react-dom"; -import { createStore, combineReducers, compose, applyMiddleware } from "redux"; -import { createBrowserHistory } from "history"; -import { Provider } from "react-redux"; - -import { connectRouter, routerMiddleware, ConnectedRouter } from "connected-react-router"; -import { reducers as keyboardReducers, middleware as keyboardMiddleware } from "./redux/keyboards.js"; -import { middleware as apiMiddleware } from "./redux/api.js"; -import { reducers as uiReducers } from "./redux/ui.js"; -import { reducers as snakeReducers, middleware as snakeMiddleware } from "./redux/snake.js"; - -import { ThemeProvider } from "styled-components"; -import { themes } from "./theming/theme.js"; -import GlobalStyle from "./theming/GlobalStyle.js"; +import Document from "./containers/Document.js"; import App from "./containers/App.js"; -const history = createBrowserHistory({ - basename: process.env.NODE_ENV === "production" ? "/redux/" : "/" -}); - -const store = createStore( - combineReducers({ - keyboards: keyboardReducers, - ui: uiReducers, - snakeGame: snakeReducers, - router: connectRouter(history) - }), - {}, - compose.apply( - this, - [ - applyMiddleware(routerMiddleware(history), apiMiddleware, keyboardMiddleware, snakeMiddleware), - process.env.NODE_ENV !== "production" && - window.__REDUX_DEVTOOLS_EXTENSION__ && - window.__REDUX_DEVTOOLS_EXTENSION__() - ].filter(m => m) - ) -); - ReactDOM.render( - - - - - - - - , + + + , document.getElementById("root") ); diff --git a/src/redux/game.backup.js b/src/redux/game.backup.js new file mode 100644 index 0000000..ca64a3e --- /dev/null +++ b/src/redux/game.backup.js @@ -0,0 +1,44 @@ +const MODULE_NAME = "GAME"; + +const initialState = { + started: false, + handlers: [] +}; + +const GAME_UPDATE_LOOP_STATE = "GAME_UPDATE_LOOP_STATE"; +const GAME_START_LOOP = "GAME_START_LOOP"; +const GAME_STOP_LOOP = "GAME_STOP_LOOP"; +const GAME_REGISTER_LOOP_HANDLER = "GAME_REGISTER_LOOP_HANDLER"; +const GAME_UNREGISTER_LOOP_HANDLER = "GAME_UNREGISTER_LOOP_HANDLER"; + +export const gameUpdateLoopState = started => ({ type: GAME_UPDATE_LOOP_STATE, started }); +export const gameStartLoop = () => ({ type: GAME_START_LOOP }); +export const gameStopLoop = () => ({ type: GAME_STOP_LOOP }); +export const gameRegisterLoopHandler = handler => ({ type: GAME_REGISTER_LOOP_HANDLER, handler }); +export const gameUnregisterLoopHandler = handler => ({ type: GAME_UNREGISTER_LOOP_HANDLER, handler }); + +export const reducers = (state = initialState, action) => { + if (action.type === GAME_UPDATE_LOOP_STATE) { + return { ...state, started: action.started }; + } + if (action.type === GAME_REGISTER_LOOP_HANDLER) { + return { ...state, handlers: [...state.handlers, action.handler] }; + } + if (action.type === GAME_UNREGISTER_LOOP_HANDLER) { + return { ...state, handlers: state.handlers.filter(handler => handler !== action.handler) }; + } + return state; +}; + +export const middleware = ({ dispatch, getState }) => next => action => { + next(action); + + if (action.type === GAME_START_LOOP) { + if (getState()[MODULE_NAME].started) dispatch(gameUpdateLoopState(true)); + } + if (action.type === GAME_STOP_LOOP) { + dispatch(gameUpdateLoopState(false)); + } +}; + +reducers.module = middleware.module = MODULE_NAME; diff --git a/src/redux/game.js b/src/redux/game.js new file mode 100644 index 0000000..c454150 --- /dev/null +++ b/src/redux/game.js @@ -0,0 +1,152 @@ +import Module from "./module.js"; + +export const MODULE_NAME = "GAME"; + +export const module = new Module(MODULE_NAME, { + loopId: null, + callers: [], + keyPressSubscribers: [], + fps: 5 +}); + +export const keys = { + UP: "ArrowUp", + RIGHT: "ArrowRight", + DOWN: "ArrowDown", + LEFT: "ArrowLeft", + PLUS: "+", + MINUS: "-" +}; + +export const [START_LOOP, startLoop] = module.action("START_LOOP"); +export const [STOP_LOOP, stopLoop] = module.action("STOP_LOOP"); +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 [SUBSCRIBE_KEY_PRESSED, subscribeKeyPressed] = module.action("SUBSCRIBE_KEY_PRESSED", name => ({ name })); +export const [UNSUBSCRIBE_KEY_PRESSED, unsubscribeKeyPressed] = module.action("UNSUBSCRIBE_KEY_PRESSED", name => ({ + name +})); + +module.reducer(UPDATE_LOOP_ID, (state, { loopId }) => { + return { ...state, loopId }; +}); + +module.reducer(SET_FPS, (state, { fps }) => { + return { ...state, fps }; +}); + +module.reducer(REGISTER_CALLER, (state, { name }) => { + if (!name) { + throw new Error("Argument 'name' is required."); + } + if (typeof name !== "string") { + throw new Error("Argument 'name' is must be a function."); + } + if (state.callers.includes(name)) { + return state; + } + return { ...state, callers: [...state.callers, name] }; +}); + +module.reducer(UNREGISTER_CALLER, (state, { name }) => { + if (!name) { + throw new Error("Argument 'name' is required."); + } + if (typeof name !== "string") { + throw new Error("Argument 'name' is must be a function."); + } + if (!state.callers.includes(name)) { + return state; + } + return { ...state, callers: state.callers.filter(caller => caller !== name) }; +}); + +module.reducer(SUBSCRIBE_KEY_PRESSED, (state, { name }) => { + if (!name) { + throw new Error("Argument 'name' is required."); + } + if (typeof name !== "string") { + throw new Error("Argument 'name' is must be a function."); + } + if (state.keyPressSubscribers.includes(name)) { + return state; + } + return { ...state, keyPressSubscribers: [...state.keyPressSubscribers, name] }; +}); + +module.reducer(UNSUBSCRIBE_KEY_PRESSED, (state, { name }) => { + if (!name) { + throw new Error("Argument 'name' is required."); + } + if (typeof name !== "string") { + throw new Error("Argument 'name' is must be a function."); + } + if (!state.keyPressSubscribers.includes(name)) { + return state; + } + 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) { + dispatch(setFps(fps + 1)); + } + if (key === keys.MINUS) { + dispatch(setFps(fps - 1)); + } +}); + +module.middleware(INVOKE_CALLERS, (dispatch, { callers = [] }) => { + callers.forEach(caller => dispatch({ type: caller })); +}); + +module.middleware(START_LOOP, (dispatch, { loopId, callers, fps }) => { + if (loopId) { + return; + } + dispatch(loop()); +}); + +module.middleware(LOOP, (dispatch, { loopId, fps }, { skip = 0, recur = false }, getState) => { + if (loopId) { + return; + } + + let start = null; + const loop = ({ recur = false, skip = 0 } = {}) => { + const { loopId, fps } = getState()[MODULE_NAME]; + + if (recur && !loopId) { + return null; + } + + return requestAnimationFrame(timestamp => { + const minDuration = 1000 / fps; + if (!start) { + start = timestamp; + } + const progress = timestamp - start; + if (progress === 0 || progress > minDuration) { + start = timestamp; + dispatch(invokeCallers()); + } + return loop({ recur: true, skip: skip > 10 ? 0 : skip + 1 }); + }); + }; + dispatch(updateLoopId(loop())); +}); + +module.middleware(STOP_LOOP, (dispatch, { loopId }) => { + if (!loopId) { + return; + } + dispatch(updateLoopId(null)); +}); + +export default module; diff --git a/src/redux/home.js b/src/redux/home.js new file mode 100644 index 0000000..2b83338 --- /dev/null +++ b/src/redux/home.js @@ -0,0 +1,20 @@ +import Module from "./module.js"; + +const MODULE_NAME = "HOME"; + +export const module = new Module(MODULE_NAME, { + counter: 0 +}); + +export const [INCREMENT_COUNTER, incrementCounter] = module.action("INCREMENT_COUNTER"); +export const [RESET_COUNTER, resetCounter] = module.action("RESET_COUNTER"); + +module.reducer(INCREMENT_COUNTER, state => { + return { ...state, counter: state.counter + 1 }; +}); + +module.reducer(RESET_COUNTER, state => { + return { ...state, counter: 0 }; +}); + +export default module; diff --git a/src/redux/module.js b/src/redux/module.js new file mode 100644 index 0000000..2502bfe --- /dev/null +++ b/src/redux/module.js @@ -0,0 +1,244 @@ +export class Middleware { + #action = null; + #middleware = null; + + constructor(action, middleware) { + if (!action) { + throw new Error("Argument 'action' is required."); + } + if (typeof action !== "string") { + throw new Error("Argument 'action' must be a string."); + } + if (!middleware) { + throw new Error("Argument 'middleware' is required."); + } + if (typeof middleware !== "function") { + throw new Error("Argument 'middleware' must be a string."); + } + this.#action = action; + this.#middleware = middleware; + } + + get action() { + return this.#action; + } + + get middleware() { + return this.#middleware; + } + + invoke(dispatch, state, action, getState) { + return this.#middleware(dispatch, state, action, getState); + } +} + +export class After { + #action = null; + #handler = null; + + constructor(action, handler) { + if (!action) { + throw new Error("Argument 'action' is required."); + } + if (typeof action !== "string") { + throw new Error("Argument 'action' must be a string."); + } + if (!handler) { + throw new Error("Argument 'handler' is required."); + } + if (typeof handler !== "function") { + throw new Error("Argument 'handler' must be a string."); + } + this.#action = action; + this.#handler = handler; + } + + get action() { + return this.#action; + } + + get handler() { + return this.#handler; + } + + invoke(dispatch, state, action, getState) { + return this.#handler(dispatch, state, action, getState); + } +} + +export class Reducer { + #action = null; + #reducer = null; + + constructor(action, reducer) { + if (!action) { + throw new Error("Argument 'action' is required."); + } + if (typeof action !== "string") { + throw new Error("Argument 'action' must be a string."); + } + if (!reducer) { + throw new Error("Argument 'reducer' is required."); + } + if (typeof reducer !== "function") { + throw new Error("Argument 'reducer' must be a string."); + } + this.#action = action; + this.#reducer = reducer; + } + + get action() { + return this.#action; + } + + get reducer() { + return this.#reducer; + } + + invoke(state, action) { + return this.#reducer(state, action); + } +} + +export class Module { + #name = null; + #initialState = null; + #actions = {}; + #after = {}; + + constructor(name, initialState) { + if (!name) { + throw new Error("Argument 'name' is required."); + } + this.#name = name; + this.#initialState = initialState; + this.middlewares = this.middlewares.bind(this); + } + + get name() { + return this.#name; + } + + middleware(action, middleware) { + this.#register(new Middleware(action, middleware)); + } + + reducer(action, reducer) { + this.#register(new Reducer(action, reducer)); + } + + after(action, middleware) { + this.#register(new After(action, middleware)); + } + + #register(reducerOrMiddleware) { + if ( + !(reducerOrMiddleware instanceof Reducer) && + !(reducerOrMiddleware instanceof Middleware) && + !(reducerOrMiddleware instanceof After) + ) { + throw new Error("Only objects of type Reducer, Middleware or After can register"); + } + if (!(reducerOrMiddleware instanceof After)) { + if (this.#actions[reducerOrMiddleware.action]) { + throw new Error(`Reducer or middleware with action "${reducerOrMiddleware.action}" is already registered`); + } + this.#actions[reducerOrMiddleware.action] = reducerOrMiddleware; + } else { + this.#after[reducerOrMiddleware.action] = reducerOrMiddleware; + } + return reducerOrMiddleware; + } + + action(name, action = () => {}) { + if (!name) { + throw new Error("Argument 'name' is required."); + } + if (typeof action !== "function") { + throw new Error("Argument 'action' must be function"); + } + const type = `[${this.#name}] ${name}`; + const typedAction = (...args) => ({ ...action.apply(this, args), type }); + return [type, typedAction]; + } + + #getActionByType(Type) { + return Object.keys(this.#actions) + .map(key => ({ key, action: this.#actions[key] })) + .reduce((actions, { key, action }) => (action instanceof Type ? { ...actions, [key]: action } : actions), {}); + } + + middlewares({ dispatch, getState }) { + return next => action => { + next(action); + const middlewares = this.#getActionByType(Middleware); + const middleware = middlewares[action.type]; + if (middleware) { + middleware.invoke(dispatch, getState()[this.#name], action, getState); + } + const after = this.#after[action.type]; + if (after) { + after.invoke(dispatch, getState()[this.#name], action, getState); + } + return null; + }; + } + + get reducers() { + return { + [this.#name]: (state = this.#initialState, action) => { + const reducers = this.#getActionByType(Reducer); + const reducer = reducers[action.type]; + if (reducer) { + return reducer.invoke(state, action); + } + return state; + } + }; + } + + state(selector) { + return state => { + if (!this.#name) { + throw new Error("Unable to detect module name"); + } + if (!state) { + throw new Error("No state was passed to the state selector"); + } + if (!state[this.#name]) { + throw new Error(`There is no state for module '${this.#name}'`); + } + return selector(state[this.#name]); + }; + } + + select(...items) { + return (items || []).reduce((selection, item) => { + console.log("item", item); + return selection; + }, {}); + } +} + +export default Module; + +export const attach = (modules, Component) => { + console.log("modules, Component", modules, Component); + return (...args) => { + console.log("args", args); + const component = Component({ + GAME: { + actions: { + startLoop() { + console.log("=== START LOOP ==="); + } + }, + state: {} + } + }); + return component; + component.props = { ...component.props }; + console.log("component", component); + return component.render.apply(this, args); + }; +}; diff --git a/src/redux/snake.backup.js b/src/redux/snake.backup.js new file mode 100644 index 0000000..083f280 --- /dev/null +++ b/src/redux/snake.backup.js @@ -0,0 +1,215 @@ +const START_SNAKE = "[Snake] START_SNAKE"; +const STOP_SNAKE = "[Snake] STOP_SNAKE"; +const PAUSE_SNAKE = "[Snake] PAUSE_SNAKE"; +const RESET_SNAKE = "[Snake] RESET_SNAKE"; +const NEXT_FRAME_SNAKE = "[Snake] NEXT_FRAME_SNAKE"; +const UPDATE_FRAME_SNAKE = "[Snake] UPDATE_FRAME_SNAKE"; +const KEY_PRESSED_SNAKE = "[Snake] KEY_PRESSED_SNAKE"; +const START_ANIMATION_SNAKE = "[Snake] START_ANIMATION_SNAKE"; +const STOP_ANIMATION_SNAKE = "[Snake] STOP_ANIMATION_SNAKE"; + +export const startSnake = () => ({ type: START_SNAKE }); +export const stopSnake = () => ({ type: STOP_SNAKE }); +export const pauseSnake = () => ({ type: PAUSE_SNAKE }); +export const resetSnake = () => ({ type: RESET_SNAKE }); +export const nextFrameSnake = () => ({ type: NEXT_FRAME_SNAKE }); +export const updateFrameSnake = () => ({ type: UPDATE_FRAME_SNAKE }); +export const keyPressedSnake = key => ({ type: KEY_PRESSED_SNAKE, key }); +export const startAnimationSnake = () => ({ type: START_ANIMATION_SNAKE }); +export const stopAnimationSnake = () => ({ type: STOP_ANIMATION_SNAKE }); + +export const keys = { + UP: "ArrowUp", + RIGHT: "ArrowRight", + DOWN: "ArrowDown", + LEFT: "ArrowLeft", + INCREASE: "+", + DECREASE: "-" +}; +export const size = 16; + +const randomPosition = size => [Math.round(Math.random() * (size - 1)), Math.round(Math.random() * (size - 1))]; +const comparePositions = (pos1, pos2) => pos1[0] === pos2[0] && pos1[1] === pos2[1]; + +const initialState = { + grid: [[]], + size, + started: false, + paused: false, + vX: 1, + vY: 0, + snake: [[0, 0], [1, 0], [2, 0]], + apple: randomPosition(size), + gameId: 0, + fps: 5 +}; + +const createGrid = size => { + const rows = []; + for (let y = 0; y < size; y += 1) { + const columns = []; + for (let x = 0; x < size; x += 1) { + if (Math.random() > 9) { + columns[x] = "x"; + } else { + columns[x] = " "; + } + } + rows[y] = columns; + } + return rows; +}; + +const markCell = (grid, [x, y], mark) => { + grid[y][x] = mark; + return grid; +}; + +const moveSnakePart = ([x, y], vX, vY, size) => { + let newX = x + vX; + let newY = y + vY; + if (newX > size - 1) { + newX = 0; + } + if (newX < 0) { + newX = size - 1; + } + if (newY > size - 1) { + newY = 0; + } + if (newY < 0) { + newY = size - 1; + } + return [newX, newY]; +}; + +// eslint-disable-next-line max-statements +export const reducers = (state = initialState, action) => { + if (action.type === STOP_SNAKE) { + return { ...state, started: false }; + } + + if (action.type === PAUSE_SNAKE) { + return { ...state, paused: !state.paused }; + } + + if (action.type === START_ANIMATION_SNAKE) { + if (state.isAnimating) { + return state; + } + return { ...state, isAnimating: true }; + } + + if (action.type === STOP_ANIMATION_SNAKE) { + if (!state.isAnimating) { + return state; + } + return { ...state, isAnimating: false }; + } + + if (action.type === RESET_SNAKE) { + const grid = createGrid(state.size); + const { snake, apple } = initialState; + markCell(grid, apple, "a"); + snake.forEach(p => markCell(grid, p, "s")); + return { ...initialState, started: true, paused: false, grid, snake, gameId: state.gameId + 1 }; + } + + if (action.type === KEY_PRESSED_SNAKE) { + if (action.key === keys.UP) { + return { ...state, vX: 0, vY: -1 }; + } + if (action.key === keys.DOWN) { + return { ...state, vX: 0, vY: 1 }; + } + if (action.key === keys.LEFT) { + return { ...state, vX: -1, vY: 0 }; + } + if (action.key === keys.RIGHT) { + return { ...state, vX: 1, vY: 0 }; + } + if (action.key === keys.INCREASE) { + return { ...state, fps: state.fps + 1 }; + } + if (action.key === keys.DECREASE) { + return { ...state, fps: state.fps - 1 }; + } + } + + if (action.type === UPDATE_FRAME_SNAKE) { + const { snake, vX, vY, size } = state; + let { apple, started, died } = state; + const grid = createGrid(size); + const newSnake = [...snake]; + const nextPosition = moveSnakePart(newSnake[newSnake.length - 1], vX, vY, size); + if (comparePositions(nextPosition, apple)) { + apple = randomPosition(size); + // eslint-disable-next-line no-loop-func + while (newSnake.filter(p => comparePositions(p, apple)).length) { + apple = randomPosition(size); + } + } else { + newSnake.shift(); + } + if (snake.filter(p => comparePositions(p, nextPosition)).length) { + started = false; + died = true; + } + markCell(grid, apple, "a"); + newSnake.push(nextPosition); + newSnake.forEach(p => markCell(grid, p, "s")); + return { ...state, grid, vX, vY, snake: newSnake, apple, started, died }; + } + + if (action.type === "UPDATE_STATE") { + console.log("action.state", action.state); + return action.state; + } + + return state; +}; + +export const middleware = ({ dispatch, getState }) => next => action => { + next(action); + + if (action.type === START_SNAKE) { + const { started } = getState().snakeGame; + if (started) { + return; + } + + dispatch(resetSnake()); + animate(1, () => dispatch(nextFrameSnake())); + } + + if (action.type === NEXT_FRAME_SNAKE) { + const { + snakeGame: { started, paused, gameId, fps, isAnimating } + } = getState(); + if (isAnimating) { + return; + } + dispatch(startAnimationSnake()); + !paused && dispatch(updateFrameSnake()); + started && + animate(fps, () => { + dispatch(stopAnimationSnake()); + if (gameId !== getState().snakeGame.gameId) { + return; + } + dispatch(nextFrameSnake()); + }); + } +}; + +const animate = (fps, callback) => { + const now = Date.now(); + const loop = () => + requestAnimationFrame(() => { + if (Date.now() - now > 1000 / fps) { + return callback(); + } + return loop(); + }); + return loop(); +}; diff --git a/src/redux/snake.js b/src/redux/snake.js index 56a6f79..cd9fa03 100644 --- a/src/redux/snake.js +++ b/src/redux/snake.js @@ -1,46 +1,59 @@ -const START_SNAKE = "[Snake] START_SNAKE"; -const STOP_SNAKE = "[Snake] STOP_SNAKE"; -const PAUSE_SNAKE = "[Snake] PAUSE_SNAKE"; -const RESET_SNAKE = "[Snake] RESET_SNAKE"; -const NEXT_FRAME_SNAKE = "[Snake] NEXT_FRAME_SNAKE"; -const UPDATE_FRAME_SNAKE = "[Snake] UPDATE_FRAME_SNAKE"; -const KEY_PRESSED_SNAKE = "[Snake] KEY_PRESSED_SNAKE"; +import Module from "./module.js"; +import { + registerCaller, + unregisterCaller, + startLoop, + stopLoop, + subscribeKeyPressed, + unsubscribeKeyPressed +} from "./game.js"; -export const startSnake = () => ({ type: START_SNAKE }); -export const stopSnake = () => ({ type: STOP_SNAKE }); -export const pauseSnake = () => ({ type: PAUSE_SNAKE }); -export const resetSnake = () => ({ type: RESET_SNAKE }); -export const nextFrameSnake = () => ({ type: NEXT_FRAME_SNAKE }); -export const updateFrameSnake = () => ({ type: UPDATE_FRAME_SNAKE }); -export const keyPressedSnake = key => ({ type: KEY_PRESSED_SNAKE, key }); +const MODULE_NAME = "SNAKE"; +const DEFAULT_GRID_SIZE = 16; + +const initialState = { + grid: createGrid(DEFAULT_GRID_SIZE), + size: DEFAULT_GRID_SIZE, + paused: false, + vX: 1, + vY: 0, + snake: [], + score: 0, + previousScore: 0, + apple: randomPosition(DEFAULT_GRID_SIZE) +}; + +/* === Snake Helper Functions ================================================================== */ + +export const directions = { + UP: "UP", + DOWN: "DOWN", + LEFT: "LEFT", + RIGHT: "RIGHT" +}; export const keys = { UP: "ArrowUp", RIGHT: "ArrowRight", DOWN: "ArrowDown", LEFT: "ArrowLeft", - INCREASE: "+", - DECREASE: "-" -}; -export const size = 16; -const randomPosition = size => [Math.round(Math.random() * (size - 1)), Math.round(Math.random() * (size - 1))]; + h: "h", + j: "j", + k: "k", + l: "l", + + p: "p", + s: "s", + r: "r" +}; + +function randomPosition(size) { + return [Math.round(Math.random() * (size - 1)), Math.round(Math.random() * (size - 1))]; +} const comparePositions = (pos1, pos2) => pos1[0] === pos2[0] && pos1[1] === pos2[1]; -const initialState = { - grid: [[]], - size, - started: false, - paused: false, - vX: 1, - vY: 0, - snake: [[0, 0], [1, 0], [2, 0]], - apple: randomPosition(size), - gameId: 0, - fps: 5 -}; - -const createGrid = size => { +function createGrid(size) { const rows = []; for (let y = 0; y < size; y += 1) { const columns = []; @@ -54,7 +67,7 @@ const createGrid = size => { rows[y] = columns; } return rows; -}; +} const markCell = (grid, [x, y], mark) => { grid[y][x] = mark; @@ -79,103 +92,134 @@ const moveSnakePart = ([x, y], vX, vY, size) => { return [newX, newY]; }; -export const reducers = (state = initialState, action) => { - if (action.type === STOP_SNAKE) { - return { ...state, started: false }; - } +export const module = new Module(MODULE_NAME, initialState); - if (action.type === PAUSE_SNAKE) { - return { ...state, paused: !state.paused }; - } +/* === Actions ================================================================================== */ - if (action.type === RESET_SNAKE) { - const grid = createGrid(state.size); - const { snake, apple } = initialState; - markCell(grid, apple, "a"); - snake.forEach(p => markCell(grid, p, "s")); - return { ...initialState, started: true, paused: false, grid, snake, gameId: state.gameId + 1 }; - } +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"); +export const [PAUSE_SNAKE, pauseSnake] = module.action("PAUSE_SNAKE"); +export const [UPDATE_FRAME_SNAKE, updateFrameSnake] = module.action("UPDATE_FRAME_SNAKE"); +export const [CHANGE_DIRECTION, changeDirection] = module.action("CHANGE_DIRECTION", direction => ({ direction })); +export const [KEY_PRESSED_SNAKE, keyPressedSnake] = module.action("KEY_PRESSED_SNAKE", key => ({ key })); +export const [UPDATE_STATE, updateState] = module.action("UPDATE_STATE", (newState, fullUpdate = false) => ({ + newState, + fullUpdate +})); - if (action.type === KEY_PRESSED_SNAKE) { - if (action.key === keys.UP) { - return { ...state, vX: 0, vY: -1 }; - } - if (action.key === keys.DOWN) { - return { ...state, vX: 0, vY: 1 }; - } - if (action.key === keys.LEFT) { - return { ...state, vX: -1, vY: 0 }; - } - if (action.key === keys.RIGHT) { - return { ...state, vX: 1, vY: 0 }; - } - if (action.key === keys.INCREASE) { - return { ...state, fps: state.fps + 1 }; - } - if (action.key === keys.DECREASE) { - return { ...state, fps: state.fps - 1 }; - } - } +/* === Reducers ================================================================================= */ - if (action.type === UPDATE_FRAME_SNAKE) { - const { snake, vX, vY, size } = state; - let { apple, started, died } = state; - const grid = createGrid(size); - const newSnake = [...snake]; - const nextPosition = moveSnakePart(newSnake[newSnake.length - 1], vX, vY, size); - if (comparePositions(nextPosition, apple)) { - apple = randomPosition(size); - // eslint-disable-next-line no-loop-func - while (newSnake.filter(p => comparePositions(p, apple)).length) { - apple = randomPosition(size); - } - } else { - newSnake.shift(); - } - if (snake.filter(p => comparePositions(p, nextPosition)).length) { - started = false; - died = true; - } - markCell(grid, apple, "a"); - newSnake.push(nextPosition); - newSnake.forEach(p => markCell(grid, p, "s")); - return { ...state, grid, vX, vY, snake: newSnake, apple, started, died }; +module.reducer(UPDATE_STATE, (state, { newState, fullUpdate }) => (fullUpdate ? newState : { ...state, ...newState })); +module.reducer(RESET_SNAKE, (state, options) => { + const grid = createGrid(state.size); + const { apple } = initialState; + const snake = options.started ? [[0, 0]] : []; + + markCell(grid, apple, "a"); + snake.forEach(p => markCell(grid, p, "s")); + return { ...state, ...initialState, grid, snake, ...options }; +}); + +module.reducer(CHANGE_DIRECTION, (state, { direction }) => { + const { vX, vY } = state; + + if (direction === directions.UP && vY !== 1) { + return { ...state, vX: 0, vY: -1 }; + } + if (direction === directions.DOWN && vY !== -1) { + return { ...state, vX: 0, vY: 1 }; + } + if (direction === directions.LEFT && vX !== 1) { + return { ...state, vX: -1, vY: 0 }; + } + if (direction === directions.RIGHT && vX !== -1) { + return { ...state, vX: 1, vY: 0 }; } return state; -}; +}); -export const middleware = ({ dispatch, getState }) => next => action => { - next(action); - - if (action.type === START_SNAKE) { - dispatch(resetSnake()); - animate(1, () => dispatch(nextFrameSnake())); +module.reducer(UPDATE_FRAME_SNAKE, state => { + const { snake, vX, vY, size } = state; + let { apple, started, died, previousScore, score } = state; + const grid = createGrid(size); + const newSnake = [...snake]; + const nextPosition = moveSnakePart(newSnake[newSnake.length - 1], vX, vY, size); + previousScore = score; + if (comparePositions(nextPosition, apple)) { + apple = randomPosition(size); + // eslint-disable-next-line no-loop-func + while (newSnake.filter(p => comparePositions(p, apple)).length) { + apple = randomPosition(size); + } + } else { + newSnake.shift(); } - - if (action.type === NEXT_FRAME_SNAKE) { - const { - snakeGame: { started, paused, gameId, fps } - } = getState(); - !paused && dispatch(updateFrameSnake()); - started && - animate(fps, () => { - if (gameId !== getState().snakeGame.gameId) { - return; - } - dispatch(nextFrameSnake()); - }); + if (newSnake.filter(p => comparePositions(p, nextPosition)).length) { + started = false; + died = true; } -}; + newSnake.push(nextPosition); + markCell(grid, apple, "a"); + const brightness = (l, i) => 20 + Math.round(0.8 * ((i + 1) / l) * 100); + newSnake.forEach((p, i) => markCell(grid, p, "s" + brightness(newSnake.length, i))); + score = newSnake.length; + return { ...state, grid, vX, vY, snake: newSnake, apple, started, died, previousScore, score }; +}); -const animate = (fps, callback) => { - const now = Date.now(); - const loop = () => - requestAnimationFrame(() => { - if (Date.now() - now > 1000 / fps) { - return callback(); - } - return loop(); - }); - return loop(); -}; +/* === Middleware =============================================================================== */ + +module.after(UPDATE_FRAME_SNAKE, (dispatch, { died }) => { + if (died) { + dispatch(unregisterCaller(UPDATE_FRAME_SNAKE)); + dispatch(stopLoop()); + } +}); + +module.middleware(START_SNAKE, (dispatch, { started }) => { + dispatch(resetSnake({ started: true, died: false })); + dispatch(registerCaller(UPDATE_FRAME_SNAKE)); + dispatch(subscribeKeyPressed(KEY_PRESSED_SNAKE)); + dispatch(startLoop()); +}); + +module.middleware(STOP_SNAKE, (dispatch, { started }) => { + dispatch(unregisterCaller(UPDATE_FRAME_SNAKE)); + dispatch(unsubscribeKeyPressed(KEY_PRESSED_SNAKE)); + dispatch(stopLoop()); + dispatch(resetSnake({ started: false })); +}); + +module.middleware(PAUSE_SNAKE, (dispatch, { paused }) => { + dispatch(updateState({ paused: !paused })); + if (paused) { + dispatch(registerCaller(UPDATE_FRAME_SNAKE)); + } else { + dispatch(unregisterCaller(UPDATE_FRAME_SNAKE)); + } +}); + +module.middleware(KEY_PRESSED_SNAKE, (dispatch, _, { key }) => { + if (key === keys.UP || key === keys.k) { + dispatch(changeDirection(directions.UP)); + } + if (key === keys.DOWN || key === keys.j) { + dispatch(changeDirection(directions.DOWN)); + } + if (key === keys.LEFT || key === keys.h) { + dispatch(changeDirection(directions.LEFT)); + } + if (key === keys.RIGHT || key === keys.l) { + dispatch(changeDirection(directions.RIGHT)); + } + if (key === keys.s) { + dispatch(stopSnake()); + } + if (key === keys.p) { + dispatch(pauseSnake()); + } + if (key === keys.r) { + dispatch(startSnake()); + } +}); diff --git a/src/theming/theme.js b/src/theming/theme.js index dcf77d2..6524f81 100644 --- a/src/theming/theme.js +++ b/src/theming/theme.js @@ -23,13 +23,14 @@ const themes = { }, snake: { cell: { - border: "0.0625rem solid silver", + border: "0.0625rem solid papayawhip", size: "1.5rem" }, cellColors: { " ": "", - a: "green", - s: "orange" + a: "", + ab: "#90dc90", + s: "#f12f00" } } }