diff --git a/.stylelintrc.cjs b/.stylelintrc.cjs index 4ec655e..c2d57d2 100644 --- a/.stylelintrc.cjs +++ b/.stylelintrc.cjs @@ -10,6 +10,7 @@ module.exports = { 'stylelint-config-prettier-scss', ], rules: { - 'selector-class-pattern': '^[a-z][a-zA-Z0-9]+$', + // 后续统一 + 'selector-class-pattern': '^[a-zA-Z0-9-_]+$', }, }; diff --git a/README.md b/README.md index 3ad31eb..0f9286b 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,8 @@ vite+react 实现,欢迎 star、issue、pr、fork(尽量标注原仓库地 - [x] 关卡生成 - [x] UI/UX 优化 - [x] 多主题 -- [ ] 计时、得分排行、保存进度机制 +- [x] 计时、得分、保存进度机制 +- [ ] 排行榜 - [ ] 性能优化 - [x] BGM/音效 - [ ] ~~点击时的缓冲队列,优化交互动画效果~~ diff --git a/diy/README.md b/diy/README.md index 2ff86e0..30fd680 100644 --- a/diy/README.md +++ b/diy/README.md @@ -37,6 +37,7 @@ yarn install - backgroundBlur 背景图片是否添加毛玻璃效果 - backgroundColor 背景颜色 CSS 色值 - pure 纯净模式,DIY 时已开启 +- maxLevel 最大关卡数,默认 50 - sounds 音效数组 - name 名称 - src 音效文件相对于 `diy/public` 的路径 @@ -88,9 +89,9 @@ ps: 如果您的项目托管在公共仓库中,请注意保护密钥,本地 应用创建后,点击【云数据库】,创建两个表 `config` 和 `file` -`config` 表用来存储自定义配置的json字符串,需要新增 `content` 列 +`config` 表用来存储自定义配置的 json 字符串,需要新增 `content` 列 -`file` 表则是为了vercel流量,将一些默认文件转为base64编码存到了数据库中,需要添加三列 +`file` 表则是为了 vercel 流量,将一些默认文件转为 base64 编码存到了数据库中,需要添加三列 ![img.png](database-file.png) 最后,开发和打包命令分别使用 `yarn dev` 和 `yarn build` 即可 diff --git a/diy/diy.theme.json b/diy/diy.theme.json index a19b909..f93c521 100644 --- a/diy/diy.theme.json +++ b/diy/diy.theme.json @@ -7,6 +7,7 @@ "backgroundBlur": false, "backgroundColor": "#8dac85", "pure": true, + "maxLevel": 50, "icons": [ { "name": "1", diff --git a/src/App.tsx b/src/App.tsx index 884317c..0e3ce6d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,6 +4,7 @@ import { domRelatedOptForTheme, LAST_LEVEL_STORAGE_KEY, LAST_SCORE_STORAGE_KEY, + LAST_TIME_STORAGE_KEY, wrapThemeDefaultSounds, } from './utils'; import { Theme } from './themes/interface'; @@ -18,6 +19,7 @@ const ConfigDialog = React.lazy(() => import('./components/ConfigDialog')); // 读取缓存关卡得分 const initLevel = Number(localStorage.getItem(LAST_LEVEL_STORAGE_KEY) || '1'); const initScore = Number(localStorage.getItem(LAST_SCORE_STORAGE_KEY) || '0'); +const initTime = Number(localStorage.getItem(LAST_TIME_STORAGE_KEY) || '0'); const App: FC<{ theme: Theme }> = ({ theme: initTheme }) => { console.log('initTheme', initTheme); @@ -69,6 +71,7 @@ const App: FC<{ theme: Theme }> = ({ theme: initTheme }) => { theme={theme} initLevel={initLevel} initScore={initScore} + initTime={initTime} />
diff --git a/src/components/Game.scss b/src/components/Game.scss index a7666b1..7b253a5 100644 --- a/src/components/Game.scss +++ b/src/components/Game.scss @@ -105,8 +105,9 @@ } .level { - font-size: 1.8em; + font-size: 1.5em; font-weight: 900; line-height: 2; text-shadow: 4px 6px 2px rgb(0 0 0 / 20%); + font-family: Menlo, Monaco, 'Courier New', monospace, serif; } diff --git a/src/components/Game.tsx b/src/components/Game.tsx index 28063b9..aeed5bd 100644 --- a/src/components/Game.tsx +++ b/src/components/Game.tsx @@ -5,53 +5,56 @@ import React, { useRef, useState, } from 'react'; - import './Game.scss'; import { LAST_LEVEL_STORAGE_KEY, LAST_SCORE_STORAGE_KEY, + LAST_TIME_STORAGE_KEY, randomString, waitTimeout, } from '../utils'; import { Icon, Theme } from '../themes/interface'; -// 最大关卡 -const maxLevel = 50; - interface MySymbol { id: string; - status: number; // 0->1->2 + status: number; // 0->1->2 正常->队列中->三连 isCover: boolean; x: number; y: number; icon: Icon; } - type Scene = MySymbol[]; -// 8*8网格 4*4->8*8 +// 随机位置、偏移量 +const randomPositionOffset: ( + offsetPool: number[], + range: number[] +) => { offset: number; row: number; column: number } = (offsetPool, range) => { + const offset = offsetPool[Math.floor(offsetPool.length * Math.random())]; + const row = range[0] + Math.floor((range[1] - range[0]) * Math.random()); + const column = range[0] + Math.floor((range[1] - range[0]) * Math.random()); + return { offset, row, column }; +}; + +// 制作场景:8*8虚拟网格 4*4->8*8 +const sceneRanges = [ + [2, 6], + [1, 6], + [1, 7], + [0, 7], + [0, 8], +]; +const offsets = [0, 25, -25, 50, -50]; const makeScene: (level: number, icons: Icon[]) => Scene = (level, icons) => { - const curLevel = Math.min(maxLevel, level); - const iconPool = icons.slice(0, 2 * curLevel); - const offsetPool = [0, 25, -25, 50, -50].slice(0, 1 + curLevel); - + // 初始图标x2 + const iconPool = icons.slice(0, 2 * level); + const offsetPool = offsets.slice(0, 1 + level); const scene: Scene = []; - - const range = [ - [2, 6], - [1, 6], - [1, 7], - [0, 7], - [0, 8], - ][Math.min(4, curLevel - 1)]; - + // 网格范围,随等级由中心扩满 + const range = sceneRanges[Math.min(4, level - 1)]; + // 在范围内随机摆放图标 const randomSet = (icon: Icon) => { - const offset = - offsetPool[Math.floor(offsetPool.length * Math.random())]; - const row = - range[0] + Math.floor((range[1] - range[0]) * Math.random()); - const column = - range[0] + Math.floor((range[1] - range[0]) * Math.random()); + const { offset, row, column } = randomPositionOffset(offsetPool, range); scene.push({ isCover: false, status: 0, @@ -61,22 +64,20 @@ const makeScene: (level: number, icons: Icon[]) => Scene = (level, icons) => { y: row * 100 + offset, }); }; - - // 大于5级别增加icon池 - let compareLevel = curLevel; + // 每间隔5级别增加icon池 + let compareLevel = level; while (compareLevel > 0) { iconPool.push( ...iconPool.slice(0, Math.min(10, 2 * (compareLevel - 5))) ); compareLevel -= 5; } - + // icon池中每个生成六张卡片 for (const icon of iconPool) { for (let i = 0; i < 6; i++) { randomSet(icon); } } - return scene; }; @@ -92,40 +93,29 @@ const fastShuffle: (arr: T[]) => T[] = (arr) => { // 洗牌 const washScene: (level: number, scene: Scene) => Scene = (level, scene) => { + // 打乱顺序 const updateScene = fastShuffle(scene); - const offsetPool = [0, 25, -25, 50, -50].slice(0, 1 + level); - const range = [ - [2, 6], - [1, 6], - [1, 7], - [0, 7], - [0, 8], - ][Math.min(4, level - 1)]; - + const offsetPool = offsets.slice(0, 1 + level); + const range = sceneRanges[Math.min(4, level - 1)]; + // 重新设置位置 const randomSet = (symbol: MySymbol) => { - const offset = - offsetPool[Math.floor(offsetPool.length * Math.random())]; - const row = - range[0] + Math.floor((range[1] - range[0]) * Math.random()); - const column = - range[0] + Math.floor((range[1] - range[0]) * Math.random()); + const { offset, row, column } = randomPositionOffset(offsetPool, range); symbol.x = column * 100 + offset; symbol.y = row * 100 + offset; symbol.isCover = false; }; - + // 仅对仍在牌堆中的进行重置 for (const symbol of updateScene) { if (symbol.status !== 0) continue; randomSet(symbol); } - return updateScene; }; +// icon对应的组件 interface SymbolProps extends MySymbol { onClick: MouseEventHandler; } - const Symbol: FC = ({ x, y, icon, isCover, status, onClick }) => { return (
= ({ x, y, icon, isCover, status, onClick }) => { const Game: FC<{ theme: Theme; - initLevel: number; - initScore: number; -}> = ({ theme, initLevel, initScore }) => { - console.log('Game FC'); + initLevel?: number; + initScore?: number; + initTime?: number; +}> = ({ theme, initLevel = 1, initScore = 0, initTime = 0 }) => { + const maxLevel = theme.maxLevel || 50; const [scene, setScene] = useState( makeScene(initLevel, theme.icons) ); @@ -185,6 +176,7 @@ const Game: FC<{ const bgmRef = useRef(null); const [bgmOn, setBgmOn] = useState(false); const [once, setOnce] = useState(false); + useEffect(() => { if (!bgmRef.current) return; if (bgmOn) { @@ -199,6 +191,7 @@ const Game: FC<{ useEffect(() => { localStorage.setItem(LAST_LEVEL_STORAGE_KEY, level.toString()); localStorage.setItem(LAST_SCORE_STORAGE_KEY, score.toString()); + localStorage.setItem(LAST_TIME_STORAGE_KEY, usedTime.toString()); }, [level]); // 队列区排序 @@ -246,11 +239,9 @@ const Game: FC<{ for (let j = i + 1; j < updateScene.length; j++) { const compare = updateScene[j]; if (compare.status !== 0) continue; - // 两区域有交集视为选中 // 两区域不重叠情况取反即为交集 const { x, y } = compare; - if (!(y + 100 <= y1 || y >= y2 || x + 100 <= x1 || x >= x2)) { cur.isCover = true; break; @@ -279,7 +270,7 @@ const Game: FC<{ // 音效 if (soundRefMap.current?.['sound-shift']) { soundRefMap.current['sound-shift'].currentTime = 0; - soundRefMap.current['sound-shift'].play(); + soundRefMap.current['sound-shift'].play().then(); } } }; @@ -299,7 +290,7 @@ const Game: FC<{ // 音效 if (soundRefMap.current?.['sound-undo']) { soundRefMap.current['sound-undo'].currentTime = 0; - soundRefMap.current['sound-undo'].play(); + soundRefMap.current['sound-undo'].play().then(); } } }; @@ -311,15 +302,17 @@ const Game: FC<{ // 音效 if (soundRefMap.current?.['sound-wash']) { soundRefMap.current['sound-wash'].currentTime = 0; - soundRefMap.current['sound-wash'].play(); + soundRefMap.current['sound-wash'].play().then(); } }; - // 加大难度 + // 加大难度,该方法由玩家点击下一关触发 const levelUp = () => { if (level >= maxLevel) { return; } + // 跳关扣关卡对应数值的分 + setScore(score - level); setFinished(false); setLevel(level + 1); setQueue([]); @@ -333,15 +326,19 @@ const Game: FC<{ setLevel(1); setQueue([]); checkCover(makeScene(1, theme.icons)); + setUsedTime(0); + startTimer(true); }; // 点击item const clickSymbol = async (idx: number) => { if (finished || animating) return; + // 第一次点击时,播放bgm,开启计时 if (!once) { setBgmOn(true); setOnce(true); + startTimer(); } const updateScene = scene.slice(); @@ -350,26 +347,25 @@ const Game: FC<{ symbol.status = 1; // 点击音效 - // 不知道为啥敲可选链会提示错误。。。 - if ( - soundRefMap.current && - soundRefMap.current[symbol.icon.clickSound] - ) { + if (soundRefMap.current?.[symbol.icon.clickSound]) { soundRefMap.current[symbol.icon.clickSound].currentTime = 0; soundRefMap.current[symbol.icon.clickSound].play().then(); } + // 将点击项目加入队列 let updateQueue = queue.slice(); updateQueue.push(symbol); - setQueue(updateQueue); checkCover(updateScene); + // 动画锁 150ms setAnimating(true); await waitTimeout(150); + // 查找当前队列中与点击项相同的 const filterSame = updateQueue.filter((sb) => sb.icon === symbol.icon); + // 后续状态判断 // 三连了 if (filterSame.length === 3) { // 三连一次+3分 @@ -380,10 +376,7 @@ const Game: FC<{ if (find) { find.status = 2; // 三连音效 - if ( - soundRefMap.current && - soundRefMap.current[symbol.icon.tripleSound] - ) { + if (soundRefMap.current?.[symbol.icon.tripleSound]) { soundRefMap.current[ symbol.icon.tripleSound ].currentTime = 0; @@ -422,6 +415,34 @@ const Game: FC<{ setAnimating(false); }; + // 计时相关 + const [startTime, setStartTime] = useState(0); + const [now, setNow] = useState(0); + const [usedTime, setUsedTime] = useState(initTime); + const intervalRef = useRef(null); + // 结束时重置计时器和关卡信息 + useEffect(() => { + if (finished) { + intervalRef.current && clearInterval(intervalRef.current); + localStorage.setItem(LAST_LEVEL_STORAGE_KEY, '1'); + localStorage.setItem(LAST_SCORE_STORAGE_KEY, '0'); + localStorage.setItem(LAST_TIME_STORAGE_KEY, '0'); + } + }, [finished]); + // 更新使用时间 + useEffect(() => { + if (startTime && now) setUsedTime(now - startTime); + }, [now]); + // 计时器 + const startTimer = (restart?: boolean) => { + setStartTime(Date.now() - (restart ? 0 : initTime)); + setNow(Date.now()); + intervalRef.current && clearInterval(intervalRef.current); + intervalRef.current = setInterval(() => { + setNow(Date.now()); + }, 10); + }; + return ( <>
@@ -456,30 +477,27 @@ const Game: FC<{ -
- 关卡{level}|剩余{scene.filter((i) => i.status === 0).length} - |得分{score} + 关卡{level}/{maxLevel} 剩余 + {scene.filter((i) => i.status === 0).length} +
+ 得分{score} +
+ 用时{(usedTime / 1000).toFixed(3)}秒
- {/*提示弹窗*/} {finished && (

{tipText}

+

得分{score}

+

用时{(usedTime / 1000).toFixed(3)}秒

)} - {/*bgm*/} {theme.bgm && ( )} - {/*音效*/} {theme.sounds.map((sound) => (