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
+1
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,
},
};
+172 -5
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>
// 如果您基于此项目二创,可以删除以下代码
// 否则请标明原仓库地址
-5
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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

+15
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;
}
+83 -60
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}
/>
</>
)}
</>
);
};
-2
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>
);
};
+19 -23
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">
+27 -24
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%);
}
+42 -29
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) => (
+77
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);
}
}
}
+14 -25
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>
);
};
-143
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);
}
}
}
-29
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>
);
};
+137
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
+48
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
);
}
}
}
+76
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>
);
};
+63
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
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>}
</>
);
};
+96 -5
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();
}
+19 -58
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;
}
}
+4
View File
@@ -4,6 +4,10 @@
flex-wrap: wrap;
}
.flex-no-wrap {
flex-wrap: nowrap;
}
.flex-center {
justify-content: center;
align-items: center;
+26 -17
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) || '',
};
};
Binary file not shown.
Binary file not shown.
+2 -3
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: [],
};
-1
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,
+3 -3
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>[];
-1
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

-50
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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+2 -16
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,
};
+53 -9
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%)';
};