mirror of
https://ghproxy.com/https://github.com/StreakingMan/solvable-sheep-game
synced 2026-07-29 20:40:53 +08:00
refactor: 主题初始化、资源缓存、UI交互等重构
This commit is contained in:
@@ -2,14 +2,12 @@ import React, { FC } from 'react';
|
||||
|
||||
export const BeiAn: FC = () => {
|
||||
return (
|
||||
<p style={{ textAlign: 'center' }}>
|
||||
<a
|
||||
href="https://beian.miit.gov.cn/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
>
|
||||
浙ICP备17007857号-2
|
||||
</a>
|
||||
</p>
|
||||
<a
|
||||
href="https://beian.miit.gov.cn/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
>
|
||||
浙ICP备17007857号-2
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,7 +2,6 @@ import React, { FC, useEffect, useRef, useState } from 'react';
|
||||
import style from './ConfigDialog.module.scss';
|
||||
import classNames from 'classnames';
|
||||
import { Icon, Sound, Theme } from '../themes/interface';
|
||||
import { defaultSounds } from '../themes/default';
|
||||
import { QRCodeCanvas } from 'qrcode.react';
|
||||
import Bmob from 'hydrogen-js-sdk';
|
||||
import { captureElement } from '../utils';
|
||||
@@ -226,25 +225,7 @@ export const ConfigDialog: FC<{
|
||||
if (!title) return Promise.reject('请填写标题');
|
||||
if (icons.length !== 10) return Promise.reject('图片素材需要提供10张');
|
||||
|
||||
let hasDefaultMaterial = false;
|
||||
const customIcons = icons.map((icon) => {
|
||||
if (!icon.clickSound) {
|
||||
hasDefaultMaterial = true;
|
||||
icon.clickSound = 'button-click';
|
||||
}
|
||||
if (!icon.tripleSound) {
|
||||
hasDefaultMaterial = true;
|
||||
icon.tripleSound = 'triple';
|
||||
}
|
||||
return { ...icon };
|
||||
});
|
||||
const customSounds = sounds.map((sounds) => ({ ...sounds }));
|
||||
if (hasDefaultMaterial) {
|
||||
customSounds.push(...defaultSounds);
|
||||
}
|
||||
|
||||
const customTheme: Theme<any> = {
|
||||
name: `自定义-${title}`,
|
||||
// 恭喜你发现纯净模式彩蛋🎉,点击文字十次可以开启纯净模式
|
||||
pure: pureCount !== 0 && pureCount % 10 === 0,
|
||||
title,
|
||||
@@ -252,11 +233,13 @@ export const ConfigDialog: FC<{
|
||||
bgm,
|
||||
background,
|
||||
backgroundBlur,
|
||||
icons: customIcons,
|
||||
sounds: customSounds,
|
||||
icons,
|
||||
sounds,
|
||||
};
|
||||
|
||||
return Promise.resolve(customTheme);
|
||||
console.log(customTheme);
|
||||
|
||||
return Promise.resolve(JSON.parse(JSON.stringify(customTheme)));
|
||||
};
|
||||
|
||||
// 预览
|
||||
@@ -335,11 +318,24 @@ export const ConfigDialog: FC<{
|
||||
>
|
||||
<p onClick={() => setPureCount(pureCount + 1)}>
|
||||
目前自定义仅支持配置https链接,可网上自行搜索素材复制链接,或者将自己处理好的素材上传第三方存储服务/图床上再复制外链
|
||||
(想白嫖的话自行搜索【免费图床】【免费对象存储】【免费mp3外链】等)
|
||||
(想白嫖的话自行搜索【免费图床】【免费对象存储】【免费mp3外链】等)。
|
||||
{pureCount != 0 &&
|
||||
pureCount % 10 === 0 &&
|
||||
'🎉🎉🎉恭喜发现彩蛋!主题分享后将开启纯净模式~'}
|
||||
</p>
|
||||
<div className="flex-container flex-no-wrap">
|
||||
<img
|
||||
style={{ width: 120, objectFit: 'contain' }}
|
||||
src="/wxqrcode.png"
|
||||
alt=""
|
||||
/>
|
||||
<p style={{ margin: 0 }}>
|
||||
<strong>
|
||||
开发不易,如果您喜欢这个项目的话可酌情扫左侧二维码
|
||||
请我喝杯咖啡(后台相关费用用爱发电中,感谢支持)
|
||||
</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/*基本配置*/}
|
||||
<h4 className="flex-container flex-center">
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
.game {
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
padding-top: 10%;
|
||||
padding-bottom: 2.5%;
|
||||
}
|
||||
|
||||
.scene {
|
||||
&-container {
|
||||
width: 100%;
|
||||
padding-bottom: 112.5%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
&-inner {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
top: 0;
|
||||
overflow: visible;
|
||||
font-size: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
.symbol {
|
||||
width: 12.5%;
|
||||
padding-bottom: 12.5%;
|
||||
position: absolute;
|
||||
transition: 150ms;
|
||||
left: 0;
|
||||
top: 0;
|
||||
border-radius: 8px;
|
||||
|
||||
&-inner {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
top: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 8px;
|
||||
border: 2px solid #444;
|
||||
transition: 0.3s;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.queue-container {
|
||||
padding-bottom: 18.75%;
|
||||
margin-bottom: 16px;
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 3.125%;
|
||||
right: 3.125%;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
border-radius: 12px;
|
||||
background-color: rgb(0 0 0 / 16%);
|
||||
border: 8px solid rgb(0 0 0 / 8%);
|
||||
}
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: fixed;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
backdrop-filter: blur(10px);
|
||||
background-color: rgb(0 0 0 / 10%);
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 10 !important;
|
||||
}
|
||||
|
||||
.bgm-button {
|
||||
position: fixed;
|
||||
left: 8px;
|
||||
top: 8px;
|
||||
padding: 4px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.zhenghuo-button {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.level {
|
||||
font-size: 1.8em;
|
||||
font-weight: 900;
|
||||
line-height: 2;
|
||||
text-shadow: 4px 6px 2px rgb(0 0 0 / 20%);
|
||||
}
|
||||
@@ -0,0 +1,504 @@
|
||||
import React, {
|
||||
FC,
|
||||
MouseEventHandler,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import './Game.scss';
|
||||
import {
|
||||
LAST_LEVEL_STORAGE_KEY,
|
||||
LAST_SCORE_STORAGE_KEY,
|
||||
randomString,
|
||||
waitTimeout,
|
||||
} from '../utils';
|
||||
import { Icon, Theme } from '../themes/interface';
|
||||
|
||||
// 最大关卡
|
||||
const maxLevel = 50;
|
||||
|
||||
interface MySymbol {
|
||||
id: string;
|
||||
status: number; // 0->1->2
|
||||
isCover: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
icon: Icon;
|
||||
}
|
||||
|
||||
type Scene = MySymbol[];
|
||||
|
||||
// 8*8网格 4*4->8*8
|
||||
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);
|
||||
|
||||
const scene: Scene = [];
|
||||
|
||||
const range = [
|
||||
[2, 6],
|
||||
[1, 6],
|
||||
[1, 7],
|
||||
[0, 7],
|
||||
[0, 8],
|
||||
][Math.min(4, curLevel - 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());
|
||||
scene.push({
|
||||
isCover: false,
|
||||
status: 0,
|
||||
icon,
|
||||
id: randomString(6),
|
||||
x: column * 100 + offset,
|
||||
y: row * 100 + offset,
|
||||
});
|
||||
};
|
||||
|
||||
// 大于5级别增加icon池
|
||||
let compareLevel = curLevel;
|
||||
while (compareLevel > 0) {
|
||||
iconPool.push(
|
||||
...iconPool.slice(0, Math.min(10, 2 * (compareLevel - 5)))
|
||||
);
|
||||
compareLevel -= 5;
|
||||
}
|
||||
|
||||
for (const icon of iconPool) {
|
||||
for (let i = 0; i < 6; i++) {
|
||||
randomSet(icon);
|
||||
}
|
||||
}
|
||||
|
||||
return scene;
|
||||
};
|
||||
|
||||
// o(n) 时间复杂度的洗牌算法
|
||||
const fastShuffle: <T = any>(arr: T[]) => T[] = (arr) => {
|
||||
const res = arr.slice();
|
||||
for (let i = 0; i < res.length; i++) {
|
||||
const idx = (Math.random() * res.length) >> 0;
|
||||
[res[i], res[idx]] = [res[idx], res[i]];
|
||||
}
|
||||
return res;
|
||||
};
|
||||
|
||||
// 洗牌
|
||||
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 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());
|
||||
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;
|
||||
};
|
||||
|
||||
interface SymbolProps extends MySymbol {
|
||||
onClick: MouseEventHandler;
|
||||
}
|
||||
|
||||
const Symbol: FC<SymbolProps> = ({ x, y, icon, isCover, status, onClick }) => {
|
||||
return (
|
||||
<div
|
||||
className="symbol"
|
||||
style={{
|
||||
transform: `translateX(${x}%) translateY(${y}%)`,
|
||||
backgroundColor: isCover ? '#999' : 'white',
|
||||
opacity: status < 2 ? 1 : 0,
|
||||
}}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div
|
||||
className="symbol-inner"
|
||||
style={{ opacity: isCover ? 0.4 : 1 }}
|
||||
>
|
||||
{typeof icon.content === 'string' ? (
|
||||
icon.content.startsWith('http') ? (
|
||||
/*图片外链*/
|
||||
<img src={icon.content} alt="" />
|
||||
) : (
|
||||
/*字符表情*/
|
||||
<i>{icon.content}</i>
|
||||
)
|
||||
) : (
|
||||
/*ReactNode*/
|
||||
icon.content
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Game: FC<{
|
||||
theme: Theme<any>;
|
||||
initLevel: number;
|
||||
initScore: number;
|
||||
}> = ({ theme, initLevel, initScore }) => {
|
||||
console.log('Game FC');
|
||||
const [scene, setScene] = useState<Scene>(
|
||||
makeScene(initLevel, theme.icons)
|
||||
);
|
||||
const [level, setLevel] = useState<number>(initLevel);
|
||||
const [score, setScore] = useState<number>(initScore);
|
||||
const [queue, setQueue] = useState<MySymbol[]>([]);
|
||||
const [sortedQueue, setSortedQueue] = useState<
|
||||
Record<MySymbol['id'], number>
|
||||
>({});
|
||||
const [finished, setFinished] = useState<boolean>(false);
|
||||
const [tipText, setTipText] = useState<string>('');
|
||||
const [animating, setAnimating] = useState<boolean>(false);
|
||||
|
||||
// 音效
|
||||
const soundRefMap = useRef<Record<string, HTMLAudioElement>>({});
|
||||
|
||||
// 第一次点击时播放bgm
|
||||
const bgmRef = useRef<HTMLAudioElement>(null);
|
||||
const [bgmOn, setBgmOn] = useState<boolean>(false);
|
||||
const [once, setOnce] = useState<boolean>(false);
|
||||
useEffect(() => {
|
||||
if (!bgmRef.current) return;
|
||||
if (bgmOn) {
|
||||
bgmRef.current.volume = 0.5;
|
||||
bgmRef.current.play().then();
|
||||
} else {
|
||||
bgmRef.current.pause();
|
||||
}
|
||||
}, [bgmOn]);
|
||||
|
||||
// 关卡缓存
|
||||
useEffect(() => {
|
||||
localStorage.setItem(LAST_LEVEL_STORAGE_KEY, level.toString());
|
||||
localStorage.setItem(LAST_SCORE_STORAGE_KEY, score.toString());
|
||||
}, [level]);
|
||||
|
||||
// 队列区排序
|
||||
useEffect(() => {
|
||||
const cache: Record<string, MySymbol[]> = {};
|
||||
// 加上索引,避免以id字典序来排
|
||||
const idx = 0;
|
||||
for (const symbol of queue) {
|
||||
if (cache[idx + symbol.icon.name]) {
|
||||
cache[idx + symbol.icon.name].push(symbol);
|
||||
} else {
|
||||
cache[idx + symbol.icon.name] = [symbol];
|
||||
}
|
||||
}
|
||||
const temp = [];
|
||||
for (const symbols of Object.values(cache)) {
|
||||
temp.push(...symbols);
|
||||
}
|
||||
const updateSortedQueue: typeof sortedQueue = {};
|
||||
let x = 50;
|
||||
for (const symbol of temp) {
|
||||
updateSortedQueue[symbol.id] = x;
|
||||
x += 100;
|
||||
}
|
||||
setSortedQueue(updateSortedQueue);
|
||||
}, [queue]);
|
||||
|
||||
// 初始化覆盖状态
|
||||
useEffect(() => {
|
||||
checkCover(scene);
|
||||
}, []);
|
||||
|
||||
// 向后检查覆盖
|
||||
const checkCover = (scene: Scene) => {
|
||||
const updateScene = scene.slice();
|
||||
for (let i = 0; i < updateScene.length; i++) {
|
||||
// 当前item对角坐标
|
||||
const cur = updateScene[i];
|
||||
cur.isCover = false;
|
||||
if (cur.status !== 0) continue;
|
||||
const { x: x1, y: y1 } = cur;
|
||||
const x2 = x1 + 100,
|
||||
y2 = y1 + 100;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
setScene(updateScene);
|
||||
};
|
||||
|
||||
// 弹出
|
||||
const popTime = useRef(0);
|
||||
const pop = () => {
|
||||
if (!queue.length) return;
|
||||
const updateQueue = queue.slice();
|
||||
const symbol = updateQueue.shift();
|
||||
setScore(score - 1);
|
||||
if (!symbol) return;
|
||||
const find = scene.find((s) => s.id === symbol.id);
|
||||
if (find) {
|
||||
setQueue(updateQueue);
|
||||
find.status = 0;
|
||||
find.x = 100 * (popTime.current % 7);
|
||||
popTime.current++;
|
||||
find.y = 800;
|
||||
checkCover(scene);
|
||||
// 音效
|
||||
if (soundRefMap.current?.['sound-shift']) {
|
||||
soundRefMap.current['sound-shift'].currentTime = 0;
|
||||
soundRefMap.current['sound-shift'].play();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 撤销
|
||||
const undo = () => {
|
||||
if (!queue.length) return;
|
||||
setScore(score - 1);
|
||||
const updateQueue = queue.slice();
|
||||
const symbol = updateQueue.pop();
|
||||
if (!symbol) return;
|
||||
const find = scene.find((s) => s.id === symbol.id);
|
||||
if (find) {
|
||||
setQueue(updateQueue);
|
||||
find.status = 0;
|
||||
checkCover(scene);
|
||||
// 音效
|
||||
if (soundRefMap.current?.['sound-undo']) {
|
||||
soundRefMap.current['sound-undo'].currentTime = 0;
|
||||
soundRefMap.current['sound-undo'].play();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 洗牌
|
||||
const wash = () => {
|
||||
setScore(score - 1);
|
||||
checkCover(washScene(level, scene));
|
||||
// 音效
|
||||
if (soundRefMap.current?.['sound-wash']) {
|
||||
soundRefMap.current['sound-wash'].currentTime = 0;
|
||||
soundRefMap.current['sound-wash'].play();
|
||||
}
|
||||
};
|
||||
|
||||
// 加大难度
|
||||
const levelUp = () => {
|
||||
if (level >= maxLevel) {
|
||||
return;
|
||||
}
|
||||
setFinished(false);
|
||||
setLevel(level + 1);
|
||||
setQueue([]);
|
||||
checkCover(makeScene(level + 1, theme.icons));
|
||||
};
|
||||
|
||||
// 重开
|
||||
const restart = () => {
|
||||
setFinished(false);
|
||||
setScore(0);
|
||||
setLevel(1);
|
||||
setQueue([]);
|
||||
checkCover(makeScene(1, theme.icons));
|
||||
};
|
||||
|
||||
// 点击item
|
||||
const clickSymbol = async (idx: number) => {
|
||||
if (finished || animating) return;
|
||||
|
||||
if (!once) {
|
||||
setBgmOn(true);
|
||||
setOnce(true);
|
||||
}
|
||||
|
||||
const updateScene = scene.slice();
|
||||
const symbol = updateScene[idx];
|
||||
if (symbol.isCover || symbol.status !== 0) return;
|
||||
symbol.status = 1;
|
||||
|
||||
// 点击音效
|
||||
// 不知道为啥敲可选链会提示错误。。。
|
||||
if (
|
||||
soundRefMap.current &&
|
||||
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);
|
||||
|
||||
setAnimating(true);
|
||||
await waitTimeout(150);
|
||||
|
||||
const filterSame = updateQueue.filter((sb) => sb.icon === symbol.icon);
|
||||
|
||||
// 三连了
|
||||
if (filterSame.length === 3) {
|
||||
// 三连一次+3分
|
||||
setScore(score + 3);
|
||||
updateQueue = updateQueue.filter((sb) => sb.icon !== symbol.icon);
|
||||
for (const sb of filterSame) {
|
||||
const find = updateScene.find((i) => i.id === sb.id);
|
||||
if (find) {
|
||||
find.status = 2;
|
||||
// 三连音效
|
||||
if (
|
||||
soundRefMap.current &&
|
||||
soundRefMap.current[symbol.icon.tripleSound]
|
||||
) {
|
||||
soundRefMap.current[
|
||||
symbol.icon.tripleSound
|
||||
].currentTime = 0;
|
||||
soundRefMap.current[symbol.icon.tripleSound]
|
||||
.play()
|
||||
.then();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 输了
|
||||
if (updateQueue.length === 7) {
|
||||
setTipText('失败了');
|
||||
setFinished(true);
|
||||
}
|
||||
|
||||
if (!updateScene.find((s) => s.status !== 2)) {
|
||||
// 胜利
|
||||
if (level === maxLevel) {
|
||||
setTipText('完成挑战');
|
||||
setFinished(true);
|
||||
return;
|
||||
}
|
||||
// 升级
|
||||
// 通关奖励关卡对应数值分数
|
||||
setScore(score + level);
|
||||
setLevel(level + 1);
|
||||
setQueue([]);
|
||||
checkCover(makeScene(level + 1, theme.icons));
|
||||
} else {
|
||||
setQueue(updateQueue);
|
||||
checkCover(updateScene);
|
||||
}
|
||||
|
||||
setAnimating(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="game">
|
||||
<div className="scene-container">
|
||||
<div className="scene-inner">
|
||||
{scene.map((item, idx) => (
|
||||
<Symbol
|
||||
key={item.id}
|
||||
{...item}
|
||||
x={
|
||||
item.status === 0
|
||||
? item.x
|
||||
: item.status === 1
|
||||
? sortedQueue[item.id]
|
||||
: -1000
|
||||
}
|
||||
y={item.status === 0 ? item.y : 945}
|
||||
onClick={() => clickSymbol(idx)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="queue-container" />
|
||||
<div className="flex-container flex-between">
|
||||
<button className="flex-grow" onClick={pop}>
|
||||
弹出
|
||||
</button>
|
||||
<button className="flex-grow" onClick={undo}>
|
||||
撤销
|
||||
</button>
|
||||
<button className="flex-grow" onClick={wash}>
|
||||
洗牌
|
||||
</button>
|
||||
<button
|
||||
className="flex-grow"
|
||||
onClick={() => {
|
||||
// 跳关扣关卡对应数值的分
|
||||
setScore(score - level);
|
||||
levelUp();
|
||||
}}
|
||||
>
|
||||
下一关
|
||||
</button>
|
||||
</div>
|
||||
<div className="level">
|
||||
关卡{level}|剩余{scene.filter((i) => i.status === 0).length}
|
||||
|得分{score}
|
||||
</div>
|
||||
|
||||
{/*提示弹窗*/}
|
||||
{finished && (
|
||||
<div className="modal">
|
||||
<h1>{tipText}</h1>
|
||||
<button onClick={restart}>再来一次</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/*bgm*/}
|
||||
{theme.bgm && (
|
||||
<button className="bgm-button" onClick={() => setBgmOn(!bgmOn)}>
|
||||
{bgmOn ? '🔊' : '🔈'}
|
||||
<audio ref={bgmRef} loop src={theme.bgm} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/*音效*/}
|
||||
{theme.sounds.map((sound) => (
|
||||
<audio
|
||||
key={sound.name}
|
||||
ref={(ref) => {
|
||||
if (ref) soundRefMap.current[sound.name] = ref;
|
||||
}}
|
||||
src={sound.src}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Game;
|
||||
@@ -0,0 +1,77 @@
|
||||
/* 可封装 */
|
||||
.info {
|
||||
position: fixed;
|
||||
left: 8px;
|
||||
bottom: 8px;
|
||||
transition: 0.3s;
|
||||
padding: 16px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 18px;
|
||||
background: rgb(0 0 0/ 50%);
|
||||
color: white;
|
||||
overflow: hidden;
|
||||
backdrop-filter: blur(8px);
|
||||
box-sizing: border-box;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
opacity: 0;
|
||||
transition: 0.6s;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #747bff;
|
||||
}
|
||||
|
||||
.icon {
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
top: 2px;
|
||||
font-size: 24px;
|
||||
font-weight: 900;
|
||||
transition: 0.2s;
|
||||
}
|
||||
|
||||
.close {
|
||||
position: absolute;
|
||||
border-radius: 8px;
|
||||
background-color: rgb(0 0 0/20%);
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
right: 0;
|
||||
top: 0;
|
||||
line-height: 36px;
|
||||
text-align: center;
|
||||
transform: scale(0);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
&.open {
|
||||
height: 100px;
|
||||
border-radius: 8px;
|
||||
|
||||
@media screen and (max-width: 500px) {
|
||||
width: calc(100% - 70px);
|
||||
}
|
||||
@media screen and (min-width: 501px) {
|
||||
width: 500px;
|
||||
}
|
||||
|
||||
p {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.close {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.icon {
|
||||
transform: scale(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
-25
@@ -1,13 +1,14 @@
|
||||
import React, { CSSProperties, FC } from 'react';
|
||||
|
||||
export const Info: FC<{ style?: CSSProperties }> = ({ style }) => {
|
||||
import React, { CSSProperties, FC, useState } from 'react';
|
||||
import style from './Info.module.scss';
|
||||
import classNames from 'classnames';
|
||||
export const Info: FC = () => {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<div style={style}>
|
||||
<p>
|
||||
<span id="busuanzi_container_site_pv">
|
||||
累计访问:<span id="busuanzi_value_site_pv"></span>次
|
||||
</span>
|
||||
</p>
|
||||
<div
|
||||
onClick={() => !open && setOpen(true)}
|
||||
className={classNames(style.info, open && style.open)}
|
||||
>
|
||||
<div className={style.icon}>i</div>
|
||||
<p>
|
||||
bgm素材:
|
||||
<a
|
||||
@@ -17,22 +18,6 @@ export const Info: FC<{ style?: CSSProperties }> = ({ style }) => {
|
||||
>
|
||||
洛天依,言和原创《普通DISCO》
|
||||
</a>
|
||||
、
|
||||
<a
|
||||
href="https://music.163.com/#/song?id=135022"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
贫民百万歌星
|
||||
</a>
|
||||
、
|
||||
<a
|
||||
href="https://y.qq.com/n/ryqq/songDetail/0020Nusb3QJGn9"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
只因你太美
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
玩法来源➡️羊了个羊➡️
|
||||
@@ -44,6 +29,10 @@ export const Info: FC<{ style?: CSSProperties }> = ({ style }) => {
|
||||
3 Tiles
|
||||
</a>
|
||||
</p>
|
||||
<p>仅供交流,禁止商用</p>
|
||||
<div className={style.close} onClick={() => setOpen(false)}>
|
||||
X
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
.loading {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
@mixin position-top {
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 50%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@mixin position-right-top {
|
||||
left: 50%;
|
||||
top: 0;
|
||||
height: 50%;
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
@mixin position-right {
|
||||
left: 50%;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
@mixin position-right-bottom {
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
height: 50%;
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
@mixin position-bottom {
|
||||
left: 0;
|
||||
top: 50%;
|
||||
height: 50%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@mixin position-left-bottom {
|
||||
left: 0;
|
||||
top: 50%;
|
||||
height: 50%;
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
@mixin position-left {
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
@mixin position-left-top {
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 50%;
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
@keyframes move {
|
||||
0% {
|
||||
@include position-left-top;
|
||||
}
|
||||
|
||||
12.5% {
|
||||
@include position-top;
|
||||
}
|
||||
|
||||
25% {
|
||||
@include position-right-top;
|
||||
}
|
||||
|
||||
37.5% {
|
||||
@include position-right;
|
||||
}
|
||||
|
||||
50% {
|
||||
@include position-right-bottom;
|
||||
}
|
||||
|
||||
62.5% {
|
||||
@include position-bottom;
|
||||
}
|
||||
|
||||
75% {
|
||||
@include position-left-bottom;
|
||||
}
|
||||
|
||||
87.5% {
|
||||
@include position-left;
|
||||
}
|
||||
}
|
||||
|
||||
.block {
|
||||
position: absolute;
|
||||
transition: 0.5s;
|
||||
border-radius: 12px;
|
||||
@include position-left-top;
|
||||
|
||||
&Container {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
&1 {
|
||||
background-color: #646cff88;
|
||||
animation: move 1s infinite ease-in-out;
|
||||
|
||||
&.error {
|
||||
animation-play-state: paused;
|
||||
transform: rotate(75deg) translateX(30px);
|
||||
}
|
||||
}
|
||||
|
||||
&2 {
|
||||
background-color: #646cff66;
|
||||
animation: move 1s infinite ease-in-out;
|
||||
animation-delay: 0.375s;
|
||||
|
||||
&.error {
|
||||
animation-play-state: paused;
|
||||
transform: rotate(175deg) translateX(10px);
|
||||
}
|
||||
}
|
||||
|
||||
&3 {
|
||||
background-color: #646cff44;
|
||||
animation: move 1s infinite ease-in-out;
|
||||
animation-delay: 0.75s;
|
||||
|
||||
&.error {
|
||||
animation-play-state: paused;
|
||||
transform: rotate(225deg) translateX(20px);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import React, { FC } from 'react';
|
||||
import style from './Loading.module.scss';
|
||||
import classNames from 'classnames';
|
||||
|
||||
export const Loading: FC<{ error: string }> = ({ error }) => {
|
||||
return (
|
||||
<div className={style.loading}>
|
||||
<div className={style.blockContainer}>
|
||||
{[1, 2, 3].map((num) => (
|
||||
<div
|
||||
key={num}
|
||||
className={classNames(
|
||||
style.block,
|
||||
style[`block${num}`],
|
||||
error && style.error
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{error ? (
|
||||
<span>
|
||||
{error},稍后再试或<a href="/">返回首页</a>
|
||||
</span>
|
||||
) : (
|
||||
<span>加载中...</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,137 @@
|
||||
@keyframes gradient {
|
||||
0% {
|
||||
background-position: 0 50%;
|
||||
}
|
||||
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: 0 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.info {
|
||||
position: fixed;
|
||||
right: 8px;
|
||||
top: 8px;
|
||||
padding: 4px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 18px;
|
||||
animation: gradient 4s ease infinite;
|
||||
background-image: linear-gradient(
|
||||
-45deg,
|
||||
#ee775288,
|
||||
#e73c7e88,
|
||||
#23a6d588,
|
||||
#23d5ab88
|
||||
);
|
||||
background-size: 400% 400%;
|
||||
background-position: 0 0;
|
||||
box-sizing: border-box;
|
||||
transition: 0.4s;
|
||||
backdrop-filter: blur(8px);
|
||||
z-index: 9;
|
||||
overflow: hidden;
|
||||
color: white;
|
||||
|
||||
* {
|
||||
transition: 0.6s;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: underline;
|
||||
color: white;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.close {
|
||||
position: absolute;
|
||||
border-radius: 8px;
|
||||
background-color: rgb(0 0 0/20%);
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
right: 0;
|
||||
top: 0;
|
||||
line-height: 36px;
|
||||
text-align: center;
|
||||
transform: scale(0);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.github {
|
||||
&Icon {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
top: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&Link {
|
||||
position: absolute;
|
||||
right: -196px;
|
||||
top: 26px;
|
||||
}
|
||||
}
|
||||
|
||||
.bilibili {
|
||||
&Icon {
|
||||
position: absolute;
|
||||
right: -100px;
|
||||
top: 10px;
|
||||
}
|
||||
|
||||
&Link {
|
||||
position: absolute;
|
||||
right: -196px;
|
||||
top: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
&.open {
|
||||
height: 100px;
|
||||
border-radius: 8px;
|
||||
|
||||
@media screen and (max-width: 500px) {
|
||||
width: calc(100% - 16px);
|
||||
}
|
||||
@media screen and (min-width: 501px) {
|
||||
width: 500px;
|
||||
}
|
||||
|
||||
.close {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.github {
|
||||
&Icon {
|
||||
right: calc(100% - 70px);
|
||||
top: 18px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
&Link {
|
||||
right: calc(100% - 200px);
|
||||
top: 26px;
|
||||
}
|
||||
}
|
||||
|
||||
.bilibili {
|
||||
&Icon {
|
||||
height: 36px;
|
||||
right: 26px;
|
||||
top: 50px;
|
||||
}
|
||||
|
||||
&Link {
|
||||
right: 110px;
|
||||
top: 58px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,48 @@
|
||||
.container {
|
||||
position: fixed;
|
||||
right: 8px;
|
||||
bottom: 8px;
|
||||
transition: 0.3s;
|
||||
color: white;
|
||||
backdrop-filter: blur(8px);
|
||||
box-sizing: border-box;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
line-height: 52px;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
overflow: visible;
|
||||
|
||||
.square {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 8px;
|
||||
background: rgb(0 0 0/ 40%);
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
transition: 0.3s;
|
||||
overflow: hidden;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
|
||||
&.open {
|
||||
.diy {
|
||||
width: 80px;
|
||||
font-weight: 900;
|
||||
font-size: 18px;
|
||||
background-image: linear-gradient(
|
||||
-45deg,
|
||||
#ee7752,
|
||||
#e73c7e,
|
||||
#23a6d5,
|
||||
#23d5ab
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import React, { FC, useState } from 'react';
|
||||
import style from './ThemeChanger.module.scss';
|
||||
import classNames from 'classnames';
|
||||
import { fishermanTheme } from '../themes/fisherman';
|
||||
import { jinlunTheme } from '../themes/jinlun';
|
||||
import { ikunTheme } from '../themes/ikun';
|
||||
import { pddTheme } from '../themes/pdd';
|
||||
import { getDefaultTheme } from '../themes/default';
|
||||
import { Theme } from '../themes/interface';
|
||||
|
||||
const BuiltinThemes = [
|
||||
getDefaultTheme(),
|
||||
fishermanTheme,
|
||||
jinlunTheme,
|
||||
ikunTheme,
|
||||
pddTheme,
|
||||
];
|
||||
|
||||
export const ThemeChanger: FC<{
|
||||
changeTheme: (theme: Theme<any>) => void;
|
||||
onDiyClick: () => void;
|
||||
}> = ({ changeTheme, onDiyClick }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<div className={classNames(style.container, open && style.open)}>
|
||||
{BuiltinThemes.map((theme, idx) => (
|
||||
<div
|
||||
className={classNames(style.square)}
|
||||
key={theme.title}
|
||||
style={{
|
||||
opacity: open ? 1 : 0.3,
|
||||
transform: open
|
||||
? `translateY(-${110 * (idx + 1)}%)`
|
||||
: '',
|
||||
}}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
changeTheme(theme);
|
||||
}}
|
||||
>
|
||||
{typeof theme.icons[0].content === 'string' ? (
|
||||
theme.icons[0].content.startsWith('http') ? (
|
||||
/*图片外链*/
|
||||
<img src={theme.icons[0].content} alt="" />
|
||||
) : (
|
||||
/*字符表情*/
|
||||
<i>{theme.icons[0].content}</i>
|
||||
)
|
||||
) : (
|
||||
/*ReactNode*/
|
||||
theme.icons[0].content
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div
|
||||
className={classNames(style.square, style.diy)}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
onDiyClick();
|
||||
}}
|
||||
style={{
|
||||
opacity: open ? 1 : 0.3,
|
||||
transform: open ? `translateY(-${110 * 6}%)` : '',
|
||||
}}
|
||||
>
|
||||
DIY!
|
||||
</div>
|
||||
<div
|
||||
onClick={() => setOpen(!open)}
|
||||
className={classNames(style.square)}
|
||||
>
|
||||
{open ? '收起' : '更多'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
@keyframes jump {
|
||||
0% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
10% {
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
20% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scale {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
line-height: 1.15;
|
||||
font-size: 2rem;
|
||||
padding-top: 1rem;
|
||||
animation: scale 0.27s infinite alternate;
|
||||
z-index: 1;
|
||||
|
||||
* {
|
||||
transition: 0.3s;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: inline-block;
|
||||
animation-name: jump;
|
||||
animation-iteration-count: infinite;
|
||||
animation-duration: 3s;
|
||||
animation-play-state: running;
|
||||
text-shadow: 4px 6px 2px rgb(0 0 0 / 20%);
|
||||
}
|
||||
}
|
||||
|
||||
.title,
|
||||
.description {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
font-size: 1rem;
|
||||
font-weight: 400;
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import React, { FC } from 'react';
|
||||
import style from './Title.module.scss';
|
||||
|
||||
export const Title: FC<{ title: string; desc?: string }> = ({
|
||||
title,
|
||||
desc,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<h1 className={style.title}>
|
||||
{[...title].map((str, i) => (
|
||||
<span
|
||||
className={style.item}
|
||||
style={{ animationDelay: i / 10 + 's' }}
|
||||
key={`${i}`}
|
||||
>
|
||||
{str}
|
||||
</span>
|
||||
))}
|
||||
</h1>
|
||||
{desc && <h2 className={style.description}>{desc}</h2>}
|
||||
</>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user