Implement highscore/leaderboard top 10 best games and add tests with jest
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,3 +1,4 @@
|
|||||||
node_modules
|
node_modules
|
||||||
public/main.js
|
public/main.js
|
||||||
*tar
|
*tar
|
||||||
|
coverage
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
FROM nginx
|
FROM nginx
|
||||||
COPY public /usr/share/nginx/html
|
COPY public /usr/share/nginx/html
|
||||||
COPY default.conf /etc/nginx/conf.d/default.conf
|
COPY ./config/default.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|||||||
@@ -4,8 +4,9 @@
|
|||||||
|
|
||||||
[ ] Create welcome / instructions page
|
[ ] Create welcome / instructions page
|
||||||
[ ] Link to repository
|
[ ] Link to repository
|
||||||
[ ] Convert components to use react hooks
|
|
||||||
[ ] Add license
|
[ ] Add license
|
||||||
|
[ ] High score
|
||||||
|
[ ] Improve banner
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
5
docker.md
Normal file
5
docker.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
npm run build
|
||||||
|
docker build -t briemens/snake .
|
||||||
|
docker tag briemens/snake briemens/snake
|
||||||
|
docker push briemens/snake
|
||||||
|
docker image save briemens/snake:latest -o snake-container.tar
|
||||||
188
jest.config.js
Normal file
188
jest.config.js
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
// For a detailed explanation regarding each configuration property, visit:
|
||||||
|
// https://jestjs.io/docs/en/configuration.html
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
// All imported modules in your tests should be mocked automatically
|
||||||
|
// automock: false,
|
||||||
|
|
||||||
|
// Stop running tests after `n` failures
|
||||||
|
// bail: 0,
|
||||||
|
|
||||||
|
// Respect "browser" field in package.json when resolving modules
|
||||||
|
// browser: false,
|
||||||
|
|
||||||
|
// The directory where Jest should store its cached dependency information
|
||||||
|
// cacheDirectory: "/private/var/folders/j0/3bkm_b290232jgx7bhffq64w0000gp/T/jest_dy",
|
||||||
|
|
||||||
|
// Automatically clear mock calls and instances between every test
|
||||||
|
clearMocks: true,
|
||||||
|
|
||||||
|
// Indicates whether the coverage information should be collected while executing the test
|
||||||
|
// collectCoverage: false,
|
||||||
|
|
||||||
|
// An array of glob patterns indicating a set of files for which coverage information should be collected
|
||||||
|
// collectCoverageFrom: null,
|
||||||
|
|
||||||
|
// The directory where Jest should output its coverage files
|
||||||
|
coverageDirectory: "coverage",
|
||||||
|
|
||||||
|
// An array of regexp pattern strings used to skip coverage collection
|
||||||
|
// coveragePathIgnorePatterns: [
|
||||||
|
// "/node_modules/"
|
||||||
|
// ],
|
||||||
|
|
||||||
|
// A list of reporter names that Jest uses when writing coverage reports
|
||||||
|
// coverageReporters: [
|
||||||
|
// "json",
|
||||||
|
// "text",
|
||||||
|
// "lcov",
|
||||||
|
// "clover"
|
||||||
|
// ],
|
||||||
|
|
||||||
|
// An object that configures minimum threshold enforcement for coverage results
|
||||||
|
// coverageThreshold: null,
|
||||||
|
|
||||||
|
// A path to a custom dependency extractor
|
||||||
|
// dependencyExtractor: null,
|
||||||
|
|
||||||
|
// Make calling deprecated APIs throw helpful error messages
|
||||||
|
// errorOnDeprecated: false,
|
||||||
|
|
||||||
|
// Force coverage collection from ignored files using an array of glob patterns
|
||||||
|
// forceCoverageMatch: [],
|
||||||
|
|
||||||
|
// A path to a module which exports an async function that is triggered once before all test suites
|
||||||
|
// globalSetup: null,
|
||||||
|
|
||||||
|
// A path to a module which exports an async function that is triggered once after all test suites
|
||||||
|
// globalTeardown: null,
|
||||||
|
|
||||||
|
// A set of global variables that need to be available in all test environments
|
||||||
|
// globals: {},
|
||||||
|
|
||||||
|
// The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers.
|
||||||
|
// maxWorkers: "50%",
|
||||||
|
|
||||||
|
// An array of directory names to be searched recursively up from the requiring module's location
|
||||||
|
// moduleDirectories: [
|
||||||
|
// "node_modules"
|
||||||
|
// ],
|
||||||
|
|
||||||
|
// An array of file extensions your modules use
|
||||||
|
// moduleFileExtensions: [
|
||||||
|
// "js",
|
||||||
|
// "json",
|
||||||
|
// "jsx",
|
||||||
|
// "ts",
|
||||||
|
// "tsx",
|
||||||
|
// "node"
|
||||||
|
// ],
|
||||||
|
|
||||||
|
// A map from regular expressions to module names that allow to stub out resources with a single module
|
||||||
|
// moduleNameMapper: {},
|
||||||
|
|
||||||
|
// An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
|
||||||
|
// modulePathIgnorePatterns: [],
|
||||||
|
|
||||||
|
// Activates notifications for test results
|
||||||
|
// notify: false,
|
||||||
|
|
||||||
|
// An enum that specifies notification mode. Requires { notify: true }
|
||||||
|
// notifyMode: "failure-change",
|
||||||
|
|
||||||
|
// A preset that is used as a base for Jest's configuration
|
||||||
|
// preset: null,
|
||||||
|
|
||||||
|
// Run tests from one or more projects
|
||||||
|
// projects: null,
|
||||||
|
|
||||||
|
// Use this configuration option to add custom reporters to Jest
|
||||||
|
// reporters: undefined,
|
||||||
|
|
||||||
|
// Automatically reset mock state between every test
|
||||||
|
// resetMocks: false,
|
||||||
|
|
||||||
|
// Reset the module registry before running each individual test
|
||||||
|
// resetModules: false,
|
||||||
|
|
||||||
|
// A path to a custom resolver
|
||||||
|
// resolver: null,
|
||||||
|
|
||||||
|
// Automatically restore mock state between every test
|
||||||
|
// restoreMocks: false,
|
||||||
|
|
||||||
|
// The root directory that Jest should scan for tests and modules within
|
||||||
|
// rootDir: null,
|
||||||
|
|
||||||
|
// A list of paths to directories that Jest should use to search for files in
|
||||||
|
// roots: [
|
||||||
|
// "<rootDir>"
|
||||||
|
// ],
|
||||||
|
|
||||||
|
// Allows you to use a custom runner instead of Jest's default test runner
|
||||||
|
// runner: "jest-runner",
|
||||||
|
|
||||||
|
// The paths to modules that run some code to configure or set up the testing environment before each test
|
||||||
|
// setupFiles: [],
|
||||||
|
|
||||||
|
// A list of paths to modules that run some code to configure or set up the testing framework before each test
|
||||||
|
// setupFilesAfterEnv: [],
|
||||||
|
|
||||||
|
// A list of paths to snapshot serializer modules Jest should use for snapshot testing
|
||||||
|
// snapshotSerializers: [],
|
||||||
|
|
||||||
|
// The test environment that will be used for testing
|
||||||
|
testEnvironment: "node",
|
||||||
|
|
||||||
|
// Options that will be passed to the testEnvironment
|
||||||
|
// testEnvironmentOptions: {},
|
||||||
|
|
||||||
|
// Adds a location field to test results
|
||||||
|
// testLocationInResults: false,
|
||||||
|
|
||||||
|
// The glob patterns Jest uses to detect test files
|
||||||
|
// testMatch: [
|
||||||
|
// "**/__tests__/**/*.[jt]s?(x)",
|
||||||
|
// "**/?(*.)+(spec|test).[tj]s?(x)"
|
||||||
|
// ],
|
||||||
|
|
||||||
|
// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
|
||||||
|
// testPathIgnorePatterns: [
|
||||||
|
// "/node_modules/"
|
||||||
|
// ],
|
||||||
|
|
||||||
|
// The regexp pattern or array of patterns that Jest uses to detect test files
|
||||||
|
// testRegex: [],
|
||||||
|
|
||||||
|
// This option allows the use of a custom results processor
|
||||||
|
// testResultsProcessor: null,
|
||||||
|
|
||||||
|
// This option allows use of a custom test runner
|
||||||
|
// testRunner: "jasmine2",
|
||||||
|
|
||||||
|
// This option sets the URL for the jsdom environment. It is reflected in properties such as location.href
|
||||||
|
// testURL: "http://localhost",
|
||||||
|
|
||||||
|
// Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout"
|
||||||
|
// timers: "real",
|
||||||
|
|
||||||
|
// A map from regular expressions to paths to transformers
|
||||||
|
// transform: null,
|
||||||
|
|
||||||
|
// An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
|
||||||
|
// transformIgnorePatterns: [
|
||||||
|
// "/node_modules/"
|
||||||
|
// ],
|
||||||
|
|
||||||
|
// An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
|
||||||
|
// unmockedModulePathPatterns: undefined,
|
||||||
|
|
||||||
|
// Indicates whether each individual test should be reported during the run
|
||||||
|
// verbose: null,
|
||||||
|
|
||||||
|
// An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode
|
||||||
|
// watchPathIgnorePatterns: [],
|
||||||
|
|
||||||
|
// Whether to use watchman for file crawling
|
||||||
|
// watchman: true,
|
||||||
|
};
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
"dev": "webpack-dev-server --mode development --open",
|
"dev": "webpack-dev-server --mode development --open",
|
||||||
"build": "webpack --optimize-minimize --config webpack.production.config.js",
|
"build": "webpack --optimize-minimize --config webpack.production.config.js",
|
||||||
"webpack": "webpack --config webpack.config.js --watch",
|
"webpack": "webpack --config webpack.config.js --watch",
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
"test": "jest"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
@@ -20,6 +20,7 @@
|
|||||||
"@babel/preset-react": "^7.0.0",
|
"@babel/preset-react": "^7.0.0",
|
||||||
"babel-loader": "^8.0.6",
|
"babel-loader": "^8.0.6",
|
||||||
"babel-plugin-styled-components": "^1.10.6",
|
"babel-plugin-styled-components": "^1.10.6",
|
||||||
|
"jest": "^24.9.0",
|
||||||
"react-hot-loader": "^4.12.11",
|
"react-hot-loader": "^4.12.11",
|
||||||
"webpack": "^4.39.2",
|
"webpack": "^4.39.2",
|
||||||
"webpack-cli": "^3.3.7",
|
"webpack-cli": "^3.3.7",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import styled from "styled-components";
|
import styled, { css } from "styled-components";
|
||||||
|
|
||||||
const StyledBanner = styled.div`
|
const StyledBanner = styled.div`
|
||||||
border-width: ${({ theme }) => theme.banner.borderWidth};
|
border-width: ${({ theme }) => theme.banner.borderWidth};
|
||||||
@@ -15,11 +15,22 @@ const StyledBanner = styled.div`
|
|||||||
transition: opacity 100ms ease-in;
|
transition: opacity 100ms ease-in;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
box-shadow: 1px 1px 5px ${({ theme }) => theme.banner.shadowColor};
|
box-shadow: 1px 1px 5px ${({ theme }) => theme.banner.shadowColor};
|
||||||
&:hover {
|
${({ fade = false }) =>
|
||||||
opacity: 0.5;
|
fade &&
|
||||||
}
|
css`
|
||||||
|
&:hover {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
`}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const Banner = ({ children }) => <StyledBanner>{children}</StyledBanner>;
|
export const Banner = ({ children }) => <StyledBanner>{children}</StyledBanner>;
|
||||||
|
|
||||||
|
Banner.Title = styled.h2`
|
||||||
|
text-align: center;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 1rem;
|
||||||
|
`;
|
||||||
|
|
||||||
export default Banner;
|
export default Banner;
|
||||||
|
|||||||
@@ -1,17 +1,29 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { NavLink } from "react-router-dom";
|
import { NavLink } from "react-router-dom";
|
||||||
import styled from "styled-components";
|
import styled, { css } from "styled-components";
|
||||||
import Tooltip from "./Tooltip.js";
|
import Tooltip from "./Tooltip.js";
|
||||||
|
|
||||||
const SharedStyle = () => `
|
const SharedStyle = css`
|
||||||
border-width: ${({ theme }) => theme.button.borderWidth};
|
border-width: ${({ theme }) => theme.button.borderWidth};
|
||||||
border-style: solid;
|
border-style: solid;
|
||||||
border-color: ${({ theme }) => theme.button.borderColor};
|
|
||||||
border-radius: ${({ theme }) => theme.button.borderRadius};
|
border-radius: ${({ theme }) => theme.button.borderRadius};
|
||||||
background-color: ${({ theme }) => theme.button.background};
|
|
||||||
color: ${({ theme }) => theme.button.color};
|
${({ inverse = false }) => {
|
||||||
|
return !inverse
|
||||||
|
? css`
|
||||||
|
border-color: ${({ theme }) => theme.button.borderColor};
|
||||||
|
background-color: ${({ theme }) => theme.button.background};
|
||||||
|
color: ${({ theme }) => theme.button.color};
|
||||||
|
`
|
||||||
|
: css`
|
||||||
|
border-color: ${({ theme }) => theme.button.color};
|
||||||
|
background-color: ${({ theme }) => theme.button.color};
|
||||||
|
color: ${({ theme }) => theme.button.background};
|
||||||
|
`;
|
||||||
|
}}
|
||||||
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: .3125rem;
|
padding: 0.3125rem;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
@@ -19,6 +31,11 @@ const SharedStyle = () => `
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:focus,
|
||||||
|
&:hover {
|
||||||
|
outline: ${({ disabled }) => (!disabled ? "1px solid silver" : "none")};
|
||||||
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: ${({ theme }) => theme.button.hover.background};
|
background-color: ${({ theme }) => theme.button.hover.background};
|
||||||
}
|
}
|
||||||
@@ -29,6 +46,17 @@ const SharedStyle = () => `
|
|||||||
color: ${({ theme }) => theme.button.active.color};
|
color: ${({ theme }) => theme.button.active.color};
|
||||||
background-color: ${({ theme }) => theme.button.active.background};
|
background-color: ${({ theme }) => theme.button.active.background};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&[disabled],
|
||||||
|
&.disabled {
|
||||||
|
// background-color: #aaa;
|
||||||
|
// color: gray;
|
||||||
|
// border-color: gray;
|
||||||
|
background-color: ${({ theme }) => theme.button.disabled.background};
|
||||||
|
color: ${({ theme }) => theme.button.disabled.color};
|
||||||
|
border-color: ${({ theme }) => theme.button.disabled.borderColor};
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const ButtonWrapper = styled.div.attrs(({ disabled }) => ({ tabIndex: disabled ? null : 0 }))`
|
const ButtonWrapper = styled.div.attrs(({ disabled }) => ({ tabIndex: disabled ? null : 0 }))`
|
||||||
@@ -85,6 +113,7 @@ export const Button = props => {
|
|||||||
onMouseLeave={() => setHover(false)}
|
onMouseLeave={() => setHover(false)}
|
||||||
onKeyUp={updatedProps.onKeyUp}
|
onKeyUp={updatedProps.onKeyUp}
|
||||||
onClick={updatedProps.onClick}
|
onClick={updatedProps.onClick}
|
||||||
|
className={disabled && "disabled"}
|
||||||
disabled={disabled}>
|
disabled={disabled}>
|
||||||
<i
|
<i
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
@@ -111,16 +140,49 @@ export const IconButton = styled(Button)`
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
&:before {
|
&:before {
|
||||||
text-shadow: 0 0 1px black, 1px 1px 3px #d0bfa3;
|
// text-shadow: 0 0 1px black, 1px 1px 3px #d0bfa3;
|
||||||
text-shadow: 0 0 1px gray, 0 0 1px #d0bfa3;
|
// text-shadow: 0 0 1px gray, 0 0 1px #d0bfa3;
|
||||||
|
text-shadow: 0 0 1px ${({ theme }) => theme.button.icon.borderColor};
|
||||||
|
}
|
||||||
|
|
||||||
|
&[disabled],
|
||||||
|
&.disabled {
|
||||||
|
background-color: ${({ theme }) => theme.button.icon.disabled.background};
|
||||||
|
color: ${({ theme }) => theme.button.icon.disabled.color};
|
||||||
|
border-color: ${({ theme }) => theme.button.icon.disabled.borderColor};
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const ToggleIconButton = styled(IconButton)`
|
export const ToggleIconButton = styled(IconButton)`
|
||||||
color: ${({ theme, toggle }) => (toggle ? theme.button.toggled.color : null)};
|
color: ${({ theme, toggle }) => (toggle ? theme.button.toggled.color : null)};
|
||||||
&:before {
|
&:before {
|
||||||
text-shadow: 0 0 1px gray, 0 0 1px #d0bfa3;
|
// text-shadow: 0 0 1px gray, 0 0 1px #d0bfa3;
|
||||||
|
text-shadow: 0 0 1px
|
||||||
|
${({ theme, toggle }) => (toggle ? theme.button.icon.disabled.borderColor : theme.button.icon.borderColor)};
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
/* ===== Button Container ============================================================== */
|
||||||
|
|
||||||
|
export const ButtonContainer = styled.div`
|
||||||
|
display: flex;
|
||||||
|
place-content: ${({ alignment = "center" }) => alignment};
|
||||||
|
align-content: space-around;
|
||||||
|
margin: ${({ margin }) => margin};
|
||||||
|
${StyledAnchor},
|
||||||
|
${StyledNavLink},
|
||||||
|
${StyledButton} {
|
||||||
|
margin-right: 1rem;
|
||||||
|
&:nth-last-child(1) {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
ButtonContainer.aligment = {
|
||||||
|
left: "flex-start",
|
||||||
|
right: "flex-end",
|
||||||
|
center: "center"
|
||||||
|
};
|
||||||
|
|
||||||
export default Button;
|
export default Button;
|
||||||
|
|||||||
@@ -76,14 +76,18 @@ export const ControlPanel = ({
|
|||||||
</ThemeSelector>
|
</ThemeSelector>
|
||||||
</HorizontalStack>
|
</HorizontalStack>
|
||||||
<HorizontalStack style={{ minHeight: "2rem" }}>
|
<HorizontalStack style={{ minHeight: "2rem" }}>
|
||||||
<span>Zoom: {zoom}</span>
|
<span>
|
||||||
|
Zoom: <b>{zoom}</b>
|
||||||
|
</span>
|
||||||
<div style={{ display: "inline-block" }}>
|
<div style={{ display: "inline-block" }}>
|
||||||
<IconButton icon="minus-circle" size={1} onClick={zoomOut} />
|
<IconButton icon="minus-circle" size={1} onClick={zoomOut} />
|
||||||
<IconButton icon="plus-circle" size={1} onClick={zoomIn} />
|
<IconButton icon="plus-circle" size={1} onClick={zoomIn} />
|
||||||
</div>
|
</div>
|
||||||
</HorizontalStack>
|
</HorizontalStack>
|
||||||
<HorizontalStack style={{ minHeight: "2rem" }}>
|
<HorizontalStack style={{ minHeight: "2rem" }}>
|
||||||
<span>FPS: {fps}</span>
|
<span>
|
||||||
|
FPS: <b>{fps}</b>
|
||||||
|
</span>
|
||||||
<div style={{ display: "inline-block" }}>
|
<div style={{ display: "inline-block" }}>
|
||||||
<IconButton icon="minus-circle" size={1} onClick={() => setFps(fps - 1)} />
|
<IconButton icon="minus-circle" size={1} onClick={() => setFps(fps - 1)} />
|
||||||
<IconButton icon="plus-circle" size={1} onClick={() => setFps(fps + 1)} />
|
<IconButton icon="plus-circle" size={1} onClick={() => setFps(fps + 1)} />
|
||||||
|
|||||||
@@ -35,7 +35,9 @@ const MenuButton = styled(NavLink)`
|
|||||||
const HeaderMenu = () => (
|
const HeaderMenu = () => (
|
||||||
<MenuPanel>
|
<MenuPanel>
|
||||||
<MenuItem>
|
<MenuItem>
|
||||||
<MenuButton to="/">Snake</MenuButton>
|
<MenuButton to="/" exact>
|
||||||
|
Snake
|
||||||
|
</MenuButton>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem>
|
<MenuItem>
|
||||||
<MenuButton to="/about" exact>
|
<MenuButton to="/about" exact>
|
||||||
|
|||||||
72
src/components/Highscore.js
Normal file
72
src/components/Highscore.js
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { module, registerHighscore } from "../redux/highscore.js";
|
||||||
|
import styled from "styled-components";
|
||||||
|
import { connect } from "react-redux";
|
||||||
|
|
||||||
|
const HighscoreContainer = styled.div`
|
||||||
|
height: 100%;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Title = styled.h1`
|
||||||
|
margin: 0.5rem 0 1rem;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const HighscoreEntries = styled.ul`
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
text-indent: 0;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Position = styled.div`
|
||||||
|
font-weight: bold;
|
||||||
|
min-width: 1rem;
|
||||||
|
text-align: right;
|
||||||
|
margin-right: 0.4rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: ${({ theme }) => theme.colors.colorInactive};
|
||||||
|
`;
|
||||||
|
const Username = styled.div`
|
||||||
|
overflow: hidden;
|
||||||
|
width: 9rem;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
`;
|
||||||
|
const Score = styled.div`
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
min-width: 1.5rem;
|
||||||
|
text-align: right;
|
||||||
|
color: ${({ theme }) => theme.colors.colorAlternate};
|
||||||
|
`;
|
||||||
|
|
||||||
|
const HighscoreEntry = styled.li`
|
||||||
|
list-style: none;
|
||||||
|
line-height: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-content: center;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Highscore = ({ highscores = [] }) => {
|
||||||
|
console.log("highscores", highscores);
|
||||||
|
return (
|
||||||
|
<HighscoreContainer>
|
||||||
|
<Title>Highscore</Title>
|
||||||
|
{!highscores.length && <p>No highscores yet</p>}
|
||||||
|
<HighscoreEntries>
|
||||||
|
{highscores.map((highscore, index) => (
|
||||||
|
<HighscoreEntry key={`${highscore.name}-${highscore.gameId}-${highscore.score}`}>
|
||||||
|
<Position>{index + 1}</Position>
|
||||||
|
<Username>{highscore.name}</Username>
|
||||||
|
<Score>{highscore.score}</Score>
|
||||||
|
</HighscoreEntry>
|
||||||
|
))}
|
||||||
|
</HighscoreEntries>
|
||||||
|
</HighscoreContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default connect(
|
||||||
|
state => ({ ...state[module.name] }),
|
||||||
|
{ registerHighscore }
|
||||||
|
)(Highscore);
|
||||||
61
src/components/HighscoreInput.js
Normal file
61
src/components/HighscoreInput.js
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import React from "react";
|
||||||
|
import styled from "styled-components";
|
||||||
|
|
||||||
|
import Banner from "./Banner.js";
|
||||||
|
import Button, { ButtonContainer } from "./Button.js";
|
||||||
|
import Input from "./Input.js";
|
||||||
|
|
||||||
|
const Field = styled.div`
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
// align-content: center;
|
||||||
|
// place-content: center;
|
||||||
|
// justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
${Input} {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const OkButton = styled(Button)`
|
||||||
|
min-width: 4.6875rem;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const HighscoreInput = ({ name, score, gameId, onSubmit }) => {
|
||||||
|
const [username, setUsername] = React.useState(name || "");
|
||||||
|
return (
|
||||||
|
<Banner>
|
||||||
|
<Banner.Title>Congratulations!</Banner.Title>
|
||||||
|
<p style={{ textAlign: "center" }}>
|
||||||
|
You have a new highscore of <b>{score}</b>
|
||||||
|
</p>
|
||||||
|
<form
|
||||||
|
onSubmit={e => e.preventDefault() || onSubmit({ name: username, score, gameId })}
|
||||||
|
onKeyUp={({ which }) => which === 27 && onSubmit(null)}>
|
||||||
|
<Field>
|
||||||
|
<label htmlFor="name">Name</label>
|
||||||
|
<Input
|
||||||
|
maxLength="20"
|
||||||
|
autoFocus={true}
|
||||||
|
type="text"
|
||||||
|
value={username}
|
||||||
|
onChange={e => setUsername(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<ButtonContainer margin="1rem 0 0" alignment={ButtonContainer.aligment.center}>
|
||||||
|
<OkButton disabled={!username.length} inverse type="submit">
|
||||||
|
OK
|
||||||
|
</OkButton>
|
||||||
|
<OkButton>Cancel</OkButton>
|
||||||
|
</ButtonContainer>
|
||||||
|
</form>
|
||||||
|
</Banner>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HighscoreInput;
|
||||||
19
src/components/Input.js
Normal file
19
src/components/Input.js
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import styled from "styled-components";
|
||||||
|
|
||||||
|
export const Input = styled.input`
|
||||||
|
appearance: none;
|
||||||
|
border-width: ${({ theme }) => theme.input.borderWidth};
|
||||||
|
border-style: solid;
|
||||||
|
border-color: ${({ theme }) => theme.input.borderColor};
|
||||||
|
background-color: ${({ theme }) => theme.input.background};
|
||||||
|
color: ${({ theme }) => theme.input.color};
|
||||||
|
padding: ${({ theme }) => theme.input.padding};
|
||||||
|
border-radius: ${({ theme }) => theme.input.borderRadius};
|
||||||
|
|
||||||
|
&:focus,
|
||||||
|
&:hover {
|
||||||
|
outline: ${({ disabled }) => (!disabled ? "1px solid silver" : "none")};
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default Input;
|
||||||
@@ -24,8 +24,8 @@ const App = ({ theme }) => (
|
|||||||
<Page>
|
<Page>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route exact path="/" component={Snake} />
|
<Route exact path="/" component={Snake} />
|
||||||
<Route path="/snake" exact component={Snake} />
|
<Route exact path="/snake" component={Snake} />
|
||||||
<Route path="/about" exact component={Home} />
|
<Route exact path="/about" component={Home} />
|
||||||
</Switch>
|
</Switch>
|
||||||
</Page>
|
</Page>
|
||||||
<Footer />
|
<Footer />
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
zoomIn,
|
zoomIn,
|
||||||
zoomOut
|
zoomOut
|
||||||
} from "../redux/snake.js";
|
} from "../redux/snake.js";
|
||||||
|
import { module as highscoreModule, hasHighscore, registerHighscore, skipHighscore } from "../redux/highscore.js";
|
||||||
|
|
||||||
/* Components */
|
/* Components */
|
||||||
import ControlPanel from "../components/ControlPanel.js";
|
import ControlPanel from "../components/ControlPanel.js";
|
||||||
@@ -23,6 +24,8 @@ import Stage from "../components/Stage.js";
|
|||||||
import SnakePart from "../components/SnakePart.js";
|
import SnakePart from "../components/SnakePart.js";
|
||||||
import Apple from "../components/Apple.js";
|
import Apple from "../components/Apple.js";
|
||||||
import Banner from "../components/Banner.js";
|
import Banner from "../components/Banner.js";
|
||||||
|
import Highscore from "../components/Highscore.js";
|
||||||
|
import HighscoreInput from "../components/HighscoreInput.js";
|
||||||
|
|
||||||
const Layout = styled.div`
|
const Layout = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -48,6 +51,10 @@ const Snake = ({
|
|||||||
pauseSnake,
|
pauseSnake,
|
||||||
updateFrameSnake,
|
updateFrameSnake,
|
||||||
changeTheme,
|
changeTheme,
|
||||||
|
hasHighscore,
|
||||||
|
registerHighscore,
|
||||||
|
skipHighscore,
|
||||||
|
lastUsername,
|
||||||
theme,
|
theme,
|
||||||
grid,
|
grid,
|
||||||
started,
|
started,
|
||||||
@@ -56,10 +63,20 @@ const Snake = ({
|
|||||||
fps,
|
fps,
|
||||||
setFps,
|
setFps,
|
||||||
score,
|
score,
|
||||||
|
gameId,
|
||||||
|
lastGameId,
|
||||||
zoom,
|
zoom,
|
||||||
zoomIn,
|
zoomIn,
|
||||||
zoomOut
|
zoomOut
|
||||||
}) => {
|
}) => {
|
||||||
|
const onSubmitHighscore = result => {
|
||||||
|
if (result) {
|
||||||
|
registerHighscore(result);
|
||||||
|
} else {
|
||||||
|
skipHighscore(gameId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
<Layout>
|
<Layout>
|
||||||
@@ -70,10 +87,14 @@ const Snake = ({
|
|||||||
(cell.type === "snake" && <SnakePart zoom={zoom} value={cell} died={died} />)
|
(cell.type === "snake" && <SnakePart zoom={zoom} value={cell} died={died} />)
|
||||||
}
|
}
|
||||||
</Stage>
|
</Stage>
|
||||||
{!started && <Banner>Press 'r' to start the game</Banner>}
|
{!started && <Banner fade>Press 'r' to start the game</Banner>}
|
||||||
|
{died && lastGameId !== gameId && hasHighscore(score) && (
|
||||||
|
<HighscoreInput score={score} gameId={gameId} name={lastUsername} onSubmit={onSubmitHighscore} />
|
||||||
|
)}
|
||||||
</StageContainer>
|
</StageContainer>
|
||||||
<SidePanel>
|
<SidePanel>
|
||||||
<Scoreboard score={score} zoom={2} />
|
<Scoreboard score={score} zoom={3} />
|
||||||
|
<Highscore />
|
||||||
<ControlPanel
|
<ControlPanel
|
||||||
updateFrameSnake={updateFrameSnake}
|
updateFrameSnake={updateFrameSnake}
|
||||||
started={started}
|
started={started}
|
||||||
@@ -98,7 +119,9 @@ const Snake = ({
|
|||||||
const mapStateToProps = state => ({
|
const mapStateToProps = state => ({
|
||||||
...state[uiModule.name],
|
...state[uiModule.name],
|
||||||
...state[gameModule.name],
|
...state[gameModule.name],
|
||||||
...state[snakeModule.name]
|
...state[snakeModule.name],
|
||||||
|
...state[highscoreModule.name],
|
||||||
|
hasHighscore: hasHighscore(state)
|
||||||
});
|
});
|
||||||
|
|
||||||
const mapActionsToProps = {
|
const mapActionsToProps = {
|
||||||
@@ -111,7 +134,9 @@ const mapActionsToProps = {
|
|||||||
setFps,
|
setFps,
|
||||||
zoomIn,
|
zoomIn,
|
||||||
zoomOut,
|
zoomOut,
|
||||||
changeTheme
|
changeTheme,
|
||||||
|
registerHighscore,
|
||||||
|
skipHighscore
|
||||||
};
|
};
|
||||||
|
|
||||||
export default connect(
|
export default connect(
|
||||||
|
|||||||
@@ -19,13 +19,17 @@ export const [SET_FPS, setFps] = module.action("SET_FPS", fps => ({ fps }));
|
|||||||
export const [INVOKE_CALLERS, invokeCallers] = module.action("INVOKE_CALLERS");
|
export const [INVOKE_CALLERS, invokeCallers] = module.action("INVOKE_CALLERS");
|
||||||
export const [REGISTER_CALLER, registerCaller] = module.action("REGISTER_CALLER", name => ({ name }));
|
export const [REGISTER_CALLER, registerCaller] = module.action("REGISTER_CALLER", name => ({ name }));
|
||||||
export const [UNREGISTER_CALLER, unregisterCaller] = module.action("UNREGISTER_CALLER", name => ({ name }));
|
export const [UNREGISTER_CALLER, unregisterCaller] = module.action("UNREGISTER_CALLER", name => ({ name }));
|
||||||
export const [KEY_PRESS, keyPress] = module.action("KEY_PRESS", ({ key, altKey, ctrlKey, metaKey, shiftKey }) => ({
|
export const [KEY_PRESS, keyPress] = module.action(
|
||||||
key,
|
"KEY_PRESS",
|
||||||
altKey,
|
({ key, altKey, ctrlKey, metaKey, shiftKey, target }) => ({
|
||||||
ctrlKey,
|
key,
|
||||||
metaKey,
|
altKey,
|
||||||
shiftKey
|
ctrlKey,
|
||||||
}));
|
metaKey,
|
||||||
|
shiftKey,
|
||||||
|
target
|
||||||
|
})
|
||||||
|
);
|
||||||
export const [SUBSCRIBE_KEY_PRESSED, subscribeKeyPressed] = module.action("SUBSCRIBE_KEY_PRESSED", name => ({ name }));
|
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 => ({
|
export const [UNSUBSCRIBE_KEY_PRESSED, unsubscribeKeyPressed] = module.action("UNSUBSCRIBE_KEY_PRESSED", name => ({
|
||||||
name
|
name
|
||||||
@@ -100,9 +104,10 @@ module.middleware(INIT, (dispatch, { loopId }) => {
|
|||||||
|
|
||||||
module.middleware(
|
module.middleware(
|
||||||
KEY_PRESS,
|
KEY_PRESS,
|
||||||
(dispatch, { keyPressSubscribers = [], fps }, { key, altKey, ctrlKey, metaKey, shiftKey }) => {
|
(dispatch, { keyPressSubscribers = [], fps }, { key, altKey, ctrlKey, metaKey, shiftKey, target }) => {
|
||||||
console.log("key", key);
|
keyPressSubscribers.forEach(subscriber =>
|
||||||
keyPressSubscribers.forEach(subscriber => dispatch({ type: subscriber, key, altKey, ctrlKey, metaKey, shiftKey }));
|
dispatch({ type: subscriber, key, altKey, ctrlKey, metaKey, shiftKey, target })
|
||||||
|
);
|
||||||
|
|
||||||
if (key === keys.PLUS || key === keys.EQUAL) {
|
if (key === keys.PLUS || key === keys.EQUAL) {
|
||||||
dispatch(setFps(fps + 1));
|
dispatch(setFps(fps + 1));
|
||||||
|
|||||||
70
src/redux/highscore.js
Normal file
70
src/redux/highscore.js
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import Module from "./Module.js";
|
||||||
|
|
||||||
|
const MODULE_NAME = "HIGHSCORE";
|
||||||
|
const HIGHSCORE_LENGTH = 10;
|
||||||
|
|
||||||
|
const initialState = {
|
||||||
|
highscores: [],
|
||||||
|
lastUsername: "",
|
||||||
|
lastGameId: null
|
||||||
|
};
|
||||||
|
|
||||||
|
export const module = new Module(MODULE_NAME, initialState);
|
||||||
|
|
||||||
|
export const [UPDATE_HIGHSCORE, updateHighscore] = module.action("UPDATE_HIGHSCORE");
|
||||||
|
export const [SKIP_HIGHSCORE, skipHighscore] = module.action("SKIP_HIGHSCORE", gameId => ({ gameId }));
|
||||||
|
export const [REGISTER_HIGHSCORE, registerHighscore] = module.action(
|
||||||
|
"REGISTER_HIGHSCORE",
|
||||||
|
({ name, score, gameId }) => ({
|
||||||
|
name,
|
||||||
|
score,
|
||||||
|
gameId
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
export const sortHighscores = highscores =>
|
||||||
|
highscores.sort((a, b) => {
|
||||||
|
if (a.score > b.score) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if (a.score < b.score) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (a.gameId < b.gameId) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if (a.gameId > b.gameId) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateHighscores = (highscores, { name, score, gameId }) => {
|
||||||
|
const newHighscores = [...highscores, { name, score, gameId }];
|
||||||
|
return sortHighscores(newHighscores).slice(0, HIGHSCORE_LENGTH);
|
||||||
|
};
|
||||||
|
|
||||||
|
/* === Selectors ================================================================================ */
|
||||||
|
|
||||||
|
export const hasHighscore = module.selector(
|
||||||
|
({ [MODULE_NAME]: { highscores = [] } = { highscores: [] } } = { [MODULE_NAME]: { highscores: [] } }, score) => {
|
||||||
|
const currentScores = sortHighscores(highscores || []).slice(0, HIGHSCORE_LENGTH);
|
||||||
|
if (currentScores.length < HIGHSCORE_LENGTH) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return score > (currentScores[currentScores.length - 1] || { score: 0 }).score;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/* === Reducers ================================================================================= */
|
||||||
|
|
||||||
|
module.reducer(REGISTER_HIGHSCORE, (state = {}, { name, score, gameId }) => ({
|
||||||
|
...state,
|
||||||
|
lastGameId: gameId,
|
||||||
|
lastUsername: name,
|
||||||
|
highscores: updateHighscores(state.highscores || [], { name, gameId, score })
|
||||||
|
}));
|
||||||
|
|
||||||
|
module.reducer(SKIP_HIGHSCORE, (state = {}, { gameId }) => ({ ...state, lastGameId: gameId }));
|
||||||
|
|
||||||
|
export default module;
|
||||||
111
src/redux/highscore.spec.js
Normal file
111
src/redux/highscore.spec.js
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import module, { skipHighscore, registerHighscore, hasHighscore } from "./highscore.js";
|
||||||
|
|
||||||
|
const reducers = module.reducers[module.name];
|
||||||
|
|
||||||
|
test("Skip highscore", () => {
|
||||||
|
const result1 = reducers(null, skipHighscore(123));
|
||||||
|
expect(result1).toStrictEqual({ lastGameId: 123 });
|
||||||
|
|
||||||
|
const result2 = reducers({ test: 456, lastGameId: 348 }, skipHighscore(999));
|
||||||
|
expect(result2).toStrictEqual({ test: 456, lastGameId: 999 });
|
||||||
|
});
|
||||||
|
|
||||||
|
const highscores = [
|
||||||
|
{ name: "mock101", score: 101, gameId: 22 },
|
||||||
|
{ name: "mock60", score: 60, gameId: 22 },
|
||||||
|
{ name: "mock20", score: 20, gameId: 10 },
|
||||||
|
{ name: "mock40", score: 40, gameId: 9 },
|
||||||
|
{ name: "mock50", score: 50, gameId: 29 },
|
||||||
|
{ name: "mock130", score: 130, gameId: 2 },
|
||||||
|
{ name: "mock80", score: 80, gameId: 12 },
|
||||||
|
{ name: "mock90", score: 90, gameId: 2 },
|
||||||
|
{ name: "mock30", score: 30, gameId: 13 },
|
||||||
|
{ name: "mock140", score: 140, gameId: 2 },
|
||||||
|
{ name: "mock100", score: 100, gameId: 2 },
|
||||||
|
{ name: "mock110", score: 110, gameId: 2 },
|
||||||
|
{ name: "mock120", score: 120, gameId: 2 },
|
||||||
|
{ name: "mock70", score: 70, gameId: 20 },
|
||||||
|
{ name: "mock150", score: 150, gameId: 2 }
|
||||||
|
];
|
||||||
|
|
||||||
|
test("Register highscore where there is only 1 out 10 scores stored", () => {
|
||||||
|
const result1 = reducers(undefined, registerHighscore({ name: "test", score: 10, gameId: 2 }));
|
||||||
|
expect(result1).toStrictEqual({ highscores: [{ name: "test", score: 10, gameId: 2 }], lastGameId: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Register highscore when there are more than 10 scores stored and the new score is not in the top 10", () => {
|
||||||
|
const result = reducers({ highscores }, registerHighscore({ name: "test", score: 10, gameId: 8 }));
|
||||||
|
expect(highscores.length).toBeGreaterThan(10);
|
||||||
|
expect(result.highscores.length).toEqual(10);
|
||||||
|
expect(result).toStrictEqual({
|
||||||
|
highscores: [
|
||||||
|
{ name: "mock150", score: 150, gameId: 2 },
|
||||||
|
{ name: "mock140", score: 140, gameId: 2 },
|
||||||
|
{ name: "mock130", score: 130, gameId: 2 },
|
||||||
|
{ name: "mock120", score: 120, gameId: 2 },
|
||||||
|
{ name: "mock110", score: 110, gameId: 2 },
|
||||||
|
{ name: "mock101", score: 101, gameId: 22 },
|
||||||
|
{ name: "mock100", score: 100, gameId: 2 },
|
||||||
|
{ name: "mock90", score: 90, gameId: 2 },
|
||||||
|
{ name: "mock80", score: 80, gameId: 12 },
|
||||||
|
{ name: "mock70", score: 70, gameId: 20 }
|
||||||
|
],
|
||||||
|
lastGameId: 8
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Register highscore when there are more than 10 scores stored and the new score is in the top 10", () => {
|
||||||
|
const result = reducers({ highscores }, registerHighscore({ name: "test", score: 103, gameId: 3 }));
|
||||||
|
expect(result).toStrictEqual({
|
||||||
|
highscores: [
|
||||||
|
{ name: "mock150", score: 150, gameId: 2 },
|
||||||
|
{ name: "mock140", score: 140, gameId: 2 },
|
||||||
|
{ name: "mock130", score: 130, gameId: 2 },
|
||||||
|
{ name: "mock120", score: 120, gameId: 2 },
|
||||||
|
{ name: "mock110", score: 110, gameId: 2 },
|
||||||
|
{ name: "test", score: 103, gameId: 3 },
|
||||||
|
{ name: "mock101", score: 101, gameId: 22 },
|
||||||
|
{ name: "mock100", score: 100, gameId: 2 },
|
||||||
|
{ name: "mock90", score: 90, gameId: 2 },
|
||||||
|
{ name: "mock80", score: 80, gameId: 12 }
|
||||||
|
],
|
||||||
|
lastGameId: 3
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Register highscore when there are more than 10 scores stored and the new score has the same score as the current pos 10", () => {
|
||||||
|
const result = reducers({ highscores }, registerHighscore({ name: "test", score: 70, gameId: 99 }));
|
||||||
|
expect(result).toStrictEqual({
|
||||||
|
highscores: [
|
||||||
|
{ name: "mock150", score: 150, gameId: 2 },
|
||||||
|
{ name: "mock140", score: 140, gameId: 2 },
|
||||||
|
{ name: "mock130", score: 130, gameId: 2 },
|
||||||
|
{ name: "mock120", score: 120, gameId: 2 },
|
||||||
|
{ name: "mock110", score: 110, gameId: 2 },
|
||||||
|
{ name: "mock101", score: 101, gameId: 22 },
|
||||||
|
{ name: "mock100", score: 100, gameId: 2 },
|
||||||
|
{ name: "mock90", score: 90, gameId: 2 },
|
||||||
|
{ name: "mock80", score: 80, gameId: 12 },
|
||||||
|
{ name: "mock70", score: 70, gameId: 20 }
|
||||||
|
],
|
||||||
|
lastGameId: 99
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("hasHighscore must return true when the new score is within the top 10", () => {
|
||||||
|
const selector = hasHighscore({});
|
||||||
|
const result = selector(34);
|
||||||
|
expect(result).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("hasHighscore must return false when the new scrore is outside the top 10", () => {
|
||||||
|
const selector = hasHighscore({ [module.name]: { highscores } });
|
||||||
|
const result = selector(1);
|
||||||
|
expect(result).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("hasHighscore must return false when the new scrore is the same as the number 10 on the list", () => {
|
||||||
|
const selector = hasHighscore({ [module.name]: { highscores } });
|
||||||
|
const result = selector(70);
|
||||||
|
expect(result).toBeFalsy();
|
||||||
|
});
|
||||||
@@ -218,6 +218,10 @@ export class Module {
|
|||||||
return selection;
|
return selection;
|
||||||
}, {});
|
}, {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
selector(selectorFn) {
|
||||||
|
return state => (...args) => selectorFn(state, ...args);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Module;
|
export default Module;
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ const initialState = {
|
|||||||
zoom: 1.5,
|
zoom: 1.5,
|
||||||
size: DEFAULT_GRID_SIZE,
|
size: DEFAULT_GRID_SIZE,
|
||||||
paused: false,
|
paused: false,
|
||||||
|
gameId: 0,
|
||||||
vX: 1,
|
vX: 1,
|
||||||
vY: 0,
|
vY: 0,
|
||||||
vXNext: undefined,
|
next: [],
|
||||||
vYNext: undefined,
|
|
||||||
snake: [],
|
snake: [],
|
||||||
score: 0,
|
score: 0,
|
||||||
apple: []
|
apple: []
|
||||||
@@ -52,6 +52,18 @@ const markCell = (grid, [x, y] = [], mark) => {
|
|||||||
return grid;
|
return grid;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateGrid = ({ grid, apple, snake }) => {
|
||||||
|
markCell(grid, apple, { type: "apple" });
|
||||||
|
|
||||||
|
snake.forEach((snakePart, i) =>
|
||||||
|
markCell(grid, snakePart, {
|
||||||
|
type: "snake",
|
||||||
|
index: snake.length - 1 - i,
|
||||||
|
length: snake.length
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const moveSnakePart = ([x, y], vX, vY, size) => {
|
const moveSnakePart = ([x, y], vX, vY, size) => {
|
||||||
let newX = x + vX;
|
let newX = x + vX;
|
||||||
let newY = y + vY;
|
let newY = y + vY;
|
||||||
@@ -105,52 +117,41 @@ module.reducer(ZOOM_OUT, (state, { step = DEFAUL_ZOOM_STEP }) =>
|
|||||||
module.reducer(RESET_SNAKE, (state, options) => {
|
module.reducer(RESET_SNAKE, (state, options) => {
|
||||||
const grid = createGrid(state.size);
|
const grid = createGrid(state.size);
|
||||||
const zoom = state.zoom;
|
const zoom = state.zoom;
|
||||||
|
const gameId = (state.gameId || 0) + 1;
|
||||||
const apple = options.started ? randomPosition(state.size) : initialState.apple;
|
const apple = options.started ? randomPosition(state.size) : initialState.apple;
|
||||||
const snake = options.started ? [[0, 0]] : initialState.snake;
|
const snake = options.started ? [[0, 0]] : initialState.snake;
|
||||||
|
updateGrid({ grid, apple, snake });
|
||||||
markCell(grid, apple, { type: "apple" });
|
return { ...state, ...initialState, grid, zoom, snake, apple, gameId, ...options };
|
||||||
snake.forEach((snakePart, i) =>
|
|
||||||
markCell(grid, snakePart, {
|
|
||||||
type: "snake",
|
|
||||||
index: snake.length - 1 - i,
|
|
||||||
length: snake.length
|
|
||||||
})
|
|
||||||
);
|
|
||||||
return { ...state, ...initialState, grid, zoom, snake, apple, ...options };
|
|
||||||
});
|
});
|
||||||
|
|
||||||
module.reducer(CHANGE_DIRECTION, (state, { direction }) => {
|
module.reducer(CHANGE_DIRECTION, (state, { direction }) => {
|
||||||
const { vX, vY, vXNext, vYNext } = state;
|
const { vX, vY, next } = state;
|
||||||
|
const [pX, pY] = next.length ? next.slice(-1)[0] : [vX, vY];
|
||||||
|
|
||||||
if (vXNext !== undefined || vYNext !== undefined) {
|
if (direction === directions.UP && !pY) {
|
||||||
return state;
|
return { ...state, next: [...next, [0, -1]] };
|
||||||
}
|
}
|
||||||
if (direction === directions.UP && vY !== 1) {
|
if (direction === directions.DOWN && !pY) {
|
||||||
return { ...state, vXNext: 0, vYNext: -1 };
|
return { ...state, next: [...next, [0, 1]] };
|
||||||
}
|
}
|
||||||
if (direction === directions.DOWN && vY !== -1) {
|
if (direction === directions.LEFT && !pX) {
|
||||||
return { ...state, vXNext: 0, vYNext: 1 };
|
return { ...state, next: [...next, [-1, 0]] };
|
||||||
}
|
}
|
||||||
if (direction === directions.LEFT && vX !== 1) {
|
if (direction === directions.RIGHT && !pX) {
|
||||||
return { ...state, vXNext: -1, vYNext: 0 };
|
return { ...state, next: [...next, [1, 0]] };
|
||||||
}
|
|
||||||
if (direction === directions.RIGHT && vX !== -1) {
|
|
||||||
return { ...state, vXNext: 1, vYNext: 0 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return state;
|
return state;
|
||||||
});
|
});
|
||||||
|
|
||||||
module.reducer(UPDATE_FRAME_SNAKE, state => {
|
module.reducer(UPDATE_FRAME_SNAKE, state => {
|
||||||
const { snake, vX, vY, vXNext, vYNext, size } = state;
|
const { snake, vX, vY, next, size } = state;
|
||||||
let { apple, started, died, score } = state;
|
let { apple, started, died, score } = state;
|
||||||
const grid = createGrid(size);
|
const grid = createGrid(size);
|
||||||
const nextPosition = moveSnakePart(
|
|
||||||
snake[snake.length - 1],
|
const [vXNext, vYNext] = next.length ? next[0] : [vX, vY];
|
||||||
vXNext !== undefined ? vXNext : vX,
|
|
||||||
vYNext !== undefined ? vYNext : vY,
|
const nextPosition = moveSnakePart(snake[snake.length - 1], vXNext, vYNext, size);
|
||||||
size
|
|
||||||
);
|
|
||||||
const newSnake = [...snake, nextPosition];
|
const newSnake = [...snake, nextPosition];
|
||||||
|
|
||||||
/* If the snake did not eat an apple then remove the last part of its tail */
|
/* If the snake did not eat an apple then remove the last part of its tail */
|
||||||
@@ -171,22 +172,15 @@ module.reducer(UPDATE_FRAME_SNAKE, state => {
|
|||||||
apple = randomPosition(size);
|
apple = randomPosition(size);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
markCell(grid, apple, { type: "apple" });
|
updateGrid({ grid, apple, snake: newSnake });
|
||||||
newSnake.forEach((snakePart, i) =>
|
|
||||||
markCell(grid, snakePart, {
|
|
||||||
type: "snake",
|
|
||||||
index: newSnake.length - 1 - i,
|
|
||||||
length: snake.length
|
|
||||||
})
|
|
||||||
);
|
|
||||||
score = newSnake.length;
|
score = newSnake.length;
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
grid,
|
grid,
|
||||||
vX: vXNext !== undefined ? vXNext : vX,
|
vX: vXNext,
|
||||||
vY: vYNext !== undefined ? vYNext : vY,
|
vY: vYNext,
|
||||||
vXNext: undefined,
|
next: next.slice(1),
|
||||||
vYNext: undefined,
|
|
||||||
snake: newSnake,
|
snake: newSnake,
|
||||||
apple,
|
apple,
|
||||||
started,
|
started,
|
||||||
@@ -208,12 +202,10 @@ module.middleware(INIT, (dispatch, { started, paused, died }) => {
|
|||||||
if (!started && !died) {
|
if (!started && !died) {
|
||||||
dispatch(resetSnake({ started: false, died: false }));
|
dispatch(resetSnake({ started: false, died: false }));
|
||||||
}
|
}
|
||||||
// dispatch({ type: "[GAME] UPDATE_LOOP_ID", loopId: undefined });
|
|
||||||
if (!paused) {
|
if (!paused) {
|
||||||
dispatch(registerCaller(UPDATE_FRAME_SNAKE));
|
dispatch(registerCaller(UPDATE_FRAME_SNAKE));
|
||||||
dispatch(subscribeKeyPressed(KEY_PRESSED_SNAKE));
|
dispatch(subscribeKeyPressed(KEY_PRESSED_SNAKE));
|
||||||
}
|
}
|
||||||
// dispatch(startLoop());
|
|
||||||
dispatch(initGame());
|
dispatch(initGame());
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -226,7 +218,6 @@ module.middleware(START_SNAKE, dispatch => {
|
|||||||
|
|
||||||
module.middleware(STOP_SNAKE, (dispatch, { started }) => {
|
module.middleware(STOP_SNAKE, (dispatch, { started }) => {
|
||||||
dispatch(unregisterCaller(UPDATE_FRAME_SNAKE));
|
dispatch(unregisterCaller(UPDATE_FRAME_SNAKE));
|
||||||
// dispatch(unsubscribeKeyPressed(KEY_PRESSED_SNAKE));
|
|
||||||
dispatch(stopLoop());
|
dispatch(stopLoop());
|
||||||
dispatch(resetSnake({ started: false }));
|
dispatch(resetSnake({ started: false }));
|
||||||
});
|
});
|
||||||
@@ -240,10 +231,13 @@ module.middleware(PAUSE_SNAKE, (dispatch, { paused }) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
module.middleware(KEY_PRESSED_SNAKE, (dispatch, _, { key, altKey, ctrlKey, metaKey, shiftKey }) => {
|
module.middleware(KEY_PRESSED_SNAKE, (dispatch, _, { key, altKey, ctrlKey, metaKey, shiftKey, target }) => {
|
||||||
if (altKey || ctrlKey || metaKey) {
|
if (altKey || ctrlKey || metaKey) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (target.nodeName === "INPUT") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (key === keys.UP || key === keys.k) {
|
if (key === keys.UP || key === keys.k) {
|
||||||
dispatch(changeDirection(directions.UP));
|
dispatch(changeDirection(directions.UP));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,28 +8,22 @@ import { throttle } from "../utils/throttle.js";
|
|||||||
import { module as snakeModule } from "./snake.js";
|
import { module as snakeModule } from "./snake.js";
|
||||||
import { module as gameModule } from "./game.js";
|
import { module as gameModule } from "./game.js";
|
||||||
import { module as uiModule } from "./ui.js";
|
import { module as uiModule } from "./ui.js";
|
||||||
|
import { module as highscoreModule } from "./highscore.js";
|
||||||
|
|
||||||
export const createStore = () => {
|
export const createStore = () => {
|
||||||
const history = createBrowserHistory({
|
const history = createBrowserHistory({
|
||||||
basename: process.env.NODE_ENV === "production" ? "/redux/" : "/"
|
basename: process.env.NODE_ENV === "production" ? "/redux/" : "/"
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("loadState", loadState(), {
|
|
||||||
router: connectRouter(history),
|
|
||||||
...uiModule.reducers,
|
|
||||||
...snakeModule.reducers,
|
|
||||||
...gameModule.reducers
|
|
||||||
});
|
|
||||||
|
|
||||||
const store = createReduxStore(
|
const store = createReduxStore(
|
||||||
combineReducers({
|
combineReducers({
|
||||||
router: connectRouter(history),
|
router: connectRouter(history),
|
||||||
...snakeModule.reducers,
|
...snakeModule.reducers,
|
||||||
...uiModule.reducers,
|
...uiModule.reducers,
|
||||||
...gameModule.reducers
|
...gameModule.reducers,
|
||||||
|
...highscoreModule.reducers
|
||||||
}),
|
}),
|
||||||
loadState(),
|
loadState(),
|
||||||
// {},
|
|
||||||
compose.apply(
|
compose.apply(
|
||||||
this,
|
this,
|
||||||
[
|
[
|
||||||
@@ -42,13 +36,14 @@ export const createStore = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (module.hot) {
|
if (module.hot) {
|
||||||
module.hot.accept(["./ui.js", "./snake.js", "./game.js", "./Module.js"], () => {
|
module.hot.accept(["./ui.js", "./snake.js", "./game.js", "./Module.js", "./highscore.js"], () => {
|
||||||
store.replaceReducer(
|
store.replaceReducer(
|
||||||
combineReducers({
|
combineReducers({
|
||||||
router: connectRouter(history),
|
router: connectRouter(history),
|
||||||
...uiModule.reducers,
|
...uiModule.reducers,
|
||||||
...snakeModule.reducers,
|
...snakeModule.reducers,
|
||||||
...gameModule.reducers
|
...gameModule.reducers,
|
||||||
|
...highscoreModule.reducers
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ const GlobalStyle = createGlobalStyle`
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
background: ${props => props.theme.body.background};
|
background: ${props => props.theme.body.background};
|
||||||
color: ${props => props.theme.body.color};
|
color: ${props => props.theme.body.color};
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
body, input, select {
|
||||||
font-family: ${({ theme }) => theme.body.fontFamily};
|
font-family: ${({ theme }) => theme.body.fontFamily};
|
||||||
font-size: ${({ theme }) => theme.body.fontSize};
|
font-size: ${({ theme }) => theme.body.fontSize};
|
||||||
min-height: 100vh;
|
|
||||||
}
|
}
|
||||||
h1, h2 {
|
h1, h2 {
|
||||||
font-family: Lato;
|
font-family: Lato;
|
||||||
|
|||||||
@@ -4,15 +4,16 @@ import { renderTheme } from "./default.js";
|
|||||||
export const colors = {
|
export const colors = {
|
||||||
background: "#333",
|
background: "#333",
|
||||||
backgroundActive: "#828282",
|
backgroundActive: "#828282",
|
||||||
backgroundInactive: "silver",
|
backgroundInactive: "#4d4d4d",
|
||||||
backgroundAlternate: "#555",
|
backgroundAlternate: "#555",
|
||||||
color: "palevioletred",
|
color: "palevioletred",
|
||||||
colorActive: "#ecb1c5",
|
colorActive: "#ecb1c5",
|
||||||
colorInactive: "#e6b4c4",
|
colorInactive: "#97727e",
|
||||||
colorAlternate: "#ecb1c5",
|
colorAlternate: "#ecb1c5",
|
||||||
shadowColor: "#222",
|
shadowColor: "#222",
|
||||||
borderColor: "#222",
|
borderColor: "#222",
|
||||||
borderColorActive: "silver",
|
borderColorActive: "#e6b4c4",
|
||||||
|
borderColorInactive: "#97727e",
|
||||||
spinnerShadow: "#444",
|
spinnerShadow: "#444",
|
||||||
spinnerHighlight: "#db7093",
|
spinnerHighlight: "#db7093",
|
||||||
cardFoldHighlight: "#ad5a75",
|
cardFoldHighlight: "#ad5a75",
|
||||||
|
|||||||
@@ -4,15 +4,16 @@ import { renderTheme } from "./default.js";
|
|||||||
export const colors = {
|
export const colors = {
|
||||||
background: "#333",
|
background: "#333",
|
||||||
backgroundActive: "#828282",
|
backgroundActive: "#828282",
|
||||||
backgroundInactive: "silver",
|
backgroundInactive: "#4d4d4d",
|
||||||
backgroundAlternate: "#555",
|
backgroundAlternate: "#555",
|
||||||
color: "#7094db",
|
color: "#7094db",
|
||||||
colorActive: "#ecb1c5",
|
colorActive: "#ecb1c5",
|
||||||
colorInactive: "#b3c3e6",
|
colorInactive: "#7d98d4",
|
||||||
colorAlternate: "#ecb1c5",
|
colorAlternate: "#ecb1c5",
|
||||||
shadowColor: "#222",
|
shadowColor: "#222",
|
||||||
borderColor: "#222",
|
borderColor: "#222",
|
||||||
borderColorActive: "silver",
|
borderColorActive: "silver",
|
||||||
|
borderColorInactive: "#7d98d4",
|
||||||
spinnerShadow: "#444",
|
spinnerShadow: "#444",
|
||||||
spinnerHighlight: "#db7093",
|
spinnerHighlight: "#db7093",
|
||||||
cardFoldHighlight: "#091225",
|
cardFoldHighlight: "#091225",
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
import merge from "deepmerge";
|
import merge from "deepmerge";
|
||||||
|
|
||||||
export const defaultColors = {
|
export const defaultColors = {
|
||||||
|
none: "none",
|
||||||
background: "white",
|
background: "white",
|
||||||
backgroundAlternate: "#ddd",
|
backgroundAlternate: "#ddd",
|
||||||
backgroundInactive: "silver",
|
backgroundInactive: "#e2e2e2",
|
||||||
backgroundActive: "#ccc",
|
backgroundActive: "#ccc",
|
||||||
color: "black",
|
color: "black",
|
||||||
colorActive: "#444",
|
colorActive: "#444",
|
||||||
colorAlternate: "#222",
|
colorAlternate: "#222",
|
||||||
colorInactive: "silver",
|
colorInactive: "#bdbdbd",
|
||||||
shadowColor: "gray",
|
shadowColor: "gray",
|
||||||
borderColor: "black",
|
borderColor: "black",
|
||||||
borderColorActive: "silver",
|
borderColorActive: "silver",
|
||||||
|
borderColorInactive: "#bdbdbd",
|
||||||
spinnerShadow: "#eee",
|
spinnerShadow: "#eee",
|
||||||
spinnerHighlight: "black",
|
spinnerHighlight: "black",
|
||||||
cardFoldHighlight: "#888",
|
cardFoldHighlight: "#888",
|
||||||
@@ -27,6 +29,7 @@ const mapRange = (l, i, min, max) => Math.round((max - min) * ((l - i) / l) + mi
|
|||||||
export const renderTheme = themeColors => {
|
export const renderTheme = themeColors => {
|
||||||
const colors = merge(defaultColors, themeColors);
|
const colors = merge(defaultColors, themeColors);
|
||||||
return {
|
return {
|
||||||
|
colors,
|
||||||
body: {
|
body: {
|
||||||
background: colors.background,
|
background: colors.background,
|
||||||
color: colors.color,
|
color: colors.color,
|
||||||
@@ -63,6 +66,11 @@ export const renderTheme = themeColors => {
|
|||||||
color: colors.background,
|
color: colors.background,
|
||||||
borderWidth: `${1 / 16}rem`,
|
borderWidth: `${1 / 16}rem`,
|
||||||
borderRadius: `${3 / 16}rem`,
|
borderRadius: `${3 / 16}rem`,
|
||||||
|
disabled: {
|
||||||
|
background: colors.backgroundInactive,
|
||||||
|
color: colors.colorInactive,
|
||||||
|
borderColor: colors.borderColorInactive
|
||||||
|
},
|
||||||
toggled: {
|
toggled: {
|
||||||
background: colors.backgroundInactive,
|
background: colors.backgroundInactive,
|
||||||
color: colors.colorInactive
|
color: colors.colorInactive
|
||||||
@@ -74,8 +82,24 @@ export const renderTheme = themeColors => {
|
|||||||
background: colors.backgroundActive,
|
background: colors.backgroundActive,
|
||||||
color: colors.colorActive,
|
color: colors.colorActive,
|
||||||
borderColor: colors.borderColorActive
|
borderColor: colors.borderColorActive
|
||||||
|
},
|
||||||
|
icon: {
|
||||||
|
borderColor: colors.borderColorActive,
|
||||||
|
disabled: {
|
||||||
|
background: colors.none,
|
||||||
|
color: colors.backgroundInactive,
|
||||||
|
borderColor: colors.borderColorInactive
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
input: {
|
||||||
|
borderColor: colors.borderColor,
|
||||||
|
padding: ".5rem",
|
||||||
|
background: colors.background,
|
||||||
|
color: colors.color,
|
||||||
|
borderWidth: `${1 / 16}rem`,
|
||||||
|
borderRadius: `${3 / 16}rem`
|
||||||
|
},
|
||||||
tooltip: {
|
tooltip: {
|
||||||
borderColor: colors.borderColor,
|
borderColor: colors.borderColor,
|
||||||
borderWidth: `${1 / 16}rem`,
|
borderWidth: `${1 / 16}rem`,
|
||||||
|
|||||||
Reference in New Issue
Block a user