Implement highscore/leaderboard top 10 best games and add tests with jest

This commit is contained in:
2019-09-05 22:48:15 +02:00
parent 1af2ceb862
commit 26a869a189
27 changed files with 760 additions and 101 deletions

1
.gitignore vendored
View File

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

View File

@@ -1,3 +1,3 @@
FROM nginx
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

View File

@@ -4,8 +4,9 @@
[ ] Create welcome / instructions page
[ ] Link to repository
[ ] Convert components to use react hooks
[ ] Add license
[ ] High score
[ ] Improve banner
## License

5
docker.md Normal file
View 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
View 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,
};

View File

@@ -7,7 +7,7 @@
"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"
"test": "jest"
},
"keywords": [],
"author": "",
@@ -20,6 +20,7 @@
"@babel/preset-react": "^7.0.0",
"babel-loader": "^8.0.6",
"babel-plugin-styled-components": "^1.10.6",
"jest": "^24.9.0",
"react-hot-loader": "^4.12.11",
"webpack": "^4.39.2",
"webpack-cli": "^3.3.7",

View File

@@ -1,5 +1,5 @@
import React from "react";
import styled from "styled-components";
import styled, { css } from "styled-components";
const StyledBanner = styled.div`
border-width: ${({ theme }) => theme.banner.borderWidth};
@@ -15,11 +15,22 @@ const StyledBanner = styled.div`
transition: opacity 100ms ease-in;
border-radius: 2px;
box-shadow: 1px 1px 5px ${({ theme }) => theme.banner.shadowColor};
&:hover {
opacity: 0.5;
}
${({ fade = false }) =>
fade &&
css`
&:hover {
opacity: 0.5;
}
`}
`;
export const Banner = ({ children }) => <StyledBanner>{children}</StyledBanner>;
Banner.Title = styled.h2`
text-align: center;
margin: 0;
padding: 0;
line-height: 1rem;
`;
export default Banner;

View File

@@ -1,17 +1,29 @@
import React from "react";
import { NavLink } from "react-router-dom";
import styled from "styled-components";
import styled, { css } from "styled-components";
import Tooltip from "./Tooltip.js";
const SharedStyle = () => `
const SharedStyle = css`
border-width: ${({ theme }) => theme.button.borderWidth};
border-style: solid;
border-color: ${({ theme }) => theme.button.borderColor};
border-radius: ${({ theme }) => theme.button.borderRadius};
background-color: ${({ theme }) => theme.button.background};
color: ${({ theme }) => theme.button.color};
${({ 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;
padding: .3125rem;
padding: 0.3125rem;
font-size: 1rem;
display: inline-block;
vertical-align: top;
@@ -19,6 +31,11 @@ const SharedStyle = () => `
user-select: none;
cursor: pointer;
&:focus,
&:hover {
outline: ${({ disabled }) => (!disabled ? "1px solid silver" : "none")};
}
&:hover {
background-color: ${({ theme }) => theme.button.hover.background};
}
@@ -29,6 +46,17 @@ const SharedStyle = () => `
color: ${({ theme }) => theme.button.active.color};
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 }))`
@@ -85,6 +113,7 @@ export const Button = props => {
onMouseLeave={() => setHover(false)}
onKeyUp={updatedProps.onKeyUp}
onClick={updatedProps.onClick}
className={disabled && "disabled"}
disabled={disabled}>
<i
disabled={disabled}
@@ -111,16 +140,49 @@ export const IconButton = styled(Button)`
cursor: pointer;
&:before {
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 black, 1px 1px 3px #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)`
color: ${({ theme, toggle }) => (toggle ? theme.button.toggled.color : null)};
&: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;

View File

@@ -76,14 +76,18 @@ export const ControlPanel = ({
</ThemeSelector>
</HorizontalStack>
<HorizontalStack style={{ minHeight: "2rem" }}>
<span>Zoom: {zoom}</span>
<span>
Zoom: <b>{zoom}</b>
</span>
<div style={{ display: "inline-block" }}>
<IconButton icon="minus-circle" size={1} onClick={zoomOut} />
<IconButton icon="plus-circle" size={1} onClick={zoomIn} />
</div>
</HorizontalStack>
<HorizontalStack style={{ minHeight: "2rem" }}>
<span>FPS: {fps}</span>
<span>
FPS: <b>{fps}</b>
</span>
<div style={{ display: "inline-block" }}>
<IconButton icon="minus-circle" size={1} onClick={() => setFps(fps - 1)} />
<IconButton icon="plus-circle" size={1} onClick={() => setFps(fps + 1)} />

View File

@@ -35,7 +35,9 @@ const MenuButton = styled(NavLink)`
const HeaderMenu = () => (
<MenuPanel>
<MenuItem>
<MenuButton to="/">Snake</MenuButton>
<MenuButton to="/" exact>
Snake
</MenuButton>
</MenuItem>
<MenuItem>
<MenuButton to="/about" exact>

View 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);

View 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
View 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;

View File

@@ -24,8 +24,8 @@ const App = ({ theme }) => (
<Page>
<Switch>
<Route exact path="/" component={Snake} />
<Route path="/snake" exact component={Snake} />
<Route path="/about" exact component={Home} />
<Route exact path="/snake" component={Snake} />
<Route exact path="/about" component={Home} />
</Switch>
</Page>
<Footer />

View File

@@ -15,6 +15,7 @@ import {
zoomIn,
zoomOut
} from "../redux/snake.js";
import { module as highscoreModule, hasHighscore, registerHighscore, skipHighscore } from "../redux/highscore.js";
/* Components */
import ControlPanel from "../components/ControlPanel.js";
@@ -23,6 +24,8 @@ import Stage from "../components/Stage.js";
import SnakePart from "../components/SnakePart.js";
import Apple from "../components/Apple.js";
import Banner from "../components/Banner.js";
import Highscore from "../components/Highscore.js";
import HighscoreInput from "../components/HighscoreInput.js";
const Layout = styled.div`
display: flex;
@@ -48,6 +51,10 @@ const Snake = ({
pauseSnake,
updateFrameSnake,
changeTheme,
hasHighscore,
registerHighscore,
skipHighscore,
lastUsername,
theme,
grid,
started,
@@ -56,10 +63,20 @@ const Snake = ({
fps,
setFps,
score,
gameId,
lastGameId,
zoom,
zoomIn,
zoomOut
}) => {
const onSubmitHighscore = result => {
if (result) {
registerHighscore(result);
} else {
skipHighscore(gameId);
}
};
return (
<React.Fragment>
<Layout>
@@ -70,10 +87,14 @@ const Snake = ({
(cell.type === "snake" && <SnakePart zoom={zoom} value={cell} died={died} />)
}
</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>
<SidePanel>
<Scoreboard score={score} zoom={2} />
<Scoreboard score={score} zoom={3} />
<Highscore />
<ControlPanel
updateFrameSnake={updateFrameSnake}
started={started}
@@ -98,7 +119,9 @@ const Snake = ({
const mapStateToProps = state => ({
...state[uiModule.name],
...state[gameModule.name],
...state[snakeModule.name]
...state[snakeModule.name],
...state[highscoreModule.name],
hasHighscore: hasHighscore(state)
});
const mapActionsToProps = {
@@ -111,7 +134,9 @@ const mapActionsToProps = {
setFps,
zoomIn,
zoomOut,
changeTheme
changeTheme,
registerHighscore,
skipHighscore
};
export default connect(

View File

@@ -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 [REGISTER_CALLER, registerCaller] = module.action("REGISTER_CALLER", name => ({ name }));
export const [UNREGISTER_CALLER, unregisterCaller] = module.action("UNREGISTER_CALLER", name => ({ name }));
export const [KEY_PRESS, keyPress] = module.action("KEY_PRESS", ({ key, altKey, ctrlKey, metaKey, shiftKey }) => ({
key,
altKey,
ctrlKey,
metaKey,
shiftKey
}));
export const [KEY_PRESS, keyPress] = module.action(
"KEY_PRESS",
({ key, altKey, ctrlKey, metaKey, shiftKey, target }) => ({
key,
altKey,
ctrlKey,
metaKey,
shiftKey,
target
})
);
export const [SUBSCRIBE_KEY_PRESSED, subscribeKeyPressed] = module.action("SUBSCRIBE_KEY_PRESSED", name => ({ name }));
export const [UNSUBSCRIBE_KEY_PRESSED, unsubscribeKeyPressed] = module.action("UNSUBSCRIBE_KEY_PRESSED", name => ({
name
@@ -100,9 +104,10 @@ module.middleware(INIT, (dispatch, { loopId }) => {
module.middleware(
KEY_PRESS,
(dispatch, { keyPressSubscribers = [], fps }, { key, altKey, ctrlKey, metaKey, shiftKey }) => {
console.log("key", key);
keyPressSubscribers.forEach(subscriber => dispatch({ type: subscriber, key, altKey, ctrlKey, metaKey, shiftKey }));
(dispatch, { keyPressSubscribers = [], fps }, { key, altKey, ctrlKey, metaKey, shiftKey, target }) => {
keyPressSubscribers.forEach(subscriber =>
dispatch({ type: subscriber, key, altKey, ctrlKey, metaKey, shiftKey, target })
);
if (key === keys.PLUS || key === keys.EQUAL) {
dispatch(setFps(fps + 1));

70
src/redux/highscore.js Normal file
View 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
View 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();
});

View File

@@ -218,6 +218,10 @@ export class Module {
return selection;
}, {});
}
selector(selectorFn) {
return state => (...args) => selectorFn(state, ...args);
}
}
export default Module;

View File

@@ -12,10 +12,10 @@ const initialState = {
zoom: 1.5,
size: DEFAULT_GRID_SIZE,
paused: false,
gameId: 0,
vX: 1,
vY: 0,
vXNext: undefined,
vYNext: undefined,
next: [],
snake: [],
score: 0,
apple: []
@@ -52,6 +52,18 @@ const markCell = (grid, [x, y] = [], mark) => {
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) => {
let newX = x + vX;
let newY = y + vY;
@@ -105,52 +117,41 @@ module.reducer(ZOOM_OUT, (state, { step = DEFAUL_ZOOM_STEP }) =>
module.reducer(RESET_SNAKE, (state, options) => {
const grid = createGrid(state.size);
const zoom = state.zoom;
const gameId = (state.gameId || 0) + 1;
const apple = options.started ? randomPosition(state.size) : initialState.apple;
const snake = options.started ? [[0, 0]] : initialState.snake;
markCell(grid, apple, { type: "apple" });
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 };
updateGrid({ grid, apple, snake });
return { ...state, ...initialState, grid, zoom, snake, apple, gameId, ...options };
});
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) {
return state;
if (direction === directions.UP && !pY) {
return { ...state, next: [...next, [0, -1]] };
}
if (direction === directions.UP && vY !== 1) {
return { ...state, vXNext: 0, vYNext: -1 };
if (direction === directions.DOWN && !pY) {
return { ...state, next: [...next, [0, 1]] };
}
if (direction === directions.DOWN && vY !== -1) {
return { ...state, vXNext: 0, vYNext: 1 };
if (direction === directions.LEFT && !pX) {
return { ...state, next: [...next, [-1, 0]] };
}
if (direction === directions.LEFT && vX !== 1) {
return { ...state, vXNext: -1, vYNext: 0 };
}
if (direction === directions.RIGHT && vX !== -1) {
return { ...state, vXNext: 1, vYNext: 0 };
if (direction === directions.RIGHT && !pX) {
return { ...state, next: [...next, [1, 0]] };
}
return 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;
const grid = createGrid(size);
const nextPosition = moveSnakePart(
snake[snake.length - 1],
vXNext !== undefined ? vXNext : vX,
vYNext !== undefined ? vYNext : vY,
size
);
const [vXNext, vYNext] = next.length ? next[0] : [vX, vY];
const nextPosition = moveSnakePart(snake[snake.length - 1], vXNext, vYNext, size);
const newSnake = [...snake, nextPosition];
/* 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);
}
}
markCell(grid, apple, { type: "apple" });
newSnake.forEach((snakePart, i) =>
markCell(grid, snakePart, {
type: "snake",
index: newSnake.length - 1 - i,
length: snake.length
})
);
updateGrid({ grid, apple, snake: newSnake });
score = newSnake.length;
return {
...state,
grid,
vX: vXNext !== undefined ? vXNext : vX,
vY: vYNext !== undefined ? vYNext : vY,
vXNext: undefined,
vYNext: undefined,
vX: vXNext,
vY: vYNext,
next: next.slice(1),
snake: newSnake,
apple,
started,
@@ -208,12 +202,10 @@ module.middleware(INIT, (dispatch, { started, paused, died }) => {
if (!started && !died) {
dispatch(resetSnake({ started: false, died: false }));
}
// dispatch({ type: "[GAME] UPDATE_LOOP_ID", loopId: undefined });
if (!paused) {
dispatch(registerCaller(UPDATE_FRAME_SNAKE));
dispatch(subscribeKeyPressed(KEY_PRESSED_SNAKE));
}
// dispatch(startLoop());
dispatch(initGame());
});
@@ -226,7 +218,6 @@ module.middleware(START_SNAKE, dispatch => {
module.middleware(STOP_SNAKE, (dispatch, { started }) => {
dispatch(unregisterCaller(UPDATE_FRAME_SNAKE));
// dispatch(unsubscribeKeyPressed(KEY_PRESSED_SNAKE));
dispatch(stopLoop());
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) {
return;
}
if (target.nodeName === "INPUT") {
return;
}
if (key === keys.UP || key === keys.k) {
dispatch(changeDirection(directions.UP));
}

View File

@@ -8,28 +8,22 @@ import { throttle } from "../utils/throttle.js";
import { module as snakeModule } from "./snake.js";
import { module as gameModule } from "./game.js";
import { module as uiModule } from "./ui.js";
import { module as highscoreModule } from "./highscore.js";
export const createStore = () => {
const history = createBrowserHistory({
basename: process.env.NODE_ENV === "production" ? "/redux/" : "/"
});
console.log("loadState", loadState(), {
router: connectRouter(history),
...uiModule.reducers,
...snakeModule.reducers,
...gameModule.reducers
});
const store = createReduxStore(
combineReducers({
router: connectRouter(history),
...snakeModule.reducers,
...uiModule.reducers,
...gameModule.reducers
...gameModule.reducers,
...highscoreModule.reducers
}),
loadState(),
// {},
compose.apply(
this,
[
@@ -42,13 +36,14 @@ export const createStore = () => {
);
if (module.hot) {
module.hot.accept(["./ui.js", "./snake.js", "./game.js", "./Module.js"], () => {
module.hot.accept(["./ui.js", "./snake.js", "./game.js", "./Module.js", "./highscore.js"], () => {
store.replaceReducer(
combineReducers({
router: connectRouter(history),
...uiModule.reducers,
...snakeModule.reducers,
...gameModule.reducers
...gameModule.reducers,
...highscoreModule.reducers
})
);
});

View File

@@ -8,9 +8,11 @@ const GlobalStyle = createGlobalStyle`
margin: 0;
background: ${props => props.theme.body.background};
color: ${props => props.theme.body.color};
min-height: 100vh;
}
body, input, select {
font-family: ${({ theme }) => theme.body.fontFamily};
font-size: ${({ theme }) => theme.body.fontSize};
min-height: 100vh;
}
h1, h2 {
font-family: Lato;

View File

@@ -4,15 +4,16 @@ import { renderTheme } from "./default.js";
export const colors = {
background: "#333",
backgroundActive: "#828282",
backgroundInactive: "silver",
backgroundInactive: "#4d4d4d",
backgroundAlternate: "#555",
color: "palevioletred",
colorActive: "#ecb1c5",
colorInactive: "#e6b4c4",
colorInactive: "#97727e",
colorAlternate: "#ecb1c5",
shadowColor: "#222",
borderColor: "#222",
borderColorActive: "silver",
borderColorActive: "#e6b4c4",
borderColorInactive: "#97727e",
spinnerShadow: "#444",
spinnerHighlight: "#db7093",
cardFoldHighlight: "#ad5a75",

View File

@@ -4,15 +4,16 @@ import { renderTheme } from "./default.js";
export const colors = {
background: "#333",
backgroundActive: "#828282",
backgroundInactive: "silver",
backgroundInactive: "#4d4d4d",
backgroundAlternate: "#555",
color: "#7094db",
colorActive: "#ecb1c5",
colorInactive: "#b3c3e6",
colorInactive: "#7d98d4",
colorAlternate: "#ecb1c5",
shadowColor: "#222",
borderColor: "#222",
borderColorActive: "silver",
borderColorInactive: "#7d98d4",
spinnerShadow: "#444",
spinnerHighlight: "#db7093",
cardFoldHighlight: "#091225",

View File

@@ -1,17 +1,19 @@
import merge from "deepmerge";
export const defaultColors = {
none: "none",
background: "white",
backgroundAlternate: "#ddd",
backgroundInactive: "silver",
backgroundInactive: "#e2e2e2",
backgroundActive: "#ccc",
color: "black",
colorActive: "#444",
colorAlternate: "#222",
colorInactive: "silver",
colorInactive: "#bdbdbd",
shadowColor: "gray",
borderColor: "black",
borderColorActive: "silver",
borderColorInactive: "#bdbdbd",
spinnerShadow: "#eee",
spinnerHighlight: "black",
cardFoldHighlight: "#888",
@@ -27,6 +29,7 @@ const mapRange = (l, i, min, max) => Math.round((max - min) * ((l - i) / l) + mi
export const renderTheme = themeColors => {
const colors = merge(defaultColors, themeColors);
return {
colors,
body: {
background: colors.background,
color: colors.color,
@@ -63,6 +66,11 @@ export const renderTheme = themeColors => {
color: colors.background,
borderWidth: `${1 / 16}rem`,
borderRadius: `${3 / 16}rem`,
disabled: {
background: colors.backgroundInactive,
color: colors.colorInactive,
borderColor: colors.borderColorInactive
},
toggled: {
background: colors.backgroundInactive,
color: colors.colorInactive
@@ -74,8 +82,24 @@ export const renderTheme = themeColors => {
background: colors.backgroundActive,
color: colors.colorActive,
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: {
borderColor: colors.borderColor,
borderWidth: `${1 / 16}rem`,