├── .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 |
9 |
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 |
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 |
--------------------------------------------------------------------------------