Implemented a snake game with redux

This commit is contained in:
2019-08-22 23:59:32 +02:00
commit fe7fae2c19
31 changed files with 1216 additions and 0 deletions

3
.babelrc Normal file
View File

@@ -0,0 +1,3 @@
{
"presets": ["@babel/preset-env", "@babel/preset-react"]
}

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules
public/main.js
*tar

3
Dockerfile Normal file
View File

@@ -0,0 +1,3 @@
FROM nginx
COPY public /usr/share/nginx/html
COPY default.conf /etc/nginx/conf.d/default.conf

44
default.conf Normal file
View File

@@ -0,0 +1,44 @@
server {
listen 80;
server_name localhost;
#charset koi8-r;
#access_log /var/log/nginx/host.access.log main;
location / {
alias /usr/share/nginx/html/;
try_files $uri /index.html;
}
#error_page 404 /404.html;
# redirect server error pages to the static page /50x.html
#
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
# proxy the PHP scripts to Apache listening on 127.0.0.1:80
#
#location ~ \.php$ {
# proxy_pass http://127.0.0.1;
#}
# pass the PHP scripts to FastCGI server listening on 127.0.0.1:9000
#
#location ~ \.php$ {
# root html;
# fastcgi_pass 127.0.0.1:9000;
# fastcgi_index index.php;
# fastcgi_param SCRIPT_FILENAME /scripts$fastcgi_script_name;
# include fastcgi_params;
#}
# deny access to .htaccess files, if Apache's document root
# concurs with nginx's one
#
#location ~ /\.ht {
# deny all;
#}
}

31
nginx.conf Normal file
View File

@@ -0,0 +1,31 @@
user nginx;
worker_processes 1;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
#tcp_nopush on;
keepalive_timeout 65;
#gzip on;
include /etc/nginx/conf.d/*.conf;
}

37
package.json Normal file
View File

@@ -0,0 +1,37 @@
{
"name": "keybook-redux",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"dev": "webpack-dev-server --mode development --open",
"build": "webpack --optimize-minimize --config webpack.production.config.js",
"webpack": "webpack --config webpack.config.js --watch",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"@babel/core": "^7.5.5",
"@babel/preset-env": "^7.5.5",
"@babel/preset-react": "^7.0.0",
"babel-loader": "^8.0.6",
"babel-plugin-styled-components": "^1.10.6",
"react-hot-loader": "^4.12.11",
"webpack": "^4.39.2",
"webpack-cli": "^3.3.7",
"webpack-dev-server": "^3.8.0"
},
"dependencies": {
"connected-react-router": "^6.5.2",
"history": "^4.9.0",
"react": "^16.9.0",
"react-dom": "^16.9.0",
"react-redux": "^7.1.0",
"react-router": "^5.0.1",
"react-router-dom": "^5.0.1",
"redux": "^4.0.4",
"styled-components": "^4.3.2"
}
}

11
public/index.html Normal file
View File

@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Redux Demo</title>
</head>
<body>
<div id="root"></div>
<script src="main.js"></script>
</body>
</html>

60
src/components/Button.js Normal file
View File

@@ -0,0 +1,60 @@
import React from "react";
import { NavLink } from "react-router-dom";
import styled from "styled-components";
const SharedStyle = () => `
border: 1px solid gray;
border-radius: .1875rem;
background-color: palevioletred;
color: papayawhip;
text-align: center;
padding: .3125rem;
font-size: 1rem;
display: inline-block;
vertical-align: top;
text-decoration: none;
user-select: none;
cursor: pointer;
&:hover {
background-color: blue;
}
`;
const StyledButton = styled.button`
${SharedStyle}
-webkit-appearance: none;
`;
const StyledAnchor = styled.a`
${SharedStyle}
`;
const StyledNavLink = styled(NavLink)`
${SharedStyle}
&.active {
border-color: silver;
color: #828282;
background-color: #ecb1c5;
}
`;
const Button = (props, refs) => {
const updatedProps = { ...props };
if (props.onClick) {
updatedProps.onClick = e => e.preventDefault() || props.onClick();
}
if (props.href) {
return StyledAnchor.render(updatedProps);
}
if (props.to) {
return StyledNavLink.render(updatedProps);
}
return StyledButton.render(updatedProps);
};
export default Button;

View File

@@ -0,0 +1,48 @@
import React from "react";
import styled from "styled-components";
import { NavLink } from "react-router-dom";
const MenuPanel = styled.ul`
display: flex;
`;
const MenuItem = styled.li`
display: inline-block;
`;
const MenuButton = styled(NavLink)`
display: inline-block;
text-align: center;
text-decoration: none;
border: 1px solid silver;
background: ${props => props.theme.header.menuButton.background};
color: ${props => props.theme.header.menuButton.color};
padding: 0.3125rem;
margin-right: 0.5rem;
border-radius: 0.125rem;
min-width: 3.125rem;
&.active {
background: ${props => props.theme.header.menuButton.active.background};
color: ${props => props.theme.header.menuButton.active.color};
}
`;
const HeaderMenu = () => (
<MenuPanel>
<MenuItem>
<MenuButton to="/" exact>
Home
</MenuButton>
</MenuItem>
{
// <MenuItem>
// <MenuButton to="/keyboards">Keyboards</MenuButton>
// </MenuItem>
}
<MenuItem>
<MenuButton to="/snake">Snake</MenuButton>
</MenuItem>
</MenuPanel>
);
export default HeaderMenu;

32
src/components/Link.js Normal file
View File

@@ -0,0 +1,32 @@
import styled from "styled-components";
import { NavLink } from "react-router-dom";
const SharedStyle = () => `
color: gray;
text-decoration: underline;
user-select: none;
cursor: pointer;
`;
const StyledLink = styled.a`
${SharedStyle}
`;
const StyledNavLink = styled(NavLink)`
${SharedStyle}
&.active {
color: black;
}
`;
const Link = (props, refs) => {
const updatedProps = { ...props };
if (props.onClick) {
updatedProps.onClick = e => e.preventDefault() || props.onClick();
}
return props.to ? StyledNavLink.render(updatedProps) : StyledLink.render(updatedProps);
};
export default Link;

23
src/components/List.js Normal file
View File

@@ -0,0 +1,23 @@
import React from "react";
import styled from "styled-components";
const StyledList = styled.ul`
margin: 0;
padding: 0;
text-indent: 0;
list-style: none;
`;
export const List = ({ items, Item, itemProps, Empty }) => {
if (items && items.length) {
return <StyledList>{items.map((item, index) => Item({ ...itemProps, item }))}</StyledList>;
}
if ((!items || !items.length) && Empty) {
return Empty({ ...itemProps });
}
return <div />;
};
List.Item = styled.li``;
export default List;

30
src/components/Logo.js Normal file
View File

@@ -0,0 +1,30 @@
import React from "react";
import styled from "styled-components";
const LogoPanel = styled.div`
display: inline-block;
h1,
h2 {
margin: 0;
padding: 0;
line-height: 1rem;
}
h1 {
line-height: 2rem;
}
h2 {
font-size: 1rem;
}
`;
const Logo = () => (
<LogoPanel>
<h1>Hello Redux</h1>
<h2>Redux demo application</h2>
</LogoPanel>
);
export default Logo;

9
src/components/Page.js Normal file
View File

@@ -0,0 +1,9 @@
import styled from "styled-components";
const Page = styled.div`
max-width: 60rem;
margin: 0 auto;
padding: 0.5em;
`;
export default Page;

60
src/components/Spinner.js Normal file
View File

@@ -0,0 +1,60 @@
import styled from "styled-components";
const Spinner = styled.div`
position: relative;
width: 1.875rem;
height: 1.875rem;
display: inline-block;
&::before {
display: block;
border-radius: 50%;
content: "";
position: absolute;
border: 0.4rem solid ${props => props.theme.spinner.shadow};
top: 0;
right: 0;
bottom: 0;
left: 0;
}
&::after {
display: block;
content: "";
position: absolute;
transform: translateZ(0);
border-top: 0.4rem solid ${props => props.theme.spinner.highlight}30;
border-right: 0.4rem solid ${props => props.theme.spinner.highlight}30;
border-bottom: 0.4rem solid ${props => props.theme.spinner.highlight}30;
border-left: 0.4rem solid ${props => props.theme.spinner.highlight};
border-radius: 50%;
top: 0;
right: 0;
bottom: 0;
left: 0;
animation: load8 1.1s infinite linear;
}
@-webkit-keyframes load8 {
0% {
-webkit-transform: rotate(0deg);
transform: rotate(0deg);
}
100% {
-webkit-transform: rotate(360deg);
transform: rotate(360deg);
}
}
@keyframes load8 {
0% {
-webkit-transform: rotate(0deg);
transform: rotate(0deg);
}
100% {
-webkit-transform: rotate(360deg);
transform: rotate(360deg);
}
}
`;
export default Spinner;

7
src/components/Title.js Normal file
View File

@@ -0,0 +1,7 @@
import styled from "styled-components";
const Title = styled.h1`
font-size: ${props => (props.large ? "3rem" : "1.5rem")};
`;
export default Title;

44
src/containers/App.js Normal file
View File

@@ -0,0 +1,44 @@
import React from "react";
import { Route, Switch } from "react-router"; // react-router v4/v5
import { connect } from "react-redux";
import { hot } from "react-hot-loader/root";
/* Containers */
import Header from "./Header";
import Footer from "./Footer";
import Page from "../components/Page";
import Home from "./Home";
import Keyboards from "./Keyboards";
import KeyboardDetails from "./KeyboardDetails";
import Snake from "./Snake";
const App = ({ title, onNewTitle, getKeyboards, keyboards }) => (
<React.Fragment>
<Header />
<Page>
<Switch>
<Route exact path="/" component={Home} />
<Route path="/keyboards/:id" exact component={KeyboardDetails} />
<Route path="/keyboards" exact component={Keyboards} />
<Route path="/snake" exact component={Snake} />
<Route path="/test/snake" exact component={Snake} />
</Switch>
</Page>
<Footer />
</React.Fragment>
);
const mapState = () => ({});
const mapActions = {};
const ConnectedApp = connect(
mapState,
mapActions
)(App);
// const App = () => <h1>Hello Redux!!!</h1>;
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(ConnectedApp) : ConnectedApp);

20
src/containers/Footer.js Normal file
View File

@@ -0,0 +1,20 @@
import React from "react";
import styled from "styled-components";
const FooterContainer = styled.header`
position: absolute;
bottom: 0;
width: 100vw;
text-align: center;
min-height: 4.25rem;
padding: 1rem;
box-sizing: border-box;
`;
const Footer = () => (
<FooterContainer>
<p>Made with by Crafity</p>
</FooterContainer>
);
export default Footer;

24
src/containers/Header.js Normal file
View File

@@ -0,0 +1,24 @@
import React from "react";
import Logo from "../components/Logo.js";
import HeaderMenu from "../components/HeaderMenu.js";
import styled from "styled-components";
const HeaderContainer = styled.header`
background-color: ${props => props.theme.header.background};
display: flex;
min-height: 6.25rem;
align-content: space-between;
place-content: space-between;
align-items: center;
padding: 1rem;
box-sizing: border-box;
`;
const Header = () => (
<HeaderContainer>
<Logo />
<HeaderMenu />
</HeaderContainer>
);
export default Header;

6
src/containers/Home.js Normal file
View File

@@ -0,0 +1,6 @@
import React from "react";
import Title from "../components/Title.js";
const Home = () => <Title large>Home Page</Title>;
export default Home;

View File

@@ -0,0 +1,67 @@
import React from "react";
import { connect } from "react-redux";
import { getKeyboards } from "../redux/keyboards.js";
import Spinner from "../components/Spinner.js";
import Title from "../components/Title.js";
const KeyboardDetailsContainer = ({ children }) => (
<React.Fragment>
<Title large>Keyboard Details</Title>
{children}
</React.Fragment>
);
class KeyboardDetails extends React.Component {
constructor(props) {
super(props);
this.id = parseInt(this.props.match.params.id, 10);
}
componentDidMount() {
this.props.getKeyboards();
}
render() {
const keyboardsLoaded = !!this.props.keyboards;
const loading = !keyboardsLoaded || this.props.spinner;
const keyboard = this.props.keyboards && this.props.keyboards.find(k => k.id === this.id);
const keyboardExists = keyboardsLoaded && !!keyboard;
if (loading) {
return (
<KeyboardDetailsContainer>
<Spinner />
</KeyboardDetailsContainer>
);
}
if (!keyboardExists) {
return (
<KeyboardDetailsContainer>
<p>Keyboard not found...</p>
</KeyboardDetailsContainer>
);
}
return (
<KeyboardDetailsContainer>
<p>{keyboard.maker}</p>
</KeyboardDetailsContainer>
);
}
}
const state = ({ keyboards, ui }) => ({
keyboards: keyboards.list,
spinner: ui.spinner
});
const actions = {
getKeyboards
};
export default connect(
state,
actions
)(KeyboardDetails);

138
src/containers/Snake.js Normal file
View File

@@ -0,0 +1,138 @@
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 Title from "../components/Title.js";
import Button from "../components/Button.js";
const ToggleButton = styled(Button)`
background-color: ${props => (props.toggle ? "silver" : null)};
`;
const Row = styled.div``;
const Cell = styled.div`
width: ${({ theme }) => theme.snake.cell.size};
height: ${({ theme }) => theme.snake.cell.size};
border: ${({ theme }) => theme.snake.cell.border};
display: inline-block;
vertical-align: top;
text-align: center;
background-color: ${props => props.value && props.theme.snake.cellColors[props.value]};
`;
const Grid = ({ data }) =>
data.map((r, y) => (
<Row key={`y${y}`}>
{r.map((c, x) => (
<Cell key={`x${x}y${y}`} value={c}>
{c === "a" && (
<span role="img" aria-label="apple">
🍏
</span>
)}
</Cell>
))}
</Row>
));
// const ArrowButton = styled(Button)`
// min-width: 5rem;
// `;
// const ArrowCluster = () => (
// <div>
// <div>
// <ArrowButton>Up</ArrowButton>
// </div>
// <div>
// <ArrowButton>Left</ArrowButton>
// <ArrowButton>Down</ArrowButton>
// <ArrowButton>Right</ArrowButton>
// </div>
// </div>
// );
class Snake extends React.Component {
constructor(props) {
super(props);
this.handleKeyDown = this.handleKeyDown.bind(this);
this.supportedKeys = Object.values(keys);
}
handleKeyDown(e) {
if (!this.supportedKeys.includes(e.key)) {
return;
}
this.props.keyPressedSnake(e.key);
}
componentDidMount() {
window.addEventListener("keydown", this.handleKeyDown, false);
this.props.startSnake();
}
render() {
const { startSnake, stopSnake, pauseSnake, updateFrameSnake, snakeGame } = this.props;
const { grid, started, paused, gameId, snake, died, fps } = snakeGame;
if (died) {
return (
<React.Fragment>
<Title large>Snake</Title>
<Title>
You died{" "}
<span role="img" aria-label="skull">
</span>{" "}
with {snake.length} points.
</Title>
<Button onClick={startSnake}>Restart</Button>
</React.Fragment>
);
}
if (!started && !died) {
return (
<React.Fragment>
<Title large>Snake</Title>
<Button onClick={startSnake}>Start</Button>
</React.Fragment>
);
}
return (
<React.Fragment>
<Title large>Snake: Attempt {gameId}</Title>
<p>Points: {snake.length}</p>
<Grid data={grid} />
<Button onClick={updateFrameSnake}>Next</Button>
<ToggleButton toggle={paused} onClick={pauseSnake}>
{paused ? "Resume" : "Pause"}
</ToggleButton>
<Button onClick={stopSnake}>Stop</Button>
<Button onClick={startSnake}>Reset</Button>
<span>FPS: {fps}</span>
</React.Fragment>
);
}
componentWillUnmount() {
window.removeEventListener("keydown", this.handleKeyDown);
}
}
const mapStateToProps = ({ snakeGame }) => ({ snakeGame: snakeGame || {} });
const mapActionsToProps = {
startSnake,
stopSnake,
pauseSnake,
updateFrameSnake,
keyPressedSnake
};
export default connect(
mapStateToProps,
mapActionsToProps
)(Snake);

View File

@@ -0,0 +1,54 @@
import React from "react";
import { connect } from "react-redux";
import Title from "../components/Title.js";
import List from "../components/List.js";
import Button from "../components/Button.js";
import Link from "../components/Link.js";
import Spinner from "../components/Spinner.js";
import { getKeyboards, showKeyboard } from "../redux/keyboards.js";
const KeyboardListItem = ({ item, showKeyboard }) => (
<List.Item key={item.id}>
<Link to={`/keyboards/${item.id}`}>
{item.maker} - {item.model}
</Link>
</List.Item>
);
const NoKeyboardsFound = ({ spinner }) => {
if (spinner) {
return <p>Loading...</p>;
}
return <p>There are no keyboards</p>;
};
class Keyboards extends React.Component {
componentDidMount() {
this.props.getKeyboards();
}
render() {
const { keyboards, spinner, getKeyboards } = this.props;
return (
<React.Fragment>
<Title large>Keyboard List</Title>
<List items={keyboards} Item={KeyboardListItem} Empty={NoKeyboardsFound} itemProps={this.props} />
<Button onClick={() => getKeyboards({ force: true })}>Refresh</Button>
{spinner && <Spinner />}
</React.Fragment>
);
}
}
const mapState = ({ keyboards, ui }) => ({
keyboards: keyboards.list,
spinner: ui.spinner
});
const mapActions = {
getKeyboards,
showKeyboard
};
export default connect(
mapState,
mapActions
)(Keyboards);

52
src/index.js Normal file
View File

@@ -0,0 +1,52 @@
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 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(
<Provider store={store}>
<ThemeProvider theme={themes.main}>
<ConnectedRouter history={history}>
<GlobalStyle />
<App />
</ConnectedRouter>
</ThemeProvider>
</Provider>,
document.getElementById("root")
);

22
src/redux/api.js Normal file
View File

@@ -0,0 +1,22 @@
const FETCH_DATA = "[API] FETCH_DATA";
export const fetchData = ({ url, onError, onSuccess }) => ({ type: FETCH_DATA, url, onError, onSuccess });
const keyboardList = [
{ id: 1, maker: "Percent Studio", model: "Canoe" },
{ id: 2, maker: "Gray Studio", model: "Space65" }
];
export const middleware = ({ dispatch }) => next => action => {
next(action);
if (action.type === FETCH_DATA) {
if (action.url === "/keyboards") {
setTimeout(() => {
dispatch({ type: action.onSuccess, data: keyboardList });
}, 3000);
} else {
dispatch({ type: action.onError, data: new Error(`Unable to fetch data from url ${action.url}`) });
}
}
};

65
src/redux/keyboards.js Normal file
View File

@@ -0,0 +1,65 @@
import { push } from "connected-react-router";
import { showSpinner, hideSpinner } from "./ui.js";
import { fetchData } from "./api.js";
const GET_KEYBOARDS = "[Keyboards] GET_KEYBOARDS";
const LOAD_KEYBOARDS = "[Keyboards] LOAD_KEYBOARDS";
const LOAD_KEYBOARDS_SUCCEEDED = "[Keyboards] LOAD_KEYBOARDS_SUCCEEDED";
const LOAD_KEYBOARDS_FAILED = "[Keyboards] LOAD_KEYBOARDS_FAILED";
const UPDATE_KEYBOARDS = "[Keyboards] UPDATE_KEYBOARDS";
const GET_KEYBOARD = "[Keyboards] GET_KEYBOARD";
const SHOW_KEYBOARD_DETAILS = "[Keyboards] SHOW_KEYBOARD_DETAILS";
export const loadKeyboards = ({ onError, onSuccess }) => ({ type: LOAD_KEYBOARDS, onError, onSuccess });
export const getKeyboards = ({ force } = { force: false }) => ({ type: GET_KEYBOARDS, force });
export const updateKeyboards = keyboards => ({ type: UPDATE_KEYBOARDS, keyboards });
export const showKeyboard = id => ({ type: SHOW_KEYBOARD_DETAILS, id });
export const getKeyboard = id => ({ type: GET_KEYBOARD, id });
export const middleware = ({ dispatch, getState }) => next => action => {
next(action);
if (action.type === SHOW_KEYBOARD_DETAILS) {
dispatch(push(`/keyboards/${action.id}`));
}
if (action.type === GET_KEYBOARDS) {
const { keyboards } = getState();
if (!keyboards.list || action.force) {
dispatch(showSpinner());
dispatch(loadKeyboards({ onError: LOAD_KEYBOARDS_FAILED, onSuccess: LOAD_KEYBOARDS_SUCCEEDED }));
}
}
if (action.type === LOAD_KEYBOARDS) {
dispatch(fetchData({ url: "/keyboards", onError: action.onError, onSuccess: action.onSuccess }));
}
if (action.type === LOAD_KEYBOARDS_SUCCEEDED) {
dispatch(hideSpinner());
dispatch(updateKeyboards(action.data));
}
};
export const reducers = (keyboards = {}, action) => {
if (action.type === UPDATE_KEYBOARDS) {
return {
...keyboards,
list: action.keyboards,
error: undefined
};
}
if (action.type === LOAD_KEYBOARDS_FAILED) {
return {
...keyboards,
list: undefined,
error: action.err
};
}
if (action.type === GET_KEYBOARD) {
return {
...keyboards
};
}
return keyboards;
};

181
src/redux/snake.js Normal file
View File

@@ -0,0 +1,181 @@
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";
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 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];
};
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 === 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 };
}
return state;
};
export const middleware = ({ dispatch, getState }) => next => action => {
next(action);
if (action.type === START_SNAKE) {
dispatch(resetSnake());
animate(1, () => dispatch(nextFrameSnake()));
}
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());
});
}
};
const animate = (fps, callback) => {
const now = Date.now();
const loop = () =>
requestAnimationFrame(() => {
if (Date.now() - now > 1000 / fps) {
return callback();
}
return loop();
});
return loop();
};

30
src/redux/ui.js Normal file
View File

@@ -0,0 +1,30 @@
const SHOW_SPINNER = "[UI] SHOW_SPINNER";
const HIDE_SPINNER = "[UI] HIDE_SPINNER";
const TOGGLE_SPINNER = "[UI] TOGGLE_SPINNER";
export const showSpinner = () => ({ type: SHOW_SPINNER });
export const hideSpinner = () => ({ type: HIDE_SPINNER });
export const toggleSpinner = () => ({ type: TOGGLE_SPINNER });
export const reducers = (ui = {}, action) => {
if (action.type === SHOW_SPINNER) {
return {
...ui,
spinner: true
};
}
if (action.type === HIDE_SPINNER) {
return {
...ui,
spinner: false
};
}
if (action.type === TOGGLE_SPINNER) {
return {
...ui,
spinner: !ui.spinner
};
}
return ui;
};

View File

@@ -0,0 +1,17 @@
import { createGlobalStyle } from "styled-components";
const GlobalStyle = createGlobalStyle`
body {
position: relative;
padding: 0 0 4.25rem;
box-sizing:border-box;
margin: 0;
background: ${props => props.theme.body.background};
color: ${props => props.theme.body.color};
font-family: sans-serif;
font-size: 1rem;
min-height: 100vh;
}
`;
export default GlobalStyle;

38
src/theming/theme.js Normal file
View File

@@ -0,0 +1,38 @@
const themes = {
main: {
body: {
background: "papayawhip",
color: "palevioletred"
},
header: {
background: "#ffe6bd",
color: "palevioletred",
menuButton: {
background: "#ffe6bd",
color: "palevioletred",
active: {
background: "palevioletred",
color: "#ffe6bd"
}
}
},
spinner: {
shadow: "#eeeeee",
highlight: "#db7093"
},
snake: {
cell: {
border: "0.0625rem solid silver",
size: "1.5rem"
},
cellColors: {
" ": "",
a: "green",
s: "orange"
}
}
}
};
export { themes as default, themes };

32
webpack.config.js Normal file
View File

@@ -0,0 +1,32 @@
const webpack = require("webpack");
const path = require("path");
module.exports = {
target: "web",
mode: "development",
entry: path.resolve(__dirname, "src/index.js"),
output: { path: path.resolve(__dirname, "public") },
devtool: "inline-source-map",
devServer: {
contentBase: path.join(__dirname, "public"),
// public: "http://localhost:9000/redux",
// publicPath: "/redux/",
hot: true,
host: "0.0.0.0",
compress: true,
port: 9000,
historyApiFallback: true
},
plugins: [new webpack.HotModuleReplacementPlugin()],
module: {
rules: [
{
test: /\.(js|jsx)$/,
exclude: /node_modules/,
use: {
loader: "babel-loader"
}
}
]
}
};

View File

@@ -0,0 +1,25 @@
const webpack = require("webpack");
const path = require("path");
module.exports = {
target: "web",
mode: "production",
entry: path.resolve(__dirname, "src/index.js"),
output: { path: path.resolve(__dirname, "public") },
plugins: [new webpack.HotModuleReplacementPlugin()],
devServer: {
hot: false,
inline: false
},
module: {
rules: [
{
test: /\.(js|jsx)$/,
exclude: /node_modules/,
use: {
loader: "babel-loader"
}
}
]
}
};