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',
],
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] UI/UX 优化
- [x] 多主题
- [ ] 计时、得分排行、保存进度机制
- [x] 计时、得分、保存进度机制
- [ ] 排行榜
- [ ] 性能优化
- [x] BGM/音效
- [ ] ~~点击时的缓冲队列,优化交互动画效果~~

View File

@ -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` 即可

View File

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

View File

@ -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<any> }> = ({ theme: initTheme }) => {
console.log('initTheme', initTheme);
@ -69,6 +71,7 @@ const App: FC<{ theme: Theme<any> }> = ({ theme: initTheme }) => {
theme={theme}
initLevel={initLevel}
initScore={initScore}
initTime={initTime}
/>
<PersonalInfo />
<div className={'flex-spacer'} />

View File

@ -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;
}

View File

@ -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: <T = any>(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<SymbolProps> = ({ x, y, icon, isCover, status, onClick }) => {
return (
<div
@ -161,10 +151,11 @@ const Symbol: FC<SymbolProps> = ({ x, y, icon, isCover, status, onClick }) => {
const Game: FC<{
theme: Theme<any>;
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<Scene>(
makeScene(initLevel, theme.icons)
);
@ -185,6 +176,7 @@ const Game: FC<{
const bgmRef = useRef<HTMLAudioElement>(null);
const [bgmOn, setBgmOn] = useState<boolean>(false);
const [once, setOnce] = useState<boolean>(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<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 (
<>
<div className="game">
@ -456,30 +477,27 @@ const Game: FC<{
<button className="flex-grow" onClick={wash}>
</button>
<button
className="flex-grow"
onClick={() => {
// 跳关扣关卡对应数值的分
setScore(score - level);
levelUp();
}}
>
<button className="flex-grow" onClick={levelUp}>
</button>
</div>
<div className="level">
{level}|{scene.filter((i) => i.status === 0).length}
|{score}
{level}/{maxLevel}
{scene.filter((i) => i.status === 0).length}
<br />
{score}
<br />
{(usedTime / 1000).toFixed(3)}
</div>
{/*提示弹窗*/}
{finished && (
<div className="modal">
<h1>{tipText}</h1>
<h1>{score}</h1>
<h1>{(usedTime / 1000).toFixed(3)}</h1>
<button onClick={restart}></button>
</div>
)}
{/*bgm*/}
{theme.bgm && (
<button className="bgm-button" onClick={() => setBgmOn(!bgmOn)}>
@ -487,7 +505,6 @@ const Game: FC<{
<audio ref={bgmRef} loop src={theme.bgm} />
</button>
)}
{/*音效*/}
{theme.sounds.map((sound) => (
<audio

View File

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

View File

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

View File

@ -23,6 +23,7 @@ export interface Theme<SoundNames> {
backgroundBlur?: boolean;
dark?: boolean;
pure?: boolean;
maxLevel?: number;
icons: Icon<SoundNames>[];
sounds: Sound<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_SCORE_STORAGE_KEY = 'lastScore';
export const LAST_TIME_STORAGE_KEY = 'lastTime';
export const LAST_UPLOAD_TIME_STORAGE_KEY = 'lastUploadTime';
export const DEFAULT_BGM_STORAGE_KEY = 'defaultBgm';
export const DEFAULT_TRIPLE_SOUND_STORAGE_KEY = 'defaultTripleSound';