Compare commits
1 Commits
Author | SHA1 | Date | |
---|---|---|---|
a3b7941e2b |
17
.eslintrc.js
@ -1,17 +0,0 @@
|
||||
module.exports = {
|
||||
env: {
|
||||
browser: true,
|
||||
es2021: true,
|
||||
node: true,
|
||||
},
|
||||
extends: ["plugin:vue/vue3-recommended", "plugin:prettier/recommended"],
|
||||
parserOptions: {
|
||||
ecmaVersion: "latest",
|
||||
parser: "@typescript-eslint/parser",
|
||||
sourceType: "module",
|
||||
},
|
||||
plugins: ["vue", "@typescript-eslint", "prettier"],
|
||||
rules: {
|
||||
"prettier/prettier": "error",
|
||||
},
|
||||
};
|
3
.gitignore
vendored
@ -8,7 +8,7 @@ pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
# dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
@ -22,4 +22,3 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
.vercel
|
||||
|
21
LICENSE
Normal file
@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022 chenxch<https://github.com/chenxch>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
109
README.md
@ -1,34 +1,87 @@
|
||||
# 鱼了个鱼
|
||||
<img src="https://cdn.staticaly.com/gh/chenxch/pic-image@master/20220929/image-31.5wzs9gnp33k.webp" />
|
||||
|
||||
> 被羊了个羊虐了百遍后,我自己做了一个!
|
||||
# xlegex / x了个X
|
||||
|
||||
在线体验:https://yulegeyu.cn
|
||||
This is a match-3 game, a simplified version of the sheep, currently based on rabbits, you can customize your own game based on this.
|
||||
|
||||
游戏视频:https://www.bilibili.com/video/BV1Pe411M7wh
|
||||
|
||||
相关文章:https://mp.weixin.qq.com/s/D_I1Tq-ofhKhlp0rkOpaLA
|
||||
|
||||
游戏截图(自定义了图案):
|
||||
|
||||
![游戏截图](doc/img.png)
|
||||
|
||||
游戏特色:
|
||||
|
||||
1. 支持选择难度(4 种)
|
||||
2. 支持自定义难度
|
||||
3. 支持自定义动物图案(比如 🐔🏀)
|
||||
4. 可以无限使用技能(道具)
|
||||
5. 不需要看广告
|
||||
6. 能通关
|
||||
|
||||
> 补一句:就出于兴趣做了几个小时,有 bug 正常哈哈,欢迎 PR~
|
||||
|
||||
简单说下实现原理,主要有 4 个点:
|
||||
|
||||
1. 游戏全局参数:做游戏的同学都知道,游戏中会涉及很多参数,比如槽位数量、层数等等。我们要将这些参数抽取成统一的全局变量,供其他变量使用。从而做到修改一处,游戏自动适配。还可以提供页面来让用户自定义这些参数,提高游戏的可玩性。
|
||||
2. 网格:为了让块的分布相对规整、并且为计算坐标提供方便,我将整个游戏画布分为 24 x 24 的虚拟网格,类似一个棋盘。一个块占用 3 x 3 的格子。
|
||||
3. 随机生成块:包括随机生成方块的图案和坐标。首先我根据全局参数计算出了总块数,然后用 shuffle 函数打乱存储所有动物图案的数组,再依次将数组中的图案填充到方块中。生成坐标的原理是随机选取坐标范围内的点,坐标范围可以随着层级的增加而递减,即生成的图案越来越挤,达到难度逐层加大的效果。
|
||||
4. 块的覆盖关系:怎么做到点击上层的块后,才能点下层的块呢?首先要给每个块指定一个层级属性。然后有两种思路,第 1 种是先逐层生成,然后每个格子里层级最高的块依次判断其周围格子有没有块层级大于它;第 2 种是在随机生成块的时候就给相互重叠的块绑定层级关系(即谁覆盖了我?我覆盖了谁?)。这里我选择了第 2 种方法,感觉效率会高一些。
|
||||
这是一个三消类的游戏,简化版的羊了个羊,目前是以兔子为素材,你可以基于这个定制你自己的游戏。
|
||||
|
||||
|
||||
|
||||
[Online Demo / 在线demo](https://chenxch.github.io/xlegex/)
|
||||
|
||||
## Game screenshot / 游戏截图
|
||||
![QQ浏览器截图20220922214942](https://cdn.staticaly.com/gh/chenxch/pic-image@master/20220929/tutu.4jhzwxilnfs0.gif)
|
||||
|
||||
|
||||
## Core Code / 核心代码
|
||||
```ts
|
||||
// useGame.ts
|
||||
useGame(config: GameConfig): Game{
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
###
|
||||
```ts
|
||||
// type.d.ts
|
||||
interface Game {
|
||||
nodes: Ref<CardNode[]>
|
||||
selectedNodes: Ref<CardNode[]>
|
||||
removeList: Ref<CardNode[]>
|
||||
removeFlag: Ref<boolean>
|
||||
backFlag: Ref<boolean>
|
||||
handleSelect: (node: CardNode) => void
|
||||
handleSelectRemove: (node: CardNode) => void
|
||||
handleBack: () => void
|
||||
handleRemove: () => void
|
||||
initData: (config?: GameConfig) => void
|
||||
}
|
||||
interface GameConfig {
|
||||
container?: Ref<HTMLElement | undefined> // cardNode容器
|
||||
cardNum: number // card类型数量
|
||||
layerNum: number // card层数
|
||||
trap?: boolean // 是否开启陷阱
|
||||
delNode?: boolean // 是否从nodes中剔除已选节点
|
||||
events?: GameEvents // 游戏事件
|
||||
}
|
||||
|
||||
interface GameEvents {
|
||||
clickCallback?: () => void
|
||||
dropCallback?: () => void
|
||||
winCallback?: () => void
|
||||
loseCallback?: () => void
|
||||
}
|
||||
```
|
||||
|
||||
## Application / 应用
|
||||
```ts
|
||||
const {
|
||||
nodes,
|
||||
selectedNodes,
|
||||
handleSelect,
|
||||
handleBack,
|
||||
backFlag,
|
||||
handleRemove,
|
||||
removeFlag,
|
||||
removeList,
|
||||
handleSelectRemove,
|
||||
initData,
|
||||
} = useGame({
|
||||
container: containerRef,
|
||||
cardNum: 4,
|
||||
layerNum: 2,
|
||||
trap: false,
|
||||
events: {
|
||||
clickCallback: handleClickCard,
|
||||
dropCallback: handleDropCard,
|
||||
winCallback: handleWin,
|
||||
loseCallback: handleLose,
|
||||
},
|
||||
})
|
||||
|
||||
initData()
|
||||
```
|
||||
|
||||
## Related Articles / 相关文章
|
||||
[juejin/掘金](https://juejin.cn/post/7147245442172977189)
|
BIN
dist/assets/1.d7c29e40.png
vendored
Normal file
After Width: | Height: | Size: 7.0 KiB |
BIN
dist/assets/10.503e2578.png
vendored
Normal file
After Width: | Height: | Size: 7.4 KiB |
BIN
dist/assets/11.d4eec1ed.png
vendored
Normal file
After Width: | Height: | Size: 12 KiB |
BIN
dist/assets/12.32050800.png
vendored
Normal file
After Width: | Height: | Size: 11 KiB |
BIN
dist/assets/13.4bda2068.png
vendored
Normal file
After Width: | Height: | Size: 10 KiB |
BIN
dist/assets/14.f7b0a8f2.png
vendored
Normal file
After Width: | Height: | Size: 11 KiB |
BIN
dist/assets/15.9468171d.png
vendored
Normal file
After Width: | Height: | Size: 11 KiB |
BIN
dist/assets/2.e06d2ca4.png
vendored
Normal file
After Width: | Height: | Size: 7.5 KiB |
BIN
dist/assets/3.8a4cfab6.png
vendored
Normal file
After Width: | Height: | Size: 6.8 KiB |
BIN
dist/assets/4.458f7dfb.png
vendored
Normal file
After Width: | Height: | Size: 7.1 KiB |
BIN
dist/assets/5.22ddcfaa.png
vendored
Normal file
After Width: | Height: | Size: 7.1 KiB |
BIN
dist/assets/6.42bf09bd.png
vendored
Normal file
After Width: | Height: | Size: 8.6 KiB |
BIN
dist/assets/7.0d186733.png
vendored
Normal file
After Width: | Height: | Size: 7.0 KiB |
BIN
dist/assets/8.75c96fe8.png
vendored
Normal file
After Width: | Height: | Size: 6.8 KiB |
BIN
dist/assets/9.8870e803.png
vendored
Normal file
After Width: | Height: | Size: 6.0 KiB |
1
dist/assets/index.338b321b.css
vendored
Normal file
2
dist/assets/index.ded8c5da.js
vendored
Normal file
BIN
dist/audio/click.mp3
vendored
Normal file
BIN
dist/audio/drop.mp3
vendored
Normal file
BIN
dist/audio/lose.mp3
vendored
Normal file
BIN
dist/audio/win.mp3
vendored
Normal file
27
dist/index.html
vendored
Normal file
@ -0,0 +1,27 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="https://chenxch.github.io/xlegex/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>兔了个兔</title>
|
||||
<script>
|
||||
var _hmt = _hmt || [];
|
||||
(function () {
|
||||
const hm = document.createElement('script')
|
||||
hm.src = 'https://hm.baidu.com/hm.js?1b051845f9998479adf57914a7ef51d1'
|
||||
const s = document.getElementsByTagName('script')[0]
|
||||
s.parentNode.insertBefore(hm, s)
|
||||
})()
|
||||
</script>
|
||||
<script type="module" crossorigin src="https://chenxch.github.io/xlegex/assets/index.ded8c5da.js"></script>
|
||||
<link rel="stylesheet" href="https://chenxch.github.io/xlegex/assets/index.338b321b.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
1
dist/vite.svg
vendored
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
After Width: | Height: | Size: 1.5 KiB |
BIN
doc/img.png
Before Width: | Height: | Size: 345 KiB |
26
index.html
@ -1,15 +1,25 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/logo.png" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>鱼了个鱼</title>
|
||||
<script charset="UTF-8" id="LA_COLLECT" src="//sdk.51.la/js-sdk-pro.min.js"></script>
|
||||
<script>LA.init({id: "JonPnywkOzKkLXdw",ck: "JonPnywkOzKkLXdw"})</script>
|
||||
</head>
|
||||
<body>
|
||||
<title>兔了个兔</title>
|
||||
<script>
|
||||
var _hmt = _hmt || [];
|
||||
(function () {
|
||||
const hm = document.createElement('script')
|
||||
hm.src = 'https://hm.baidu.com/hm.js?1b051845f9998479adf57914a7ef51d1'
|
||||
const s = document.getElementsByTagName('script')[0]
|
||||
s.parentNode.insertBefore(hm, s)
|
||||
})()
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
6672
package-lock.json
generated
42
package.json
@ -1,37 +1,31 @@
|
||||
{
|
||||
"name": "yulegeyu",
|
||||
"name": "xlegex",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons-vue": "^6.1.0",
|
||||
"ant-design-vue": "^3.2.11",
|
||||
"lodash": "^4.17.21",
|
||||
"pinia": "^2.0.19",
|
||||
"pinia-plugin-persistedstate": "^2.1.1",
|
||||
"vue": "^3.2.37",
|
||||
"vue-router": "4"
|
||||
"canvas-confetti": "^1.5.1",
|
||||
"lodash-es": "^4.17.21",
|
||||
"vue": "^3.2.37"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/lodash": "^4.14.185",
|
||||
"@typescript-eslint/eslint-plugin": "^5.23.0",
|
||||
"@typescript-eslint/parser": "^5.23.0",
|
||||
"@vitejs/plugin-vue": "^3.0.3",
|
||||
"eslint": "^8.15.0",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"eslint-config-standard": "^17.0.0",
|
||||
"eslint-plugin-import": "^2.26.0",
|
||||
"eslint-plugin-n": "^15.2.0",
|
||||
"eslint-plugin-prettier": "^4.0.0",
|
||||
"eslint-plugin-promise": "^6.0.0",
|
||||
"eslint-plugin-vue": "^8.7.1",
|
||||
"prettier": "^2.7.1",
|
||||
"@antfu/eslint-config": "^0.26.3",
|
||||
"@iconify-json/carbon": "^1.1.8",
|
||||
"@types/canvas-confetti": "^1.4.3",
|
||||
"@types/lodash-es": "^4.17.6",
|
||||
"@types/node": "^18.7.18",
|
||||
"@vitejs/plugin-vue": "^3.1.0",
|
||||
"eslint": "^8.23.1",
|
||||
"typescript": "^4.6.4",
|
||||
"vite": "^3.0.7",
|
||||
"vue-tsc": "^0.39.5"
|
||||
"unocss": "^0.45.21",
|
||||
"vite": "^3.1.0",
|
||||
"vue-tsc": "^0.40.4"
|
||||
}
|
||||
}
|
3260
pnpm-lock.yaml
generated
Normal file
BIN
public/audio/click.mp3
Normal file
BIN
public/audio/drop.mp3
Normal file
BIN
public/audio/lose.mp3
Normal file
BIN
public/audio/win.mp3
Normal file
BIN
public/logo.png
Before Width: | Height: | Size: 4.5 KiB |
1
public/vite.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
After Width: | Height: | Size: 1.5 KiB |
272
src/App.vue
@ -1,21 +1,259 @@
|
||||
<template>
|
||||
<div id="app">
|
||||
<div class="content">
|
||||
<router-view />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts"></script>
|
||||
<style scoped>
|
||||
#app {
|
||||
background: url("assets/bg.jpeg");
|
||||
padding: 16px 16px 50px;
|
||||
min-height: 100vh;
|
||||
background-size: 100% 100%;
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import Card from './components/card.vue'
|
||||
import { useGame } from './core/useGame'
|
||||
import { basicCannon, schoolPride } from './core/utils'
|
||||
|
||||
const containerRef = ref<HTMLElement | undefined>()
|
||||
const clickAudioRef = ref<HTMLAudioElement | undefined>()
|
||||
const dropAudioRef = ref<HTMLAudioElement | undefined>()
|
||||
const winAudioRef = ref<HTMLAudioElement | undefined>()
|
||||
const loseAudioRef = ref<HTMLAudioElement | undefined>()
|
||||
const curLevel = ref(1)
|
||||
const showTip = ref(false)
|
||||
const LevelConfig = [
|
||||
{ cardNum: 4, layerNum: 2, trap: false },
|
||||
{ cardNum: 9, layerNum: 3, trap: false },
|
||||
{ cardNum: 15, layerNum: 6, trap: false },
|
||||
]
|
||||
|
||||
const isWin = ref(false)
|
||||
|
||||
const {
|
||||
nodes,
|
||||
selectedNodes,
|
||||
handleSelect,
|
||||
handleBack,
|
||||
backFlag,
|
||||
handleRemove,
|
||||
removeFlag,
|
||||
removeList,
|
||||
handleSelectRemove,
|
||||
initData,
|
||||
} = useGame({
|
||||
container: containerRef,
|
||||
cardNum: 4,
|
||||
layerNum: 2,
|
||||
trap: false,
|
||||
events: {
|
||||
clickCallback: handleClickCard,
|
||||
dropCallback: handleDropCard,
|
||||
winCallback: handleWin,
|
||||
loseCallback: handleLose,
|
||||
},
|
||||
})
|
||||
|
||||
function handleClickCard() {
|
||||
if (clickAudioRef.value?.paused) {
|
||||
clickAudioRef.value.play()
|
||||
}
|
||||
else if (clickAudioRef.value) {
|
||||
clickAudioRef.value.load()
|
||||
clickAudioRef.value.play()
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
function handleDropCard() {
|
||||
dropAudioRef.value?.play()
|
||||
}
|
||||
|
||||
function handleWin() {
|
||||
winAudioRef.value?.play()
|
||||
// fireworks()
|
||||
if (curLevel.value < LevelConfig.length) {
|
||||
basicCannon()
|
||||
showTip.value = true
|
||||
setTimeout(() => {
|
||||
showTip.value = false
|
||||
}, 1500)
|
||||
setTimeout(() => {
|
||||
initData(LevelConfig[curLevel.value])
|
||||
curLevel.value++
|
||||
}, 2000)
|
||||
}
|
||||
else {
|
||||
isWin.value = true
|
||||
schoolPride()
|
||||
}
|
||||
}
|
||||
|
||||
function handleLose() {
|
||||
loseAudioRef.value?.play()
|
||||
setTimeout(() => {
|
||||
alert('槽位已满,再接再厉~')
|
||||
// window.location.reload()
|
||||
nodes.value = []
|
||||
removeList.value = []
|
||||
selectedNodes.value = []
|
||||
curLevel.value = 0
|
||||
showTip.value = true
|
||||
setTimeout(() => {
|
||||
showTip.value = false
|
||||
}, 1500)
|
||||
setTimeout(() => {
|
||||
initData(LevelConfig[curLevel.value])
|
||||
curLevel.value++
|
||||
}, 2000)
|
||||
}, 500)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
initData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div flex flex-col w-full h-full>
|
||||
<div text-44px text-center w-full color="#000" fw-600 h-60px flex items-center justify-center mt-10px>
|
||||
兔了个兔
|
||||
</div>
|
||||
<div ref="containerRef" flex-1 flex>
|
||||
<div w-full relative flex-1>
|
||||
<template v-for="item in nodes" :key="item.id">
|
||||
<transition name="slide-fade">
|
||||
<Card
|
||||
v-if="[0, 1].includes(item.state)"
|
||||
:node="item"
|
||||
@click-card="handleSelect"
|
||||
/>
|
||||
</transition>
|
||||
</template>
|
||||
</div>
|
||||
<transition name="bounce">
|
||||
<div v-if="isWin" color="#000" flex items-center justify-center w-full text-28px fw-bold>
|
||||
成功加入兔圈~
|
||||
</div>
|
||||
</transition>
|
||||
<transition name="bounce">
|
||||
<div v-if="showTip" color="#000" flex items-center justify-center w-full text-28px fw-bold>
|
||||
第{{ curLevel + 1 }}关
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
|
||||
<div text-center h-50px flex items-center justify-center>
|
||||
<Card
|
||||
v-for="item in removeList" :key="item.id" :node="item"
|
||||
is-dock
|
||||
@click-card="handleSelectRemove"
|
||||
/>
|
||||
</div>
|
||||
<div w-full flex items-center justify-center>
|
||||
<div border="~ 4px dashed #000" w-295px h-44px flex>
|
||||
<template v-for="item in selectedNodes" :key="item.id">
|
||||
<transition name="bounce">
|
||||
<Card
|
||||
v-if="item.state === 2"
|
||||
:node="item"
|
||||
is-dock
|
||||
/>
|
||||
</transition>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div h-50px flex items-center w-full justify-center>
|
||||
<button :disabled="removeFlag" mr-10px @click="handleRemove">
|
||||
移出前三个
|
||||
</button>
|
||||
<button :disabled="backFlag" @click="handleBack">
|
||||
回退
|
||||
</button>
|
||||
</div>
|
||||
<div w-full color="#000" fw-600 text-center pb-10px>
|
||||
<span mr-20px>designer: Teacher Face</span>
|
||||
by: Xc
|
||||
<a
|
||||
class="icon-btn"
|
||||
color="#000"
|
||||
i-carbon-logo-github
|
||||
rel="noreferrer"
|
||||
href="https://github.com/chenxch"
|
||||
target="_blank"
|
||||
title="GitHub"
|
||||
/>
|
||||
<span
|
||||
text-12px
|
||||
color="#00000018"
|
||||
>
|
||||
<span
|
||||
class="icon-btn"
|
||||
text-2
|
||||
i-carbon:arrow-up-left
|
||||
/>
|
||||
star buff</span>
|
||||
</div>
|
||||
<audio
|
||||
ref="clickAudioRef"
|
||||
style="display: none;"
|
||||
controls
|
||||
src="./audio/click.mp3"
|
||||
/>
|
||||
<audio
|
||||
ref="dropAudioRef"
|
||||
style="display: none;"
|
||||
controls
|
||||
src="./audio/drop.mp3"
|
||||
/>
|
||||
<audio
|
||||
ref="winAudioRef"
|
||||
style="display: none;"
|
||||
controls
|
||||
src="./audio/win.mp3"
|
||||
/>
|
||||
<audio
|
||||
ref="loseAudioRef"
|
||||
style="display: none;"
|
||||
controls
|
||||
src="./audio/lose.mp3"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
body{
|
||||
background-color: #c3fe8b;
|
||||
}
|
||||
|
||||
.bounce-enter-active {
|
||||
animation: bounce-in 0.5s;
|
||||
}
|
||||
.bounce-leave-active {
|
||||
animation: bounce-in 0.5s reverse;
|
||||
}
|
||||
@keyframes bounce-in {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.25);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.slide-fade-enter-active {
|
||||
transition: all 0.2s ease-out;
|
||||
}
|
||||
|
||||
.slide-fade-leave-active {
|
||||
transition: all 0.2s cubic-bezier(1, 0.5, 0.8, 1);
|
||||
}
|
||||
|
||||
.slide-fade-enter-from,
|
||||
.slide-fade-leave-to {
|
||||
transform: translateY(25vh);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.v-enter-active,
|
||||
.v-leave-active {
|
||||
transition: opacity 0.5s ease;
|
||||
}
|
||||
|
||||
.v-enter-from,
|
||||
.v-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
BIN
src/assets/1.jpg
Normal file
After Width: | Height: | Size: 41 KiB |
BIN
src/assets/1.png
Normal file
After Width: | Height: | Size: 16 KiB |
BIN
src/assets/10.jpg
Normal file
After Width: | Height: | Size: 128 KiB |
BIN
src/assets/10.png
Normal file
After Width: | Height: | Size: 15 KiB |
BIN
src/assets/11.jpg
Normal file
After Width: | Height: | Size: 126 KiB |
BIN
src/assets/11.png
Normal file
After Width: | Height: | Size: 12 KiB |
BIN
src/assets/12.png
Normal file
After Width: | Height: | Size: 15 KiB |
BIN
src/assets/13.png
Normal file
After Width: | Height: | Size: 14 KiB |
BIN
src/assets/2.jpg
Normal file
After Width: | Height: | Size: 77 KiB |
BIN
src/assets/2.png
Normal file
After Width: | Height: | Size: 14 KiB |
BIN
src/assets/3.jpg
Normal file
After Width: | Height: | Size: 147 KiB |
BIN
src/assets/3.png
Normal file
After Width: | Height: | Size: 17 KiB |
BIN
src/assets/4.jpg
Normal file
After Width: | Height: | Size: 8.9 KiB |
BIN
src/assets/4.png
Normal file
After Width: | Height: | Size: 16 KiB |
BIN
src/assets/5.jpg
Normal file
After Width: | Height: | Size: 30 KiB |
BIN
src/assets/5.png
Normal file
After Width: | Height: | Size: 12 KiB |
BIN
src/assets/6.jpg
Normal file
After Width: | Height: | Size: 23 KiB |
BIN
src/assets/6.png
Normal file
After Width: | Height: | Size: 14 KiB |
BIN
src/assets/7.jpg
Normal file
After Width: | Height: | Size: 48 KiB |
BIN
src/assets/7.png
Normal file
After Width: | Height: | Size: 14 KiB |
BIN
src/assets/8.jpg
Normal file
After Width: | Height: | Size: 158 KiB |
BIN
src/assets/8.png
Normal file
After Width: | Height: | Size: 16 KiB |
BIN
src/assets/9.jpg
Normal file
After Width: | Height: | Size: 129 KiB |
BIN
src/assets/9.png
Normal file
After Width: | Height: | Size: 12 KiB |
Before Width: | Height: | Size: 128 KiB |
Before Width: | Height: | Size: 4.3 KiB |
Before Width: | Height: | Size: 4.5 KiB |
BIN
src/assets/tutu/1.png
Normal file
After Width: | Height: | Size: 8.5 KiB |
BIN
src/assets/tutu/10.png
Normal file
After Width: | Height: | Size: 8.7 KiB |
BIN
src/assets/tutu/11.png
Normal file
After Width: | Height: | Size: 13 KiB |
BIN
src/assets/tutu/12.png
Normal file
After Width: | Height: | Size: 13 KiB |
BIN
src/assets/tutu/13.png
Normal file
After Width: | Height: | Size: 12 KiB |
BIN
src/assets/tutu/2.png
Normal file
After Width: | Height: | Size: 9.3 KiB |
BIN
src/assets/tutu/3.png
Normal file
After Width: | Height: | Size: 9.0 KiB |
BIN
src/assets/tutu/4.png
Normal file
After Width: | Height: | Size: 9.1 KiB |
BIN
src/assets/tutu/5.png
Normal file
After Width: | Height: | Size: 9.0 KiB |
BIN
src/assets/tutu/6.png
Normal file
After Width: | Height: | Size: 9.6 KiB |
BIN
src/assets/tutu/7.png
Normal file
After Width: | Height: | Size: 8.7 KiB |
BIN
src/assets/tutu/8.png
Normal file
After Width: | Height: | Size: 8.0 KiB |
BIN
src/assets/tutu/9.png
Normal file
After Width: | Height: | Size: 7.5 KiB |
BIN
src/assets/tutu2/1.png
Normal file
After Width: | Height: | Size: 7.0 KiB |
BIN
src/assets/tutu2/10.png
Normal file
After Width: | Height: | Size: 7.4 KiB |
BIN
src/assets/tutu2/11.png
Normal file
After Width: | Height: | Size: 12 KiB |
BIN
src/assets/tutu2/12.png
Normal file
After Width: | Height: | Size: 11 KiB |
BIN
src/assets/tutu2/13.png
Normal file
After Width: | Height: | Size: 10 KiB |
BIN
src/assets/tutu2/14.png
Normal file
After Width: | Height: | Size: 11 KiB |
BIN
src/assets/tutu2/15.png
Normal file
After Width: | Height: | Size: 11 KiB |
BIN
src/assets/tutu2/2.png
Normal file
After Width: | Height: | Size: 7.5 KiB |
BIN
src/assets/tutu2/3.png
Normal file
After Width: | Height: | Size: 6.8 KiB |
BIN
src/assets/tutu2/4.png
Normal file
After Width: | Height: | Size: 7.1 KiB |
BIN
src/assets/tutu2/5.png
Normal file
After Width: | Height: | Size: 7.1 KiB |
BIN
src/assets/tutu2/6.png
Normal file
After Width: | Height: | Size: 8.6 KiB |
BIN
src/assets/tutu2/7.png
Normal file
After Width: | Height: | Size: 7.0 KiB |
BIN
src/assets/tutu2/8.png
Normal file
After Width: | Height: | Size: 6.8 KiB |
BIN
src/assets/tutu2/9.png
Normal file
After Width: | Height: | Size: 6.0 KiB |
38
src/components/HelloWorld.vue
Normal file
@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
defineProps<{ msg: string }>()
|
||||
|
||||
const count = ref(0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h1>{{ msg }}</h1>
|
||||
|
||||
<div class="card">
|
||||
<button type="button" @click="count++">count is {{ count }}</button>
|
||||
<p>
|
||||
Edit
|
||||
<code>components/HelloWorld.vue</code> to test HMR
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
Check out
|
||||
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
|
||||
>create-vue</a
|
||||
>, the official Vue + Vite starter
|
||||
</p>
|
||||
<p>
|
||||
Install
|
||||
<a href="https://github.com/johnsoncodehk/volar" target="_blank">Volar</a>
|
||||
in your IDE for a better DX
|
||||
</p>
|
||||
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
</style>
|
@ -1,15 +0,0 @@
|
||||
<template>
|
||||
<div class="my-ad">
|
||||
<a href="https://github.com/liyupi/yulegeyu" target="_blank">
|
||||
<div style="background: rgba(0, 0, 0, 0.8); padding: 12px">
|
||||
<github-outlined />
|
||||
代码完全开源,欢迎 star
|
||||
</div>
|
||||
</a>
|
||||
<!-- <a href="https://space.bilibili.com/12890453/"> 欢迎关注程序员鱼皮 </a>-->
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { GithubOutlined } from "@ant-design/icons-vue";
|
||||
</script>
|
||||
<style></style>
|
75
src/components/card.vue
Normal file
@ -0,0 +1,75 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits(['clickCard'])
|
||||
|
||||
// 加载图片资源
|
||||
const modules = import.meta.glob('../assets/tutu2/*.png', {
|
||||
as: 'url',
|
||||
import: 'default',
|
||||
eager: true,
|
||||
})
|
||||
const IMG_MAP = Object.keys(modules).reduce((acc, cur) => {
|
||||
const key = cur.replace('../assets/tutu2/', '').replace('.png', '')
|
||||
acc[key] = modules[cur]
|
||||
return acc
|
||||
}, {} as Record<string, string>)
|
||||
|
||||
interface Props {
|
||||
node: CardNode
|
||||
isDock?: boolean
|
||||
}
|
||||
const isFreeze = computed(() => {
|
||||
return props.node.parents.length > 0 ? props.node.parents.some(o => o.state < 2) : false
|
||||
},
|
||||
)
|
||||
|
||||
function handleClick() {
|
||||
if (!isFreeze.value)
|
||||
emit('clickCard', props.node)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="card"
|
||||
:style="isDock ? {} : { position: 'absolute', zIndex: node.zIndex, top: `${node.top}px`, left: `${node.left}px` }"
|
||||
@click="handleClick"
|
||||
>
|
||||
<!-- {{ node.zIndex }}-{{ node.type }} -->
|
||||
<!-- {{ node.id }} -->
|
||||
<img :src="IMG_MAP[node.type]" width="40" height="40" :alt="`${node.type}`">
|
||||
<div v-if="isFreeze" class="mask" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.card{
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
/* border: 1px solid red; */
|
||||
background: #f9f7e1;
|
||||
color:#000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #000;
|
||||
box-shadow: 1px 5px 5px -1px #000;
|
||||
cursor: pointer;
|
||||
}
|
||||
img{
|
||||
border-radius: 4px;
|
||||
}
|
||||
.mask {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: 0;
|
||||
left: 0;
|
||||
background-color: rgba(0, 0, 0, 0.55);
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
@ -1,19 +0,0 @@
|
||||
import { RouteRecordRaw } from "vue-router";
|
||||
import IndexPage from "../pages/IndexPage.vue";
|
||||
import GamePage from "../pages/GamePage.vue";
|
||||
import ConfigPage from "../pages/ConfigPage.vue";
|
||||
|
||||
export default [
|
||||
{
|
||||
path: "/",
|
||||
component: IndexPage,
|
||||
},
|
||||
{
|
||||
path: "/game",
|
||||
component: GamePage,
|
||||
},
|
||||
{
|
||||
path: "/config",
|
||||
component: ConfigPage,
|
||||
},
|
||||
] as RouteRecordRaw[];
|
512
src/core/game.ts
@ -1,512 +0,0 @@
|
||||
/**
|
||||
* 游戏逻辑 V2(不固定 level)
|
||||
*
|
||||
* @author yupi https://github.com/liyupi
|
||||
*/
|
||||
import { useGlobalStore } from "./globalStore";
|
||||
// @ts-ignore
|
||||
import _ from "lodash";
|
||||
import { ref } from "vue";
|
||||
|
||||
const useGame = () => {
|
||||
const { gameConfig } = useGlobalStore();
|
||||
|
||||
// 游戏状态:0 - 初始化, 1 - 进行中, 2 - 失败结束, 3 - 胜利
|
||||
const gameStatus = ref(0);
|
||||
|
||||
// 各层块
|
||||
const levelBlocksVal = ref<BlockType[]>([]);
|
||||
// 随机区块
|
||||
const randomBlocksVal = ref<BlockType[][]>([]);
|
||||
// 插槽区
|
||||
const slotAreaVal = ref<BlockType[]>([]);
|
||||
// 当前槽占用数
|
||||
const currSlotNum = ref(0);
|
||||
|
||||
// 保存所有块(包括随机块)
|
||||
const allBlocks: BlockType[] = [];
|
||||
const blockData: Record<number, BlockType> = {};
|
||||
|
||||
// 总块数
|
||||
let totalBlockNum = ref(0);
|
||||
|
||||
// 已消除块数
|
||||
let clearBlockNum = ref(0);
|
||||
|
||||
// 总共划分 24 x 24 的格子,每个块占 3 x 3 的格子,生成的起始 x 和 y 坐标范围均为 0 ~ 21
|
||||
const boxWidthNum = 24;
|
||||
const boxHeightNum = 24;
|
||||
|
||||
// 每个格子的宽高
|
||||
const widthUnit = 14;
|
||||
const heightUnit = 14;
|
||||
|
||||
// 保存整个 "棋盘" 的每个格子状态(下标为格子起始点横纵坐标)
|
||||
let chessBoard: ChessBoardUnitType[][] = [];
|
||||
|
||||
// 操作历史(存储点击的块)
|
||||
let opHistory: BlockType[] = [];
|
||||
|
||||
// region 技能相关
|
||||
|
||||
const isHolyLight = ref(false);
|
||||
|
||||
const canSeeRandom = ref(false);
|
||||
|
||||
// endregion
|
||||
|
||||
/**
|
||||
* 初始化指定大小的棋盘
|
||||
* @param width
|
||||
* @param height
|
||||
*/
|
||||
const initChessBoard = (width: number, height: number) => {
|
||||
chessBoard = new Array(width);
|
||||
for (let i = 0; i < width; i++) {
|
||||
chessBoard[i] = new Array(height);
|
||||
for (let j = 0; j < height; j++) {
|
||||
chessBoard[i][j] = {
|
||||
blocks: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 初始化棋盘
|
||||
initChessBoard(boxWidthNum, boxHeightNum);
|
||||
|
||||
/**
|
||||
* 游戏初始化
|
||||
*/
|
||||
const initGame = () => {
|
||||
console.log("initGame", gameConfig);
|
||||
|
||||
// 0. 设置父容器宽高
|
||||
const levelBoardDom: any = document.getElementsByClassName("level-board");
|
||||
levelBoardDom[0].style.width = widthUnit * boxWidthNum + "px";
|
||||
levelBoardDom[0].style.height = heightUnit * boxHeightNum + "px";
|
||||
|
||||
// 1. 规划块数
|
||||
// 块数单位(总块数必须是该值的倍数)
|
||||
const blockNumUnit = gameConfig.composeNum * gameConfig.typeNum;
|
||||
console.log("块数单位", blockNumUnit);
|
||||
|
||||
// 随机生成的总块数
|
||||
const totalRandomBlockNum = gameConfig.randomBlocks.reduce(
|
||||
(pre: number, curr: number) => {
|
||||
return pre + curr;
|
||||
},
|
||||
0
|
||||
);
|
||||
console.log("随机生成的总块数", totalRandomBlockNum);
|
||||
|
||||
// 需要的最小块数
|
||||
const minBlockNum =
|
||||
gameConfig.levelNum * gameConfig.levelBlockNum + totalRandomBlockNum;
|
||||
console.log("需要的最小块数", minBlockNum);
|
||||
|
||||
// 补齐到 blockNumUnit 的倍数
|
||||
// e.g. minBlockNum = 14, blockNumUnit = 6, 补到 18
|
||||
totalBlockNum.value = minBlockNum;
|
||||
if (totalBlockNum.value % blockNumUnit !== 0) {
|
||||
totalBlockNum.value =
|
||||
(Math.floor(minBlockNum / blockNumUnit) + 1) * blockNumUnit;
|
||||
}
|
||||
console.log("总块数", totalBlockNum.value);
|
||||
|
||||
// 2. 初始化块,随机生成块的内容
|
||||
// 保存所有块的数组
|
||||
const animalBlocks: string[] = [];
|
||||
// 需要用到的动物数组
|
||||
const needAnimals = gameConfig.animals.slice(0, gameConfig.typeNum);
|
||||
// 依次把块塞到数组里
|
||||
for (let i = 0; i < totalBlockNum.value; i++) {
|
||||
animalBlocks.push(needAnimals[i % gameConfig.typeNum]);
|
||||
}
|
||||
// 打乱数组
|
||||
const randomAnimalBlocks = _.shuffle(animalBlocks);
|
||||
|
||||
// 初始化
|
||||
for (let i = 0; i < totalBlockNum.value; i++) {
|
||||
const newBlock = {
|
||||
id: i,
|
||||
status: 0,
|
||||
level: 0,
|
||||
type: randomAnimalBlocks[i],
|
||||
higherThanBlocks: [] as BlockType[],
|
||||
lowerThanBlocks: [] as BlockType[],
|
||||
} as BlockType;
|
||||
allBlocks.push(newBlock);
|
||||
}
|
||||
|
||||
// 下一个要塞入的块
|
||||
let pos = 0;
|
||||
|
||||
// 3. 计算随机生成的块
|
||||
const randomBlocks: BlockType[][] = [];
|
||||
gameConfig.randomBlocks.forEach((randomBlock: number, idx: number) => {
|
||||
randomBlocks[idx] = [];
|
||||
for (let i = 0; i < randomBlock; i++) {
|
||||
randomBlocks[idx].push(allBlocks[pos]);
|
||||
blockData[pos] = allBlocks[pos];
|
||||
pos++;
|
||||
}
|
||||
});
|
||||
|
||||
// 剩余块数
|
||||
let leftBlockNum = totalBlockNum.value - totalRandomBlockNum;
|
||||
|
||||
// 4. 计算有层级关系的块
|
||||
const levelBlocks: BlockType[] = [];
|
||||
let minX = 0;
|
||||
let maxX = 22;
|
||||
let minY = 0;
|
||||
let maxY = 22;
|
||||
// 分为 gameConfig.levelNum 批,依次生成,每批的边界不同
|
||||
for (let i = 0; i < gameConfig.levelNum; i++) {
|
||||
let nextBlockNum = Math.min(gameConfig.levelBlockNum, leftBlockNum);
|
||||
// 最后一批,分配所有 leftBlockNum
|
||||
if (i == gameConfig.levelNum - 1) {
|
||||
nextBlockNum = leftBlockNum;
|
||||
}
|
||||
// 边界收缩
|
||||
if (gameConfig.borderStep > 0) {
|
||||
const dir = i % 4;
|
||||
if (i > 0) {
|
||||
if (dir === 0) {
|
||||
minX += gameConfig.borderStep;
|
||||
} else if (dir === 1) {
|
||||
maxY -= gameConfig.borderStep;
|
||||
} else if (dir === 2) {
|
||||
minY += gameConfig.borderStep;
|
||||
} else {
|
||||
maxX -= gameConfig.borderStep;
|
||||
}
|
||||
}
|
||||
}
|
||||
const nextGenBlocks = allBlocks.slice(pos, pos + nextBlockNum);
|
||||
levelBlocks.push(...nextGenBlocks);
|
||||
pos = pos + nextBlockNum;
|
||||
// 生成块的坐标
|
||||
genLevelBlockPos(nextGenBlocks, minX, minY, maxX, maxY);
|
||||
leftBlockNum -= nextBlockNum;
|
||||
if (leftBlockNum <= 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
console.log("最终剩余块数", leftBlockNum);
|
||||
|
||||
// 4. 初始化空插槽
|
||||
const slotArea: BlockType[] = new Array(gameConfig.slotNum).fill(null);
|
||||
console.log("随机块情况", randomBlocks);
|
||||
|
||||
return {
|
||||
levelBlocks,
|
||||
randomBlocks,
|
||||
slotArea,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 生成一批层级块(坐标、层级关系)
|
||||
* @param blocks
|
||||
* @param minX
|
||||
* @param minY
|
||||
* @param maxX
|
||||
* @param maxY
|
||||
*/
|
||||
const genLevelBlockPos = (
|
||||
blocks: BlockType[],
|
||||
minX: number,
|
||||
minY: number,
|
||||
maxX: number,
|
||||
maxY: number
|
||||
) => {
|
||||
// 记录这批块的坐标,用于保证同批次元素不能完全重叠
|
||||
const currentPosSet = new Set<string>();
|
||||
for (let i = 0; i < blocks.length; i++) {
|
||||
const block = blocks[i];
|
||||
// 随机生成坐标
|
||||
let newPosX;
|
||||
let newPosY;
|
||||
let key;
|
||||
while (true) {
|
||||
newPosX = Math.floor(Math.random() * maxX + minX);
|
||||
newPosY = Math.floor(Math.random() * maxY + minY);
|
||||
key = newPosX + "," + newPosY;
|
||||
// 同批次元素不能完全重叠
|
||||
if (!currentPosSet.has(key)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
chessBoard[newPosX][newPosY].blocks.push(block);
|
||||
currentPosSet.add(key);
|
||||
block.x = newPosX;
|
||||
block.y = newPosY;
|
||||
// 填充层级关系
|
||||
genLevelRelation(block);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 给块绑定层级关系(用于确认哪些元素是当前可点击的)
|
||||
* 核心逻辑:每个块压住和其坐标有交集棋盘格内所有 level 大于它的点,双向建立联系
|
||||
* @param block
|
||||
*/
|
||||
const genLevelRelation = (block: BlockType) => {
|
||||
// 确定该块附近的格子坐标范围
|
||||
const minX = Math.max(block.x - 2, 0);
|
||||
const minY = Math.max(block.y - 2, 0);
|
||||
const maxX = Math.min(block.x + 3, boxWidthNum - 2);
|
||||
const maxY = Math.min(block.y + 3, boxWidthNum - 2);
|
||||
// 遍历该块附近的格子
|
||||
let maxLevel = 0;
|
||||
for (let i = minX; i < maxX; i++) {
|
||||
for (let j = minY; j < maxY; j++) {
|
||||
const relationBlocks = chessBoard[i][j].blocks;
|
||||
if (relationBlocks.length > 0) {
|
||||
// 取当前位置最高层的块
|
||||
const maxLevelRelationBlock =
|
||||
relationBlocks[relationBlocks.length - 1];
|
||||
// 排除自己
|
||||
if (maxLevelRelationBlock.id === block.id) {
|
||||
continue;
|
||||
}
|
||||
maxLevel = Math.max(maxLevel, maxLevelRelationBlock.level);
|
||||
block.higherThanBlocks.push(maxLevelRelationBlock);
|
||||
maxLevelRelationBlock.lowerThanBlocks.push(block);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 比最高层的块再高一层(初始为 1)
|
||||
block.level = maxLevel + 1;
|
||||
};
|
||||
|
||||
/**
|
||||
* 点击块事件
|
||||
* @param block
|
||||
* @param randomIdx 随机区域下标,>= 0 表示点击的是随机块
|
||||
* @param force 强制移除
|
||||
*/
|
||||
const doClickBlock = (block: BlockType, randomIdx = -1, force = false) => {
|
||||
// 已经输了 / 已经被点击 / 有上层块(且非强制和圣光),不能再点击
|
||||
if (
|
||||
currSlotNum.value >= gameConfig.slotNum ||
|
||||
block.status !== 0 ||
|
||||
(block.lowerThanBlocks.length > 0 && !force && !isHolyLight.value)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
isHolyLight.value = false;
|
||||
// 修改元素状态为已点击
|
||||
block.status = 1;
|
||||
// 移除当前元素
|
||||
if (randomIdx >= 0) {
|
||||
// 移除所点击的随机区域的第一个元素
|
||||
randomBlocksVal.value[randomIdx] = randomBlocksVal.value[randomIdx].slice(
|
||||
1,
|
||||
randomBlocksVal.value[randomIdx].length
|
||||
);
|
||||
} else {
|
||||
// 非随机区才可撤回
|
||||
opHistory.push(block);
|
||||
// 移除覆盖关系
|
||||
block.higherThanBlocks.forEach((higherThanBlock) => {
|
||||
_.remove(higherThanBlock.lowerThanBlocks, (lowerThanBlock) => {
|
||||
return lowerThanBlock.id === block.id;
|
||||
});
|
||||
});
|
||||
}
|
||||
// 新元素加入插槽
|
||||
let tempSlotNum = currSlotNum.value;
|
||||
slotAreaVal.value[tempSlotNum] = block;
|
||||
// 检查是否有可消除的
|
||||
// block => 出现次数
|
||||
const map: Record<string, number> = {};
|
||||
// 去除空槽
|
||||
const tempSlotAreaVal = slotAreaVal.value.filter(
|
||||
(slotBlock) => !!slotBlock
|
||||
);
|
||||
tempSlotAreaVal.forEach((slotBlock) => {
|
||||
const type = slotBlock.type;
|
||||
if (!map[type]) {
|
||||
map[type] = 1;
|
||||
} else {
|
||||
map[type]++;
|
||||
}
|
||||
});
|
||||
console.log("tempSlotAreaVal", tempSlotAreaVal);
|
||||
console.log("map", map);
|
||||
// 得到新数组
|
||||
const newSlotAreaVal = new Array(gameConfig.slotNum).fill(null);
|
||||
tempSlotNum = 0;
|
||||
tempSlotAreaVal.forEach((slotBlock) => {
|
||||
// 成功消除(不添加到新数组中)
|
||||
if (map[slotBlock.type] >= gameConfig.composeNum) {
|
||||
// 块状态改为已消除
|
||||
slotBlock.status = 2;
|
||||
// 已消除块数 +1
|
||||
clearBlockNum.value++;
|
||||
// 清除操作记录,防止撤回
|
||||
opHistory = [];
|
||||
return;
|
||||
}
|
||||
newSlotAreaVal[tempSlotNum++] = slotBlock;
|
||||
});
|
||||
slotAreaVal.value = newSlotAreaVal;
|
||||
currSlotNum.value = tempSlotNum;
|
||||
// 游戏结束
|
||||
if (tempSlotNum >= gameConfig.slotNum) {
|
||||
gameStatus.value = 2;
|
||||
setTimeout(() => {
|
||||
alert("你输了");
|
||||
}, 2000);
|
||||
}
|
||||
if (clearBlockNum.value >= totalBlockNum.value) {
|
||||
gameStatus.value = 3;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 开始游戏
|
||||
*/
|
||||
const doStart = () => {
|
||||
gameStatus.value = 0;
|
||||
const { levelBlocks, randomBlocks, slotArea } = initGame();
|
||||
console.log(levelBlocks, randomBlocks, slotArea);
|
||||
levelBlocksVal.value = levelBlocks;
|
||||
randomBlocksVal.value = randomBlocks;
|
||||
slotAreaVal.value = slotArea;
|
||||
gameStatus.value = 1;
|
||||
};
|
||||
|
||||
// region 技能
|
||||
|
||||
/**
|
||||
* 洗牌
|
||||
*
|
||||
* @desc 随机重洗所有未被点击的块
|
||||
*/
|
||||
const doShuffle = () => {
|
||||
// 遍历所有未消除的块
|
||||
const originBlocks = allBlocks.filter((block) => block.status === 0);
|
||||
const newBlockTypes = _.shuffle(originBlocks.map((block) => block.type));
|
||||
let pos = 0;
|
||||
originBlocks.forEach((block) => {
|
||||
block.type = newBlockTypes[pos++];
|
||||
});
|
||||
levelBlocksVal.value = [...levelBlocksVal.value];
|
||||
};
|
||||
|
||||
/**
|
||||
* 破坏
|
||||
*
|
||||
* @desc 消除一组层级块
|
||||
*/
|
||||
const doBroke = () => {
|
||||
// 类型,块列表映射
|
||||
const typeBlockMap: Record<string, BlockType[]> = {};
|
||||
const blocks = levelBlocksVal.value.filter((block) => block.status === 0);
|
||||
// 遍历所有未消除的层级块
|
||||
for (let i = 0; i < blocks.length; i++) {
|
||||
const block = blocks[i];
|
||||
if (!typeBlockMap[block.type]) {
|
||||
typeBlockMap[block.type] = [];
|
||||
}
|
||||
typeBlockMap[block.type].push(block);
|
||||
// 有能消除的一组块
|
||||
if (typeBlockMap[block.type].length >= gameConfig.composeNum) {
|
||||
typeBlockMap[block.type].forEach((clickBlock) => {
|
||||
doClickBlock(clickBlock, -1, true);
|
||||
});
|
||||
console.log("doBroke", typeBlockMap[block.type]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 撤回
|
||||
*
|
||||
* @desc 后退一步
|
||||
*/
|
||||
const doRevert = () => {
|
||||
if (opHistory.length < 1) {
|
||||
return;
|
||||
}
|
||||
opHistory[opHistory.length - 1].status = 0;
|
||||
// @ts-ignore
|
||||
slotAreaVal.value[currSlotNum.value - 1] = null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 移出块
|
||||
*/
|
||||
const doRemove = () => {
|
||||
// 移除第一个块
|
||||
const block = slotAreaVal.value[0];
|
||||
if (!block) {
|
||||
return;
|
||||
}
|
||||
// 槽移除块
|
||||
for (let i = 0; i < slotAreaVal.value.length - 1; i++) {
|
||||
slotAreaVal.value[i] = slotAreaVal.value[i + 1];
|
||||
}
|
||||
// @ts-ignore
|
||||
slotAreaVal.value[slotAreaVal.value.length - 1] = null;
|
||||
// 改变新块的坐标
|
||||
block.x = Math.floor(Math.random() * (boxWidthNum - 2));
|
||||
block.y = boxHeightNum - 2;
|
||||
block.status = 0;
|
||||
// 移除的是随机块的元素,移到层级区域
|
||||
if (block.level < 1) {
|
||||
block.level = 10000;
|
||||
levelBlocksVal.value.push(block);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 圣光
|
||||
*
|
||||
* @desc 下一个块可以任意点击
|
||||
*/
|
||||
const doHolyLight = () => {
|
||||
isHolyLight.value = true;
|
||||
};
|
||||
|
||||
/**
|
||||
* 透视
|
||||
*
|
||||
* @desc 可以看到随机块
|
||||
*/
|
||||
const doSeeRandom = () => {
|
||||
canSeeRandom.value = !canSeeRandom.value;
|
||||
};
|
||||
|
||||
// endregion
|
||||
|
||||
return {
|
||||
gameStatus,
|
||||
levelBlocksVal,
|
||||
randomBlocksVal,
|
||||
slotAreaVal,
|
||||
widthUnit,
|
||||
heightUnit,
|
||||
currSlotNum,
|
||||
opHistory,
|
||||
totalBlockNum,
|
||||
clearBlockNum,
|
||||
isHolyLight,
|
||||
canSeeRandom,
|
||||
doClickBlock,
|
||||
doStart,
|
||||
doShuffle,
|
||||
doBroke,
|
||||
doRemove,
|
||||
doRevert,
|
||||
doHolyLight,
|
||||
doSeeRandom,
|
||||
};
|
||||
};
|
||||
|
||||
export default useGame;
|