Implemented theming and game resuming via local storage

This commit is contained in:
2019-09-02 11:37:41 +02:00
parent 8ba4a30644
commit a7f4059311
26 changed files with 698 additions and 244 deletions

View File

@@ -27,6 +27,7 @@
},
"dependencies": {
"connected-react-router": "^6.5.2",
"deepmerge": "^4.0.0",
"history": "^4.9.0",
"react": "^16.9.0",
"react-dom": "^16.9.0",

View File

@@ -19,7 +19,6 @@ const Apple = styled.div.attrs(({ theme, zoom }) => ({
&:after {
content: "${props => (!props.died ? "🍎" : "🐛")}";
// content: "🍎";
display: inline-block;
animation: ${fade} 1s ease-out alternate infinite;
}

25
src/components/Banner.js Normal file
View File

@@ -0,0 +1,25 @@
import React from "react";
import styled from "styled-components";
const StyledBanner = styled.div`
border-width: ${({ theme }) => theme.banner.borderWidth};
border-style: solid;
border-color: ${({ theme }) => theme.banner.borderColor};
background-color: ${({ theme }) => theme.banner.background};
color: ${({ theme }) => theme.banner.color};
padding: ${({ theme }) => theme.banner.padding};
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
transition: opacity 100ms ease-in;
border-radius: 2px;
box-shadow: 1px 1px 5px ${({ theme }) => theme.banner.shadowColor};
&:hover {
opacity: 0.5;
}
`;
export const Banner = ({ children }) => <StyledBanner>{children}</StyledBanner>;
export default Banner;

View File

@@ -1,12 +1,15 @@
import React from "react";
import { NavLink } from "react-router-dom";
import styled, { css } from "styled-components";
import styled from "styled-components";
import Tooltip from "./Tooltip.js";
const SharedStyle = () => `
border: 1px solid gray;
border-radius: .1875rem;
background-color: palevioletred;
color: papayawhip;
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};
text-align: center;
padding: .3125rem;
font-size: 1rem;
@@ -17,7 +20,14 @@ const SharedStyle = () => `
cursor: pointer;
&:hover {
background-color: blue;
background-color: ${({ theme }) => theme.button.hover.background};
}
&:active,
&.active {
border-color: ${({ theme }) => theme.button.active.borderColor};
color: ${({ theme }) => theme.button.active.color};
background-color: ${({ theme }) => theme.button.active.background};
}
`;
@@ -37,31 +47,6 @@ const StyledAnchor = styled.a`
const StyledNavLink = styled(NavLink)`
${SharedStyle}
&.active {
border-color: silver;
color: #828282;
background-color: #ecb1c5;
}
`;
const Tooltip = styled.div`
position: absolute;
margin-top: 0.5rem;
border: 1px solid gray;
background-color: palevioletred;
color: papayawhip;
padding: 5px;
box-shadow: 1px 1px 3px #d0bfa3;
${props =>
props.hover
? css`
transition: opacity 400ms 200ms ease-in;
`
: css`
transition: opacity 100ms ease-out;
`}
white-space: nowrap;
opacity: ${props => (props.hover ? 1 : 0)};
`;
export const Button = (props, refs) => {
@@ -102,9 +87,10 @@ export const Button = (props, refs) => {
};
export const ToggleButton = styled(Button)`
background-color: ${props => (props.toggle ? "silver" : null)};
background-color: ${({ theme, toggle }) => (toggle ? theme.button.toggled.background : null)};
`;
// TODO: extract text shadow colors
export const IconButton = styled(Button)`
margin: 0 0.2rem;
display: inline-block;
@@ -116,7 +102,7 @@ export const IconButton = styled(Button)`
`;
export const ToggleIconButton = styled(IconButton)`
color: ${props => (props.toggle ? "#e6b4c4" : null)};
color: ${({ theme, toggle }) => (toggle ? theme.button.toggled.color : null)};
&:before {
text-shadow: 0 0 1px gray, 0 0 1px #d0bfa3;
}

View File

@@ -1,6 +1,6 @@
import React from "react";
import styled from "styled-components";
import Button, { IconButton, ToggleIconButton } from "../components/Button.js";
import { IconButton, ToggleIconButton } from "../components/Button.js";
const buttonSize = 2.5;
@@ -23,20 +23,40 @@ const HorizontalStack = styled.div`
place-content: space-between;
`;
const VerticalStack = styled.div`
display: flex;
flex-direction: column;
place-content: center;
align-content: space-around;
const ThemeSelector = styled.select`
appearance: none;
border: 1px solid transparent;
background: none;
border-radius: 3px;
// padding: 5px;
line-height: 1.5em;
color: ${({ theme }) => theme.body.color};
font-size: ${({ theme }) => theme.body.fontSize};
font-family: ${({ theme }) => theme.body.fontFamily};
height: 100%;
width: 100%;
cursor: pointer;
background-image: url('data:image/svg+xml;utf8,
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
<polygon points="0,3 16,3 8,13" fill="${({ theme }) => theme.select.color.replace("#", "%23")}" />
</svg>');
background-repeat: no-repeat;
background-position: right 0.625rem center;
background-size: 1rem 1rem;
outline-offset: 3px;
&:hover {
outline: 1px solid silver;
}
`;
export const ControlPanel = ({
updateFrameSnake,
paused,
started,
pauseSnake,
stopSnake,
startSnake,
theme,
changeTheme,
fps,
zoom,
setFps,
@@ -44,13 +64,14 @@ export const ControlPanel = ({
zoomOut
}) => (
<Layout>
{
// <h1>Control Panel</h1>
// <ToggleButton toggle={paused} onClick={pauseSnake}>
// {paused ? "Resume" : "Pause"}
// </ToggleButton>
}
<HorizontalStack style={{ minHeight: "2rem" }}>
<ThemeSelector onChange={e => changeTheme(e.target.value)} value={theme}>
<option value="light">Light</option>
<option value="dark">Miami Night</option>
<option value="darkOcean">Dark Ocean</option>
<option value="default">Black / White</option>
</ThemeSelector>
</HorizontalStack>
<HorizontalStack style={{ minHeight: "2rem" }}>
<span>Zoom: {zoom}</span>
<div style={{ display: "inline-block" }}>

View File

@@ -30,12 +30,12 @@ const MenuButton = styled(NavLink)`
const HeaderMenu = () => (
<MenuPanel>
<MenuItem>
<MenuButton to="/" exact>
Home
</MenuButton>
<MenuButton to="/snake">Snake</MenuButton>
</MenuItem>
<MenuItem>
<MenuButton to="/snake">Snake</MenuButton>
<MenuButton to="/" exact>
About
</MenuButton>
</MenuItem>
</MenuPanel>
);

View File

@@ -22,7 +22,7 @@ const Card = styled.div`
font-size: ${props => (props.zoom || 1) * 1.5}rem;
font-family: Rubik,monospace;
font-weight: 500;
box-shadow: 1px 1px 3px #d0bfa3;
box-shadow: 1px 1px 3px ${({ theme }) => theme.card.shadow};
text-shadow: 0 0 1px black;
overflow: hidden;
@@ -34,7 +34,7 @@ const Card = styled.div`
height: 50%;
width: 100%;
left: 0;
border-bottom: solid 2px #bdb19a;
border-bottom: solid 2px ${({ theme }) => theme.card.fold.highlight};
}
&::after {
@@ -44,7 +44,7 @@ const Card = styled.div`
top: 0;
height: 50%;
width: 100%;
border-bottom: dotted 2px #ad5a75;
border-bottom: dotted 2px ${({ theme }) => theme.card.fold.shadow};
z-index:1;
}
`;

View File

@@ -1,32 +1,23 @@
import styled, { css } from "styled-components";
import styled from "styled-components";
export const SnakePart = styled.div.attrs(({ theme, zoom }) => ({
export const SnakePart = styled.div.attrs(({ theme, zoom, died, value: { index, length } }) => ({
style: {
height: theme.snake.cell.size * (zoom || 1) + "rem",
width: theme.snake.cell.size * (zoom || 1) + "rem",
fontSize: (zoom || 1) * 1.3 + "rem",
lineHeight: (zoom || 1) * 1.7 + "rem",
paddingLeft: (zoom || 1) * 0.0625 + "rem"
paddingLeft: (zoom || 1) * 0.0625 + "rem",
backgroundColor: `${theme.snakePart.getColor(length, index, died)}`
}
}))`
display: inline-block;
vertical-align: top;
box-shadow: ${({ theme }) => theme.snakePart.boxShadow};
${props =>
!props.died
? css`
background-color: ${props => props.value && `hsl(340, ${props.value.brightness}%, 65%)`};
box-shadow: 1px 1px 2px #ddd inset, -1px -1px 2px gray inset;
`
: css`
background-color: ${props => props.value && `hsl(0, 0%, ${100 - props.value.brightness}% )`};
box-shadow: 1px 1px 2px #ddd inset, -1px -1px 2px gray inset;
&:after {
content: "${props => props.value && props.value.index === 0 && "💀"}";
display: inline-block;
}
`}
&:after {
content: "${({ value: { index }, died }) => index === 0 && died && "💀"}";
display: inline-block;
}
`;
export default SnakePart;

View File

@@ -19,7 +19,7 @@ const Cell = styled.div.attrs(({ theme, zoom }) => ({
display: inline-block;
vertical-align: top;
text-align: center;
box-shadow: 1px 1px 4px gray inset, -1px -1px 4px #fff inset;
box-shadow: ${({ theme }) => theme.stage.cell.boxShadow};
`;
export const Stage = ({ data, children, zoom = 1 }) => (

26
src/components/Tooltip.js Normal file
View File

@@ -0,0 +1,26 @@
import styled, { css } from "styled-components";
export const Tooltip = styled.div`
position: absolute;
border-width: ${({ theme }) => theme.tooltip.borderWidth};
border-style: solid;
border-color: ${({ theme }) => theme.tooltip.borderColor};
border-radius: ${({ theme }) => theme.tooltip.borderRadius};
background-color: ${({ theme }) => theme.tooltip.background};
color: ${({ theme }) => theme.tooltip.color};
margin-top: 0.5rem;
padding: ${({ theme }) => theme.tooltip.padding};
box-shadow: 1px 1px 3px ${({ theme }) => theme.tooltip.shadowColor};
${props =>
props.hover
? css`
transition: opacity 400ms 200ms ease-in;
`
: css`
transition: opacity 100ms ease-out;
`}
white-space: nowrap;
opacity: ${props => (props.hover ? 1 : 0)};
`;
export default Tooltip;

View File

@@ -1,25 +1,27 @@
import React from "react";
import { Provider } from "react-redux";
import { connect } from "react-redux";
import { hot } from "react-hot-loader/root";
import { ConnectedRouter } from "connected-react-router";
import { createStore } from "../redux/store";
import { MODULE_NAME as UI_MODULE_NAME } from "../redux/ui.js";
import { ThemeProvider } from "styled-components";
import { themes } from "../theming/theme.js";
import GlobalStyle from "../theming/GlobalStyle.js";
const store = createStore();
const Document = ({ children }) => (
<Provider store={store}>
<ThemeProvider theme={themes.main}>
<ConnectedRouter history={store.history}>
<GlobalStyle />
{children}
</ConnectedRouter>
</ThemeProvider>
</Provider>
const Document = ({ theme, children }) => (
<ThemeProvider theme={themes[theme]}>
<React.Fragment>
<GlobalStyle />
{children}
</React.Fragment>
</ThemeProvider>
);
export default (process.env.NODE_ENV === "development" ? hot(Document) : Document);
const ConnectedDocument = connect(
state => ({
theme: state[UI_MODULE_NAME].theme || "light"
}),
{}
)(Document);
export default (process.env.NODE_ENV === "development" ? hot(ConnectedDocument) : ConnectedDocument);
// export default (process.env.NODE_ENV === "development" ? hot(Document) : Document);

View File

@@ -3,6 +3,7 @@ import { connect } from "react-redux";
import styled from "styled-components";
/* Redux Modules */
import { module as uiModule, changeTheme } from "../redux/ui.js";
import { module as gameModule, keyPress, setFps } from "../redux/game.js";
import {
module as snakeModule,
@@ -21,6 +22,7 @@ import Scoreboard from "../components/Scoreboard.js";
import Stage from "../components/Stage.js";
import SnakePart from "../components/SnakePart.js";
import Apple from "../components/Apple.js";
import Banner from "../components/Banner.js";
const Layout = styled.div`
display: flex;
@@ -29,6 +31,10 @@ const Layout = styled.div`
margin-top: 2rem;
`;
const StageContainer = styled.div`
position: relative;
`;
const SidePanel = styled.div`
display: flex;
flex-direction: column;
@@ -36,76 +42,61 @@ const SidePanel = styled.div`
place-content: space-between;
`;
class Snake extends React.Component {
constructor(props) {
super(props);
this.handleKeyDown = this.handleKeyDown.bind(this);
window.me = this;
}
handleKeyDown(e) {
this.props.keyPress(e.key);
}
componentDidMount() {
window.addEventListener("keydown", this.handleKeyDown, false);
!this.props.started && this.props.startSnake();
}
render() {
const {
startSnake,
stopSnake,
pauseSnake,
updateFrameSnake,
grid,
started,
paused,
died,
fps,
setFps,
score,
zoom,
zoomIn,
zoomOut
} = this.props;
return (
<React.Fragment>
<Layout>
const Snake = ({
startSnake,
stopSnake,
pauseSnake,
updateFrameSnake,
changeTheme,
theme,
grid,
started,
paused,
died,
fps,
setFps,
score,
zoom,
zoomIn,
zoomOut
}) => {
return (
<React.Fragment>
<Layout>
<StageContainer>
<Stage data={grid} zoom={zoom}>
{cell =>
(cell.type === "apple" && <Apple zoom={zoom} died={died} />) ||
(cell.type === "snake" && <SnakePart zoom={zoom} value={cell} died={died} />)
}
</Stage>
<SidePanel>
<Scoreboard score={score} zoom={2} />
<ControlPanel
updateFrameSnake={updateFrameSnake}
started={started}
paused={paused}
pauseSnake={pauseSnake}
stopSnake={stopSnake}
startSnake={startSnake}
fps={fps}
setFps={setFps}
zoom={zoom}
zoomIn={zoomIn}
zoomOut={zoomOut}
/>
</SidePanel>
</Layout>
</React.Fragment>
);
}
componentWillUnmount() {
window.removeEventListener("keydown", this.handleKeyDown);
}
}
{!started && <Banner>Press 'r' to start the game</Banner>}
</StageContainer>
<SidePanel>
<Scoreboard score={score} zoom={2} />
<ControlPanel
updateFrameSnake={updateFrameSnake}
started={started}
paused={paused}
pauseSnake={pauseSnake}
stopSnake={stopSnake}
startSnake={startSnake}
theme={theme}
changeTheme={changeTheme}
fps={fps}
setFps={setFps}
zoom={zoom}
zoomIn={zoomIn}
zoomOut={zoomOut}
/>
</SidePanel>
</Layout>
</React.Fragment>
);
};
const mapStateToProps = state => ({
...state[uiModule.name],
...state[gameModule.name],
...state[snakeModule.name]
});
@@ -119,7 +110,8 @@ const mapActionsToProps = {
keyPress,
setFps,
zoomIn,
zoomOut
zoomOut,
changeTheme
};
export default connect(

View File

@@ -1,12 +1,34 @@
import React from "react";
import ReactDOM from "react-dom";
import { Provider } from "react-redux";
import { ConnectedRouter } from "connected-react-router";
import { createStore } from "./redux/store";
import { initSnake } from "./redux/snake";
import { keyPress } from "./redux/game";
import Document from "./containers/Document.js";
import App from "./containers/App.js";
const store = createStore();
window.store = store;
ReactDOM.render(
<Document>
<App />
</Document>,
<Provider store={store}>
<ConnectedRouter history={store.history}>
<Document>
<App />
</Document>
</ConnectedRouter>
</Provider>,
document.getElementById("root")
);
store.dispatch(initSnake());
/* Register global key down handler */
if (window.__KEY_DOWN_LISTENER__) {
window.removeEventListener("keydown", window.__KEY_DOWN_LISTENER__);
}
window.__KEY_DOWN_LISTENER__ = e => store.dispatch(keyPress(e));
window.addEventListener("keydown", window.__KEY_DOWN_LISTENER__, false);

View File

@@ -1,4 +1,4 @@
import Module from "./module.js";
import Module from "./Module.js";
import keys from "../enums/keys.js";
export const MODULE_NAME = "GAME";
@@ -7,18 +7,25 @@ export const module = new Module(MODULE_NAME, {
loopId: null,
callers: [],
keyPressSubscribers: [],
fps: 8
fps: 6
});
export const [START_LOOP, startLoop] = module.action("START_LOOP");
export const [STOP_LOOP, stopLoop] = module.action("STOP_LOOP");
export const [INIT, initGame] = module.action("INIT");
export const [LOOP, loop] = module.action("LOOP", ({ recur, skip } = {}) => ({ recur, skip }));
export const [UPDATE_LOOP_ID, updateLoopId] = module.action("UPDATE_LOOP_ID", loopId => ({ loopId }));
export const [SET_FPS, setFps] = module.action("SET_FPS", fps => ({ fps }));
export const [INVOKE_CALLERS, invokeCallers] = module.action("INVOKE_CALLERS");
export const [REGISTER_CALLER, registerCaller] = module.action("REGISTER_CALLER", name => ({ name }));
export const [UNREGISTER_CALLER, unregisterCaller] = module.action("UNREGISTER_CALLER", name => ({ name }));
export const [KEY_PRESS, keyPress] = module.action("KEY_PRESS", key => ({ key }));
export const [KEY_PRESS, keyPress] = module.action("KEY_PRESS", ({ key, altKey, ctrlKey, metaKey, shiftKey }) => ({
key,
altKey,
ctrlKey,
metaKey,
shiftKey
}));
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
@@ -84,16 +91,28 @@ module.reducer(UNSUBSCRIBE_KEY_PRESSED, (state, { name }) => {
return { ...state, keyPressSubscribers: state.keyPressSubscribers.filter(subscriber => subscriber !== name) };
});
module.middleware(KEY_PRESS, (dispatch, { keyPressSubscribers = [], fps }, { key }) => {
keyPressSubscribers.forEach(subscriber => dispatch({ type: subscriber, key }));
if (key === keys.PLUS || key === keys.EQUAL) {
dispatch(setFps(fps + 1));
}
if (key === keys.MINUS) {
dispatch(setFps(fps - 1));
module.middleware(INIT, (dispatch, { loopId }) => {
dispatch(updateLoopId(null));
if (loopId) {
dispatch(startLoop());
}
});
module.middleware(
KEY_PRESS,
(dispatch, { keyPressSubscribers = [], fps }, { key, altKey, ctrlKey, metaKey, shiftKey }) => {
keyPressSubscribers.forEach(subscriber => dispatch({ type: subscriber, key, altKey, ctrlKey, metaKey, shiftKey }));
if (key === keys.PLUS || key === keys.EQUAL) {
dispatch(setFps(fps + 1));
}
if (key === keys.MINUS) {
dispatch(setFps(fps - 1));
}
}
);
module.middleware(INVOKE_CALLERS, (dispatch, { callers = [] }) => {
callers.forEach(caller => dispatch({ type: caller }));
});

23
src/redux/localStorage.js Normal file
View File

@@ -0,0 +1,23 @@
export const loadState = () => {
try {
const serializedState = localStorage.getItem("state");
if (serializedState === null) {
return undefined;
}
return JSON.parse(serializedState) || {};
} catch (err) {
return undefined;
}
};
export const saveState = state => {
if (!state) {
return;
}
try {
const serializedState = JSON.stringify(state);
localStorage.setItem("state", serializedState);
} catch {
// ignore write errors
}
};

View File

@@ -1,7 +1,7 @@
import Module from "./module.js";
import Module from "./Module.js";
import keys from "../enums/keys.js";
import directions from "../enums/directions.js";
import { registerCaller, unregisterCaller, startLoop, stopLoop, subscribeKeyPressed } from "./game.js";
import { registerCaller, unregisterCaller, startLoop, stopLoop, subscribeKeyPressed, initGame } from "./game.js";
const MODULE_NAME = "SNAKE";
const DEFAULT_GRID_SIZE = 16;
@@ -28,8 +28,6 @@ function randomPosition(size) {
}
const comparePositions = (pos1, pos2) => pos1[0] === pos2[0] && pos1[1] === pos2[1];
const brightness = (l, i) => 20 + Math.round(0.8 * ((i + 1) / l) * 100);
function createGrid(size) {
const rows = [];
for (let y = 0; y < size; y += 1) {
@@ -76,6 +74,7 @@ export const module = new Module(MODULE_NAME, initialState);
/* === Actions ================================================================================== */
export const [INIT, initSnake] = module.action("INIT");
export const [START_SNAKE, startSnake] = module.action("START_SNAKE");
export const [RESET_SNAKE, resetSnake] = module.action("RESET_SNAKE", options => ({ ...options }));
export const [STOP_SNAKE, stopSnake] = module.action("STOP_SNAKE");
@@ -99,7 +98,7 @@ module.reducer(ZOOM_IN, (state, { step = DEFAUL_ZOOM_STEP }) => ({
...state,
zoom: Math.round((state.zoom + step) * 10) / 10
}));
module.reducer(SET_ZOOM_LEVEL, (state, { level }) => ({ ...state, zoom: level }));
module.reducer(SET_ZOOM_LEVEL, (state, { level }) => ({ ...state, zoom: Math.round(level * 10) / 10 }));
module.reducer(ZOOM_OUT, (state, { step = DEFAUL_ZOOM_STEP }) =>
state.zoom - step >= 0.5 ? { ...state, zoom: Math.round((state.zoom - step) * 10) / 10 } : { ...state, zoom: 1 }
);
@@ -111,7 +110,11 @@ module.reducer(RESET_SNAKE, (state, options) => {
markCell(grid, apple, { type: "apple" });
snake.forEach((snakePart, i) =>
markCell(grid, snakePart, { type: "snake", index: snake.length - 1 - i, brightness: brightness(snake.length, i) })
markCell(grid, snakePart, {
type: "snake",
index: snake.length - 1 - i,
length: snake.length
})
);
return { ...state, ...initialState, grid, zoom, snake, apple, ...options };
});
@@ -173,7 +176,7 @@ module.reducer(UPDATE_FRAME_SNAKE, state => {
markCell(grid, snakePart, {
type: "snake",
index: newSnake.length - 1 - i,
brightness: brightness(newSnake.length, i)
length: snake.length
})
);
score = newSnake.length;
@@ -201,6 +204,19 @@ module.after(UPDATE_FRAME_SNAKE, (dispatch, { died }) => {
}
});
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());
});
module.middleware(START_SNAKE, dispatch => {
dispatch(resetSnake({ started: true, died: false }));
dispatch(registerCaller(UPDATE_FRAME_SNAKE));
@@ -224,7 +240,10 @@ module.middleware(PAUSE_SNAKE, (dispatch, { paused }) => {
}
});
module.middleware(KEY_PRESSED_SNAKE, (dispatch, _, { key }) => {
module.middleware(KEY_PRESSED_SNAKE, (dispatch, _, { key, altKey, ctrlKey, metaKey, shiftKey }) => {
if (altKey || ctrlKey || metaKey) {
return;
}
if (key === keys.UP || key === keys.k) {
dispatch(changeDirection(directions.UP));
}

View File

@@ -2,23 +2,34 @@ import { createStore as createReduxStore, combineReducers, compose, applyMiddlew
import { createBrowserHistory } from "history";
import { connectRouter, routerMiddleware } from "connected-react-router";
import { reducers as uiReducers } from "./ui.js";
import { loadState, saveState } from "./localStorage.js";
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";
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),
ui: uiReducers,
...snakeModule.reducers,
...uiModule.reducers,
...gameModule.reducers
}),
{},
loadState(),
// {},
compose.apply(
this,
[
@@ -31,11 +42,11 @@ 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"], () => {
store.replaceReducer(
combineReducers({
ui: uiReducers,
router: connectRouter(history),
...uiModule.reducers,
...snakeModule.reducers,
...gameModule.reducers
})
@@ -45,6 +56,12 @@ export const createStore = () => {
store.history = history;
store.subscribe(
throttle(() => {
saveState(store.getState());
}, 500)
);
return store;
};

View File

@@ -1,30 +1,41 @@
const SHOW_SPINNER = "[UI] SHOW_SPINNER";
const HIDE_SPINNER = "[UI] HIDE_SPINNER";
const TOGGLE_SPINNER = "[UI] TOGGLE_SPINNER";
import Module from "./Module.js";
export const showSpinner = () => ({ type: SHOW_SPINNER });
export const hideSpinner = () => ({ type: HIDE_SPINNER });
export const toggleSpinner = () => ({ type: TOGGLE_SPINNER });
export const reducers = (state = {}, action) => {
if (action.type === SHOW_SPINNER) {
return {
...state,
spinner: true
};
}
if (action.type === HIDE_SPINNER) {
return {
...state,
spinner: false
};
}
if (action.type === TOGGLE_SPINNER) {
return {
...state,
spinner: !state.spinner
};
}
return state;
export const MODULE_NAME = "UI";
export const initialState = {
theme: "light"
};
export const module = new Module(MODULE_NAME, initialState);
export const [SHOW_SPINNER, showSpinner] = module.action("SHOW_SPINNER");
export const [HIDE_SPINNER, hideSpinner] = module.action("HIDE_SPINNER");
export const [TOGGLE_SPINNER, toggleSpinner] = module.action("TOGGLE_SPINNER");
export const [CHANGE_THEME, changeTheme] = module.action("CHANGE_THEME", theme => ({ theme }));
module.reducer(SHOW_SPINNER, state => {
return {
...state,
spinner: true
};
});
module.reducer(HIDE_SPINNER, state => {
return {
...state,
spinner: false
};
});
module.reducer(TOGGLE_SPINNER, state => {
return {
...state,
spinner: !state.spinner
};
});
module.reducer(CHANGE_THEME, (state, { theme }) => {
return {
...state,
theme
};
});
export default module;

View File

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

View File

@@ -1,39 +1,13 @@
const themes = {
main: {
body: {
background: "papayawhip",
color: "palevioletred"
},
header: {
background: "#ffe6bd",
color: "palevioletred",
import defaultTheme from "./themes/default.js";
import lightTheme from "./themes/light.js";
import darkTheme from "./themes/dark.js";
import darkOceanTheme from "./themes/darkOcean.js";
menuButton: {
background: "#ffe6bd",
color: "palevioletred",
active: {
background: "palevioletred",
color: "#ffe6bd"
}
}
},
spinner: {
shadow: "#eeeeee",
highlight: "#db7093"
},
snake: {
cell: {
border: "0.0625rem solid papayawhip",
size: 1.5
},
cellColors: {
" ": "",
a: "",
ab: "#90dc90",
s: "#f12f00"
}
}
}
const themes = {
default: defaultTheme,
light: lightTheme,
dark: darkTheme,
darkOcean: darkOceanTheme
};
export { themes as default, themes };

View File

@@ -0,0 +1,47 @@
import merge from "deepmerge";
import { renderTheme } from "./default.js";
export const colors = {
background: "#333",
backgroundActive: "#828282",
backgroundInactive: "silver",
backgroundAlternate: "#555",
color: "palevioletred",
colorActive: "#ecb1c5",
colorInactive: "#e6b4c4",
colorAlternate: "#ecb1c5",
shadowColor: "#222",
borderColor: "#222",
borderColorActive: "silver",
spinnerShadow: "#444",
spinnerHighlight: "#db7093",
cardFoldHighlight: "#ad5a75",
cardFoldShadow: "#bdb19a",
selectColor: "#db7093"
};
const mapRange = (l, i, min, max) => Math.round((max - min) * ((l - i) / l) + min);
export const darkTheme = merge(renderTheme(colors), {
snakePart: {
boxShadow: "1px 1px 2px #888 inset, -1px -1px 2px #222 inset",
getColor(length, index, died) {
const hue = 340;
if (!died) {
const saturation = mapRange(length, index, 20, 65);
const brightness = 65;
return `hsl(${hue}, ${saturation}%, ${brightness}%)`;
}
const saturation = 0;
const brightness = mapRange(length, index, 70, 0);
return `hsl(${hue}, ${saturation}%, ${brightness}%)`;
}
},
stage: {
cell: {
boxShadow: "1px 1px 3px #191919 inset, -1px -1px 1px #777777 inset"
}
}
});
export default darkTheme;

View File

@@ -0,0 +1,47 @@
import merge from "deepmerge";
import { renderTheme } from "./default.js";
export const colors = {
background: "#333",
backgroundActive: "#828282",
backgroundInactive: "silver",
backgroundAlternate: "#555",
color: "#7094db",
colorActive: "#ecb1c5",
colorInactive: "#b3c3e6",
colorAlternate: "#ecb1c5",
shadowColor: "#222",
borderColor: "#222",
borderColorActive: "silver",
spinnerShadow: "#444",
spinnerHighlight: "#db7093",
cardFoldHighlight: "#091225",
cardFoldShadow: "#bdb19a",
selectColor: "#7094db"
};
const mapRange = (l, i, min, max) => Math.round((max - min) * ((l - i) / l) + min);
export const darkTheme = merge(renderTheme(colors), {
snakePart: {
boxShadow: "1px 1px 2px #888 inset, -1px -1px 2px #222 inset",
getColor(length, index, died) {
const hue = 220;
if (!died) {
const saturation = mapRange(length, index, 20, 65);
const brightness = 65;
return `hsl(${hue}, ${saturation}%, ${brightness}%)`;
}
const saturation = 0;
const brightness = mapRange(length, index, 70, 0);
return `hsl(${hue}, ${saturation}%, ${brightness}%)`;
}
},
stage: {
cell: {
boxShadow: "1px 1px 3px #191919 inset, -1px -1px 1px #777777 inset"
}
}
});
export default darkTheme;

View File

@@ -0,0 +1,135 @@
import merge from "deepmerge";
export const defaultColors = {
background: "white",
backgroundAlternate: "#ddd",
backgroundInactive: "silver",
backgroundActive: "#ccc",
color: "black",
colorActive: "#444",
colorAlternate: "#222",
colorInactive: "silver",
shadowColor: "gray",
borderColor: "black",
borderColorActive: "silver",
spinnerShadow: "#eee",
spinnerHighlight: "black",
cardFoldHighlight: "#888",
cardFoldShadow: "#bbb",
snakePartHueAlive: "340",
snakePartHueDied: "0",
snakePartLightness: "0%",
selectColor: "black"
};
const mapRange = (l, i, min, max) => Math.round((max - min) * ((l - i) / l) + min);
export const renderTheme = themeColors => {
const colors = merge(defaultColors, themeColors);
return {
body: {
background: colors.background,
color: colors.color,
fontSize: "1rem",
fontFamily: "Raleway"
},
banner: {
background: colors.color,
color: colors.background,
borderColor: colors.borderColor,
shadowColor: colors.shadowColor,
padding: `2rem`,
borderWidth: `${1 / 16}rem`
},
select: {
color: colors.selectColor
},
header: {
background: colors.backgroundAlternate,
color: colors.color,
menuButton: {
background: colors.backgroundAlternate,
color: colors.color,
active: {
background: colors.color,
color: colors.backgroundAlternate
}
}
},
button: {
borderColor: colors.borderColor,
background: colors.color,
color: colors.background,
borderWidth: `${1 / 16}rem`,
borderRadius: `${3 / 16}rem`,
toggled: {
background: colors.backgroundInactive,
color: colors.colorInactive
},
hover: {
background: colors.colorAlternate
},
active: {
background: colors.backgroundActive,
color: colors.colorActive,
borderColor: colors.borderColorActive
}
},
tooltip: {
borderColor: colors.borderColor,
borderWidth: `${1 / 16}rem`,
borderRadius: `${3 / 16}rem`,
padding: `${5 / 16}rem`,
background: colors.color,
color: colors.background,
shadowColor: colors.shadowColor
},
card: {
shadow: colors.shadowColor,
fold: {
highlight: colors.cardFoldHighlight,
shadow: colors.cardFoldShadow
}
},
apple: {
effect: "none"
},
snakePart: {
boxShadow: "1px 1px 2px #ddd inset, -1px -1px 2px gray inset",
getColor(length, index, died) {
const hue = 0;
if (!died) {
const saturation = 0;
const brightness = mapRange(length, index, 70, 0);
return `hsl(${hue}, ${saturation}%, ${brightness}%)`;
}
const saturation = mapRange(length, index, 70, 100);
const brightness = 65;
return `hsl(${hue}, ${saturation}%, ${brightness}%)`;
},
hueAlive: colors.snakePartHueAlive,
hueDied: colors.snakePartHueDied,
lightness: colors.snakePartLightness
},
stage: {
cell: {
boxShadow: "1px 1px 4px grey inset, -1px -1px 4px #fff inset"
}
},
spinner: {
shadow: colors.spinnerShadow,
highlight: colors.spinnerHighlight
},
snake: {
cell: {
border: `0.0625rem solid ${colors.background}`,
size: 1.5
}
}
};
};
export const defaultTheme = renderTheme(defaultColors);
export default defaultTheme;

View File

@@ -0,0 +1,42 @@
import merge from "deepmerge";
import { renderTheme } from "./default.js";
export const colors = {
background: "papayawhip",
backgroundActive: "#828282",
backgroundInactive: "silver",
backgroundAlternate: "#ffe6bd",
color: "palevioletred",
colorActive: "#ecb1c5",
colorInactive: "#e6b4c4",
colorAlternate: "#ecb1c5",
shadowColor: "#d0bfa3",
shadowColorx: "gray",
borderColor: "gray",
borderColorActive: "silver",
spinnerShadow: "#eee",
spinnerHighlight: "#db7093",
cardFoldHighlight: "#ad5a75",
cardFoldShadow: "#bdb19a",
selectColor: "#db7093"
};
const mapRange = (l, i, min, max) => Math.round((max - min) * ((l - i) / l) + min);
export const lightTheme = merge(renderTheme(colors), {
snakePart: {
getColor(length, index, died) {
const hue = 340;
if (!died) {
const saturation = mapRange(length, index, 20, 65);
const brightness = 65;
return `hsl(${hue}, ${saturation}%, ${brightness}%)`;
}
const saturation = 0;
const brightness = mapRange(length, index, 70, 0);
return `hsl(${hue}, ${saturation}%, ${brightness}%)`;
}
}
});
export default lightTheme;

34
src/utils/throttle.js Normal file
View File

@@ -0,0 +1,34 @@
export const throttle = (fn, wait = 0) => {
let timeoutId = null;
const throttleFn = () => {
if (timeoutId) {
return;
}
timeoutId = setTimeout(() => {
fn();
timeoutId = null;
}, wait);
};
throttleFn.cancel = () => {
if (!timeoutId) {
return;
}
clearTimeout(timeoutId);
timeoutId = null;
};
throttleFn.flush = () => {
if (!timeoutId) {
return;
}
clearTimeout(timeoutId);
timeoutId = null;
fn();
};
return throttleFn;
};
export default throttle;

21
tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"checkJs": false,
"skipLibCheck": true,
"strictNullChecks": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react"
},
"include": ["src"]
}