mirror of
https://ghproxy.com/https://github.com/StreakingMan/solvable-sheep-game
synced 2025-05-23 03:42:37 +08:00
feat: 计时、最大关卡配置
This commit is contained in:
parent
84fa8483d9
commit
b7482fa209
|
@ -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-_]+$',
|
||||
},
|
||||
};
|
||||
|
|
|
@ -56,7 +56,8 @@ vite+react 实现,欢迎 star、issue、pr、fork(尽量标注原仓库地
|
|||
- [x] 关卡生成
|
||||
- [x] UI/UX 优化
|
||||
- [x] 多主题
|
||||
- [ ] 计时、得分排行、保存进度机制
|
||||
- [x] 计时、得分、保存进度机制
|
||||
- [ ] 排行榜
|
||||
- [ ] 性能优化
|
||||
- [x] BGM/音效
|
||||
- [ ] ~~点击时的缓冲队列,优化交互动画效果~~
|
||||
|
|
|
@ -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 编码存到了数据库中,需要添加三列
|
||||

|
||||
|
||||
最后,开发和打包命令分别使用 `yarn dev` 和 `yarn build` 即可
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
"backgroundBlur": false,
|
||||
"backgroundColor": "#8dac85",
|
||||
"pure": true,
|
||||
"maxLevel": 50,
|
||||
"icons": [
|
||||
{
|
||||
"name": "1",
|
||||
|
|
|
@ -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'} />
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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';
|
||||
|
|
Loading…
Reference in New Issue
Block a user