feat: 计时、最大关卡配置

This commit is contained in:
streakingman 2022-10-10 03:39:46 +08:00
parent 84fa8483d9
commit b7482fa209
11 changed files with 120 additions and 88 deletions

View File

@ -10,6 +10,7 @@ module.exports = {
'stylelint-config-prettier-scss', 'stylelint-config-prettier-scss',
], ],
rules: { rules: {
'selector-class-pattern': '^[a-z][a-zA-Z0-9]+$', // 后续统一
'selector-class-pattern': '^[a-zA-Z0-9-_]+$',
}, },
}; };

View File

@ -56,7 +56,8 @@ vite+react 实现,欢迎 star、issue、pr、fork尽量标注原仓库地
- [x] 关卡生成 - [x] 关卡生成
- [x] UI/UX 优化 - [x] UI/UX 优化
- [x] 多主题 - [x] 多主题
- [ ] 计时、得分排行、保存进度机制 - [x] 计时、得分、保存进度机制
- [ ] 排行榜
- [ ] 性能优化 - [ ] 性能优化
- [x] BGM/音效 - [x] BGM/音效
- [ ] ~~点击时的缓冲队列,优化交互动画效果~~ - [ ] ~~点击时的缓冲队列,优化交互动画效果~~

View File

@ -37,6 +37,7 @@ yarn install
- backgroundBlur 背景图片是否添加毛玻璃效果 - backgroundBlur 背景图片是否添加毛玻璃效果
- backgroundColor 背景颜色 CSS 色值 - backgroundColor 背景颜色 CSS 色值
- pure 纯净模式DIY 时已开启 - pure 纯净模式DIY 时已开启
- maxLevel 最大关卡数,默认 50
- sounds 音效数组 - sounds 音效数组
- name 名称 - name 名称
- src 音效文件相对于 `diy/public` 的路径 - src 音效文件相对于 `diy/public` 的路径
@ -88,9 +89,9 @@ ps: 如果您的项目托管在公共仓库中,请注意保护密钥,本地
应用创建后,点击【云数据库】,创建两个表 `config``file` 应用创建后,点击【云数据库】,创建两个表 `config``file`
`config` 表用来存储自定义配置的json字符串需要新增 `content` `config` 表用来存储自定义配置的 json 字符串,需要新增 `content`
`file` 表则是为了vercel流量将一些默认文件转为base64编码存到了数据库中需要添加三列 `file` 表则是为了 vercel 流量,将一些默认文件转为 base64 编码存到了数据库中,需要添加三列
![img.png](database-file.png) ![img.png](database-file.png)
最后,开发和打包命令分别使用 `yarn dev``yarn build` 即可 最后,开发和打包命令分别使用 `yarn dev``yarn build` 即可

View File

@ -7,6 +7,7 @@
"backgroundBlur": false, "backgroundBlur": false,
"backgroundColor": "#8dac85", "backgroundColor": "#8dac85",
"pure": true, "pure": true,
"maxLevel": 50,
"icons": [ "icons": [
{ {
"name": "1", "name": "1",

View File

@ -4,6 +4,7 @@ import {
domRelatedOptForTheme, domRelatedOptForTheme,
LAST_LEVEL_STORAGE_KEY, LAST_LEVEL_STORAGE_KEY,
LAST_SCORE_STORAGE_KEY, LAST_SCORE_STORAGE_KEY,
LAST_TIME_STORAGE_KEY,
wrapThemeDefaultSounds, wrapThemeDefaultSounds,
} from './utils'; } from './utils';
import { Theme } from './themes/interface'; 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 initLevel = Number(localStorage.getItem(LAST_LEVEL_STORAGE_KEY) || '1');
const initScore = Number(localStorage.getItem(LAST_SCORE_STORAGE_KEY) || '0'); 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<any> }> = ({ theme: initTheme }) => { const App: FC<{ theme: Theme<any> }> = ({ theme: initTheme }) => {
console.log('initTheme', initTheme); console.log('initTheme', initTheme);
@ -69,6 +71,7 @@ const App: FC<{ theme: Theme<any> }> = ({ theme: initTheme }) => {
theme={theme} theme={theme}
initLevel={initLevel} initLevel={initLevel}
initScore={initScore} initScore={initScore}
initTime={initTime}
/> />
<PersonalInfo /> <PersonalInfo />
<div className={'flex-spacer'} /> <div className={'flex-spacer'} />

View File

@ -105,8 +105,9 @@
} }
.level { .level {
font-size: 1.8em; font-size: 1.5em;
font-weight: 900; font-weight: 900;
line-height: 2; line-height: 2;
text-shadow: 4px 6px 2px rgb(0 0 0 / 20%); text-shadow: 4px 6px 2px rgb(0 0 0 / 20%);
font-family: Menlo, Monaco, 'Courier New', monospace, serif;
} }

View File

@ -5,53 +5,56 @@ import React, {
useRef, useRef,
useState, useState,
} from 'react'; } from 'react';
import './Game.scss'; import './Game.scss';
import { import {
LAST_LEVEL_STORAGE_KEY, LAST_LEVEL_STORAGE_KEY,
LAST_SCORE_STORAGE_KEY, LAST_SCORE_STORAGE_KEY,
LAST_TIME_STORAGE_KEY,
randomString, randomString,
waitTimeout, waitTimeout,
} from '../utils'; } from '../utils';
import { Icon, Theme } from '../themes/interface'; import { Icon, Theme } from '../themes/interface';
// 最大关卡
const maxLevel = 50;
interface MySymbol { interface MySymbol {
id: string; id: string;
status: number; // 0->1->2 status: number; // 0->1->2 正常->队列中->三连
isCover: boolean; isCover: boolean;
x: number; x: number;
y: number; y: number;
icon: Icon; icon: Icon;
} }
type Scene = MySymbol[]; 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 makeScene: (level: number, icons: Icon[]) => Scene = (level, icons) => {
const curLevel = Math.min(maxLevel, level); // 初始图标x2
const iconPool = icons.slice(0, 2 * curLevel); const iconPool = icons.slice(0, 2 * level);
const offsetPool = [0, 25, -25, 50, -50].slice(0, 1 + curLevel); const offsetPool = offsets.slice(0, 1 + level);
const scene: Scene = []; const scene: Scene = [];
// 网格范围,随等级由中心扩满
const range = [ const range = sceneRanges[Math.min(4, level - 1)];
[2, 6], // 在范围内随机摆放图标
[1, 6],
[1, 7],
[0, 7],
[0, 8],
][Math.min(4, curLevel - 1)];
const randomSet = (icon: Icon) => { const randomSet = (icon: Icon) => {
const offset = const { offset, row, column } = randomPositionOffset(offsetPool, range);
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());
scene.push({ scene.push({
isCover: false, isCover: false,
status: 0, status: 0,
@ -61,22 +64,20 @@ const makeScene: (level: number, icons: Icon[]) => Scene = (level, icons) => {
y: row * 100 + offset, y: row * 100 + offset,
}); });
}; };
// 每间隔5级别增加icon池
// 大于5级别增加icon池 let compareLevel = level;
let compareLevel = curLevel;
while (compareLevel > 0) { while (compareLevel > 0) {
iconPool.push( iconPool.push(
...iconPool.slice(0, Math.min(10, 2 * (compareLevel - 5))) ...iconPool.slice(0, Math.min(10, 2 * (compareLevel - 5)))
); );
compareLevel -= 5; compareLevel -= 5;
} }
// icon池中每个生成六张卡片
for (const icon of iconPool) { for (const icon of iconPool) {
for (let i = 0; i < 6; i++) { for (let i = 0; i < 6; i++) {
randomSet(icon); randomSet(icon);
} }
} }
return scene; return scene;
}; };
@ -92,40 +93,29 @@ const fastShuffle: <T = any>(arr: T[]) => T[] = (arr) => {
// 洗牌 // 洗牌
const washScene: (level: number, scene: Scene) => Scene = (level, scene) => { const washScene: (level: number, scene: Scene) => Scene = (level, scene) => {
// 打乱顺序
const updateScene = fastShuffle(scene); const updateScene = fastShuffle(scene);
const offsetPool = [0, 25, -25, 50, -50].slice(0, 1 + level); const offsetPool = offsets.slice(0, 1 + level);
const range = [ const range = sceneRanges[Math.min(4, level - 1)];
[2, 6], // 重新设置位置
[1, 6],
[1, 7],
[0, 7],
[0, 8],
][Math.min(4, level - 1)];
const randomSet = (symbol: MySymbol) => { const randomSet = (symbol: MySymbol) => {
const offset = const { offset, row, column } = randomPositionOffset(offsetPool, range);
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());
symbol.x = column * 100 + offset; symbol.x = column * 100 + offset;
symbol.y = row * 100 + offset; symbol.y = row * 100 + offset;
symbol.isCover = false; symbol.isCover = false;
}; };
// 仅对仍在牌堆中的进行重置
for (const symbol of updateScene) { for (const symbol of updateScene) {
if (symbol.status !== 0) continue; if (symbol.status !== 0) continue;
randomSet(symbol); randomSet(symbol);
} }
return updateScene; return updateScene;
}; };
// icon对应的组件
interface SymbolProps extends MySymbol { interface SymbolProps extends MySymbol {
onClick: MouseEventHandler; onClick: MouseEventHandler;
} }
const Symbol: FC<SymbolProps> = ({ x, y, icon, isCover, status, onClick }) => { const Symbol: FC<SymbolProps> = ({ x, y, icon, isCover, status, onClick }) => {
return ( return (
<div <div
@ -161,10 +151,11 @@ const Symbol: FC<SymbolProps> = ({ x, y, icon, isCover, status, onClick }) => {
const Game: FC<{ const Game: FC<{
theme: Theme<any>; theme: Theme<any>;
initLevel: number; initLevel?: number;
initScore: number; initScore?: number;
}> = ({ theme, initLevel, initScore }) => { initTime?: number;
console.log('Game FC'); }> = ({ theme, initLevel = 1, initScore = 0, initTime = 0 }) => {
const maxLevel = theme.maxLevel || 50;
const [scene, setScene] = useState<Scene>( const [scene, setScene] = useState<Scene>(
makeScene(initLevel, theme.icons) makeScene(initLevel, theme.icons)
); );
@ -185,6 +176,7 @@ const Game: FC<{
const bgmRef = useRef<HTMLAudioElement>(null); const bgmRef = useRef<HTMLAudioElement>(null);
const [bgmOn, setBgmOn] = useState<boolean>(false); const [bgmOn, setBgmOn] = useState<boolean>(false);
const [once, setOnce] = useState<boolean>(false); const [once, setOnce] = useState<boolean>(false);
useEffect(() => { useEffect(() => {
if (!bgmRef.current) return; if (!bgmRef.current) return;
if (bgmOn) { if (bgmOn) {
@ -199,6 +191,7 @@ const Game: FC<{
useEffect(() => { useEffect(() => {
localStorage.setItem(LAST_LEVEL_STORAGE_KEY, level.toString()); localStorage.setItem(LAST_LEVEL_STORAGE_KEY, level.toString());
localStorage.setItem(LAST_SCORE_STORAGE_KEY, score.toString()); localStorage.setItem(LAST_SCORE_STORAGE_KEY, score.toString());
localStorage.setItem(LAST_TIME_STORAGE_KEY, usedTime.toString());
}, [level]); }, [level]);
// 队列区排序 // 队列区排序
@ -246,11 +239,9 @@ const Game: FC<{
for (let j = i + 1; j < updateScene.length; j++) { for (let j = i + 1; j < updateScene.length; j++) {
const compare = updateScene[j]; const compare = updateScene[j];
if (compare.status !== 0) continue; if (compare.status !== 0) continue;
// 两区域有交集视为选中 // 两区域有交集视为选中
// 两区域不重叠情况取反即为交集 // 两区域不重叠情况取反即为交集
const { x, y } = compare; const { x, y } = compare;
if (!(y + 100 <= y1 || y >= y2 || x + 100 <= x1 || x >= x2)) { if (!(y + 100 <= y1 || y >= y2 || x + 100 <= x1 || x >= x2)) {
cur.isCover = true; cur.isCover = true;
break; break;
@ -279,7 +270,7 @@ const Game: FC<{
// 音效 // 音效
if (soundRefMap.current?.['sound-shift']) { if (soundRefMap.current?.['sound-shift']) {
soundRefMap.current['sound-shift'].currentTime = 0; 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']) { if (soundRefMap.current?.['sound-undo']) {
soundRefMap.current['sound-undo'].currentTime = 0; 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']) { if (soundRefMap.current?.['sound-wash']) {
soundRefMap.current['sound-wash'].currentTime = 0; soundRefMap.current['sound-wash'].currentTime = 0;
soundRefMap.current['sound-wash'].play(); soundRefMap.current['sound-wash'].play().then();
} }
}; };
// 加大难度 // 加大难度,该方法由玩家点击下一关触发
const levelUp = () => { const levelUp = () => {
if (level >= maxLevel) { if (level >= maxLevel) {
return; return;
} }
// 跳关扣关卡对应数值的分
setScore(score - level);
setFinished(false); setFinished(false);
setLevel(level + 1); setLevel(level + 1);
setQueue([]); setQueue([]);
@ -333,15 +326,19 @@ const Game: FC<{
setLevel(1); setLevel(1);
setQueue([]); setQueue([]);
checkCover(makeScene(1, theme.icons)); checkCover(makeScene(1, theme.icons));
setUsedTime(0);
startTimer(true);
}; };
// 点击item // 点击item
const clickSymbol = async (idx: number) => { const clickSymbol = async (idx: number) => {
if (finished || animating) return; if (finished || animating) return;
// 第一次点击时播放bgm开启计时
if (!once) { if (!once) {
setBgmOn(true); setBgmOn(true);
setOnce(true); setOnce(true);
startTimer();
} }
const updateScene = scene.slice(); const updateScene = scene.slice();
@ -350,26 +347,25 @@ const Game: FC<{
symbol.status = 1; symbol.status = 1;
// 点击音效 // 点击音效
// 不知道为啥敲可选链会提示错误。。。 if (soundRefMap.current?.[symbol.icon.clickSound]) {
if (
soundRefMap.current &&
soundRefMap.current[symbol.icon.clickSound]
) {
soundRefMap.current[symbol.icon.clickSound].currentTime = 0; soundRefMap.current[symbol.icon.clickSound].currentTime = 0;
soundRefMap.current[symbol.icon.clickSound].play().then(); soundRefMap.current[symbol.icon.clickSound].play().then();
} }
// 将点击项目加入队列
let updateQueue = queue.slice(); let updateQueue = queue.slice();
updateQueue.push(symbol); updateQueue.push(symbol);
setQueue(updateQueue); setQueue(updateQueue);
checkCover(updateScene); checkCover(updateScene);
// 动画锁 150ms
setAnimating(true); setAnimating(true);
await waitTimeout(150); await waitTimeout(150);
// 查找当前队列中与点击项相同的
const filterSame = updateQueue.filter((sb) => sb.icon === symbol.icon); const filterSame = updateQueue.filter((sb) => sb.icon === symbol.icon);
// 后续状态判断
// 三连了 // 三连了
if (filterSame.length === 3) { if (filterSame.length === 3) {
// 三连一次+3分 // 三连一次+3分
@ -380,10 +376,7 @@ const Game: FC<{
if (find) { if (find) {
find.status = 2; find.status = 2;
// 三连音效 // 三连音效
if ( if (soundRefMap.current?.[symbol.icon.tripleSound]) {
soundRefMap.current &&
soundRefMap.current[symbol.icon.tripleSound]
) {
soundRefMap.current[ soundRefMap.current[
symbol.icon.tripleSound symbol.icon.tripleSound
].currentTime = 0; ].currentTime = 0;
@ -422,6 +415,34 @@ const Game: FC<{
setAnimating(false); setAnimating(false);
}; };
// 计时相关
const [startTime, setStartTime] = useState<number>(0);
const [now, setNow] = useState<number>(0);
const [usedTime, setUsedTime] = useState<number>(initTime);
const intervalRef = useRef<NodeJS.Timeout | null>(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 ( return (
<> <>
<div className="game"> <div className="game">
@ -456,30 +477,27 @@ const Game: FC<{
<button className="flex-grow" onClick={wash}> <button className="flex-grow" onClick={wash}>
</button> </button>
<button <button className="flex-grow" onClick={levelUp}>
className="flex-grow"
onClick={() => {
// 跳关扣关卡对应数值的分
setScore(score - level);
levelUp();
}}
>
</button> </button>
</div> </div>
<div className="level"> <div className="level">
{level}|{scene.filter((i) => i.status === 0).length} {level}/{maxLevel}
|{score} {scene.filter((i) => i.status === 0).length}
<br />
{score}
<br />
{(usedTime / 1000).toFixed(3)}
</div> </div>
{/*提示弹窗*/} {/*提示弹窗*/}
{finished && ( {finished && (
<div className="modal"> <div className="modal">
<h1>{tipText}</h1> <h1>{tipText}</h1>
<h1>{score}</h1>
<h1>{(usedTime / 1000).toFixed(3)}</h1>
<button onClick={restart}></button> <button onClick={restart}></button>
</div> </div>
)} )}
{/*bgm*/} {/*bgm*/}
{theme.bgm && ( {theme.bgm && (
<button className="bgm-button" onClick={() => setBgmOn(!bgmOn)}> <button className="bgm-button" onClick={() => setBgmOn(!bgmOn)}>
@ -487,7 +505,6 @@ const Game: FC<{
<audio ref={bgmRef} loop src={theme.bgm} /> <audio ref={bgmRef} loop src={theme.bgm} />
</button> </button>
)} )}
{/*音效*/} {/*音效*/}
{theme.sounds.map((sound) => ( {theme.sounds.map((sound) => (
<audio <audio

View File

@ -1,6 +1,9 @@
.info { .info {
left: 8px; left: 8px;
bottom: 8px; bottom: 8px;
display: flex;
justify-content: center;
flex-direction: column;
p { p {
margin: 0; margin: 0;
@ -22,6 +25,8 @@
} }
&.open { &.open {
height: 120px;
@media screen and (max-width: 500px) { @media screen and (max-width: 500px) {
width: calc(100% - 70px); width: calc(100% - 70px);
} }

View File

@ -11,7 +11,7 @@ body {
#root { #root {
text-align: center; text-align: center;
width: 100%; width: 100%;
max-width: 500px; max-width: 450px;
margin: 0 auto; margin: 0 auto;
min-height: 100vh; min-height: 100vh;
display: flex; display: flex;

View File

@ -23,6 +23,7 @@ export interface Theme<SoundNames> {
backgroundBlur?: boolean; backgroundBlur?: boolean;
dark?: boolean; dark?: boolean;
pure?: boolean; pure?: boolean;
maxLevel?: number;
icons: Icon<SoundNames>[]; icons: Icon<SoundNames>[];
sounds: Sound<SoundNames>[]; sounds: Sound<SoundNames>[];
operateSoundMap?: Record<Operation, SoundNames>; operateSoundMap?: Record<Operation, SoundNames>;

View File

@ -3,6 +3,7 @@ import { getDefaultTheme } from './themes/default';
export const LAST_LEVEL_STORAGE_KEY = 'lastLevel'; export const LAST_LEVEL_STORAGE_KEY = 'lastLevel';
export const LAST_SCORE_STORAGE_KEY = 'lastScore'; export const LAST_SCORE_STORAGE_KEY = 'lastScore';
export const LAST_TIME_STORAGE_KEY = 'lastTime';
export const LAST_UPLOAD_TIME_STORAGE_KEY = 'lastUploadTime'; export const LAST_UPLOAD_TIME_STORAGE_KEY = 'lastUploadTime';
export const DEFAULT_BGM_STORAGE_KEY = 'defaultBgm'; export const DEFAULT_BGM_STORAGE_KEY = 'defaultBgm';
export const DEFAULT_TRIPLE_SOUND_STORAGE_KEY = 'defaultTripleSound'; export const DEFAULT_TRIPLE_SOUND_STORAGE_KEY = 'defaultTripleSound';