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'] +};