Merge branch 'refactor'

# Conflicts:
#	src/App.tsx
This commit is contained in:
streakingman 2022-10-04 14:24:17 +08:00
commit bac732a42b
49 changed files with 1595 additions and 975 deletions

View File

@ -26,5 +26,6 @@ module.exports = {
'prettier/prettier': 'error',
'@typescript-eslint/no-explicit-any': 0,
'@typescript-eslint/ban-ts-comment': 0,
'@typescript-eslint/no-non-null-assertion': 0,
},
};

View File

@ -19,18 +19,185 @@
s.parentNode.insertBefore(hm, s);
})();
</script>
<style>
html,
body {
margin: 0;
padding: 0;
background-color: white;
transition: background-color 0.3s 0.4s;
color: rgb(0 0 0 / 60%);
}
a {
color: currentColor;
text-decoration: inherit;
}
#root {
transition: opacity 0.5s;
}
#loading {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 32px;
overflow: visible;
}
@keyframes move {
0% {
left: 0;
top: 0;
height: 50%;
width: 50%;
}
12.5% {
left: 0;
top: 0;
height: 50%;
width: 100%;
}
25% {
left: 50%;
top: 0;
height: 50%;
width: 50%;
}
37.5% {
left: 50%;
top: 0;
height: 100%;
width: 50%;
}
50% {
left: 50%;
top: 50%;
height: 50%;
width: 50%;
}
62.5% {
left: 0;
top: 50%;
height: 50%;
width: 100%;
}
75% {
left: 0;
top: 50%;
height: 50%;
width: 50%;
}
87.5% {
left: 0;
top: 0;
height: 100%;
width: 50%;
}
}
@keyframes wave {
0% {
transform: translateY(30px);
}
100% {
transform: translateY(-30px);
}
}
.loadingBlock {
position: absolute;
transition: 0.6s;
border-radius: 12px;
left: 0;
top: 0;
height: 50%;
width: 50%;
animation: move 1s infinite ease-in-out;
}
.loadingBlockContainer {
width: 64px;
height: 64px;
position: relative;
}
#loading.error .loadingBlock,
#loading.success .loadingBlock {
animation-play-state: paused;
}
.loadingBlock1 {
background-color: #8dac8588;
}
#loading.error .loadingBlock1 {
transform: rotate(75deg) translateX(30px);
animation: wave 1s infinite alternate;
}
#loading.success .loadingBlock1 {
transform: rotate(75deg) translateX(200px) scale(4);
transition-delay: 0.1s;
}
.loadingBlock2 {
background-color: #8dac8566;
animation-delay: 0.375s;
}
#loading.error .loadingBlock2 {
transform: rotate(175deg) translateX(10px);
}
#loading.success .loadingBlock2 {
transform: rotate(175deg) translateX(200px) scale(2);
transition-delay: 0.05s;
}
.loadingBlock3 {
background-color: #8dac8544;
animation-delay: 0.75s;
}
#loading.error .loadingBlock3 {
transform: rotate(225deg) translateX(20px);
}
#loading.success .loadingBlock3 {
transform: rotate(225deg) translateX(200px) scale(3);
}
#loadingTips {
font-size: 0.8em;
line-height: 1.8;
text-align: center;
transition: 0.3s;
}
#loading.success #loadingTips {
transform: translateY(300px);
opacity: 0;
}
#backHomeTip {
visibility: hidden;
}
</style>
</head>
<body>
<div id="root"></div>
<div id="root">
<!--数据加载提示在react渲染之前做-->
<div id="loading" class="loading">
<div class="loadingBlockContainer">
<div class="loadingBlock loadingBlock1"></div>
<div class="loadingBlock loadingBlock2"></div>
<div class="loadingBlock loadingBlock3"></div>
</div>
<div id="loadingTips">
<span id="loadingText">加载中...</span><br />
<span id="backHomeTip">
稍后再试或
<a href="/" style="color: #646cff"> 返回首页 </a>
</span>
</div>
</div>
</div>
<script>
// vite没有global手动声明
var global = global || window;
</script>
<script type="module" src="/src/main.tsx"></script>
<script
async
src="//busuanzi.ibruce.info/busuanzi/2.3/busuanzi.pure.mini.js"
></script>
<script async>
// 如果您基于此项目二创,可以删除以下代码
// 否则请标明原仓库地址
@ -45,13 +212,12 @@
'href',
'https://github.com/StreakingMan/solvable-sheep-game'
);
a.setAttribute('target', '_blank');
a.setAttribute('target', '_self');
a.style.cursor = 'pointer'
a.style.zIndex = '15'
a.innerText =
'本项目仅供交流禁止商业用途点击查看原github仓库';
const p = document.createElement('p');
p.style.textAlign = 'center';
p.append(a);
document.body.prepend(p);
document.getElementById('root')?.prepend(a);
}
}, 5000);
</script>

BIN
public/wxqrcode.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

View File

@ -3,101 +3,13 @@
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;
>:not(.background) {
z-index: 1;
}
}
.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;
@ -105,5 +17,5 @@
width: 100vw;
height: 100vh;
object-fit: cover;
z-index: -1;
z-index: 0;
}

View File

@ -1,649 +1,99 @@
import React, {
FC,
MouseEventHandler,
useEffect,
useRef,
useState,
} from 'react';
import React, { FC, useEffect, useState } from 'react';
import './App.scss';
import { BilibiliLink, PersonalInfo } from './components/PersonalInfo';
import {
parsePathCustomThemeId,
parsePathThemeName,
randomString,
waitTimeout,
domRelatedOptForTheme,
LAST_LEVEL_STORAGE_KEY,
LAST_SCORE_STORAGE_KEY,
wrapThemeDefaultSounds,
} from './utils';
import { defaultTheme } from './themes/default';
import { Icon, 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 { Theme } from './themes/interface';
import Game from './components/Game';
import { BeiAn } from './components/BeiAn';
import { Title } from './components/Title';
import { PersonalInfo } from './components/PersonalInfo';
import { Info } from './components/Info';
import { owTheme } from './themes/ow';
import { ThemeChanger } from './components/ThemeChanger';
import { ConfigDialog } from './components/ConfigDialog';
import Bmob from 'hydrogen-js-sdk';
// 内置主题
const builtInThemes: Theme<any>[] = [
defaultTheme,
fishermanTheme,
jinlunTheme,
ikunTheme,
pddTheme,
owTheme,
];
// 读取缓存关卡得分
const initLevel = Number(localStorage.getItem(LAST_LEVEL_STORAGE_KEY) || '1');
const initScore = Number(localStorage.getItem(LAST_SCORE_STORAGE_KEY) || '0');
// 最大关卡
const maxLevel = 50;
const App: FC<{ theme: Theme<any> }> = ({ theme: initTheme }) => {
console.log('initTheme', initTheme);
// console.log(JSON.stringify(theme));
interface MySymbol {
id: string;
status: number; // 0->1->2
isCover: boolean;
x: number;
y: number;
icon: Icon;
}
const [theme, setTheme] = useState<Theme<any>>(initTheme);
const [diyDialogShow, setDiyDialogShow] = useState<boolean>(false);
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,
});
const changeTheme = (theme: Theme<any>) => {
wrapThemeDefaultSounds(theme);
domRelatedOptForTheme(theme);
setTheme({ ...theme });
};
// 大于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;
const previewTheme = (_theme: Theme<any>) => {
const theme = JSON.parse(JSON.stringify(_theme));
wrapThemeDefaultSounds(theme);
domRelatedOptForTheme(theme);
setTheme(theme);
};
// 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 customThemeIdFromPath = parsePathCustomThemeId(location.href);
const CUSTOM_THEME_FAIL_TIP = '查询配置失败';
const App: FC = () => {
const [curTheme, setCurTheme] = useState<Theme<any>>(
customThemeIdFromPath
? { title: '', icons: [], sounds: [], name: '' }
: defaultTheme
);
const [themes, setThemes] = useState<Theme<any>[]>(builtInThemes);
const [pureMode, setPureMode] = useState<boolean>(!!customThemeIdFromPath);
const [scene, setScene] = useState<Scene>(makeScene(1, curTheme.icons));
const [level, setLevel] = useState<number>(1);
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(() => {
if (customThemeIdFromPath) {
// 自定义主题
const storageTheme = localStorage.getItem(customThemeIdFromPath);
if (storageTheme) {
// 节省请求
try {
const customTheme = JSON.parse(storageTheme);
if (!customTheme.pure) {
setPureMode(false);
setThemes([...themes, customTheme]);
}
setCurTheme(customTheme);
} catch (e) {
console.log(e);
}
} else {
Bmob.Query('config')
.get(customThemeIdFromPath)
.then((res) => {
// @ts-ignore
const { content } = res;
localStorage.setItem(customThemeIdFromPath, content);
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);
});
}
} else if (themeFromPath) {
// 内置主题
setCurTheme(
themes.find((theme) => theme.name === themeFromPath) ??
defaultTheme
);
console.log(import.meta.env.MODE);
if (import.meta.env.PROD) {
const busuanziScript = document.createElement('script');
busuanziScript.src =
'//busuanzi.ibruce.info/busuanzi/2.3/busuanzi.pure.mini.js';
document.getElementById('root')?.appendChild(busuanziScript);
}
}, []);
// 主题切换
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;
}
}
}
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, 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 (
<>
{curTheme.background && (
{theme.background && (
<img
alt="background"
src={curTheme.background}
src={theme.background}
className="background"
style={{
filter: curTheme.backgroundBlur ? 'blur(8px)' : 'none',
filter: theme.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 && (
<Title title={theme.title} desc={theme.desc} />
<PersonalInfo />
<Game
key={theme.title}
theme={theme}
initLevel={initLevel}
initScore={initScore}
/>
<div className={'flex-spacer'} />
<p style={{ textAlign: 'center', fontSize: 10, opacity: 0.5 }}>
<span id="busuanzi_container_site_pv">
{' '}
访
<span id="busuanzi_value_site_pv" />
</span>
<br />
<BeiAn />
</p>
<Info />
{!theme.pure && (
<>
:
{/*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>
<ThemeChanger
changeTheme={changeTheme}
onDiyClick={() => setDiyDialogShow(true)}
/>
<ConfigDialog
show={diyDialogShow}
closeMethod={() => setDiyDialogShow(false)}
previewMethod={previewTheme}
/>
</>
)}
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' }} />
<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 ||
'https://wj1.kumeiwp.com:912/wj/bl/2022/03/14/48298e7f30fdd8c21f02a3f5ef080134.mp3'
}
/>
</button>
{/*音效*/}
{curTheme.sounds.map((sound) => (
<audio
key={sound.name}
ref={(ref) => {
if (ref) soundRefMap.current[sound.name] = ref;
}}
src={sound.src}
/>
))}
</>
);
};

View File

@ -2,7 +2,6 @@ import React, { FC } from 'react';
export const BeiAn: FC = () => {
return (
<p style={{ textAlign: 'center' }}>
<a
href="https://beian.miit.gov.cn/"
target="_blank"
@ -10,6 +9,5 @@ export const BeiAn: FC = () => {
>
ICP备17007857号-2
</a>
</p>
);
};

View File

@ -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">

112
src/components/Game.scss Normal file
View File

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

504
src/components/Game.tsx Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

24
src/components/Title.tsx Normal file
View File

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

View File

@ -4,6 +4,51 @@ import App from './App';
import './styles/global.scss';
import './styles/utils.scss';
import Bmob from 'hydrogen-js-sdk';
import {
DEFAULT_BGM_STORAGE_KEY,
domRelatedOptForTheme,
parsePathCustomThemeId,
wrapThemeDefaultSounds,
} from './utils';
import { getDefaultTheme } from './themes/default';
import { Theme } from './themes/interface';
// react渲染
const render = (theme: Theme<any>) => {
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<App theme={theme} />
</React.StrictMode>
);
};
// 错误提示
const errorTip = (tip: string) => {
setTimeout(() => {
document.getElementById('loading')?.classList.add('error');
document.getElementById('loadingText')!.innerText = tip;
document.getElementById('backHomeTip')!.style.visibility = 'visible';
}, 600);
};
// 加载成功后数据转换runtime以及转场
const successTrans = (theme: Theme<any>) => {
wrapThemeDefaultSounds(theme);
setTimeout(() => {
domRelatedOptForTheme(theme);
const root = document.getElementById('root');
root!.style.opacity = '0';
document.getElementById('loading')?.classList.add('success');
setTimeout(() => {
render(theme);
root!.style.opacity = '1';
}, 600);
}, 500);
};
// 从url初始化主题
const customThemeIdFromPath = parsePathCustomThemeId(location.href);
// Bmob初始化
// @ts-ignore
@ -12,8 +57,54 @@ Bmob.initialize(
import.meta.env.VITE_BMOB_SECCODE
);
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
const loadTheme = () => {
// 请求主题
if (customThemeIdFromPath) {
const storageTheme = localStorage.getItem(customThemeIdFromPath);
if (storageTheme) {
try {
const customTheme = JSON.parse(storageTheme);
successTrans(customTheme);
} catch (e) {
errorTip('主题配置解析失败');
}
} else {
Bmob.Query('config')
.get(customThemeIdFromPath)
.then((res) => {
const { content } = res as any;
localStorage.setItem(customThemeIdFromPath, content);
try {
const customTheme = JSON.parse(content);
successTrans(customTheme);
} catch (e) {
errorTip('主题配置解析失败');
}
})
.catch(({ error }) => {
errorTip(error);
});
}
} else {
successTrans(getDefaultTheme());
}
};
// 音效资源请求
if (!localStorage.getItem(DEFAULT_BGM_STORAGE_KEY)) {
const query = Bmob.Query('file');
query.equalTo('type', '==', 'default');
query
.find()
.then((results) => {
for (const file of results as any) {
localStorage.setItem(file.name, file.base64);
}
loadTheme();
})
.catch((e) => {
errorTip(e);
});
} else {
loadTheme();
}

View File

@ -1,11 +1,6 @@
:root {
font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
font-size: 16px;
line-height: 24px;
font-weight: 400;
color-scheme: light dark;
color: rgb(255 255 255 / 87%);
background-color: #242424;
body {
padding: 0 32px;
-webkit-tap-highlight-color: transparent;
font-synthesis: none;
text-rendering: optimizelegibility;
-webkit-font-smoothing: antialiased;
@ -13,24 +8,26 @@
text-size-adjust: 100%;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
#root {
text-align: center;
width: 100%;
max-width: 500px;
margin: 0 auto;
min-height: 100vh;
display: flex;
flex-direction: column;
}
a:hover {
color: #535bf2;
}
button {
color: white;
border: none;
padding: 8px 16px;
border-radius: 8px;
background-color: #3338;
body {
margin: 0;
padding: 0 32px;
&.primary {
background-color: #747bff;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
select {
@ -45,40 +42,4 @@ input {
padding: 8px 12px;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
word-break: keep-all;
outline: none;
&.primary {
background-color: #646cff;
color: white;
}
&:hover:not(.primary) {
border-color: #646cff;
}
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #fff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}

View File

@ -4,6 +4,10 @@
flex-wrap: wrap;
}
.flex-no-wrap {
flex-wrap: nowrap;
}
.flex-center {
justify-content: center;
align-items: center;

View File

@ -1,4 +1,9 @@
import { Theme } from '../interface';
import {
DEFAULT_BGM_STORAGE_KEY,
DEFAULT_CLICK_SOUND_STORAGE_KEY,
DEFAULT_TRIPLE_SOUND_STORAGE_KEY,
} from '../../utils';
const icons = <const>[
`🎨`,
@ -15,27 +20,31 @@ const icons = <const>[
export type DefaultSoundNames = 'button-click' | 'triple';
import soundButtonClickUrl from './sounds/sound-button-click.mp3';
import soundTripleUrl from './sounds/sound-triple.mp3';
export const defaultSounds: Theme<DefaultSoundNames>['sounds'] = [
{
name: 'button-click',
src: soundButtonClickUrl,
},
{
name: 'triple',
src: soundTripleUrl,
},
];
export const defaultTheme: Theme<DefaultSoundNames> = {
title: '有解的羊了个羊(DEMO)',
name: '默认',
export const getDefaultTheme: () => Theme<DefaultSoundNames> = () => {
return {
title: '有解的羊了个羊',
desc: '真的可以通关~',
dark: true,
backgroundColor: '#8dac85',
icons: icons.map((icon) => ({
name: icon,
content: icon,
clickSound: 'button-click',
tripleSound: 'triple',
})),
sounds: defaultSounds,
sounds: [
{
name: 'button-click',
src:
localStorage.getItem(DEFAULT_CLICK_SOUND_STORAGE_KEY) || '',
},
{
name: 'triple',
src:
localStorage.getItem(DEFAULT_TRIPLE_SOUND_STORAGE_KEY) ||
'',
},
],
bgm: localStorage.getItem(DEFAULT_BGM_STORAGE_KEY) || '',
};
};

View File

@ -1,7 +1,7 @@
// 钓鱼佬主题
import React from 'react';
import { Theme } from '../interface';
import { DefaultSoundNames, defaultSounds } from '../default';
import { DefaultSoundNames } from '../default';
const imagesUrls = import.meta.glob('./images/*.png', {
import: 'default',
@ -17,12 +17,11 @@ const fishes = Object.entries(imagesUrls).map(([key, value]) => ({
export const fishermanTheme: Theme<DefaultSoundNames> = {
title: '🐟鱼了个鱼🐟',
name: '钓鱼佬',
icons: fishes.map(({ name, content }) => ({
name,
content,
clickSound: 'button-click',
tripleSound: 'triple',
})),
sounds: defaultSounds,
sounds: [],
};

View File

@ -53,7 +53,6 @@ const icons = Object.entries(imagesUrls).map(([key, value]) => ({
export const ikunTheme: Theme<SoundNames> = {
title: '🐔鸡了个鸡🐔',
name: 'iKun',
bgm,
icons: icons.map(({ name, content }) => ({
name,

View File

@ -14,14 +14,14 @@ export interface Sound<T = string> {
type Operation = 'shift' | 'undo' | 'wash';
// TODO title name 冗余
export interface Theme<SoundNames> {
title: string;
desc?: ReactNode;
name: string;
desc?: string;
bgm?: string;
background?: string;
backgroundColor?: string;
backgroundBlur?: boolean;
dark?: boolean;
pure?: boolean;
icons: Icon<SoundNames>[];
sounds: Sound<SoundNames>[];

View File

@ -25,7 +25,6 @@ const icons = Object.entries(imagesUrls).map(([key, value]) => ({
export const jinlunTheme: Theme<string> = {
title: '🐎马了个马🐎',
name: '金轮',
icons: icons.map(({ name, content }) => ({
name,
content,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

View File

@ -1,50 +0,0 @@
import { Theme } from '../interface';
import React from 'react';
import { defaultSounds } from '../default';
const soundUrls = import.meta.glob('./sounds/*.mp3', {
import: 'default',
eager: true,
});
const sounds = Object.entries(soundUrls).map(([key, value]) => ({
name: key.slice(9, -4),
src: value,
})) as Theme<string>['sounds'];
const imagesUrls = import.meta.glob('./images/*.png', {
import: 'default',
eager: true,
});
const icons = Object.entries(imagesUrls).map(([key, value]) => ({
name: key.slice(9, -4),
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
content: <img src={value} alt="" />,
}));
export const owTheme: Theme<string> = {
title: '守望先锋',
desc: (
<p>
<a
href="https://space.bilibili.com/228122468"
target="_blank"
rel="noreferrer"
>
</a>
</p>
),
name: 'OW',
icons: icons.map(({ name, content }) => ({
name,
content,
clickSound: 'button-click',
tripleSound: name === 'ow' ? 'triple' : name,
})),
sounds: [...defaultSounds, ...sounds],
};

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,7 +1,6 @@
// 骚猪主题
import React from 'react';
import { Theme } from '../interface';
import { defaultSounds } from '../default';
import bgm from './sounds/bgm.mp3';
const soundUrls = import.meta.glob('./sounds/*.mp3', {
@ -28,20 +27,7 @@ const images = Object.entries(imagesUrls).map(([key, value]) => ({
export const pddTheme: Theme<string> = {
title: '🐷猪了个猪🐷',
desc: (
<p>
<a
href="https://space.bilibili.com/81966051"
target="_blank"
rel="noreferrer"
>
</a>
</p>
),
name: '骚猪',
desc: '感谢 @猪酱的日常 提供素材',
bgm: bgm,
icons: images.map(({ name, content }) => ({
name,
@ -49,5 +35,5 @@ export const pddTheme: Theme<string> = {
clickSound: 'button-click',
tripleSound: name,
})),
sounds: [defaultSounds[0], ...sounds],
sounds,
};

View File

@ -1,4 +1,11 @@
import { Theme } from './themes/interface';
import { getDefaultTheme } from './themes/default';
export const LAST_LEVEL_STORAGE_KEY = 'lastLevel';
export const LAST_SCORE_STORAGE_KEY = 'lastScore';
export const DEFAULT_BGM_STORAGE_KEY = 'defaultBgm';
export const DEFAULT_TRIPLE_SOUND_STORAGE_KEY = 'defaultTripleSound';
export const DEFAULT_CLICK_SOUND_STORAGE_KEY = 'defaultClickSound';
export const randomString: (len: number) => string = (len) => {
const pool = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
@ -18,14 +25,7 @@ export const waitTimeout: (timeout: number) => Promise<void> = (timeout) => {
});
};
// 从url获取内置主题name
export const parsePathThemeName: (url: string) => string = (url) => {
const urlObj = new URL(url);
const params = urlObj.searchParams;
return decodeURIComponent(params.get('theme') || '默认');
};
// 从url解析自定义主题JSON
// 从url获取自定义主题Id
export const parsePathCustomThemeId: (url: string) => string = (url) => {
const urlObj = new URL(url);
const params = urlObj.searchParams;
@ -63,3 +63,47 @@ export const captureElement = (id: string, filename: string) => {
1
);
};
export const wrapThemeDefaultSounds: (theme: Theme<any>) => void = (theme) => {
const defaultTheme = getDefaultTheme();
// 默认音频资源补充
if (!theme.bgm) {
theme.bgm = defaultTheme.bgm;
}
let hasUseDefaultTriple, hasUseDefaultClick;
for (const icon of theme.icons) {
if (!icon.clickSound) icon.clickSound = 'button-click';
if (!icon.tripleSound) icon.tripleSound = 'triple';
if (icon.clickSound === 'button-click') hasUseDefaultClick = true;
if (icon.tripleSound === 'triple') hasUseDefaultTriple = true;
}
if (
hasUseDefaultClick &&
!theme.sounds.find((s) => s.name === 'button-click')
) {
const defaultClick = defaultTheme.sounds.find(
(s) => s.name === 'button-click'
);
defaultClick && theme.sounds.push(defaultClick);
}
if (hasUseDefaultTriple && !theme.sounds.find((s) => s.name === 'triple')) {
const defaultTripleSound = defaultTheme.sounds.find(
(s) => s.name === 'triple'
);
defaultTripleSound && theme.sounds.push(defaultTripleSound);
}
// 兼容旧数据
for (const sound of theme.sounds) {
if (['triple', 'button-click'].includes(sound.name))
// @ts-ignore
sound.src = defaultTheme.sounds.find(
(s) => s.name === sound.name
).src;
}
};
export const domRelatedOptForTheme = (theme: Theme<any>) => {
document.body.style.backgroundColor = theme.backgroundColor || 'white';
document.body.style.color = theme.dark ? 'white' : 'rgb(0 0 0 / 60%)';
};