chore: 组件初步拆分

This commit is contained in:
streakingman 2022-10-02 21:44:38 +08:00
parent 0afeac0f87
commit 90852ce91d
7 changed files with 808 additions and 697 deletions

View File

@ -4,106 +4,3 @@
max-width: 500px;
margin: 0 auto;
}
.app {
width: 100%;
margin: 0 auto;
}
.scene {
&-container {
width: 100%;
padding-bottom: 100%;
position: relative;
margin: 10% 0;
}
&-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 {
border-radius: 8px;
width: 100%;
padding-bottom: 15%;
border: 2px solid gray;
margin-bottom: 16px;
}
.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;
}
.bgm-button {
position: fixed;
left: 0;
top: 0;
padding: 4px;
width: 36px;
height: 36px;
}
.zhenghuo-button {
width: 100%;
margin-top: 8px;
}
.background {
position: fixed;
left: 0;
top: 0;
width: 100vw;
height: 100vh;
object-fit: cover;
z-index: -1;
}

View File

@ -1,30 +1,21 @@
import React, {
FC,
MouseEventHandler,
useEffect,
useRef,
useState,
} from 'react';
import React, { FC, useState } from 'react';
import './App.scss';
import { BilibiliLink, PersonalInfo } from './components/PersonalInfo';
import {
LAST_LEVEL_STORAGE_KEY,
parsePathCustomThemeId,
parsePathThemeName,
randomString,
waitTimeout,
} from './utils';
import { defaultTheme } from './themes/default';
import { Icon, Theme } from './themes/interface';
import { Theme } from './themes/interface';
import { fishermanTheme } from './themes/fisherman';
import { jinlunTheme } from './themes/jinlun';
import { ikunTheme } from './themes/ikun';
import { pddTheme } from './themes/pdd';
import { BeiAn } from './components/BeiAn';
import { Info } from './components/Info';
import { owTheme } from './themes/ow';
import { ConfigDialog } from './components/ConfigDialog';
import Bmob from 'hydrogen-js-sdk';
import Game from './Game';
import { Loading } from './components/Loading';
import { BeiAn } from './components/BeiAn';
// 内置主题
const builtInThemes: Theme<any>[] = [
@ -35,154 +26,9 @@ const builtInThemes: Theme<any>[] = [
pddTheme,
owTheme,
];
// 最大关卡
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>
);
};
// 从url初始化主题
const themeFromPath: string = parsePathThemeName(location.href);
const themeFromPath = parsePathThemeName(location.href);
const customThemeIdFromPath = parsePathCustomThemeId(location.href);
const CUSTOM_THEME_FAIL_TIP = '查询配置失败';
const initTheme = customThemeIdFromPath
? { title: '', icons: [], sounds: [], name: '' }
: themeFromPath
@ -191,444 +37,40 @@ const initTheme = customThemeIdFromPath
: defaultTheme;
// 读取缓存关卡数
const LAST_LEVEL_STORAGE_KEY = 'lastLevel';
const initLevel = Number(localStorage.getItem(LAST_LEVEL_STORAGE_KEY) || '1');
const App: FC = () => {
const [curTheme, setCurTheme] = useState<Theme<any>>(initTheme);
const [themes, setThemes] = useState<Theme<any>[]>(builtInThemes);
const [pureMode, setPureMode] = useState<boolean>(!!customThemeIdFromPath);
const [scene, setScene] = useState<Scene>(
makeScene(initLevel, curTheme.icons)
);
const [level, setLevel] = useState<number>(initLevel);
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 [configDialogShow, setConfigDialogShow] = 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());
}, [level]);
// 初始化主题
useEffect(() => {
if (customThemeIdFromPath) {
// 自定义主题
Bmob.Query('config')
.get(customThemeIdFromPath)
.then((res) => {
// @ts-ignore
const { content } = res;
try {
const customTheme = JSON.parse(content);
if (!customTheme.pure) {
setPureMode(false);
setThemes([...themes, customTheme]);
}
setCurTheme(customTheme);
} catch (e) {
console.log(e);
}
})
.catch((e) => {
setCurTheme({ ...curTheme, title: CUSTOM_THEME_FAIL_TIP });
console.log(e);
});
}
}, []);
// 主题切换
useEffect(() => {
// 初始化时不加载bgm
if (once) {
setBgmOn(false);
setTimeout(() => {
setBgmOn(true);
}, 300);
}
restart();
// 更改路径query
if (customThemeIdFromPath) return;
history.pushState(
{},
curTheme.title,
`/?theme=${encodeURIComponent(curTheme.name)}`
);
}, [curTheme]);
// 队列区排序
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;
console.log('???');
const [theme, setTheme] = useState<Theme<any>>(initTheme);
const [loading, setLoading] = useState<boolean>(!!customThemeIdFromPath);
const [error, setError] = useState<string>('');
if (customThemeIdFromPath) {
// debugger
// 自定义主题
/*Bmob.Query('config')
.get(customThemeIdFromPath)
.then((res) => {
// @ts-ignore
const { content } = res;
try {
const customTheme = JSON.parse(content);
setTheme(customTheme);
setLoading(false);
} catch (e) {
setError('主题配置解析失败');
}
}
}
setScene(updateScene);
};
})
.catch(({ error }) => {
setError(error);
});*/
}
// 弹出
const pop = () => {
if (!queue.length) return;
const updateQueue = queue.slice();
const symbol = updateQueue.shift();
if (!symbol) return;
const find = scene.find((s) => s.id === symbol.id);
if (find) {
setQueue(updateQueue);
find.status = 0;
find.x = 100 * Math.floor(8 * Math.random());
find.y = 700;
checkCover(scene);
// 音效
if (soundRefMap.current?.['sound-shift']) {
soundRefMap.current['sound-shift'].currentTime = 0;
soundRefMap.current['sound-shift'].play();
}
}
};
// 撤销
const undo = () => {
if (!queue.length) return;
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 = () => {
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, curTheme.icons));
};
// 重开
const restart = () => {
setFinished(false);
setLevel(1);
setQueue([]);
checkCover(makeScene(1, curTheme.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) {
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;
}
// 升级
setLevel(level + 1);
setQueue([]);
checkCover(makeScene(level + 1, curTheme.icons));
} else {
setQueue(updateQueue);
checkCover(updateScene);
}
setAnimating(false);
};
// 自定义整活
const customZhenghuo = (theme: Theme<string>) => {
setCurTheme(theme);
};
return (
return loading ? (
<Loading error={error} />
) : (
<>
{curTheme.background && (
<img
alt="background"
src={curTheme.background}
className="background"
style={{
filter: curTheme.backgroundBlur ? 'blur(8px)' : 'none',
}}
/>
)}
<h2>
{curTheme.title}{' '}
{curTheme.title === CUSTOM_THEME_FAIL_TIP && (
<a href="/"></a>
)}
</h2>
{curTheme.desc}
{!pureMode && <PersonalInfo />}
<h3 className="flex-container flex-center">
{!pureMode && (
<>
:
{/*TODO themes维护方式调整*/}
<select
value={themes.findIndex(
(theme) => theme.name === curTheme.name
)}
onChange={(e) =>
setCurTheme(themes[Number(e.target.value)])
}
>
{themes.map((t, idx) => (
<option key={t.name} value={idx}>
{t.name}
</option>
))}
</select>
</>
)}
Level: {level}
</h3>
<div className="app">
<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 : 895}
onClick={() => clickSymbol(idx)}
/>
))}
</div>
</div>
</div>
<div className="queue-container flex-container flex-center" />
<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={levelUp}>
</button>
</div>
{!pureMode && (
<button
onClick={() => setConfigDialogShow(true)}
className="zhenghuo-button primary"
>
</button>
)}
<Info style={{ display: pureMode ? 'none' : 'block' }} />
<Game theme={theme} initLevel={initLevel} pureMode={!!theme.pure} />
<BeiAn />
{pureMode && <BilibiliLink />}
{/*提示弹窗*/}
{finished && (
<div className="modal">
<h1>{tipText}</h1>
<button onClick={restart}></button>
</div>
)}
{/*自定义主题弹窗*/}
<ConfigDialog
show={configDialogShow}
closeMethod={() => setConfigDialogShow(false)}
previewMethod={customZhenghuo}
/>
{/*bgm*/}
<button className="bgm-button" onClick={() => setBgmOn(!bgmOn)}>
{bgmOn ? '🔊' : '🔈'}
<audio
ref={bgmRef}
loop
src={curTheme?.bgm || '/sound-disco.mp3'}
/>
</button>
{/*音效*/}
{curTheme.sounds.map((sound) => (
<audio
key={sound.name}
ref={(ref) => {
if (ref) soundRefMap.current[sound.name] = ref;
}}
src={sound.src}
/>
))}
</>
);
};

109
src/Game.scss Normal file
View File

@ -0,0 +1,109 @@
#root {
text-align: center;
width: 100%;
max-width: 500px;
margin: 0 auto;
}
.app {
width: 100%;
margin: 0 auto;
}
.scene {
&-container {
width: 100%;
padding-bottom: 100%;
position: relative;
margin: 10% 0;
}
&-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 {
border-radius: 8px;
width: 100%;
padding-bottom: 15%;
border: 2px solid gray;
margin-bottom: 16px;
}
.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;
}
.bgm-button {
position: fixed;
left: 0;
top: 0;
padding: 4px;
width: 36px;
height: 36px;
}
.zhenghuo-button {
width: 100%;
margin-top: 8px;
}
.background {
position: fixed;
left: 0;
top: 0;
width: 100vw;
height: 100vh;
object-fit: cover;
z-index: -1;
}

491
src/Game.tsx Normal file
View File

@ -0,0 +1,491 @@
import React, {
FC,
MouseEventHandler,
useEffect,
useRef,
useState,
} from 'react';
import './Game.scss';
import { LAST_LEVEL_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;
pureMode?: boolean;
}> = ({ theme, initLevel, pureMode = false }) => {
const [scene, setScene] = useState<Scene>(
makeScene(initLevel, theme.icons)
);
const [level, setLevel] = useState<number>(initLevel);
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 [configDialogShow, setConfigDialogShow] = 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());
}, [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 pop = () => {
if (!queue.length) return;
const updateQueue = queue.slice();
const symbol = updateQueue.shift();
if (!symbol) return;
const find = scene.find((s) => s.id === symbol.id);
if (find) {
setQueue(updateQueue);
find.status = 0;
find.x = 100 * Math.floor(8 * Math.random());
find.y = 700;
checkCover(scene);
// 音效
if (soundRefMap.current?.['sound-shift']) {
soundRefMap.current['sound-shift'].currentTime = 0;
soundRefMap.current['sound-shift'].play();
}
}
};
// 撤销
const undo = () => {
if (!queue.length) return;
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 = () => {
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);
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) {
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;
}
// 升级
setLevel(level + 1);
setQueue([]);
checkCover(makeScene(level + 1, theme.icons));
} else {
setQueue(updateQueue);
checkCover(updateScene);
}
setAnimating(false);
};
return (
<>
{theme.background && (
<img
alt="background"
src={theme.background}
className="background"
style={{
filter: theme.backgroundBlur ? 'blur(8px)' : 'none',
}}
/>
)}
<h3 className="flex-container flex-center">Level: {level}</h3>
<div className="app">
<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 : 895}
onClick={() => clickSymbol(idx)}
/>
))}
</div>
</div>
</div>
<div className="queue-container flex-container flex-center" />
<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={levelUp}>
</button>
</div>
{/*提示弹窗*/}
{finished && (
<div className="modal">
<h1>{tipText}</h1>
<button onClick={restart}></button>
</div>
)}
{/*bgm*/}
<button className="bgm-button" onClick={() => setBgmOn(!bgmOn)}>
{bgmOn ? '🔊' : '🔈'}
<audio
ref={bgmRef}
loop
src={theme?.bgm || '/sound-disco.mp3'}
/>
</button>
{/*音效*/}
{theme.sounds.map((sound) => (
<audio
key={sound.name}
ref={(ref) => {
if (ref) soundRefMap.current[sound.name] = ref;
}}
src={sound.src}
/>
))}
</>
);
};
export default Game;

View File

@ -0,0 +1,143 @@
.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);
}
}
}

View File

@ -0,0 +1,29 @@
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>
);
};

View File

@ -1,5 +1,3 @@
import { Theme } from './themes/interface';
export const randomString: (len: number) => string = (len) => {
const pool = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
let res = '';
@ -63,3 +61,5 @@ export const captureElement = (id: string, filename: string) => {
1
);
};
export const LAST_LEVEL_STORAGE_KEY = 'lastLevel';