├── .eslintrc.js ├── .gitignore ├── README.md ├── babel.config.js ├── jest.config.js ├── package-lock.json ├── package.json ├── postcss.config.js ├── public ├── index.html └── sounds │ ├── eat.wav │ ├── game_over.wav │ └── game_start.wav ├── src ├── assets │ └── styles.css ├── components │ ├── Board.vue │ ├── Scoreboard.vue │ └── Square.vue ├── config │ ├── direction.js │ ├── firebase.js │ ├── keys.js │ └── snake.js └── main.js └── tests └── unit ├── .eslintrc.js ├── board.test.js └── square.test.js /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "env": { 3 | "browser": true, 4 | "es6": true 5 | }, 6 | "extends": [ 7 | "eslint:recommended", 8 | "plugin:vue/essential" 9 | ], 10 | "globals": { 11 | "Atomics": "readonly", 12 | "SharedArrayBuffer": "readonly" 13 | }, 14 | "parserOptions": { 15 | "ecmaVersion": 2018, 16 | "sourceType": "module" 17 | }, 18 | "plugins": [ 19 | "vue" 20 | ], 21 | "rules": { 22 | } 23 | }; -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | # local env files 6 | .env.local 7 | .env.*.local 8 | 9 | # Log files 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | 14 | # Editor directories and files 15 | .idea 16 | .vscode 17 | *.suo 18 | *.ntvs* 19 | *.njsproj 20 | *.sln 21 | *.sw* 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🐍 Snake game built with Vue 2 | 3 | 📚 Just for learning purposes. 4 | 5 | This is my first project wih Vue, so there will be a lot of code 6 | that can be done more efficiently. Please, feel free to create any 7 | pull request you want 🙂 8 | 9 | # Installation 10 | 1. Make sure you are using a version of Node >8 and it's corresponding version of NPM. v8.16.2 has specifically been tested to work. 11 | - A recommendation is to use [NVM](https://github.com/nvm-sh/nvm) to manage your Node versions. 12 | - (Windows users: Use [this](https://github.com/coreybutler/nvm-windows) version of NVM) 13 | - After installing nvm, run `nvm install 8` to install the latest 8 version, or `nvm install 8.16.2` to install the specific version. 14 | - Run `nvm use 8` or `nvm use 8.16.2` to ensure your Node version is the correct version. 15 | 2. Run `npm install` to install all of the dependencies. 16 | 3. Run `npm run serve` to start the server. 17 | 4. Go to http://localhost:8080/ to play your local development copy of the game. 18 | 19 | # Road Map 20 | 21 | - [x] Add tests for the components 22 | - [ ] Finish the test suite for the current code 23 | - [ ] Add score board with local storage 24 | - [X] Add button for resetting the game 25 | - [X] Add Webpack, postCSS or whatever it's needed to add Tailwind 26 | - [X] Add TailwindCSS 27 | - [X] Center the board properly 28 | - [X] Remove vertical scrolling with overflow 29 | - [X] Add retro sounds when moving, eating and game over 30 | - [X] Speed system needs improvements, it gets impossible to play after 25 points 31 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@vue/app' 4 | ] 5 | }; 6 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | moduleFileExtensions: [ 3 | 'js', 4 | 'jsx', 5 | 'json', 6 | 'vue' 7 | ], 8 | transform: { 9 | '^.+\\.vue$': 'vue-jest', 10 | '.+\\.(css|styl|less|sass|scss|svg|png|jpg|ttf|woff|woff2)$': 'jest-transform-stub', 11 | '^.+\\.jsx?$': 'babel-jest' 12 | }, 13 | transformIgnorePatterns: [ 14 | '/node_modules/' 15 | ], 16 | moduleNameMapper: { 17 | '^@/(.*)$': '/src/$1' 18 | }, 19 | snapshotSerializers: [ 20 | 'jest-serializer-vue' 21 | ], 22 | testMatch: [ 23 | '**/tests/unit/**/*.test.(js|jsx|ts|tsx)|**/__tests__/*.(js|jsx|ts|tsx)' 24 | ], 25 | testURL: 'http://localhost/', 26 | watchPlugins: [ 27 | 'jest-watch-typeahead/filename', 28 | 'jest-watch-typeahead/testname' 29 | ] 30 | }; 31 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-snake", 3 | "version": "1.0.0", 4 | "private": true, 5 | "scripts": { 6 | "serve": "vue-cli-service serve", 7 | "build": "vue-cli-service build", 8 | "lint": "vue-cli-service lint", 9 | "test:unit": "vue-cli-service test:unit" 10 | }, 11 | "dependencies": { 12 | "core-js": "^3.6.5", 13 | "firebase": "^7.14.2", 14 | "tailwindcss": "^1.4.4", 15 | "vue": "^2.6.11", 16 | "vuefire": "^2.2.2" 17 | }, 18 | "devDependencies": { 19 | "@vue/cli-plugin-babel": "^4.3.1", 20 | "@vue/cli-plugin-eslint": "^4.3.1", 21 | "@vue/cli-plugin-unit-jest": "^4.3.1", 22 | "@vue/cli-service": "^4.3.1", 23 | "@vue/eslint-config-standard": "^4.0.0", 24 | "@vue/test-utils": "1.0.0-beta.29", 25 | "babel-core": "7.0.0-bridge.0", 26 | "babel-eslint": "^10.1.0", 27 | "babel-jest": "^25.5.1", 28 | "eslint": "^5.16.0", 29 | "eslint-plugin-vue": "^5.2.3", 30 | "vue-template-compiler": "^2.6.11" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [ 3 | require('tailwindcss'), 4 | require('autoprefixer') 5 | ] 6 | }; 7 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Snake game made with Vue 9 | 10 | 11 | 12 | 13 | 16 | 17 |
18 | 19 | 20 | -------------------------------------------------------------------------------- /public/sounds/eat.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lloople/vue-snake/0d98ff69920faf4c965aae750fa6bef6d436b2b2/public/sounds/eat.wav -------------------------------------------------------------------------------- /public/sounds/game_over.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lloople/vue-snake/0d98ff69920faf4c965aae750fa6bef6d436b2b2/public/sounds/game_over.wav -------------------------------------------------------------------------------- /public/sounds/game_start.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lloople/vue-snake/0d98ff69920faf4c965aae750fa6bef6d436b2b2/public/sounds/game_start.wav -------------------------------------------------------------------------------- /src/assets/styles.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | 3 | @tailwind components; 4 | 5 | @tailwind utilities; 6 | 7 | * { 8 | font-family: 'Avenir', Helvetica, Arial, sans-serif; 9 | -webkit-font-smoothing: antialiased; 10 | -moz-osx-font-smoothing: grayscale; 11 | text-align: center; 12 | color: #2c3e50; 13 | } 14 | 15 | body { 16 | overflow: hidden; 17 | } 18 | 19 | h2 { 20 | font-weight: normal; 21 | } 22 | 23 | .button { 24 | @apply .select-none .uppercase .font-bold .font-bold .py-2 .px-6 .border-b-4 .rounded; 25 | } 26 | 27 | .button-gray { 28 | @apply .bg-gray-300 .border-gray-500; 29 | } 30 | 31 | .button-gray-pressed { 32 | @apply .bg-gray-500; 33 | } 34 | 35 | .button:active { 36 | @apply .border-b-2 .border-t-2; 37 | } 38 | -------------------------------------------------------------------------------- /src/components/Board.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 305 | -------------------------------------------------------------------------------- /src/components/Scoreboard.vue: -------------------------------------------------------------------------------- 1 | 42 | 43 | 63 | -------------------------------------------------------------------------------- /src/components/Square.vue: -------------------------------------------------------------------------------- 1 | 4 | 26 | 54 | -------------------------------------------------------------------------------- /src/config/direction.js: -------------------------------------------------------------------------------- 1 | export default Object.freeze({ 2 | UP: 'up', 3 | DOWN: 'down', 4 | LEFT: 'left', 5 | RIGHT: 'right', 6 | }); -------------------------------------------------------------------------------- /src/config/firebase.js: -------------------------------------------------------------------------------- 1 | import firebase from 'firebase/app'; 2 | import 'firebase/firestore'; 3 | 4 | export const db = firebase.initializeApp({ 5 | projectId: "vue-snake-scoreboard" 6 | }).firestore(); -------------------------------------------------------------------------------- /src/config/keys.js: -------------------------------------------------------------------------------- 1 | export default Object.freeze({ 2 | UP: 38, 3 | DOWN: 40, 4 | LEFT: 37, 5 | RIGHT: 39, 6 | SPACE: 32 7 | }); -------------------------------------------------------------------------------- /src/config/snake.js: -------------------------------------------------------------------------------- 1 | export default Object.freeze({ 2 | FOOD: 3, 3 | HEAD: 2, 4 | BODY: 1, 5 | NONE: 0, 6 | HEAD_START: '8,3', 7 | BODY_START: ['7,3', '6,3', '5,3', '5,4', '5,5'], 8 | }); -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import Board from './components/Board.vue'; 3 | import './assets/styles.css'; 4 | import { firestorePlugin } from 'vuefire'; 5 | 6 | Vue.config.productionTip = false; 7 | Vue.use(firestorePlugin); 8 | 9 | new Vue({ 10 | 11 | render: h => h(Board) 12 | 13 | }).$mount('#app'); 14 | -------------------------------------------------------------------------------- /tests/unit/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | jest: true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /tests/unit/board.test.js: -------------------------------------------------------------------------------- 1 | import {shallowMount} from '@vue/test-utils'; 2 | import Board from '@/components/Board.vue'; 3 | import SNAKE from '@/config/snake.js'; 4 | import DIRECTION from '@/config/direction.js'; 5 | 6 | describe('Board', () => { 7 | 8 | test('contains the desired amount of squares', () => { 9 | 10 | const board = shallowMount(Board, {width: 20, height: 20}); 11 | 12 | expect(Object.keys(board.vm.squares).length).toBe(20 * 20); 13 | }); 14 | 15 | test('snake is loaded in the default squares', () => { 16 | 17 | const board = shallowMount(Board); 18 | 19 | expect(board.vm.squares[SNAKE.HEAD_START]).toBe(SNAKE.HEAD); 20 | 21 | }); 22 | 23 | test('increases speed by 10%', () => { 24 | 25 | const board = shallowMount(Board); 26 | 27 | board.vm.increaseSpeed(); 28 | expect(board.vm.speed).toBe(450); 29 | 30 | board.vm.increaseSpeed(); 31 | expect(board.vm.speed).toBe(405); 32 | 33 | }); 34 | 35 | test('increases score on eat', () => { 36 | 37 | window.HTMLMediaElement.prototype.play = () => { /* disable audio for testing purposes */ }; 38 | 39 | const board = shallowMount(Board); 40 | 41 | 42 | board.vm.squares[board.vm.snakeHead] = SNAKE.FOOD; 43 | 44 | board.vm.eat(); 45 | board.vm.eat(); 46 | board.vm.eat(); 47 | board.vm.eat(); 48 | 49 | expect(board.vm.score).toBe(4); 50 | 51 | }); 52 | 53 | test('puts food in a random non occupied coord', () => { 54 | 55 | const board = shallowMount(Board); 56 | 57 | expect(board.vm.squares[board.vm.getFoodRandomCoords()]).toBe(SNAKE.NONE); 58 | expect(board.vm.squares[board.vm.getFoodRandomCoords()]).toBe(SNAKE.NONE); 59 | expect(board.vm.squares[board.vm.getFoodRandomCoords()]).toBe(SNAKE.NONE); 60 | expect(board.vm.squares[board.vm.getFoodRandomCoords()]).toBe(SNAKE.NONE); 61 | expect(board.vm.squares[board.vm.getFoodRandomCoords()]).toBe(SNAKE.NONE); 62 | 63 | }); 64 | 65 | test('can detect self collision', () => { 66 | 67 | const board = shallowMount(Board); 68 | 69 | expect(board.vm.isSelfCollision( 70 | board.vm.snakeBody[0] 71 | )).toBeTruthy(); 72 | }); 73 | 74 | test('can detect border collision', () => { 75 | 76 | const board = shallowMount(Board); 77 | 78 | expect(board.vm.isBorderCollision('-1,0')).toBeTruthy(); 79 | expect(board.vm.isBorderCollision('1,-1')).toBeTruthy(); 80 | expect(board.vm.isBorderCollision('0,20')).toBeTruthy(); 81 | expect(board.vm.isBorderCollision('20,0')).toBeTruthy(); 82 | }); 83 | 84 | test('can determine where the head will be placed depending on direction', () => { 85 | 86 | const board = shallowMount(Board); 87 | 88 | expect(board.vm.guessHeadNewPosition()).toBe('9,3'); 89 | 90 | board.setData({direction: DIRECTION.UP}); 91 | expect(board.vm.guessHeadNewPosition()).toBe('8,2'); 92 | 93 | board.setData({direction: DIRECTION.LEFT}); 94 | expect(board.vm.guessHeadNewPosition()).toBe('7,3'); 95 | 96 | board.setData({direction: DIRECTION.DOWN}); 97 | expect(board.vm.guessHeadNewPosition()).toBe('8,4'); 98 | 99 | }); 100 | 101 | test('can remove snake from the board', () => { 102 | 103 | const board = shallowMount(Board); 104 | 105 | board.vm.cleanSnake(); 106 | 107 | expect(board.vm.squares[board.vm.snakeHead]).toBe(SNAKE.NONE); 108 | 109 | board.vm.snakeBody.forEach(body => { 110 | expect(board.vm.squares[body]).toBe(SNAKE.NONE); 111 | }); 112 | }); 113 | 114 | test('can draw the snake back to the board', () => { 115 | 116 | const board = shallowMount(Board); 117 | 118 | board.vm.cleanSnake(); 119 | 120 | expect(board.vm.squares[board.vm.snakeHead]).toBe(SNAKE.NONE); 121 | 122 | board.vm.resetSnake(); 123 | 124 | expect(board.vm.squares[board.vm.snakeHead]).toBe(SNAKE.HEAD); 125 | 126 | board.vm.snakeBody.forEach(body => { 127 | expect(board.vm.squares[body]).toBe(SNAKE.BODY); 128 | }); 129 | }); 130 | 131 | test('it moves all the body parts in the correct direction', () => { 132 | const board = shallowMount(Board); 133 | 134 | // Remove the default snake from the board. 135 | board.vm.cleanSnake(); 136 | 137 | board.setData({ 138 | direction: DIRECTION.UP, 139 | snakeHead: '5,5', 140 | snakeBody: ['5,6', '5,7', '4,7'] 141 | }); 142 | 143 | board.vm.move(); 144 | 145 | expect(board.vm.snakeHead).toBe('5,4'); 146 | expect(board.vm.snakeBody[0]).toBe('5,5'); 147 | expect(board.vm.snakeBody[1]).toBe('5,6'); 148 | expect(board.vm.snakeBody[2]).toBe('5,7'); 149 | 150 | }); 151 | }); 152 | -------------------------------------------------------------------------------- /tests/unit/square.test.js: -------------------------------------------------------------------------------- 1 | import {shallowMount} from '@vue/test-utils'; 2 | import Square from '@/components/Square.vue'; 3 | import SNAKE from '@/config/snake.js'; 4 | 5 | describe('Square.vue', () => { 6 | 7 | test('can determine it\'s computed class', () => { 8 | 9 | const square = shallowMount(Square); 10 | 11 | expect(square.vm.contentClass).toBe(null); 12 | 13 | square.setProps({content: SNAKE.HEAD}); 14 | expect(square.vm.contentClass).toBe('head'); 15 | 16 | square.setProps({content: SNAKE.BODY}); 17 | expect(square.vm.contentClass).toBe('body'); 18 | 19 | square.setProps({content: SNAKE.FOOD}); 20 | expect(square.vm.contentClass).toBe('food'); 21 | }); 22 | }); 23 | --------------------------------------------------------------------------------