refactor: 主题初始化、资源缓存、UI交互等重构

This commit is contained in:
streakingman 2022-10-04 13:57:33 +08:00
parent 90852ce91d
commit 5e29a1e55a
53 changed files with 1075 additions and 555 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>
// 如果您基于此项目二创,可以删除以下代码
// 否则请标明原仓库地址

View File

@ -1,5 +0,0 @@
<svg height="32" aria-hidden="true" viewBox="0 0 16 16" version="1.1" width="32" data-view-component="true"
class="octicon octicon-mark-github v-align-middle">
<path fill-rule="evenodd"
d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
</svg>

Before

Width:  |  Height:  |  Size: 788 B

Binary file not shown.

BIN
public/wxqrcode.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

View File

@ -3,4 +3,19 @@
width: 100%;
max-width: 500px;
margin: 0 auto;
position: relative;
>:not(.background) {
z-index: 1;
}
}
.background {
position: fixed;
left: 0;
top: 0;
width: 100vw;
height: 100vh;
object-fit: cover;
z-index: 0;
}

View File

@ -1,76 +1,99 @@
import React, { FC, useState } from 'react';
import React, { FC, useEffect, useState } from 'react';
import './App.scss';
import {
domRelatedOptForTheme,
LAST_LEVEL_STORAGE_KEY,
parsePathCustomThemeId,
parsePathThemeName,
LAST_SCORE_STORAGE_KEY,
wrapThemeDefaultSounds,
} from './utils';
import { defaultTheme } from './themes/default';
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 { owTheme } from './themes/ow';
import Game from './Game';
import { Loading } from './components/Loading';
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 { ThemeChanger } from './components/ThemeChanger';
import { ConfigDialog } from './components/ConfigDialog';
// 内置主题
const builtInThemes: Theme<any>[] = [
defaultTheme,
fishermanTheme,
jinlunTheme,
ikunTheme,
pddTheme,
owTheme,
];
// 从url初始化主题
const themeFromPath = parsePathThemeName(location.href);
const customThemeIdFromPath = parsePathCustomThemeId(location.href);
const initTheme = customThemeIdFromPath
? { title: '', icons: [], sounds: [], name: '' }
: themeFromPath
? builtInThemes.find((theme) => theme.name === themeFromPath) ??
defaultTheme
: defaultTheme;
// 读取缓存关卡数
// 读取缓存关卡得分
const initLevel = Number(localStorage.getItem(LAST_LEVEL_STORAGE_KEY) || '1');
const initScore = Number(localStorage.getItem(LAST_SCORE_STORAGE_KEY) || '0');
const App: FC<{ theme: Theme<any> }> = ({ theme: initTheme }) => {
console.log('initTheme', initTheme);
// console.log(JSON.stringify(theme));
const App: FC = () => {
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('主题配置解析失败');
}
})
.catch(({ error }) => {
setError(error);
});*/
}
const [diyDialogShow, setDiyDialogShow] = useState<boolean>(false);
return loading ? (
<Loading error={error} />
) : (
const changeTheme = (theme: Theme<any>) => {
wrapThemeDefaultSounds(theme);
domRelatedOptForTheme(theme);
setTheme({ ...theme });
};
const previewTheme = (_theme: Theme<any>) => {
const theme = JSON.parse(JSON.stringify(_theme));
wrapThemeDefaultSounds(theme);
domRelatedOptForTheme(theme);
setTheme(theme);
};
// 生产环境才统计
useEffect(() => {
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);
}
}, []);
return (
<>
<Game theme={theme} initLevel={initLevel} pureMode={!!theme.pure} />
{theme.background && (
<img
alt="background"
src={theme.background}
className="background"
style={{
filter: theme.backgroundBlur ? 'blur(8px)' : 'none',
}}
/>
)}
<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 && (
<>
<ThemeChanger
changeTheme={changeTheme}
onDiyClick={() => setDiyDialogShow(true)}
/>
<ConfigDialog
show={diyDialogShow}
closeMethod={() => setDiyDialogShow(false)}
previewMethod={previewTheme}
/>
</>
)}
</>
);
};

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

View File

@ -1,21 +1,15 @@
#root {
text-align: center;
width: 100%;
max-width: 500px;
margin: 0 auto;
}
.app {
.game {
width: 100%;
margin: 0 auto;
padding-top: 10%;
padding-bottom: 2.5%;
}
.scene {
&-container {
width: 100%;
padding-bottom: 100%;
padding-bottom: 112.5%;
position: relative;
margin: 10% 0;
}
&-inner {
@ -63,11 +57,21 @@
}
.queue-container {
border-radius: 8px;
width: 100%;
padding-bottom: 15%;
border: 2px solid gray;
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 {
@ -82,15 +86,17 @@
background-color: rgb(0 0 0 / 10%);
top: 0;
left: 0;
z-index: 10 !important;
}
.bgm-button {
position: fixed;
left: 0;
top: 0;
left: 8px;
top: 8px;
padding: 4px;
width: 36px;
height: 36px;
cursor: pointer;
}
.zhenghuo-button {
@ -98,12 +104,9 @@
margin-top: 8px;
}
.background {
position: fixed;
left: 0;
top: 0;
width: 100vw;
height: 100vh;
object-fit: cover;
z-index: -1;
.level {
font-size: 1.8em;
font-weight: 900;
line-height: 2;
text-shadow: 4px 6px 2px rgb(0 0 0 / 20%);
}

View File

@ -7,8 +7,13 @@ import React, {
} from 'react';
import './Game.scss';
import { LAST_LEVEL_STORAGE_KEY, randomString, waitTimeout } from './utils';
import { Icon, Theme } from './themes/interface';
import {
LAST_LEVEL_STORAGE_KEY,
LAST_SCORE_STORAGE_KEY,
randomString,
waitTimeout,
} from '../utils';
import { Icon, Theme } from '../themes/interface';
// 最大关卡
const maxLevel = 50;
@ -156,12 +161,14 @@ const Symbol: FC<SymbolProps> = ({ x, y, icon, isCover, status, onClick }) => {
const Game: FC<{
theme: Theme<any>;
initLevel: number;
pureMode?: boolean;
}> = ({ theme, initLevel, pureMode = false }) => {
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>
@ -169,7 +176,6 @@ const Game: FC<{
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>>({});
@ -191,6 +197,7 @@ const Game: FC<{
// 关卡缓存
useEffect(() => {
localStorage.setItem(LAST_LEVEL_STORAGE_KEY, level.toString());
localStorage.setItem(LAST_SCORE_STORAGE_KEY, score.toString());
}, [level]);
// 队列区排序
@ -253,17 +260,20 @@ const Game: FC<{
};
// 弹出
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 * Math.floor(8 * Math.random());
find.y = 700;
find.x = 100 * (popTime.current % 7);
popTime.current++;
find.y = 800;
checkCover(scene);
// 音效
if (soundRefMap.current?.['sound-shift']) {
@ -276,6 +286,7 @@ const Game: FC<{
// 撤销
const undo = () => {
if (!queue.length) return;
setScore(score - 1);
const updateQueue = queue.slice();
const symbol = updateQueue.pop();
if (!symbol) return;
@ -294,6 +305,7 @@ const Game: FC<{
// 洗牌
const wash = () => {
setScore(score - 1);
checkCover(washScene(level, scene));
// 音效
if (soundRefMap.current?.['sound-wash']) {
@ -316,6 +328,7 @@ const Game: FC<{
// 重开
const restart = () => {
setFinished(false);
setScore(0);
setLevel(1);
setQueue([]);
checkCover(makeScene(1, theme.icons));
@ -358,6 +371,8 @@ const Game: FC<{
// 三连了
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);
@ -393,6 +408,8 @@ const Game: FC<{
return;
}
// 升级
// 通关奖励关卡对应数值分数
setScore(score + level);
setLevel(level + 1);
setQueue([]);
checkCover(makeScene(level + 1, theme.icons));
@ -406,20 +423,7 @@ const Game: FC<{
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="game">
<div className="scene-container">
<div className="scene-inner">
{scene.map((item, idx) => (
@ -433,14 +437,14 @@ const Game: FC<{
? sortedQueue[item.id]
: -1000
}
y={item.status === 0 ? item.y : 895}
y={item.status === 0 ? item.y : 945}
onClick={() => clickSymbol(idx)}
/>
))}
</div>
</div>
</div>
<div className="queue-container flex-container flex-center" />
<div className="queue-container" />
<div className="flex-container flex-between">
<button className="flex-grow" onClick={pop}>
@ -451,10 +455,21 @@ const Game: FC<{
<button className="flex-grow" onClick={wash}>
</button>
<button className="flex-grow" onClick={levelUp}>
<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 && (
@ -465,14 +480,12 @@ const Game: FC<{
)}
{/*bgm*/}
{theme.bgm && (
<button className="bgm-button" onClick={() => setBgmOn(!bgmOn)}>
{bgmOn ? '🔊' : '🔈'}
<audio
ref={bgmRef}
loop
src={theme?.bgm || '/sound-disco.mp3'}
/>
<audio ref={bgmRef} loop src={theme.bgm} />
</button>
)}
{/*音效*/}
{theme.sounds.map((sound) => (

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

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

View File

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

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;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
&.primary {
background-color: #747bff;
}
}
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,3 +1,12 @@
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';
let res = '';
@ -16,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;
@ -62,4 +64,46 @@ export const captureElement = (id: string, filename: string) => {
);
};
export const LAST_LEVEL_STORAGE_KEY = 'lastLevel';
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') hasUseDefaultTriple = true;
if (icon.tripleSound === 'triple') hasUseDefaultClick = 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%)';
};