├── .gitignore ├── screenshot ├── pc.png └── phone.jpg ├── template ├── layout │ ├── footer.pug │ ├── grid.pug │ ├── header.pug │ └── head.pug ├── plugin │ ├── script.pug │ └── githubFork.pug └── index.pug ├── css ├── plugin │ ├── githubFork.scss │ ├── counter.scss │ └── button.scss ├── layout │ ├── footer.scss │ ├── header.scss │ └── grid.scss ├── common │ ├── variable.scss │ └── common.scss └── style.scss ├── .editorconfig ├── js ├── entry.js ├── window.js ├── i18n.js ├── animation.js ├── support.js └── main.js ├── README.md ├── LICENSE ├── package.json └── webpack.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | ./vscode 3 | commit.sh 4 | update.sh 5 | /public 6 | -------------------------------------------------------------------------------- /screenshot/pc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SimonAKing/2048-Game/HEAD/screenshot/pc.png -------------------------------------------------------------------------------- /screenshot/phone.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SimonAKing/2048-Game/HEAD/screenshot/phone.jpg -------------------------------------------------------------------------------- /template/layout/footer.pug: -------------------------------------------------------------------------------- 1 | #newGameBtn(class="fade button button-3d button-primary button-rounded") 2 | span.lang 3 | -------------------------------------------------------------------------------- /css/plugin/githubFork.scss: -------------------------------------------------------------------------------- 1 | #githubFork { 2 | @media screen and (max-width: 1200px) { 3 | display: none; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /template/layout/grid.pug: -------------------------------------------------------------------------------- 1 | #grid-container.fade 2 | - for(let i=0;i<4;++i) 3 | - for(let j=0;j<4;++j) 4 | .grid-cell(id=`grid-cell-${i}-${j}`) 5 | 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | indent_size = 2 6 | charset = utf-8 7 | trim_trailing_whitespace = false 8 | insert_final_newline = false -------------------------------------------------------------------------------- /js/entry.js: -------------------------------------------------------------------------------- 1 | import 'css/style' 2 | 3 | import './i18n' 4 | 5 | import newGame from './main' 6 | 7 | newGame(!localStorage.getItem('score')) 8 | 9 | import './window' 10 | -------------------------------------------------------------------------------- /template/plugin/script.pug: -------------------------------------------------------------------------------- 1 | script(src="https://hm.baidu.com/hm.js?ac588a7c3137281d3c4f1032a9e4e46e") 2 | script(src='https://cdn.jsdelivr.net/gh/Tomotoes/Tomotoes.github.io/registerSW.js') 3 | -------------------------------------------------------------------------------- /template/index.pug: -------------------------------------------------------------------------------- 1 | doctype html 2 | html 3 | include layout/head 4 | body 5 | include plugin/githubFork 6 | include layout/header 7 | include layout/grid 8 | include layout/footer 9 | include plugin/script -------------------------------------------------------------------------------- /css/layout/footer.scss: -------------------------------------------------------------------------------- 1 | #newGameBtn { 2 | display: block; 3 | margin: 10px auto 10px; 4 | width: 180px; 5 | cursor: url('https://tomotoes.com/images/blog/pointer.cur'), auto !important; 6 | @media screen and (max-width: 800px) { 7 | margin: 20px auto 10px; 8 | } 9 | } -------------------------------------------------------------------------------- /template/layout/header.pug: -------------------------------------------------------------------------------- 1 | header.fade 2 | h1#title 2048 3 | - const digit = `${Array.from({ length: 10 }, (el, idx) => (el = `${idx}`)).join('')}`.repeat(4) 4 | p #[span.lang] #[span#score.counter !{digit}]#[br]#[span.lang] #[span#maxscore.counter !{digit}] -------------------------------------------------------------------------------- /css/common/variable.scss: -------------------------------------------------------------------------------- 1 | @mixin Text($color: #666) { 2 | color: $color; 3 | font-weight: bold; 4 | text-shadow: 3px 1px 3px #bec1c4; 5 | } 6 | 7 | .fade { 8 | opacity: 0; 9 | transition: all 1s; 10 | transform: translateY(200px); 11 | } 12 | 13 | .fade.in { 14 | opacity: 1; 15 | transform: none; 16 | } -------------------------------------------------------------------------------- /css/style.scss: -------------------------------------------------------------------------------- 1 | @import 'common/common'; 2 | @import 'common/variable'; 3 | 4 | @import 'layout/header'; 5 | @import 'layout/grid'; 6 | @import 'layout/footer'; 7 | 8 | @import 'plugin/counter'; 9 | @import 'plugin/button'; 10 | @import 'plugin/githubFork'; 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /css/common/common.scss: -------------------------------------------------------------------------------- 1 | html { 2 | background-image: linear-gradient(180deg, #e9e9e9 0%, #ccc0b3 100%); 3 | } 4 | 5 | body { 6 | overflow: hidden; 7 | min-height: 100vh; 8 | cursor: url('https://tomotoes.com/images/blog/default.cur'), auto !important; 9 | } 10 | 11 | img, 12 | a { 13 | cursor: url('https://tomotoes.com/images/blog/pointer.cur'), auto !important; 14 | } 15 | -------------------------------------------------------------------------------- /css/plugin/counter.scss: -------------------------------------------------------------------------------- 1 | .counter { 2 | display: inline-block; 3 | margin-bottom: -10px; 4 | span { 5 | position: relative; 6 | display: inline-block; 7 | width: 30px; 8 | height: 30px; 9 | line-height: 44px; 10 | overflow: hidden; 11 | i { 12 | position: absolute; 13 | top: 0; 14 | left: 0px; 15 | display: inline-block; 16 | height: 100%; 17 | width: 100%; 18 | font-style: normal; 19 | transition: top 0.3s; 20 | } 21 | 22 | @for $i from 0 through 9 { 23 | &.n#{$i} { 24 | i { 25 | @for $j from 0 through 9 { 26 | &:nth-child(#{$j + 1}) { 27 | top: calc(#{$i - $j} * 110%); 28 | } 29 | } 30 | } 31 | } 32 | } 33 | } 34 | } -------------------------------------------------------------------------------- /css/layout/header.scss: -------------------------------------------------------------------------------- 1 | header { 2 | display: block; 3 | margin: 0 auto; 4 | width: 100%; 5 | text-align: center; 6 | font-family: Arial; 7 | h1 { 8 | color: #776e65; 9 | font-family: 'Comic Sans MS', 'Helvetica Neue', 'Microsoft Yahei', 10 | 'Microsoft Yahei', -apple-system, sans-serif; 11 | text-shadow: 0 0 2px #fafafa; 12 | margin: 0 auto -10px; 13 | letter-spacing: 2px; 14 | } 15 | p { 16 | display: inline-block; 17 | font-size: 18px; 18 | padding: 5px; 19 | border-radius: 5px; 20 | box-shadow: inset 0px 1px 20px 0px #8f7a66; 21 | text-shadow: 0 0 2px #ccc; 22 | background: #bbada0; 23 | color: #fff; 24 | } 25 | #score { 26 | @include Text(#f6f6f6); 27 | } 28 | #maxscore { 29 | @include Text(#f6f6f6); 30 | } 31 | @media screen and (max-width: 800px) { 32 | margin: 60px auto 15px; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 2048 2 | 3 | Made just for fun. [Play it here!](https://tomotoes.com/2048/) 4 | 5 | ### Screenshot 6 | 7 |

8 | Screenshot 9 | Screenshot 10 |

11 | 12 | That screenshot is fake, by the way. I never reached 1848 :smile: 13 | 14 | ## Install 15 | 1. git clone https://github.com/Tomotoes/2048-Game.git 16 | 2. cd 2048-Game 17 | 3. npm i 18 | 4. npm run dev 19 | 20 | ## Reward 21 | If it brings you happiness, please buy me a cup of coffee. 22 |

23 | Reward 24 |

25 | 26 | ## Thanks 27 | **感谢刘琳小朋友 以其可爱的舍友们测试、提议 ,感谢家铭同学优化代码的建议。** 28 | 29 | ## License 30 | 2048 is licensed under the [MIT license.](https://github.com/gabrielecirulli/2048/blob/master/LICENSE.txt) -------------------------------------------------------------------------------- /js/window.js: -------------------------------------------------------------------------------- 1 | const fadeEls = [...document.querySelectorAll('.fade')] 2 | function openWindow() { 3 | fadeEls.forEach(e => e.classList.add('in')) 4 | if (!localStorage.getItem('hadPlay')) { 5 | swal({ 6 | icon: 'info', 7 | title: lang.introduce.title, 8 | text: lang.introduce.content 9 | }) 10 | localStorage.setItem('hadPlay', true) 11 | } 12 | } 13 | window.addEventListener('load', openWindow) 14 | 15 | function closeWindow() { 16 | localStorage.setItem('board', JSON.stringify(board)) 17 | localStorage.setItem('hasConflicted', JSON.stringify(hasConflicted)) 18 | localStorage.setItem('score', score) 19 | localStorage.setItem('maxValue', maxValue) 20 | if (maxScore < score) { 21 | localStorage.setItem('maxScore', score) 22 | } 23 | fadeEls.forEach(e => e.classList.remove('in')) 24 | } 25 | 26 | window.addEventListener('beforeunload', closeWindow) 27 | -------------------------------------------------------------------------------- /css/layout/grid.scss: -------------------------------------------------------------------------------- 1 | #grid-container { 2 | width: 460px; 3 | height: 460px; 4 | padding: 20px; 5 | margin: 0 auto; 6 | background-color: #bbada0; 7 | border-radius: 10px; 8 | position: relative; 9 | box-shadow: 0px 1px 20px 0px #8f7a66; 10 | .grid-cell { 11 | width: 100px; 12 | height: 100px; 13 | border-radius: 6px; 14 | background-color: #ccc0b3; 15 | position: absolute; 16 | } 17 | .number-cell { 18 | z-index: 999; 19 | border-radius: 6px; 20 | font-weight: bold; 21 | font-size: 40px; 22 | line-height: 100px; 23 | text-align: center; 24 | position: absolute; 25 | } 26 | } 27 | 28 | @keyframes pop { 29 | 0% { 30 | transform: scale(1); 31 | } 32 | 33 | 50% { 34 | transform: scale(1.2); 35 | } 36 | 37 | 100% { 38 | transform: scale(1); 39 | } 40 | } 41 | .cell-merged{ 42 | animation: pop 200ms ease 100ms; 43 | } 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /js/i18n.js: -------------------------------------------------------------------------------- 1 | window.langs = { 2 | en: { 3 | els: ['Score :', 'Best :', 'Restart'], 4 | introduce: { 5 | title: 'Game Rule', 6 | content: 7 | 'You need to control all the squares to move in the same direction. The computer uses the up and down keys, the phone can slide. Two squares with the same number will be combined into their sum after colliding with each other. After each operation, a 2 or 4 will be randomly generated. Finally, a " 2048" square will be considered a victory.' 8 | }, 9 | gameOver: { 10 | buttons: ['Don\'t continue', 'Another round'], 11 | newMaxScoreTip: 'Wow, you made history!', 12 | newMinScoreTip: 'Wow, you have created a new lowest score!', 13 | failToNewMaxScoreTip: 'Stupid ah ~ please continue to work hard!' 14 | } 15 | }, 16 | cn: { 17 | els: ['当前分数 :', '最高分数 :', '新的游戏'], 18 | introduce: { 19 | title: '游戏规则', 20 | content: 21 | '你需要控制所有方块向同一个方向运动,电脑使用上下左右键,手机可以滑动即可。两个相同数字方块撞在一起之后合并成为他们的和,每次操作之后会随机生成一个2或者4,最终得到一个“2048”的方块就算胜利了。' 22 | }, 23 | gameOver: { 24 | buttons: ['玩不动了', '再来一局'], 25 | newMaxScoreTip: '哇,你创造了新的最高分!', 26 | newMinScoreTip: '哇,你又创造了新的最低分!', 27 | failToNewMaxScoreTip: '笨蛋啊~ 请继续努力!' 28 | } 29 | } 30 | } 31 | window.lang = 32 | navigator && navigator.language 33 | ? window.langs[navigator.language === 'zh-CN' ? 'cn' : 'en'] 34 | : 'zn-CN' 35 | ;[...document.querySelectorAll('.lang')].forEach( 36 | (el, idx) => (el.textContent = lang.els[idx]) 37 | ) 38 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "2048", 3 | "version": "1.0.0", 4 | "description": "2048-Game", 5 | "main": "index.js", 6 | "scripts": { 7 | "build": "webpack --progress --colors", 8 | "predev": "cross-env NODEV_ENV=DEV npm run build", 9 | "dev": "webpack-dev-server", 10 | "predeploy": "cross-env NODEV_ENV=DEPLOY npm run build", 11 | "deploy": "cd public && git init && git remote add origin git@github.com:Tomotoes/2048.git && git add -A && git commit -am\"commit 2048\" && git push -f origin master" 12 | }, 13 | "author": "Simon Ma", 14 | "home": "https://github.com/Tomotoes/2048-Game", 15 | "license": "MIT", 16 | "devDependencies": { 17 | "autoprefixer": "^9.5.0", 18 | "babel-core": "^6.26.0", 19 | "babel-loader": "^7.1.2", 20 | "babel-preset-es2015": "^6.24.1", 21 | "babel-preset-latest": "^6.24.1", 22 | "clean-webpack-plugin": "^2.0.1", 23 | "cross-env": "^7.0.0", 24 | "css-loader": "^2.1.1", 25 | "extract-text-webpack-plugin": "^4.0.0-beta.0", 26 | "html-webpack-plugin": "^3.2.0", 27 | "i18n-webpack-plugin": "^1.0.0", 28 | "jquery": "^3.3.1", 29 | "node-sass": "^4.11.0", 30 | "postcss-loader": "^3.0.0", 31 | "pug": "^2.0.3", 32 | "pug-loader": "^2.4.0", 33 | "raw-loader": "^1.0.0", 34 | "sass-loader": "^7.1.0", 35 | "style-loader": "^0.23.1", 36 | "uglifyjs-webpack-plugin": "^2.1.2", 37 | "webpack": "^4.29.6", 38 | "webpack-cli": "^3.3.0", 39 | "webpack-dev-server": "^3.2.1" 40 | }, 41 | "dependencies": { 42 | "sweetalert": "^2.1.2" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /template/plugin/githubFork.pug: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /js/animation.js: -------------------------------------------------------------------------------- 1 | import * as support from './support' 2 | 3 | import swal from 'sweetalert' 4 | import newGame from './main' 5 | 6 | export function showNumber(i, j, number) { 7 | const numberCell = $(`#number-cell-${i}-${j}`) 8 | 9 | numberCell.css({ 10 | 'background-color': support.getNumberBackgroundColor(number), 11 | color: support.getNumberColor(number) 12 | }) 13 | 14 | numberCell.text(number) 15 | 16 | numberCell.animate( 17 | { 18 | width: support.cellSideLength, 19 | height: support.cellSideLength, 20 | top: support.getPosTop(i), 21 | left: support.getPosLeft(j), 22 | opacity: 1 23 | }, 24 | 150 25 | ) 26 | } 27 | 28 | export function showMove(fromX, fromY, toX, toY, canMerge) { 29 | const numberCell = $(`#number-cell-${fromX}-${fromY}`) 30 | const animate = { 31 | top: support.getPosTop(toX), 32 | left: support.getPosLeft(toY) 33 | } 34 | const gridCell = $(`#grid-cell-${toX}-${toY}`) 35 | if (canMerge) { 36 | gridCell.addClass('cell-merged') 37 | } 38 | numberCell.animate(animate, 180, () => { 39 | if (canMerge) { 40 | gridCell.removeClass('cell-merged') 41 | } 42 | }) 43 | } 44 | 45 | function setCounter(count, selector) { 46 | let $digital = $(`${selector} span`) 47 | 48 | for (let i = $digital.length - 1; i >= 0; i--) { 49 | let val = parseInt(count / Math.pow(10, i), 10) 50 | count = count % Math.pow(10, i) 51 | $digital.eq($digital.length - 1 - i).attr('class', `n${val % 10}`) 52 | } 53 | } 54 | export function updateScore(score) { 55 | setCounter(+score, '#score') 56 | if (score > maxScore) { 57 | updateMaxScore(score) 58 | } 59 | } 60 | export function updateMaxScore(score) { 61 | setCounter(+score, '#maxscore') 62 | } 63 | 64 | export function showGameover(isNewMaxScore, isNewMinScore) { 65 | const content = isNewMaxScore 66 | ? lang.gameOver.newMaxScoreTip 67 | : isNewMinScore 68 | ? lang.gameOver.newMinScoreTip 69 | : lang.gameOver.failToNewMaxScoreTip 70 | 71 | swal({ 72 | title: 'Game Over.', 73 | text: content, 74 | icon: 'success', 75 | buttons: lang.gameOver.buttons, 76 | dangerMode: true 77 | }).then(willReStart => { 78 | if (willReStart) { 79 | newGame(true) 80 | } 81 | }) 82 | } -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const webpack = require('webpack') 3 | const htmlWebpackPlugin = require('html-webpack-plugin') 4 | const ExtractTextPlugin = require('extract-text-webpack-plugin') 5 | const CleanWebpackPlugin = require('clean-webpack-plugin') 6 | const UglifyJsPlugin = require('uglifyjs-webpack-plugin') 7 | 8 | module.exports = { 9 | entry: { 10 | app: './js/entry.js' 11 | }, 12 | output: { 13 | path: `${__dirname}/public/`, 14 | filename: '[name].[hash].js', 15 | publicPath: 16 | process.env.NODE_ENV !== 'DEV' 17 | ? 'https://cdn.jsdelivr.net/gh/Tomotoes/2048/' 18 | : '/' 19 | }, 20 | devServer: { 21 | contentBase: './public', 22 | inline: true, 23 | hot: true, 24 | open: true 25 | }, 26 | resolve: { 27 | extensions: ['.js', '.html', '.css', '.txt', '.scss', '.ejs', '.json'], 28 | alias: { 29 | template: path.resolve(__dirname, 'template/'), 30 | css: path.resolve(__dirname, 'css/') 31 | } 32 | }, 33 | module: { 34 | rules: [ 35 | { 36 | test: /\.(css|scss)?$/, 37 | use: ExtractTextPlugin.extract({ 38 | fallback: 'style-loader', 39 | use: [ 40 | { loader: 'css-loader?modules', options: { importLoaders: 1 } }, 41 | { 42 | loader: 'postcss-loader', 43 | options: { plugins: loader => [require('autoprefixer')()] } 44 | }, 45 | { loader: 'sass-loader' } 46 | ] 47 | }) 48 | }, 49 | { 50 | test: /\.js$/, 51 | loader: 'babel-loader', 52 | options: { presets: ['latest'] }, 53 | include: path.resolve(__dirname, './js'), 54 | exclude: path.resolve(__dirname, './node_modules') 55 | }, 56 | { 57 | test: /\.pug$/, 58 | use: { 59 | loader: 'pug-loader', 60 | options: { self: true, pretty: true } 61 | } 62 | } 63 | ] 64 | }, 65 | plugins: [ 66 | new CleanWebpackPlugin(), 67 | 68 | new htmlWebpackPlugin({ 69 | filename: 'index.html', 70 | template: './template/index.pug', 71 | title: '2048', 72 | minify: { 73 | removeComments: true, 74 | collapseWhitespace: true, 75 | minifyJS: true, 76 | minifyCSS: true, 77 | minifyURLs: true 78 | } 79 | }), 80 | 81 | new ExtractTextPlugin('css/[name].[hash].css'), 82 | 83 | new webpack.BannerPlugin('Anthor:Simon'), 84 | 85 | new webpack.ProvidePlugin({ 86 | $: 'jquery', 87 | jQuery: 'jquery' 88 | }), 89 | 90 | new webpack.HotModuleReplacementPlugin() 91 | ], 92 | optimization: { 93 | minimizer: [new UglifyJsPlugin()] 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /js/support.js: -------------------------------------------------------------------------------- 1 | export const documentWidth = window.screen.availWidth 2 | export let gridContainerWidth = 0.92 * documentWidth 3 | export let cellSideLength = 0.18 * documentWidth 4 | export let cellSpace = 0.04 * documentWidth 5 | 6 | export function prepareForMobile() { 7 | if (documentWidth > 700) { 8 | gridContainerWidth = 500 9 | cellSpace = 20 10 | cellSideLength = 100 11 | } 12 | $('#grid-container').css({ 13 | width: gridContainerWidth - 2 * cellSpace, 14 | height: gridContainerWidth - 2 * cellSpace, 15 | padding: cellSpace, 16 | 'border-radius': 0.02 * gridContainerWidth 17 | }) 18 | 19 | $('.grid-cell').css({ 20 | width: cellSideLength, 21 | height: cellSideLength, 22 | 'border-radius': 0.02 * cellSideLength 23 | }) 24 | } 25 | export function getPosTop(i) { 26 | return i * (cellSideLength + cellSpace) + cellSpace 27 | } 28 | export function getPosLeft(j) { 29 | return j * (cellSideLength + cellSpace) + cellSpace 30 | } 31 | export function getNumberBackgroundColor(number) { 32 | switch (number) { 33 | case 2: 34 | return '#eee4da' 35 | break 36 | case 4: 37 | return '#ede0c8' 38 | break 39 | case 8: 40 | return '#f2b179' 41 | break 42 | case 16: 43 | return '#f25956' 44 | break 45 | case 32: 46 | return '#f67c5f' 47 | break 48 | case 64: 49 | return '#f65e3b' 50 | break 51 | case 128: 52 | return '#edcf72' 53 | break 54 | case 256: 55 | return '#edcc61' 56 | break 57 | case 512: 58 | return '#9c0' 59 | break 60 | case 1024: 61 | return '#33b5e5' 62 | break 63 | case 2048: 64 | return '#09c' 65 | break 66 | case 4096: 67 | return '#a6c' 68 | break 69 | case 8192: 70 | return '#93c' 71 | break 72 | default: 73 | return '#000' 74 | break 75 | } 76 | } 77 | export function getNumberColor(number) { 78 | if (number <= 4) { 79 | return '#776e65' 80 | } 81 | return '#fff' 82 | } 83 | 84 | export function noSpace(board) { 85 | for (let i = 0; i < 4; ++i) { 86 | for (let j = 0; j < 4; ++j) { 87 | if (board[i][j] === 0) { 88 | return false 89 | } 90 | } 91 | } 92 | return true 93 | } 94 | 95 | export function canMoveLeft(board) { 96 | for (let i = 0; i < 4; ++i) { 97 | for (let j = 1; j < 4; ++j) { 98 | if (board[i][j] !== 0) { 99 | if (board[i][j - 1] === 0 || board[i][j - 1] === board[i][j]) { 100 | return true 101 | } 102 | } 103 | } 104 | } 105 | return false 106 | } 107 | export function canMoveRight(board) { 108 | for (let i = 0; i < 4; ++i) { 109 | for (let j = 2; j >= 0; --j) { 110 | if (board[i][j] !== 0) { 111 | if (board[i][j + 1] === 0 || board[i][j + 1] === board[i][j]) { 112 | return true 113 | } 114 | } 115 | } 116 | } 117 | return false 118 | } 119 | export function canMoveUp(board) { 120 | for (let j = 0; j < 4; ++j) { 121 | for (let i = 1; i < 4; ++i) { 122 | if (board[i][j] !== 0) { 123 | if (board[i - 1][j] === 0 || board[i - 1][j] === board[i][j]) { 124 | return true 125 | } 126 | } 127 | } 128 | } 129 | return false 130 | } 131 | export function canMoveDown(board) { 132 | for (let j = 0; j < 4; ++j) { 133 | for (let i = 2; i >= 0; --i) { 134 | if (board[i][j] !== 0) { 135 | if (board[i + 1][j] === 0 || board[i + 1][j] === board[i][j]) { 136 | return true 137 | } 138 | } 139 | } 140 | } 141 | return false 142 | } 143 | 144 | export function noBlockHorizontal(row, col1, col2, board) { 145 | for (let i = col1 + 1; i < col2; ++i) { 146 | if (board[row][i] !== 0) { 147 | return false 148 | } 149 | } 150 | return true 151 | } 152 | 153 | export function noBlockVertical(col, row1, row2, board) { 154 | for (let i = row1 + 1; i < row2; ++i) { 155 | if (board[i][col] !== 0) { 156 | return false 157 | } 158 | } 159 | return true 160 | } 161 | 162 | export function noMove(board) { 163 | return !( 164 | canMoveDown(board) || 165 | canMoveUp(board) || 166 | canMoveLeft(board) || 167 | canMoveRight(board) 168 | ) 169 | } 170 | -------------------------------------------------------------------------------- /css/plugin/button.scss: -------------------------------------------------------------------------------- 1 | .button { 2 | color: #666; 3 | background-color: #eee; 4 | border-color: #eee; 5 | text-decoration: none; 6 | text-align: center; 7 | line-height: 40px; 8 | height: 40px; 9 | padding: 0 40px; 10 | margin: 0; 11 | display: inline-block; 12 | border: none; 13 | box-sizing: border-box; 14 | transition-property: all; 15 | transition-duration: 0.3s; 16 | font-size: 18px; 17 | font-weight: bold; 18 | user-select: none; 19 | letter-spacing: 3px; 20 | 21 | &:visited { 22 | color: #666; 23 | } 24 | &:hover, 25 | &:focus { 26 | background-color: #f6f6f6; 27 | text-decoration: none; 28 | outline: none; 29 | } 30 | &:active, 31 | &.active, 32 | &.is-active { 33 | text-shadow: 0 1px 0 rgba(255, 255, 255, 0.3); 34 | text-decoration: none; 35 | background-color: #eeeeee; 36 | border-color: #cfcfcf; 37 | color: #d4d4d4; 38 | transition-duration: 0s; 39 | box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.2); 40 | } 41 | &.disabled, 42 | &.is-disabled, 43 | &:disabled { 44 | top: 0 !important; 45 | background: #eee !important; 46 | border: 1px solid #ddd !important; 47 | text-shadow: 0 1px 1px white !important; 48 | color: #ccc !important; 49 | box-shadow: none !important; 50 | opacity: 0.8 !important; 51 | } 52 | &-primary { 53 | background-color: #bbada0; 54 | border-color: #bbada0; 55 | color: #fff; 56 | &:visited { 57 | color: #fff; 58 | } 59 | &:hover, 60 | &:focus { 61 | background-color: #bbada0; 62 | border-color: #bbada0; 63 | color: #fff; 64 | } 65 | &:active, 66 | &.active, 67 | &.is-active { 68 | background-color: #8f7a66; 69 | border-color: #8f7a66; 70 | color: #8f7a66; 71 | } 72 | } 73 | 74 | &-rounded { 75 | border-radius: 4px; 76 | } 77 | 78 | &-3d { 79 | position: relative; 80 | top: 0; 81 | box-shadow: 0 7px 0 #bbbbbb, 0 8px 3px rgba(0, 0, 0, 0.2); 82 | &:hover, 83 | &:focus { 84 | box-shadow: 0 7px 0 #bbbbbb, 0 8px 3px rgba(0, 0, 0, 0.2); 85 | } 86 | &:active, 87 | &.active, 88 | &.is-active { 89 | top: 5px; 90 | transition-property: all; 91 | transition-duration: 0.15s; 92 | box-shadow: 0 2px 0 #bbbbbb, 0 3px 3px rgba(0, 0, 0, 0.2); 93 | } 94 | &.button-primary { 95 | box-shadow: 0 7px 0 #8f7a66, 0 8px 3px rgba(0, 0, 0, 0.3); 96 | } 97 | &.button-primary:hover, 98 | &.button-primary:focus { 99 | box-shadow: 0 7px 0 #8f7a66, 0 8px 3px rgba(0, 0, 0, 0.3); 100 | } 101 | &.button-primary:active, 102 | &.button-primary.active, 103 | &.button-primary.is-active { 104 | box-shadow: 0 2px 0 #8f7a66, 0 3px 3px rgba(0, 0, 0, 0.2); 105 | } 106 | } 107 | } 108 | 109 | .button-border.button-primary, 110 | .button-primary.button-border-thin, 111 | .button-primary.button-border-thick, 112 | .button-border-thin.button-primary, 113 | .button-border-thick.button-primary { 114 | color: #bbada0; 115 | } 116 | .button-border.button-primary:hover, 117 | .button-primary.button-border-thin:hover, 118 | .button-primary.button-border-thick:hover, 119 | .button-border.button-primary:focus, 120 | .button-primary.button-border-thin:focus, 121 | .button-primary.button-border-thick:focus, 122 | .button-border-thin.button-primary:hover, 123 | .button-border-thin.button-primary:focus, 124 | .button-border-thick.button-primary:hover, 125 | .button-border-thick.button-primary:focus { 126 | background-color: #bbada0; 127 | color: rgba(255, 255, 255, 0.9); 128 | } 129 | .button-border.button-primary:active, 130 | .button-primary.button-border-thin:active, 131 | .button-primary.button-border-thick:active, 132 | .button-border.button-primary.active, 133 | .button-primary.active.button-border-thin, 134 | .button-primary.active.button-border-thick, 135 | .button-border.button-primary.is-active, 136 | .button-primary.is-active.button-border-thin, 137 | .button-primary.is-active.button-border-thick, 138 | .button-border-thin.button-primary:active, 139 | .button-border-thin.button-primary.active, 140 | .button-border-thin.button-primary.is-active, 141 | .button-border-thick.button-primary:active, 142 | .button-border-thick.button-primary.active, 143 | .button-border-thick.button-primary.is-active { 144 | background-color: #bbada0; 145 | color: rgba(255, 255, 255, 0.5); 146 | opacity: 0.3; 147 | } 148 | -------------------------------------------------------------------------------- /template/layout/head.pug: -------------------------------------------------------------------------------- 1 | head 2 | title 2048-Game 3 | meta(charset="utf-8") 4 | meta(name="viewport" content="width=device-width,initial-scale=1,maximum-scale=2,viewport-fit=cover") 5 | meta(http-equiv="X-UA-Compatible" content="IE=edge,chrome=1") 6 | meta(name="renderer" content="webkit") 7 | meta(name="author" content="Simon Ma") 8 | 9 | meta(name="theme-color" content="#e9e8e8") 10 | meta(name="apple-mobile-web-app-status-bar-style" content="#e9e8e8") 11 | meta(name="msapplication-navbutton-color" content="#e9e8e8") 12 | 13 | meta(http-equiv="x-dns-prefetch-control" content="on") 14 | link(rel="dns-prefetch" href="https://cdn.jsdelivr.net") 15 | link(rel="dns-prefetch" href="https://www.googletagmanager.com") 16 | link(rel="prefetch" href="https://cdn.jsdelivr.net/") 17 | link(rel="next" href="https://tomotoes.com/blog") 18 | link(rel="preconnect" href="https://tomotoes.com/blog/") 19 | link(rel="prefetch" href="https://tomotoes.com/blog/") 20 | link(rel="prefetch" href="https://tomotoes.com/") 21 | 22 | link(rel="icon" href="https://cdn.jsdelivr.net/gh/Tomotoes/images/blog/favicon.ico" type="image/x-icon") 23 | link(rel="shortcut icon" href="https://cdn.jsdelivr.net/gh/Tomotoes/images/blog/favicon.ico" type="image/x-icon") 24 | link(rel="apple-touch-icon" href="https://cdn.jsdelivr.net/gh/Tomotoes/images/PWA/apple-touch-icon.png") 25 | 26 | link(href="https://tomotoes.com/2048" rel="canonical") 27 | link(rel="alternate" type="application/atom+xml" title="一个坏掉的番茄" href="https://tomotoes.com/blog/atom.xml") 28 | 29 | meta(rel="manifest" href='https://tomotoes.com/manifest.json') 30 | meta(name="mobile-web-app-capable" content="yes") 31 | meta(name="mobile-web-app-title" content='一个坏掉的番茄') 32 | meta(name="msapplication-starturl" content="https://tomotoes.com") 33 | meta(name="mobile-web-app-title" content='一个坏掉的番茄') 34 | meta(name="application-name" content="yes") 35 | meta(name="apple-mobile-web-app-capable" content="yes") 36 | meta(name="apple-mobile-web-app-title" content='一个坏掉的番茄') 37 | 38 | meta(property="og:title" content="一个坏掉的番茄") 39 | meta(property="og:site_name" content="一个坏掉的番茄") 40 | meta(property="og:type" content="website") 41 | meta(property="og:url" content="https://tomotoes.com/2048") 42 | meta(property="og:locale" content="en") 43 | meta(name="description" content="Read(); Think(); Try(); Repeat(); - Simon Ma - 一个坏掉的番茄") 44 | meta(name="keywords" content="一个坏掉的番茄,2048,Game,Tomotoes,SimonMa,SimonAKing,Codenter,jinma,马寂寞") 45 | meta(name="twitter:card" content="summary") 46 | meta(name="twitter:site" content="https://tomotoes.com/2048") 47 | meta(name="twitter:creator" content="Simon Ma") 48 | 49 | script(type="application/ld+json"). 50 | { 51 | "@context": "http://schema.org", 52 | "@type": "Blog", 53 | "author": { 54 | "@type": "Person", 55 | "name": "Simon Ma", 56 | "image": { 57 | "@type": "ImageObject", 58 | "url": "https://cdn.jsdelivr.net/gh/Tomotoes/images/blog/avatar.jpg" 59 | }, 60 | "description": "Read(); Think(); Try(); Repeat();" 61 | }, 62 | "publisher": { 63 | "@type": "Organization", 64 | "name": "一个坏掉的番茄", 65 | "logo": { 66 | "@type": "ImageObject", 67 | "url": "https://tomotoes.com/images/PWA/192.png" 68 | } 69 | }, 70 | "url": "https://tomotoes.com/2048", 71 | "logo": "https://tomotoes.com/images/PWA/192.png", 72 | "image": { 73 | "@type": "ImageObject", 74 | "url": "https://tomotoes.com/images/PWA/192.png" 75 | }, 76 | "mainEntityOfPage": { 77 | "@type": "WebPage", 78 | "@id": "https://tomotoes.com/2048" 79 | }, 80 | "keywords": "一个坏掉的番茄,2048,Game,Tomotoes,SimonMa,SimonAKing,Codenter,jinma,马寂寞", 81 | "description": "Read(); Think(); Try(); Repeat(); - Simon Ma - 一个坏掉的番茄" 82 | } 83 | 84 | script(async src="https://www.googletagmanager.com/gtag/js?id=UA-109696496-3") 85 | script. 86 | window.dataLayer = window.dataLayer || []; 87 | function gtag() { dataLayer.push(arguments); } 88 | gtag('js', new Date()); 89 | gtag('config', 'UA-109696496-3'); 90 | 91 | 102 | -------------------------------------------------------------------------------- /js/main.js: -------------------------------------------------------------------------------- 1 | window.board = [] 2 | window.hasConflicted = [] 3 | 4 | Object.defineProperty(window, 'score', { 5 | _score: 0, 6 | get() { 7 | return this._score 8 | }, 9 | set(value) { 10 | this._score = value 11 | animation.updateScore(value) 12 | } 13 | }) 14 | 15 | Object.defineProperty(window, 'maxScore', { 16 | _maxScore: 0, 17 | get() { 18 | return this._maxScore 19 | }, 20 | set(value) { 21 | this._maxScore = value 22 | animation.updateMaxScore(value) 23 | } 24 | }) 25 | 26 | window.minScore = Number.parseInt(localStorage.getItem('minScore')) 27 | 28 | import * as support from './support' 29 | 30 | import * as animation from './animation' 31 | 32 | window.maxValue = 0 33 | 34 | function updateBoardView() { 35 | $('.number-cell').remove() 36 | 37 | for (let i = 0; i < 4; ++i) { 38 | for (let j = 0; j < 4; ++j) { 39 | $(`#grid-cell-${i}-${j}`).after( 40 | `
` 41 | ) 42 | 43 | const numberCell = $(`#number-cell-${i}-${j}`) 44 | 45 | if (board[i][j] === 0) { 46 | numberCell.css({ 47 | width: '0px', 48 | height: '0px', 49 | top: support.getPosTop(i) + support.cellSideLength / 2, 50 | left: support.getPosLeft(j) + support.cellSideLength / 2 51 | }) 52 | } else { 53 | numberCell.css({ 54 | width: support.cellSideLength, 55 | height: support.cellSideLength, 56 | top: support.getPosTop(i), 57 | left: support.getPosLeft(j), 58 | 'background-color': support.getNumberBackgroundColor(board[i][j]), 59 | color: support.getNumberColor(board[i][j]) 60 | }) 61 | 62 | numberCell.text(board[i][j]) 63 | maxValue = Math.max(maxValue, board[i][j]) 64 | } 65 | hasConflicted[i][j] = false 66 | } 67 | } 68 | $('.number-cell').css({ 69 | 'line-height': `${support.cellSideLength}px`, 70 | 'font-size': `${0.4 * support.cellSideLength}px` 71 | }) 72 | } 73 | export default function newGame(playNew) { 74 | support.prepareForMobile() 75 | 76 | /* 初始化棋盘格 */ 77 | Init(playNew) 78 | 79 | if (playNew) { 80 | generateOneNumver() 81 | generateOneNumver() 82 | } 83 | } 84 | function Init(playNew) { 85 | for (let i = 0; i < 4; ++i) { 86 | for (let j = 0; j < 4; ++j) { 87 | const gridCell = $(`#grid-cell-${i}-${j}`) 88 | 89 | gridCell.css({ 90 | top: support.getPosTop(i), 91 | left: support.getPosLeft(j) 92 | }) 93 | } 94 | } 95 | if (!localStorage.getItem('score') || playNew) { 96 | for (let i = 0; i < 4; ++i) { 97 | board[i] = [] 98 | hasConflicted[i] = [] 99 | for (let j = 0; j < 4; ++j) { 100 | board[i][j] = 0 101 | hasConflicted[i][j] = false 102 | } 103 | } 104 | score = 0 105 | maxScore = maxScore ? maxScore : 0 106 | maxValue = 0 107 | } else { 108 | board = JSON.parse(localStorage.getItem('board')) || [] 109 | score = Number.parseInt(localStorage.getItem('score')) || 0 110 | maxScore = Number.parseInt(localStorage.getItem('maxScore')) || 0 111 | hasConflicted = JSON.parse(localStorage.getItem('hasConflicted')) || [] 112 | maxValue = Number.parseInt(localStorage.getItem('maxValue')) || 0 113 | } 114 | 115 | updateBoardView() 116 | } 117 | 118 | function generateRandomNumber() { 119 | return maxValue < 512 120 | ? Math.random() < 0.5 121 | ? 2 122 | : Math.random() > 0.85 123 | ? 8 124 | : 4 125 | : Math.random() < 0.2 126 | ? 2 127 | : Math.random() > 0.6 128 | ? 4 129 | : 8 130 | } 131 | 132 | function generateOneNumver() { 133 | if (support.noSpace(board)) { 134 | return false 135 | } 136 | const emptyCells = [] 137 | for (let i = 0; i < 4; ++i) { 138 | for (let j = 0; j < 4; ++j) { 139 | if (board[i][j] === 0) { 140 | emptyCells.push({ x: i, y: j }) 141 | } 142 | } 143 | } 144 | if (emptyCells.length === 0) { 145 | return false 146 | } 147 | 148 | const { x: randomX, y: randomY } = emptyCells[ 149 | Math.floor(Math.random() * emptyCells.length) 150 | ] 151 | 152 | const randomNumber = generateRandomNumber() 153 | 154 | board[randomX][randomY] = randomNumber 155 | 156 | animation.showNumber(randomX, randomY, randomNumber) 157 | 158 | return true 159 | } 160 | 161 | function isGameover() { 162 | if (support.noSpace(board) && support.noMove(board)) { 163 | const isNewMinScore = minScore ? minScore > score : false 164 | const isNewMaxScore = score > maxScore 165 | 166 | setTimeout(() => animation.showGameover(isNewMaxScore, isNewMinScore), 500) 167 | 168 | if (!minScore || isNewMinScore) { 169 | localStorage.setItem('minScore', score) 170 | minScore = score 171 | } 172 | 173 | if (isNewMaxScore) { 174 | maxScore = score 175 | localStorage.setItem('maxScore', score) 176 | } 177 | } 178 | } 179 | 180 | function moveLeft() { 181 | if (!support.canMoveLeft(board)) { 182 | return false 183 | } 184 | for (let i = 0; i < 4; ++i) { 185 | for (let j = 0; j < 4; ++j) { 186 | if (board[i][j] !== 0) { 187 | for (let k = 0; k < j; ++k) { 188 | if (support.noBlockHorizontal(i, k, j, board)) { 189 | if (board[i][k] === 0) { 190 | animation.showMove(i, j, i, k) 191 | board[i][k] = board[i][j] 192 | board[i][j] = 0 193 | } else if (board[i][k] === board[i][j] && !hasConflicted[i][k]) { 194 | animation.showMove(i, j, i, k, true) 195 | board[i][k] += board[i][j] 196 | board[i][j] = 0 197 | 198 | score += board[i][k] 199 | 200 | hasConflicted[i][k] = true 201 | } 202 | } 203 | } 204 | } 205 | } 206 | } 207 | setTimeout(() => updateBoardView(), 200) 208 | return true 209 | } 210 | 211 | function moveRight() { 212 | if (!support.canMoveRight(board)) { 213 | return false 214 | } 215 | for (let i = 0; i < 4; ++i) { 216 | for (let j = 2; j >= 0; --j) { 217 | if (board[i][j] !== 0) { 218 | for (let k = 3; k > j; --k) { 219 | if (support.noBlockHorizontal(i, j, k, board)) { 220 | if (board[i][k] === 0) { 221 | animation.showMove(i, j, i, k) 222 | board[i][k] = board[i][j] 223 | board[i][j] = 0 224 | } else if (board[i][k] === board[i][j] && !hasConflicted[i][k]) { 225 | animation.showMove(i, j, i, k, true) 226 | board[i][k] += board[i][j] 227 | board[i][j] = 0 228 | 229 | score += board[i][k] 230 | hasConflicted[i][k] = true 231 | } 232 | } 233 | } 234 | } 235 | } 236 | } 237 | setTimeout(() => updateBoardView(), 200) 238 | return true 239 | } 240 | 241 | function moveUp() { 242 | if (!support.canMoveUp(board)) { 243 | return false 244 | } 245 | for (let j = 0; j < 4; ++j) { 246 | for (let i = 1; i < 4; ++i) { 247 | if (board[i][j] !== 0) { 248 | for (let k = 0; k < i; ++k) { 249 | if (support.noBlockVertical(j, k, i, board)) { 250 | if (board[k][j] === 0) { 251 | animation.showMove(i, j, k, j) 252 | board[k][j] = board[i][j] 253 | board[i][j] = 0 254 | } else if (board[k][j] === board[i][j] && !hasConflicted[k][j]) { 255 | animation.showMove(i, j, k, j, true) 256 | board[k][j] += board[i][j] 257 | board[i][j] = 0 258 | 259 | score += board[k][j] 260 | hasConflicted[k][j] = true 261 | } 262 | } 263 | } 264 | } 265 | } 266 | } 267 | setTimeout(() => updateBoardView(), 200) 268 | return true 269 | } 270 | 271 | function moveDown() { 272 | if (!support.canMoveDown(board)) { 273 | return false 274 | } 275 | for (let j = 0; j < 4; ++j) { 276 | for (let i = 2; i >= 0; --i) { 277 | if (board[i][j] !== 0) { 278 | for (let k = 3; k > i; --k) { 279 | if (support.noBlockVertical(j, i, k, board)) { 280 | if (board[k][j] === 0) { 281 | animation.showMove(i, j, k, j) 282 | board[k][j] = board[i][j] 283 | board[i][j] = 0 284 | } else if (board[k][j] === board[i][j] && !hasConflicted[k][j]) { 285 | animation.showMove(i, j, k, j, true) 286 | board[k][j] += board[i][j] 287 | board[i][j] = 0 288 | 289 | score += board[k][j] 290 | hasConflicted[k][j] = true 291 | } 292 | } 293 | } 294 | } 295 | } 296 | } 297 | setTimeout(() => updateBoardView(), 200) 298 | return true 299 | } 300 | 301 | const keys = { '37': moveLeft, '87': moveUp, '68': moveRight, '83': moveDown, '65': moveLeft, '38': moveUp, '39': moveRight, '40': moveDown } 302 | 303 | $(document).on('keydown', e => { 304 | if (Object.keys(keys).some(el => el == e.keyCode)) { 305 | if (keys[e.keyCode]()) { 306 | e.preventDefault() 307 | 308 | setTimeout(() => generateOneNumver(), 210) 309 | setTimeout(() => isGameover(), 300) 310 | } 311 | } 312 | }) 313 | 314 | $('#newGameBtn').on('click', newGame.bind(true)) 315 | 316 | let startX = 0 317 | let startY = 0 318 | let endX = 0 319 | let endY = 0 320 | 321 | document.addEventListener('touchstart', e => { 322 | startX = e.touches[0].pageX 323 | startY = e.touches[0].pageY 324 | }) 325 | 326 | document.addEventListener('touchend', e => { 327 | endX = e.changedTouches[0].pageX 328 | endY = e.changedTouches[0].pageY 329 | 330 | const deltaX = endX - startX 331 | const deltaY = endY - startY 332 | 333 | const absX = Math.abs(deltaX) 334 | const absY = Math.abs(deltaY) 335 | 336 | const clickWidth = support.documentWidth * 0.3 337 | 338 | /* 判断是否点击事件 */ 339 | if (absX < clickWidth && absY < clickWidth) { 340 | return 341 | } 342 | 343 | if ( 344 | absX > absY 345 | ? deltaX > 0 346 | ? moveRight() 347 | : moveLeft() 348 | : deltaY > 0 349 | ? moveDown() 350 | : moveUp() 351 | ) { 352 | e.preventDefault() 353 | setTimeout(() => generateOneNumver(), 210) 354 | setTimeout(() => isGameover(), 300) 355 | } 356 | }) 357 | 358 | document.addEventListener('touchmove', e => { 359 | e.preventDefault() 360 | }) 361 | --------------------------------------------------------------------------------