diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..d5f19d8
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+node_modules
+package-lock.json
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..17ae5fc
--- /dev/null
+++ b/package.json
@@ -0,0 +1,38 @@
+{
+ "name": "vkapp",
+ "version": "0.1.0",
+ "description": "VK APP",
+ "author": "Zevako Dmitry",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "react": "^16.5.2",
+ "react-dom": "^16.5.2",
+ "react-keyboard-event-handler": "^1.5.4",
+ "rest": "^1.3.1"
+ },
+ "scripts": {
+ "build": "--mode 'production' --config './webpack/config.js'",
+ "dev": "webpack-dev-server --hot --inline --config './webpack/config.js'",
+ "flow": "flow"
+ },
+ "devDependencies": {
+ "@babel/core": "^7.1.0",
+ "@babel/plugin-proposal-class-properties": "^7.2.1",
+ "@babel/preset-env": "^7.1.0",
+ "@babel/preset-flow": "^7.0.0",
+ "@babel/preset-react": "^7.0.0",
+ "babel-loader": "^8.0.2",
+ "classnames": "^2.2.6",
+ "css-loader": "^0.28.11",
+ "html-webpack-plugin": "^3.2.0",
+ "less": "^3.8.1",
+ "less-loader": "^4.1.0",
+ "mini-css-extract-plugin": "^0.4.0",
+ "my-local-ip": "^1.0.0",
+ "postcss-loader": "^2.1.5",
+ "style-loader": "^0.21.0",
+ "webpack": "^4.19.1",
+ "webpack-cli": "^3.1.0",
+ "webpack-dev-server": "^3.1.14"
+ }
+}
diff --git a/src/main/js/app.js b/src/main/js/app.js
new file mode 100644
index 0000000..4943365
--- /dev/null
+++ b/src/main/js/app.js
@@ -0,0 +1,21 @@
+import Page from './components/page/Page';
+
+const React = require('react');
+const ReactDOM = require('react-dom');
+
+/**
+ * Client Entry Point
+ */
+class App extends React.Component {
+
+ render() {
+ return
+ }
+}
+
+ReactDOM.render (
+ ,
+ document.getElementById('root')
+ )
+
+export default App;
\ No newline at end of file
diff --git a/src/main/js/components/game/Game.jsx b/src/main/js/components/game/Game.jsx
new file mode 100644
index 0000000..8253383
--- /dev/null
+++ b/src/main/js/components/game/Game.jsx
@@ -0,0 +1,168 @@
+import GameField from './GameField.jsx';
+import ControlPanel from './controls/ControlPanel.jsx';
+
+const React = require('react');
+const KeyboardEventHandler = require('react-keyboard-event-handler');
+
+/**
+ * Главное поле игры
+*/
+class Game extends React.Component {
+
+ roundId = null;
+ left = 'left';
+ right = 'right';
+ up = 'up';
+ down = 'down';
+ leftRight = [this.left, this.right];
+ upDown = [this.up, this.down];
+ speed = 1000;
+ boost = 0;
+
+ constructor(props) {
+ super(props);
+
+ this.state = {crash: true,
+ score: 0};
+ }
+
+ startRound = () => {
+
+ this.speed = this.props.speed;
+
+ this.setState({target: this.getNewTarget(),
+ snake: [{x: 39, y: 7}, {x: 38, y: 7}, {x: 37, y: 7}],
+ direction: this.right,
+ crash: false,
+ score: 0})
+
+ this.start();
+ }
+
+ start() {
+ this.roundId = setInterval(() => this.roundStep(), this.speed);
+ }
+
+ restartWithBoost() {
+ if (this.boost == 0) {
+ return;
+ }
+ clearInterval(this.roundId);
+ this.speed = this.speed - this.speed * this.boost / 100;
+ this.start();
+ }
+
+ stopRound() {
+ clearInterval(this.roundId);
+ }
+
+ roundStep() {
+ let {target, snake, direction, crash, score} = this.state;
+ const frontElement = this.getNextStep(snake[0], direction);
+
+ if (target.x === frontElement.x && target.y === frontElement.y) {
+ target = this.getNewTarget();
+ score = score + 1;
+ snake.unshift(frontElement);
+ this.restartWithBoost();
+ } else {
+ if (this.moveIsValid(snake, frontElement)) {
+ snake.pop();
+ snake.unshift(frontElement);
+ } else {
+ crash = true;
+ this.stopRound();
+ }
+ }
+
+ this.setState({target, snake, crash, score})
+ }
+
+ getNextStep(frontElement, direction) {
+ let x = frontElement.x;
+ let y = frontElement.y;
+ switch (direction) {
+ case this.left: x = x - 1; break;
+ case this.right: x = x + 1; break;
+ case this.up: y = y - 1; break;
+ case this.down: y = y + 1; break;
+ }
+
+ return {x, y};
+ }
+
+ moveIsValid(snake, frontElement) {
+ const {width, height} = this.props;
+ const x = frontElement.x;
+ const y = frontElement.y;
+ if (x < 0 || y < 0 || x >= width || y >= height) {
+ return false;
+ }
+
+ const lastIndex = snake.length - 1;
+ const hitch = snake.filter((item, index) => {
+ return index != lastIndex && item.x == x && item.y == y
+ });
+
+ if (hitch.length > 0) {
+ return false;
+ }
+
+ return true;
+ }
+
+ onBoostChange = (value) => {
+ this.boost = (!value || value < 1 || value > 50) ? 0 : value;
+ console.log("setBoost: " + this.boost)
+ }
+
+ render() {
+ const {target, snake, crash, score} = this.state;
+ const {width, height, step} = this.props;
+ const {left, right, up, down} = this;
+
+ return (
+
+
+
+ this.handleChangeDirection(key)}
+ />
+
+ )
+ }
+
+ getNewTarget() {
+ return {x : this.getRandomInt(50), y : this.getRandomInt(30)};
+ }
+
+ handleChangeDirection(key) {
+ let {direction} = this.state;
+
+ // Forbid turn for 180 degrees
+ if (this.upDown.indexOf(key) > -1 && this.upDown.indexOf(direction) > -1
+ || this.leftRight.indexOf(key) > -1 && this.leftRight.indexOf(direction) > -1) {
+ return;
+ }
+
+ this.setState({direction : key});
+ }
+
+ getRandomInt(max) {
+ return Math.floor(Math.random() * Math.floor(max));
+ }
+}
+
+export default Game;
\ No newline at end of file
diff --git a/src/main/js/components/game/GameField.css b/src/main/js/components/game/GameField.css
new file mode 100644
index 0000000..f014f57
--- /dev/null
+++ b/src/main/js/components/game/GameField.css
@@ -0,0 +1,14 @@
+:local {
+ .gameField {
+ margin: auto;
+ background: #9fc5d4;
+ border: 5px solid #797979;
+ position: relative;
+ }
+
+ .crash {
+ background: #f1a5af;
+ border-color: #b76570;
+ transition: background .5s, border-color .5s;
+ }
+ }
\ No newline at end of file
diff --git a/src/main/js/components/game/GameField.jsx b/src/main/js/components/game/GameField.jsx
new file mode 100644
index 0000000..cb03695
--- /dev/null
+++ b/src/main/js/components/game/GameField.jsx
@@ -0,0 +1,41 @@
+import Target from '../target/Target.jsx';
+import SnakeElement from '../snake/SnakeElement.jsx';
+import styles from './GameField.css';
+
+const React = require('react');
+
+/**
+ * Главное поле игры
+*/
+class GameField extends React.Component {
+
+ render() {
+
+ const {width, height, step, target, snake, crash} = this.props;
+
+ const style = {
+ width: width * step + "px",
+ height: height * step + "px",
+ };
+
+ const cm = styles.gameField + (crash ? " " + styles.crash : "");
+
+ return (
+
+
+ {target && (
+
+ )}
+
+ {snake && snake.map((el, index) => {
+ return (
+
+ );
+ })}
+
+
+ )
+ }
+}
+
+export default GameField;
\ No newline at end of file
diff --git a/src/main/js/components/game/controls/ControlPanel.css b/src/main/js/components/game/controls/ControlPanel.css
new file mode 100644
index 0000000..70c3851
--- /dev/null
+++ b/src/main/js/components/game/controls/ControlPanel.css
@@ -0,0 +1,48 @@
+:local {
+ .controlPanel {
+ padding: 16px;
+ background: #e2e2e2;
+ }
+ .controlPanel > * {
+ display: inline-block;
+ margin-right: 32px;
+ }
+
+ .startButton {
+ height: 32px;
+ width: 120px;
+ background: #eb992e;
+ border: none;
+ color: white;
+ letter-spacing: 4px;
+ }
+ .startButton:hover {
+ background: #d38c2e;
+ }
+ .startButton:active {
+ background: #b8761f;
+ }
+
+ .disabled {
+ background-color: #ccc !important;
+ color: grey !important;
+ }
+
+ .score {
+ font-size: 32px;
+ color: #eb992e;
+ vertical-align: middle;
+ float: right;
+ }
+ .boost {
+ color: #24c524;
+ font-size: 24px;
+ vertical-align: middle;
+ }
+ .boost input {
+ height: 32px;
+ width: 40px;
+ margin: 0 8px;
+ padding: 0 4px;
+ }
+ }
\ No newline at end of file
diff --git a/src/main/js/components/game/controls/ControlPanel.jsx b/src/main/js/components/game/controls/ControlPanel.jsx
new file mode 100644
index 0000000..982df44
--- /dev/null
+++ b/src/main/js/components/game/controls/ControlPanel.jsx
@@ -0,0 +1,48 @@
+import styles from './ControlPanel.css';
+
+const React = require('react');
+
+/**
+ * Панель управления
+ */
+class ControlPanel extends React.Component {
+
+ handleClick = () => {
+ const {onClick} = this.props;
+ onClick && onClick();
+ }
+
+ handleBoostChange= (value) => {
+ const {onBoostChange} = this.props;
+ onBoostChange && onBoostChange(value);
+ }
+
+ mayBeDisabled = (style, disabled) => {
+ return style + (disabled ? " " + styles.disabled : "");
+ }
+
+ render() {
+ const {onClick, score} = this.props;
+
+ return (
+
+
+
+ Boost:
+ this.handleBoostChange(e.target.value)}
+ />
+ %
+
+
Score: {score}
+
+ );
+ }
+}
+
+export default ControlPanel;
\ No newline at end of file
diff --git a/src/main/js/components/page/Page.css b/src/main/js/components/page/Page.css
new file mode 100644
index 0000000..4ad605b
--- /dev/null
+++ b/src/main/js/components/page/Page.css
@@ -0,0 +1,25 @@
+:local {
+ .header {
+ height: 60px;
+ background: #2a3347;
+ color: azure;
+ font-size: 32px;
+ letter-spacing: 48px;
+ text-align: center;
+ line-height: 60px;
+ }
+
+ .topMenu {
+ height: 20px;
+ }
+
+ .container {
+ display: table;
+ margin: auto;
+ }
+ }
+
+body {
+ margin: 0;
+ background: #b7b681;
+}
\ No newline at end of file
diff --git a/src/main/js/components/page/Page.jsx b/src/main/js/components/page/Page.jsx
new file mode 100644
index 0000000..604f894
--- /dev/null
+++ b/src/main/js/components/page/Page.jsx
@@ -0,0 +1,24 @@
+
+import Game from '../game/Game.jsx';
+import styles from './Page.css';
+
+const React = require('react');
+
+/**
+ * Страница с игрой
+ */
+class Page extends React.Component {
+
+ render() {
+ return (
+ [
+ SNAKE
,
+
+
+
+ ]
+ )
+ }
+}
+
+export default Page;
\ No newline at end of file
diff --git a/src/main/js/components/snake/SnakeElement.css b/src/main/js/components/snake/SnakeElement.css
new file mode 100644
index 0000000..9741cc0
--- /dev/null
+++ b/src/main/js/components/snake/SnakeElement.css
@@ -0,0 +1,8 @@
+:local {
+ .snakeElement {
+ border-radius: 8px;
+ background: #9a4900;
+ box-sizing: border-box;
+ position: absolute;
+ }
+ }
\ No newline at end of file
diff --git a/src/main/js/components/snake/SnakeElement.jsx b/src/main/js/components/snake/SnakeElement.jsx
new file mode 100644
index 0000000..7367d74
--- /dev/null
+++ b/src/main/js/components/snake/SnakeElement.jsx
@@ -0,0 +1,24 @@
+import styles from './SnakeElement.css';
+
+const React = require('react');
+
+/**
+ * Элемент тела змейки
+ */
+class SnakeElement extends React.Component {
+
+ render() {
+ const {x, y, size} = this.props;
+ const style = {
+ width: size + "px",
+ height: size + "px",
+ left: x * size + "px",
+ top: y * size + "px"
+ };
+ return (
+
+ )
+ }
+}
+
+export default SnakeElement;
\ No newline at end of file
diff --git a/src/main/js/components/target/Target.css b/src/main/js/components/target/Target.css
new file mode 100644
index 0000000..ec085c8
--- /dev/null
+++ b/src/main/js/components/target/Target.css
@@ -0,0 +1,23 @@
+:local {
+ .target {
+ border-radius: 3px;
+ background: #dcdc45;
+ border: 1px solid #797979;
+ box-sizing: border-box;
+ position: absolute;
+ }
+
+ .blink {
+ animation-name: blinker;
+ animation-iteration-count: infinite;
+ animation-timing-function: cubic-bezier(1.0,0,0,1.0);
+ animation-duration: 2s;
+ }
+
+ @keyframes blinker {
+ from { opacity: 1.0; }
+ to { opacity: 0.0; }
+ }
+ }
+
+
\ No newline at end of file
diff --git a/src/main/js/components/target/Target.jsx b/src/main/js/components/target/Target.jsx
new file mode 100644
index 0000000..86fd9d9
--- /dev/null
+++ b/src/main/js/components/target/Target.jsx
@@ -0,0 +1,28 @@
+import styles from './Target.css';
+
+const React = require('react');
+
+/**
+ * Цель для змейки
+ */
+class Target extends React.Component {
+
+ render() {
+ const {x, y, size} = this.props;
+ const style = {
+ width: size + "px",
+ height: size + "px",
+ left: x * size + "px",
+ top: y * size + "px"
+ };
+ return (
+
+ )
+ }
+
+ getRandomInt(max) {
+ return Math.floor(Math.random() * Math.floor(max));
+ }
+}
+
+export default Target;
\ No newline at end of file
diff --git a/src/main/resources/templates/index.html b/src/main/resources/templates/index.html
new file mode 100644
index 0000000..0920ac0
--- /dev/null
+++ b/src/main/resources/templates/index.html
@@ -0,0 +1,16 @@
+
+
+
+
+
+ ReactGame
+
+
+
+
+
+
+
+
+
+
diff --git a/webpack/config.js b/webpack/config.js
new file mode 100644
index 0000000..4ff6363
--- /dev/null
+++ b/webpack/config.js
@@ -0,0 +1,52 @@
+'use strict';
+
+const path = require('path');
+const express = require('express');
+const localIp = require('my-local-ip');
+
+const define = require('./define');
+const loaders = require('./loaders');
+const optimization = require('./optimization');
+const plugins = require('./plugins');
+const resolve = require('./resolve');
+
+console.log('Reading webpack config...');
+
+console.log('entry.index = ' + path.join(define.src, 'app.js'));
+console.log('output.path = ' + path.resolve(__dirname, "../src/main/resources/static/built"));
+
+module.exports = {
+
+ target: "web",
+
+ entry: {
+ index: path.join(define.src, 'app.js')
+ },
+
+ output: {
+ path: define.buildPath,
+ //path: path.resolve(__dirname, "../src/main/resources/static"),
+ filename: 'index.js',
+ //publicPath : path.resolve(__dirname, "../target/classes/static")
+ },
+
+ devtool: define.development ? 'inline-source-map' : false,
+
+ cache: true,
+
+ mode: define.mode,
+
+ module: loaders,
+
+ optimization,
+
+ plugins,
+
+ resolve,
+
+ devServer: {
+ host: localIp(),
+ port: define.port.dev,
+ contentBase: define.staticPath
+ },
+};
diff --git a/webpack/define.js b/webpack/define.js
new file mode 100644
index 0000000..856ff37
--- /dev/null
+++ b/webpack/define.js
@@ -0,0 +1,25 @@
+// @flow
+'use strict';
+
+const {resolve} = require('path');
+
+const environment = 'development'; // process.env.NODE_ENV;
+const development = environment === 'development';
+const staticPath = resolve(__dirname, '../src/main/resources/static');
+const buildPath = resolve(__dirname, '../build');
+const production = environment === 'production';
+const src = resolve(__dirname, '../src/main/js');
+const port = {
+ dev: 9000,
+ prod: 8080
+ }
+
+module.exports = {
+ development,
+ staticPath,
+ buildPath,
+ mode: environment,
+ production,
+ src,
+ port
+};
diff --git a/webpack/loaders.js b/webpack/loaders.js
new file mode 100644
index 0000000..667c2da
--- /dev/null
+++ b/webpack/loaders.js
@@ -0,0 +1,65 @@
+// @flow
+'use strict';
+
+const Autoprefixer = require('autoprefixer');
+const define = require('./define');
+const MiniCssExtractPlugin = require('mini-css-extract-plugin');
+
+module.exports = {
+ rules: [
+ {
+ exclude: /(node_modules|bower_components)/,
+ test: /\.jsx?$/,
+ use: {
+ loader: 'babel-loader',
+ options: {
+ presets: ["@babel/preset-env", "@babel/preset-react"],
+ plugins: ["@babel/plugin-proposal-class-properties"]
+ }
+ }
+ },
+ {
+ test: /\.(css|less)$/,
+ use: [
+ {
+ loader: MiniCssExtractPlugin.loader
+ },
+ {
+ loader: 'css-loader',
+ options: {
+ localIdentName: define.development ? '[path][name]__[local]' : '[hash:base64]',
+ sourceMap: define.development
+ }
+ },
+ {
+ loader: 'postcss-loader',
+ options: {
+ plugins: [
+ new Autoprefixer({
+ browsers: [
+ '>1%',
+ 'last 3 versions',
+ 'ie > 8'
+ ]
+ })
+ ],
+ sourceMap: define.development
+ }
+ },
+ {
+ loader: 'less-loader',
+ options: {
+ relativeUrls: true,
+ sourceMap: define.development
+ }
+ }
+ ]
+ },
+ {
+ test: /\.(gif|png|jpg|jpeg|woff|woff2|ttf|eot|svg)$/,
+ use: {
+ loader: 'file-loader'
+ }
+ }
+ ]
+};
diff --git a/webpack/optimization.js b/webpack/optimization.js
new file mode 100644
index 0000000..8e9eac0
--- /dev/null
+++ b/webpack/optimization.js
@@ -0,0 +1,17 @@
+// @flow
+'use strict';
+
+const define = require('./define');
+const UglifyJsPlugin = require('uglifyjs-webpack-plugin');
+
+const optimization = define.production
+ ? {
+ minimizer: [
+ new UglifyJsPlugin({
+ cache: false
+ })
+ ]
+ }
+ : {};
+
+module.exports = optimization;
diff --git a/webpack/plugins.js b/webpack/plugins.js
new file mode 100644
index 0000000..ce99de9
--- /dev/null
+++ b/webpack/plugins.js
@@ -0,0 +1,22 @@
+// @flow
+'use strict';
+
+const {join} = require('path');
+
+const HtmlWebpackPlugin = require('html-webpack-plugin');
+// MiniCssExtractPlugin заменяет ExtractTextWebpackPlugin и выполняет ту же задачу (сборку css в один файл)
+const MiniCssExtractPlugin = require('mini-css-extract-plugin');
+
+const plugins = [
+ new MiniCssExtractPlugin({
+ chunkFilename: '[id].css',
+ filename: '[name].css'
+ }),
+ new HtmlWebpackPlugin({
+ filename: 'index.html',
+ template: join(__dirname, '../src/main/resources/templates/index.html'),
+ title: 'Scheduler'
+ })
+];
+
+module.exports = plugins;
diff --git a/webpack/resolve.js b/webpack/resolve.js
new file mode 100644
index 0000000..cc55206
--- /dev/null
+++ b/webpack/resolve.js
@@ -0,0 +1,21 @@
+// @flow
+'use strict';
+
+const define = require('./define');
+const {resolve} = require('path');
+
+module.exports = {
+ alias: {
+ 'actions': resolve(define.src, 'actions'),
+ 'component': resolve(define.src, 'component'),
+ 'constants': resolve(define.src, 'constants'),
+ 'helpers': resolve(define.src, 'helpers'),
+ 'images': resolve(define.src, 'images'),
+ 'init': resolve(define.src, 'init'),
+ 'reducers': resolve(define.src, 'reducers'),
+ 'store': resolve(define.src, 'store'),
+ 'styles': resolve(define.src, 'styles'),
+ 'types': resolve(define.src, 'types')
+ },
+ extensions: ['.js', '.jsx']
+};