├── .browserslistrc ├── public ├── favicon.ico ├── index.html └── icon.svg ├── src ├── assets │ ├── help.png │ ├── pause.png │ ├── continue.png │ └── restart.png ├── shims-vue.d.ts ├── engine │ ├── status.ts │ ├── math.ts │ ├── timer.ts │ ├── gameData.ts │ └── game.ts ├── shims-tsx.d.ts ├── components │ ├── HighScore.vue │ ├── Help.vue │ ├── Info.vue │ └── GameWin.vue ├── main.ts └── App.vue ├── babel.config.js ├── vue.config.js ├── .gitignore ├── .eslintrc.js ├── .github └── workflows │ └── gh-pages.deploy.yml ├── tsconfig.json ├── README_CN.md ├── package.json ├── LICENSE └── README.md /.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | last 2 versions 3 | not dead 4 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pengfeiw/vue-hextris/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /src/assets/help.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pengfeiw/vue-hextris/HEAD/src/assets/help.png -------------------------------------------------------------------------------- /src/assets/pause.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pengfeiw/vue-hextris/HEAD/src/assets/pause.png -------------------------------------------------------------------------------- /src/assets/continue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pengfeiw/vue-hextris/HEAD/src/assets/continue.png -------------------------------------------------------------------------------- /src/assets/restart.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pengfeiw/vue-hextris/HEAD/src/assets/restart.png -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@vue/cli-plugin-babel/preset' 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /src/shims-vue.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.vue' { 2 | import Vue from 'vue' 3 | export default Vue 4 | } 5 | -------------------------------------------------------------------------------- /vue.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | publicPath: process.env.NODE_ENV === 'production' ? "/vue-hextris/" : "" 3 | }; 4 | -------------------------------------------------------------------------------- /src/engine/status.ts: -------------------------------------------------------------------------------- 1 | export enum GameStatus { 2 | /** 未开始 */ 3 | UNSTART, 4 | /** 游戏中 */ 5 | RUNNING, 6 | /** 暂停 */ 7 | PAUSED, 8 | /** 结束 */ 9 | OVER 10 | } 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | 6 | # local env files 7 | .env.local 8 | .env.*.local 9 | 10 | # Log files 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | pnpm-debug.log* 15 | 16 | # Editor directories and files 17 | .idea 18 | .vscode 19 | *.suo 20 | *.ntvs* 21 | *.njsproj 22 | *.sln 23 | *.sw? 24 | -------------------------------------------------------------------------------- /src/shims-tsx.d.ts: -------------------------------------------------------------------------------- 1 | import Vue, { VNode } from 'vue' 2 | 3 | declare global { 4 | namespace JSX { 5 | // tslint:disable no-empty-interface 6 | interface Element extends VNode {} 7 | // tslint:disable no-empty-interface 8 | interface ElementClass extends Vue {} 9 | interface IntrinsicElements { 10 | [elem: string]: any 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true 5 | }, 6 | 'extends': [ 7 | 'plugin:vue/essential', 8 | 'eslint:recommended', 9 | '@vue/typescript/recommended' 10 | ], 11 | parserOptions: { 12 | ecmaVersion: 2020 13 | }, 14 | rules: { 15 | 'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off', 16 | 'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off', 17 | "@typescript-eslint/explicit-module-boundary-types": "off" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | <%= htmlWebpackPlugin.options.title %> 9 | 10 | 11 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/components/HighScore.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 19 | 20 | 36 | -------------------------------------------------------------------------------- /.github/workflows/gh-pages.deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy to GitHub Pages 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | 11 | strategy: 12 | matrix: 13 | node-version: [14.x] 14 | 15 | steps: 16 | - name: Get files 17 | uses: actions/checkout@v2 18 | - name: Use Node.js ${{ matrix.node-version }} 19 | uses: actions/setup-node@v2 20 | with: 21 | node-version: ${{ matrix.node-version }} 22 | - name: Install packages 23 | run: npm ci 24 | - name: Build project 25 | run: npm run build 26 | - name: Deploy 27 | uses: JamesIves/github-pages-deploy-action@4.1.1 28 | with: 29 | branch: gh-pages 30 | folder: dist 31 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "esnext", 5 | "strict": true, 6 | "jsx": "preserve", 7 | "importHelpers": true, 8 | "moduleResolution": "node", 9 | "skipLibCheck": true, 10 | "esModuleInterop": true, 11 | "allowSyntheticDefaultImports": true, 12 | "sourceMap": true, 13 | "baseUrl": ".", 14 | "types": [ 15 | "webpack-env" 16 | ], 17 | "paths": { 18 | "@/*": [ 19 | "src/*" 20 | ] 21 | }, 22 | "lib": [ 23 | "esnext", 24 | "dom", 25 | "dom.iterable", 26 | "scripthost" 27 | ] 28 | }, 29 | "include": [ 30 | "src/**/*.ts", 31 | "src/**/*.tsx", 32 | "src/**/*.vue", 33 | "tests/**/*.ts", 34 | "tests/**/*.tsx" 35 | ], 36 | "exclude": [ 37 | "node_modules" 38 | ] 39 | } 40 | -------------------------------------------------------------------------------- /README_CN.md: -------------------------------------------------------------------------------- 1 | # Vue Hextris 2 | 3 | [English Description](./README.md) 4 | 5 | 用 Vue.js 写的小游戏。 6 | 7 | 游戏是一个复制版本,游戏玩法和设计来源于[这里](https://github.com/Hextris/hextris)。十分有趣,我第一次玩这个游戏的时候,就被吸引了,因为我是一个程序员,所以就将它用 Vue.js 重写了。 8 | 9 | 目前,我没有完全复制原来的版本,所以它与原版会有一点差别,但是核心玩法是一样的。 10 | 11 | ## Where to play 12 | 13 | 1. 在线 14 | 15 | 你可以试玩[在线版本](https://pengfeiw.github.io/vue-hextris). 16 | 17 | 2. 本地 18 | 19 | 也可以通过下面几个步骤,在本地进行游戏。 20 | 21 | 克隆仓库: 22 | ``` 23 | git clone https://github.com/pengfeiw/vue-hextris.git 24 | ``` 25 | 26 | 安装依赖包: 27 | ``` 28 | npm install 29 | ``` 30 | 31 | 启动服务: 32 | ``` 33 | npm run serve 34 | ``` 35 | 36 | 在浏览器打开 [http://localhost:8080](http://localhost:8080) 页面。 37 | 38 | ## How to control 39 | 40 | 1. 电脑 41 | 42 | 在电脑上,你可以使用键盘左、右以及下键控制,也可以使用鼠标左键点击控制。 43 | 44 | 键盘: 45 | - 左键:左旋转 46 | - 右键:右旋转 47 | - 下键:加速下落 48 | 49 | 鼠标: 50 | - 左侧单击:左旋转 51 | - 右侧单击:右旋转 52 | 53 | 2. 触屏设备 54 | 55 | 也支持触屏操作,所以你可以使用手机玩这个游戏。 56 | 57 | - 左侧触屏:左旋转 58 | - 右侧触屏:右旋转 59 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 2 | // @ts-nocheck 3 | import Vue from 'vue' 4 | import App from './App.vue' 5 | import ElementUI from 'element-ui'; 6 | import 'element-ui/lib/theme-chalk/index.css'; 7 | import Vuex from 'vuex' 8 | import Game from './engine/game'; 9 | 10 | Vue.use(Vuex); 11 | Vue.use(ElementUI); 12 | 13 | export const store = new Vuex.Store({ 14 | state: { 15 | game: new Game() 16 | }, 17 | mutations: { 18 | restart(state) { 19 | state.game.updateScoreToLocalstorage(); 20 | const outerLen = state.game.outerSideL; 21 | state.game = new Game(); 22 | state.game.outerSideL = outerLen; 23 | state.game.start(); 24 | } 25 | }, 26 | getters: { 27 | status: (state) => { 28 | return state.game.status; 29 | } 30 | } 31 | }); 32 | 33 | Vue.config.productionTip = false 34 | 35 | new Vue({ 36 | render: h => h(App), 37 | store 38 | }).$mount('#app') 39 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hextris", 3 | "version": "0.1.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 | }, 10 | "dependencies": { 11 | "core-js": "^3.6.5", 12 | "element-ui": "^2.15.6", 13 | "gsap": "^3.9.1", 14 | "vue": "^2.6.11", 15 | "vuex": "^3.6.2" 16 | }, 17 | "devDependencies": { 18 | "@typescript-eslint/eslint-plugin": "^4.18.0", 19 | "@typescript-eslint/parser": "^4.18.0", 20 | "@vue/cli-plugin-babel": "~4.5.0", 21 | "@vue/cli-plugin-eslint": "~4.5.0", 22 | "@vue/cli-plugin-typescript": "~4.5.0", 23 | "@vue/cli-service": "~4.5.0", 24 | "@vue/eslint-config-typescript": "^7.0.0", 25 | "eslint": "^6.7.2", 26 | "eslint-plugin-vue": "^6.2.2", 27 | "less": "^3.0.4", 28 | "less-loader": "^5.0.0", 29 | "typescript": "~4.1.5", 30 | "vue-template-compiler": "^2.6.11" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022-present, Pengfei Wang 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 29 | 30 | 60 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Vue Hextris 2 | 3 | [中文说明](./README_CN.md) 4 | 5 | An addictive puzzle game, written with Vue.js. 6 | 7 | The design of this game come from [here](https://github.com/Hextris/hextris). It's interesting, I was attracted deeply when I first play this game. So I remake it with vue.js. 8 | 9 | So far, I haven't copied enough, There is a little difference between origin version and this version, but the core is same. 10 | 11 | ## Where to play 12 | 13 | 1. online 14 | 15 | You can play at [here](https://pengfeiw.github.io/vue-hextris). 16 | 17 | 2. local 18 | 19 | Also play at local with serval steps. 20 | 21 | clone repository: 22 | ``` 23 | git clone https://github.com/pengfeiw/vue-hextris.git 24 | ``` 25 | 26 | install package: 27 | ``` 28 | npm install 29 | ``` 30 | 31 | run serve: 32 | ``` 33 | npm run serve 34 | ``` 35 | 36 | open browser [http://localhost:8080](http://localhost:8080). 37 | 38 | ## How to Control 39 | 40 | 1. pc 41 | 42 | You can control it with left、right and down arrrow keys or mouse click. 43 | 44 | keys: 45 | - left arrow: rotate left 46 | - right arrow: rotate right 47 | - down arrow: speed down 48 | 49 | mouse: 50 | - click on left part: rotate left 51 | - click on right part: rotate right 52 | 53 | 2. touchscreen device 54 | 55 | We Also support touch event, so you can play on you mobile. 56 | 57 | - touch on left part: rotate left 58 | - touch on right part: rotate right 59 | -------------------------------------------------------------------------------- /src/components/Help.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 36 | 37 | 60 | -------------------------------------------------------------------------------- /src/engine/math.ts: -------------------------------------------------------------------------------- 1 | export interface Vector { 2 | x: number; 3 | y: number; 4 | } 5 | export const degreeToRadians = (degree: number) => degree / 180 * Math.PI; 6 | export const radiansToDegree = (radians: number) => radians / Math.PI * 180; 7 | 8 | /** 9 | * get random integer from [minInt, maxInt) 10 | */ 11 | export const randomInteger = (min: number, max: number) => { 12 | const minInt = Math.ceil(min); 13 | const maxInt = Math.floor(max); 14 | return Math.floor(Math.random() * (maxInt - minInt)) + minInt; 15 | }; 16 | 17 | export const normalizeVector = (vector: Vector) => { 18 | const {x, y} = vector; 19 | const len = Math.sqrt(x * x + y * y); 20 | return { 21 | x: x / len, 22 | y: y / len 23 | }; 24 | }; 25 | 26 | export const addVec = (...vectors: Vector[]) => { 27 | if (vectors.length < 1) { 28 | throw new Error("addVec Error: the params length must greater then 1"); 29 | } 30 | 31 | const res = {x: vectors[0].x, y: vectors[0].y}; 32 | for (let i = 1; i < vectors.length; i++) { 33 | res.x += vectors[i].x; 34 | res.y += vectors[i].y; 35 | } 36 | 37 | return res; 38 | }; 39 | 40 | export const multiplyVectorByScalar = (vec: Vector, scalar: number): Vector => { 41 | return { 42 | x: vec.x * scalar, 43 | y: vec.y * scalar 44 | } 45 | }; 46 | 47 | export const getPointDistance = (p1: Vector, p2: Vector) => { 48 | return Math.sqrt(p1.x - p2.x + p1.y - p2.y); 49 | }; 50 | -------------------------------------------------------------------------------- /src/engine/timer.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-non-null-assertion */ 2 | class Timer { 3 | private _timerId?: number; 4 | private _callback: () => void; 5 | private _remainTime: number; 6 | private _start = 0; 7 | public constructor(callback: () => void, delay: number, autoStart = true) { 8 | this._callback = callback; 9 | this._remainTime = delay; 10 | 11 | if (autoStart) { 12 | this.resume(); 13 | } 14 | } 15 | 16 | public pause() { 17 | if (this._timerId) { 18 | window.clearTimeout(this._timerId); 19 | this._timerId = undefined; 20 | this._remainTime = Date.now() - this._start; 21 | } 22 | } 23 | 24 | public resume() { 25 | if (this._timerId) { 26 | return; 27 | } 28 | 29 | this._start = Date.now(); 30 | this._timerId = window.setTimeout(this._callback, this._remainTime); 31 | } 32 | 33 | public start() { 34 | this.resume(); 35 | } 36 | } 37 | 38 | class IntervalTimer { 39 | private _timerId?: number; 40 | private _startTime?: number; 41 | private _remaining?: number; 42 | private _interval: number; 43 | private _callback: () => void; 44 | /** 45 | * 0 - not start 46 | * 1 - time is running in window.setInterval 47 | * 2 - time is running in window.setTimeout 48 | * 3 - paused by window.clearInterval 49 | * 4 - paused by window.clearTimeout 50 | * */ 51 | private state: 0 | 1 | 2 | 3 | 4 = 0; 52 | private timeoutTimer?: Timer; 53 | public constructor(callback: () => void, interval: number, autoStart = false) { 54 | this._callback = callback; 55 | this._interval = interval; 56 | if (autoStart) { 57 | this.start(); 58 | } 59 | } 60 | public start() { 61 | if (this.state !== 0) { 62 | return; 63 | } 64 | 65 | this._startTime = Date.now(); 66 | this._timerId = window.setInterval(this._callback, this._interval); 67 | this.toggleState(); 68 | } 69 | public pause() { 70 | if (this.state !== 1 && this.state !== 2) { 71 | return; 72 | } 73 | 74 | if (this.state === 1) { 75 | this._remaining = this._interval - (Date.now() - this._startTime!); 76 | window.clearInterval(this._timerId); 77 | } else if (this.state === 2) { 78 | this.timeoutTimer!.pause(); 79 | } 80 | 81 | this.toggleState(); 82 | } 83 | 84 | public resume() { 85 | if (this.state !== 3 && this.state !== 4) { 86 | return; 87 | } 88 | 89 | if (this.state === 3) { 90 | this.timeoutTimer = new Timer(this.timeoutCallback.bind(this), this._remaining as number); 91 | } else if (this.state === 4) { 92 | this.timeoutTimer?.resume(); 93 | } 94 | 95 | this.toggleState(); 96 | } 97 | 98 | private timeoutCallback() { 99 | this._callback(); 100 | this._startTime = Date.now(); 101 | this._timerId = window.setInterval(this._callback, this._interval); 102 | this.state = 1; 103 | } 104 | 105 | /** 106 | * change state between paused(or idle) and running 107 | */ 108 | private toggleState() { 109 | switch (this.state) { 110 | case 0: 111 | this.state = 1; 112 | break; 113 | case 1: 114 | this.state = 3; 115 | break; 116 | case 2: 117 | this.state = 4; 118 | break; 119 | case 3: 120 | case 4: 121 | this.state = 2; 122 | break; 123 | default: 124 | throw new Error("IntervalTimer: unkonw state"); 125 | } 126 | } 127 | } 128 | 129 | export {Timer, IntervalTimer}; 130 | -------------------------------------------------------------------------------- /src/components/Info.vue: -------------------------------------------------------------------------------- 1 | 66 | 67 | 102 | 103 | 182 | -------------------------------------------------------------------------------- /src/components/GameWin.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 151 | 152 | 162 | -------------------------------------------------------------------------------- /src/engine/gameData.ts: -------------------------------------------------------------------------------- 1 | import Game from "./game"; 2 | 3 | export type BlcokType = 1 | 2 | 3 | 4; 4 | export interface Data { 5 | data: BlcokType; 6 | visible: boolean; 7 | willDelete?: boolean; 8 | } 9 | export type OneGroupData = Data[]; 10 | 11 | export const getColorByData = (type: BlcokType) => { 12 | switch (type) { 13 | case 1: 14 | return "#2E94B9"; 15 | case 2: 16 | return "#08D9D6"; 17 | case 3: 18 | return "#F0B775"; 19 | case 4: 20 | return "#D25565"; 21 | default: 22 | throw new Error("unkonw block dataIndex"); 23 | } 24 | } 25 | class GameData { 26 | public groupSize = 8; 27 | private _data: OneGroupData[] = [[], [], [], [], [], []]; 28 | public get data() { 29 | return this._data; 30 | } 31 | public isOver() { 32 | return !this._data.every((group) => (group.filter((item) => item.willDelete === undefined 33 | || item.willDelete === false).length <= this.groupSize)); 34 | } 35 | 36 | /** 37 | * 递归回溯检查是否可消除 38 | */ 39 | public eliminateUpdateScore(game: Game) { 40 | let resData: boolean[][] = []; // false 表示不用消除,true 表示需要消除 41 | let checkData: boolean[][] = []; 42 | let eliminateCount = 0; 43 | 44 | for (let i = 0; i < this.data.length; i++) { 45 | const group = this.data[i]; 46 | resData.push([] as boolean[]); 47 | checkData.push([] as boolean[]); 48 | for (let j = 0; j < group.length; j++) { 49 | resData[i].push(false); 50 | checkData[i].push(false); 51 | } 52 | } 53 | 54 | const loop = (i: number, j: number, dataType: BlcokType) => { 55 | if (!(i < this.data.length && i >= 0 && j < this.data[i].length && j >= 0)) { 56 | return; 57 | } 58 | 59 | if (this.data[i][j].willDelete || this.data[i][j].data !== dataType || resData[i][j] === true) { 60 | // checkData[i][j] = true; 61 | return; 62 | } 63 | 64 | resData[i][j] = true; 65 | checkData[i][j] = true; 66 | 67 | if (j < this.data[i].length) { 68 | loop(i, j + 1, dataType); 69 | } 70 | if (j > 0) { 71 | loop(i, j - 1, dataType); 72 | } 73 | loop((i + 1) % 6, j, dataType); 74 | loop((i - 1 + 6) % 6, j, dataType); 75 | }; 76 | 77 | for (let i = 0; i < this.data.length; i++) { 78 | for (let j = 0; j < this.data[i].length; j++) { 79 | if (checkData[i][j] === false) { 80 | const preResData = JSON.parse(JSON.stringify(resData)) as boolean[][]; 81 | const preCheckData = JSON.parse(JSON.stringify(checkData)) as boolean[][]; 82 | loop(i, j, this.data[i][j].data); 83 | 84 | let preEliminatecount = 0; 85 | for (let ii = 0; ii < preResData.length; ii++) { 86 | preEliminatecount += preResData[ii].filter((value) => value).length; 87 | } 88 | 89 | let curEliminatecount = 0; 90 | for (let ii = 0; ii < resData.length; ii++) { 91 | curEliminatecount += resData[ii].filter((value) => value).length; 92 | } 93 | 94 | if (curEliminatecount - preEliminatecount < 3) { 95 | resData = preResData; 96 | checkData = preCheckData; 97 | } 98 | } 99 | } 100 | } 101 | 102 | for (let i = 0; i < resData.length; i++) { 103 | for (let j = resData[i].length - 1; j >= 0; j--) { 104 | if (resData[i][j]) { 105 | // this.data[i].splice(j, 1); 106 | this.data[i][j].willDelete = true; 107 | eliminateCount++; 108 | } 109 | } 110 | } 111 | 112 | const interval = setInterval(() => { 113 | for (let i = 0; i < resData.length; i++) { 114 | for (let j = resData[i].length - 1; j >= 0; j--) { 115 | if (resData[i][j]) { 116 | this.data[i][j].visible = !this.data[i][j].visible; 117 | } 118 | } 119 | } 120 | }, 200); 121 | 122 | setTimeout(() => { 123 | window.clearInterval(interval); 124 | 125 | for (let i = 0; i < resData.length; i++) { 126 | for (let j = resData[i].length - 1; j >= 0; j--) { 127 | if (resData[i][j]) { 128 | this.data[i].splice(j, 1); 129 | } 130 | } 131 | } 132 | 133 | if (eliminateCount > 0) { 134 | this.eliminateUpdateScore(game); 135 | } 136 | }, 600); 137 | 138 | if (eliminateCount > 0) { 139 | game.score += eliminateCount * 10; 140 | 141 | return true; 142 | } 143 | 144 | return false; 145 | } 146 | } 147 | 148 | export default GameData; 149 | -------------------------------------------------------------------------------- /public/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | 41 | 43 | 47 | 56 | 59 | 77 | ? 89 | 90 | 93 | 98 | 116 | 119 | 125 | 131 | 139 | 157 | 158 | 159 | 160 | 165 | 183 | 187 | 194 | 201 | 202 | 203 | 208 | 226 | 244 | 245 | 246 | 247 | -------------------------------------------------------------------------------- /src/engine/game.ts: -------------------------------------------------------------------------------- 1 | import GameData, {getColorByData, BlcokType} from "./gameData"; 2 | import {degreeToRadians} from "./math"; 3 | import {GameStatus} from "./status"; 4 | import {IntervalTimer} from "./timer"; 5 | import * as MathUtil from "./math"; 6 | 7 | type Point = { 8 | x: number; 9 | y: number; 10 | } 11 | 12 | interface ActiveBlock { 13 | /** the type(color) of block. */ 14 | type: BlcokType; 15 | /** 16 | * the block direction, 17 | * there are six direction of block, 18 | * they are top(0)、right top(1)、right bottom(2)、bottom(3)、left bottom(4)、left top(5) 19 | * */ 20 | index: number; 21 | /** 22 | * multiply this value with Game.outerSideL get the innerSideL of block, 23 | */ 24 | blockInnerSideL2OutersideL: number; 25 | } 26 | 27 | const outerContainerColor = "rgba(234,234,234,1)"; 28 | const innerContainerColor = "rgba(80,80,80,1)"; 29 | const startButtonColor = "rgba(255, 255, 255, 1)"; 30 | const textColor = "rgba(32,73,105,1)"; 31 | 32 | /** 33 | * 获取中心点在 (0, 0) 点的六边形的六个顶点 34 | * @param sideLen 六边形边长 35 | */ 36 | const getHextrisPoints = (sideLen: number) => { 37 | const p1 = {x: 0.5 * sideLen, y: -0.5 * sideLen / Math.tan(degreeToRadians(30))}; 38 | const p2 = {x: sideLen, y: 0}; 39 | const p3 = {x: p1.x, y: 0.5 * sideLen / Math.tan(degreeToRadians(30))}; 40 | const p4 = {x: -0.5 * sideLen, y: p3.y}; 41 | const p5 = {x: -sideLen, y: 0}; 42 | const p6 = {x: p4.x, y: p1.y}; 43 | 44 | return [p1, p2, p3, p4, p5, p6]; 45 | }; 46 | 47 | class Game { 48 | public status = GameStatus.UNSTART; 49 | public data = new GameData(); 50 | public outerSideL = 300; 51 | public get innerSideL() { 52 | return this.outerSideL * 0.27; 53 | } 54 | public innerRotation = 0; 55 | public highScore = 0; 56 | public score = 0; 57 | private _speedTimer: IntervalTimer; 58 | private _generateBlockDelay = 3000; 59 | private _generateBlockElapse = 0; 60 | private _lastTickTime = 0; 61 | private _outlineColor = getColorByData(1); 62 | public speed = 1; 63 | public activeBlocks: ActiveBlock[] = []; 64 | /** 65 | * 用于确定更新分数的时机,只有当一组 active block 都变成 settled block 时,启动消除检测 66 | */ 67 | private activeBlocks2: ActiveBlock[][] = []; 68 | public get blockSideL() { 69 | return (this.outerSideL - this.innerSideL) / this.data.groupSize; 70 | } 71 | public constructor() { 72 | this._speedTimer = new IntervalTimer(() => { 73 | if (this.speed <= 2.4) { 74 | this.speed += 0.1; 75 | } 76 | this._generateBlockDelay -= 20; 77 | }, 10000, false); 78 | 79 | const highscoreStr = localStorage.getItem("highscore"); 80 | if (highscoreStr) { 81 | this.highScore = parseInt(highscoreStr); 82 | } 83 | } 84 | public pause() { 85 | this.status = GameStatus.PAUSED; 86 | this._speedTimer.pause(); 87 | } 88 | public resume() { 89 | this.status = GameStatus.RUNNING; 90 | this._speedTimer.resume(); 91 | } 92 | public start() { 93 | this.status = GameStatus.RUNNING; 94 | this._speedTimer.start(); 95 | 96 | this._lastTickTime = Date.now(); 97 | this.generateRandomBlock(); 98 | } 99 | public tick(ctx: CanvasRenderingContext2D, delta: number) { 100 | const status = this.status; 101 | 102 | // generate random block 103 | const now = Date.now(); 104 | if (this.status === GameStatus.RUNNING) { 105 | this._generateBlockElapse += now - this._lastTickTime; 106 | if (this._generateBlockElapse >= this._generateBlockDelay) { 107 | this.generateRandomBlock(); 108 | this._generateBlockElapse = 0; 109 | this._outlineColor = getColorByData(MathUtil.randomInteger(1, 5) as BlcokType); 110 | } 111 | } 112 | this._lastTickTime = now; 113 | 114 | // move active blocks 115 | if (status === GameStatus.RUNNING) { 116 | for (let i = 0; i < this.activeBlocks.length; i++) { 117 | this.activeBlocks[i].blockInnerSideL2OutersideL -= delta * this.speed * 0.0003; 118 | } 119 | } 120 | 121 | this.updateData(); 122 | this.draw(ctx); 123 | } 124 | private draw(ctx: CanvasRenderingContext2D) { 125 | ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); 126 | const width = ctx.canvas.clientWidth; 127 | const height = ctx.canvas.clientHeight; 128 | const center = { 129 | x: width * 0.5, 130 | y: height * 0.5 131 | }; 132 | this.drawOutline(ctx, center); 133 | this.drawContainer(ctx, center); 134 | this.drawSettledBlock(ctx, center); 135 | this.drawActiveBlock(ctx, center); 136 | this.drawUnrunningInfo(ctx, center); 137 | } 138 | private drawContainer(ctx: CanvasRenderingContext2D, center: Point) { 139 | const [o1, o2, o3, o4, o5, o6] = getHextrisPoints(this.outerSideL); 140 | const [i1, i2, i3, i4, i5, i6] = getHextrisPoints(this.innerSideL); 141 | 142 | const trans = ctx.getTransform(); 143 | 144 | ctx.translate(center.x, center.y); 145 | ctx.beginPath(); 146 | ctx.fillStyle = outerContainerColor; 147 | ctx.moveTo(o1.x, o1.y); 148 | ctx.lineTo(o2.x, o2.y); 149 | ctx.lineTo(o3.x, o3.y); 150 | ctx.lineTo(o4.x, o4.y); 151 | ctx.lineTo(o5.x, o5.y); 152 | ctx.lineTo(o6.x, o6.y); 153 | ctx.closePath(); 154 | ctx.fill(); 155 | 156 | ctx.rotate(degreeToRadians(this.innerRotation)); 157 | ctx.beginPath(); 158 | ctx.fillStyle = innerContainerColor; 159 | ctx.moveTo(i1.x, i1.y); 160 | ctx.lineTo(i2.x, i2.y); 161 | ctx.lineTo(i3.x, i3.y); 162 | ctx.lineTo(i4.x, i4.y); 163 | ctx.lineTo(i5.x, i5.y); 164 | ctx.lineTo(i6.x, i6.y); 165 | ctx.closePath(); 166 | ctx.fill(); 167 | 168 | ctx.setTransform(trans); 169 | } 170 | private drawOutline(ctx: CanvasRenderingContext2D, center: Point) { 171 | const trans = ctx.getTransform(); 172 | let lenPercent = 1 - this._generateBlockElapse / this._generateBlockDelay; 173 | const points = getHextrisPoints(this.outerSideL); 174 | ctx.translate(center.x, center.y); 175 | ctx.beginPath(); 176 | let pIndex = 1; 177 | ctx.moveTo(points[0].x, points[0].y); 178 | 179 | while (pIndex <= 5 && lenPercent > 1 / 6) { 180 | ctx.lineTo(points[pIndex].x, points[pIndex].y); 181 | pIndex++; 182 | lenPercent -= 1 / 6; 183 | } 184 | const p1 = points[pIndex - 1]; 185 | const p2 = points[pIndex % 6]; 186 | const vec = MathUtil.normalizeVector({ 187 | x: p2.x - p1.x, 188 | y: p2.y - p1.y 189 | }); 190 | const last = MathUtil.addVec(p1, MathUtil.multiplyVectorByScalar(vec, lenPercent * this.outerSideL * 6)) 191 | 192 | if (MathUtil.getPointDistance(last, points[0]) < 0.01) { 193 | ctx.closePath(); 194 | } else { 195 | ctx.lineTo(last.x, last.y); 196 | } 197 | 198 | ctx.lineWidth = 10; 199 | ctx.strokeStyle = this._outlineColor; 200 | ctx.stroke(); 201 | ctx.setTransform(trans); 202 | } 203 | private drawSettledBlock(ctx: CanvasRenderingContext2D, center: Point) { 204 | for (let i = 0; i < this.data.data.length; i++) { 205 | const group = this.data.data[i]; 206 | 207 | for (let j = 0; j < group.length; j++) { 208 | if (group[j].visible) { 209 | const blockData = group[j].data; 210 | const or = this.innerSideL + (j + 1) * this.blockSideL; 211 | const ir = this.innerSideL + this.blockSideL * j; 212 | const o1 = {x: -0.5 * or, y: -0.5 * or / Math.tan(degreeToRadians(30))}; 213 | const o2 = {x: 0.5 * or, y: o1.y}; 214 | const i1 = {x: -0.5 * ir, y: -0.5 * ir / Math.tan(degreeToRadians(30))}; 215 | const i2 = {x: 0.5 * ir, y: i1.y}; 216 | 217 | const trans = ctx.getTransform(); 218 | ctx.translate(center.x, center.y); 219 | ctx.rotate(degreeToRadians(60 * i + this.innerRotation)); 220 | ctx.beginPath(); 221 | ctx.fillStyle = getColorByData(blockData); 222 | ctx.moveTo(o1.x, o1.y); 223 | ctx.lineTo(o2.x, o2.y); 224 | ctx.lineTo(i2.x, i2.y); 225 | ctx.lineTo(i1.x, i1.y); 226 | ctx.closePath(); 227 | ctx.fill(); 228 | 229 | ctx.setTransform(trans); 230 | } 231 | } 232 | } 233 | } 234 | 235 | private drawActiveBlock(ctx: CanvasRenderingContext2D, center: Point) { 236 | for (let i = 0; i < this.activeBlocks.length; i++) { 237 | const blockInnerSideL = this.activeBlocks[i].blockInnerSideL2OutersideL * this.outerSideL; 238 | const ir = blockInnerSideL; 239 | const or = blockInnerSideL + this.blockSideL; 240 | const o1 = {x: -0.5 * or, y: -0.5 * or / Math.tan(degreeToRadians(30))}; 241 | const o2 = {x: 0.5 * or, y: o1.y}; 242 | const i1 = {x: -0.5 * ir, y: -0.5 * ir / Math.tan(degreeToRadians(30))}; 243 | const i2 = {x: 0.5 * ir, y: i1.y}; 244 | 245 | const trans = ctx.getTransform(); 246 | ctx.translate(center.x, center.y); 247 | ctx.rotate(degreeToRadians(60 * this.activeBlocks[i].index)); 248 | ctx.beginPath(); 249 | ctx.fillStyle = getColorByData(this.activeBlocks[i].type); 250 | ctx.moveTo(o1.x, o1.y); 251 | ctx.lineTo(o2.x, o2.y); 252 | ctx.lineTo(i2.x, i2.y); 253 | ctx.lineTo(i1.x, i1.y); 254 | ctx.closePath(); 255 | ctx.fill(); 256 | ctx.setTransform(trans); 257 | } 258 | } 259 | 260 | private drawUnrunningInfo(ctx: CanvasRenderingContext2D, center: Point) { 261 | if (this.status === GameStatus.UNSTART) { 262 | const startBtnSideL = 0.7 * this.innerSideL; 263 | const startBtnP1 = {x: center.x - startBtnSideL * Math.sqrt(3) / 4, y: center.y - startBtnSideL * 0.5}; 264 | const startBtnP2 = {x: center.x + startBtnSideL * Math.sqrt(3) / 4, y: center.y}; 265 | const startBtnP3 = {x: startBtnP1.x, y: center.y + startBtnSideL * 0.5}; 266 | 267 | ctx.beginPath(); 268 | ctx.fillStyle = startButtonColor; 269 | ctx.moveTo(startBtnP1.x, startBtnP1.y); 270 | ctx.lineTo(startBtnP2.x, startBtnP2.y); 271 | ctx.lineTo(startBtnP3.x, startBtnP3.y); 272 | ctx.closePath(); 273 | ctx.fill(); 274 | 275 | ctx.textAlign = "center"; 276 | ctx.textBaseline = "bottom"; 277 | ctx.font = "5rem serif"; 278 | ctx.fillStyle = textColor; 279 | ctx.fillText("Hextris", center.x, center.y - 1.2 * this.innerSideL); 280 | 281 | ctx.textAlign = "center"; 282 | ctx.textBaseline = "top"; 283 | ctx.font = "1.5rem serif"; 284 | ctx.fillStyle = textColor; 285 | ctx.fillText("Click Triangle or press SPACE to start!", center.x, center.y + 1.2 * this.innerSideL); 286 | } else { 287 | ctx.textAlign = "center"; 288 | ctx.textBaseline = "middle"; 289 | ctx.font = "2rem serif"; 290 | ctx.fillStyle = "rgba(255, 255, 255, 1)"; 291 | ctx.fillText(this.score.toString(), center.x, center.y); 292 | } 293 | } 294 | 295 | private generateRandomBlock() { 296 | const counts = [1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 3, 3, 4, 5, 6]; 297 | const count = counts[MathUtil.randomInteger(0, counts.length)]; 298 | const indexes = [0, 1, 2, 3, 4, 5]; 299 | const blockGroup = []; 300 | for (let i = 0; i < count; i++) { 301 | const [index] = indexes.splice(~~(Math.random() * indexes.length), 1); 302 | const type = MathUtil.randomInteger(1, 5) as BlcokType; 303 | const blockInnerSideL2OutersideL = 1.3; 304 | const block = { 305 | index, 306 | type, 307 | blockInnerSideL2OutersideL 308 | }; 309 | blockGroup.push(block); 310 | this.activeBlocks.push(block); 311 | } 312 | 313 | this.activeBlocks2.push(blockGroup); 314 | } 315 | 316 | private updateData() { 317 | if (this.status === GameStatus.RUNNING) { 318 | if (this.score > this.highScore) { 319 | this.highScore = this.score; 320 | } 321 | for (let i = this.activeBlocks.length - 1; i >= 0; i--) { 322 | const activeBlock = this.activeBlocks[i]; 323 | const innerSideL = activeBlock.blockInnerSideL2OutersideL * this.outerSideL; 324 | let index = (activeBlock.index - Math.ceil(this.innerRotation / 60) % 6) % 6; 325 | 326 | 327 | if (index < 0) { 328 | index += 6; 329 | } 330 | 331 | const groupData = this.data.data[index]; 332 | const groupOuterSideL = this.innerSideL + groupData.length * this.blockSideL; 333 | 334 | if (groupOuterSideL >= innerSideL) { 335 | groupData.push({ 336 | data: activeBlock.type, 337 | visible: true 338 | }); 339 | 340 | this.activeBlocks.splice(i, 1); 341 | 342 | // 消除block,更新分数 343 | let groupIndex = -1, groupBlockIndex = -1; 344 | for (let j = 0; j < this.activeBlocks2.length; j++) { 345 | const temIndex = this.activeBlocks2[j].indexOf(activeBlock); 346 | 347 | if (temIndex !== -1) { 348 | groupIndex = j; 349 | groupBlockIndex = temIndex; 350 | break; 351 | } 352 | } 353 | 354 | if (groupIndex !== -1 && groupBlockIndex !== -1) { 355 | if (this.activeBlocks2[groupIndex].length === 1) { 356 | this.activeBlocks2.splice(groupIndex, 1); 357 | this.data.eliminateUpdateScore(this); 358 | 359 | this.checkOver(); 360 | } else { 361 | this.activeBlocks2[groupIndex].splice(groupBlockIndex, 1); 362 | } 363 | } 364 | } 365 | } 366 | } 367 | } 368 | 369 | private checkOver() { 370 | if (this.data.isOver()) { 371 | this.status = GameStatus.OVER; 372 | } 373 | } 374 | public updateScoreToLocalstorage() { 375 | localStorage.setItem("highscore", this.highScore.toString()); 376 | } 377 | } 378 | 379 | export default Game; 380 | --------------------------------------------------------------------------------