Implemented a snake game with redux
This commit is contained in:
3
.babelrc
Normal file
3
.babelrc
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"presets": ["@babel/preset-env", "@babel/preset-react"]
|
||||
}
|
||||
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
public/main.js
|
||||
*tar
|
||||
3
Dockerfile
Normal file
3
Dockerfile
Normal 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
44
default.conf
Normal 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
31
nginx.conf
Normal 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
37
package.json
Normal 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
11
public/index.html
Normal 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
60
src/components/Button.js
Normal 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;
|
||||
48
src/components/HeaderMenu.js
Normal file
48
src/components/HeaderMenu.js
Normal 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
32
src/components/Link.js
Normal 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
23
src/components/List.js
Normal 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
30
src/components/Logo.js
Normal 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
9
src/components/Page.js
Normal 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
60
src/components/Spinner.js
Normal 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
7
src/components/Title.js
Normal 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
44
src/containers/App.js
Normal 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
20
src/containers/Footer.js
Normal 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
24
src/containers/Header.js
Normal 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
6
src/containers/Home.js
Normal 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;
|
||||
67
src/containers/KeyboardDetails.js
Normal file
67
src/containers/KeyboardDetails.js
Normal 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
138
src/containers/Snake.js
Normal 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);
|
||||
54
src/containers/keyboards.js
Normal file
54
src/containers/keyboards.js
Normal 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
52
src/index.js
Normal 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
22
src/redux/api.js
Normal 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
65
src/redux/keyboards.js
Normal 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
181
src/redux/snake.js
Normal 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
30
src/redux/ui.js
Normal 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;
|
||||
};
|
||||
17
src/theming/GlobalStyle.js
Normal file
17
src/theming/GlobalStyle.js
Normal 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
38
src/theming/theme.js
Normal 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
32
webpack.config.js
Normal 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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
25
webpack.production.config.js
Normal file
25
webpack.production.config.js
Normal 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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user