Compare commits

..

29 Commits

Author SHA1 Message Date
streakingman
09e43ae02a chore(release): 1.1.0 2023-05-05 00:44:31 +08:00
streakingman
c5bd3f01d9 chore: 停用分享说明 2023-05-05 00:44:02 +08:00
streakingman
be0faff9fd fix: 图片文件大小校验逻辑 2023-05-05 00:33:21 +08:00
streakingman
cebf64847f chore: 服务到期说明 2023-05-05 00:20:14 +08:00
streakingman
ffbb38495f feat: 本地图片文件压缩 2023-05-04 23:52:21 +08:00
streakingman
e553e5360f chore: update diy README.md 2023-01-17 22:02:14 +08:00
StreakingMan
d234bd06a8 1 binaries uploaded 2023-01-17 21:03:05 +08:00
streakingman
680811c50c fix: 音频文件切换为minio外链 2022-11-10 07:57:54 +08:00
streakingman
ffabf6805f fix: 自定义主题编辑回显 2022-11-04 10:11:12 +08:00
streakingman
58bff1ce98 revert: 默认主题关卡数 2022-10-19 23:56:13 +08:00
streakingman
cd5501ebea chore(release): 1.0.1 2022-10-19 23:49:34 +08:00
streakingman
331b96c8f0 fix: 通关成功状态判断,完成游戏后无法点击问题 2022-10-19 23:49:17 +08:00
streakingman
c6671038a8 fix: diy模式得分页再来一次按钮显示 2022-10-14 20:21:12 +08:00
streakingman
5579d5a32e fix: 主题配置表单样式优化 2022-10-13 22:06:04 +08:00
streakingman
8dec5b6420 fix: 主题配置表单样式优化 2022-10-13 20:27:44 +08:00
streakingman
3913982716 fix: 纯净模式隐藏二维码 2022-10-13 20:07:10 +08:00
streakingman
5353a40416 fix: 排行榜滚动定位 2022-10-13 12:44:47 +08:00
streakingman
fd1d429f59 chore(release): 1.0.0 2022-10-13 00:51:35 +08:00
streakingman
81503973a7 docs: diy指南排名表配置 2022-10-13 00:43:29 +08:00
streakingman
acb5083e22 docs: update README.md 2022-10-12 23:47:40 +08:00
streakingman
639b1a3db1 fix: 排行榜样式调整 2022-10-12 23:44:47 +08:00
streakingman
a8a6312e2a fix: 分数缓存逻辑调整 2022-10-12 23:02:30 +08:00
streakingman
9682d31c49 feat: 排行榜 2022-10-12 21:21:19 +08:00
streakingman
01d85610eb fix: 自定义主题icons完成度校验 2022-10-12 00:13:49 +08:00
streakingman
d070c67d3f fix: 主题ID恢复为一次性,交互优化 2022-10-11 23:40:54 +08:00
streakingman
fc7a880607 fix: 图标路径判断问题 2022-10-11 22:51:38 +08:00
streakingman
47caa7ccf9 docs: update README.md 2022-10-11 21:51:06 +08:00
streakingman
3fa7059272 chore: 文档完善、错别字更正 2022-10-11 21:42:47 +08:00
streakingman
cc06bb14e3 fix: wxQrcode路径 2022-10-11 18:57:41 +08:00
26 changed files with 1067 additions and 250 deletions

0
.husky/commit-msg Executable file → Normal file
View File

0
.husky/pre-commit Executable file → Normal file
View File

View File

@ -2,6 +2,49 @@
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
## [1.1.0](https://github.com/StreakingMan/solvable-sheep-game/compare/v1.0.1...v1.1.0) (2023-05-04)
### Features
* 本地图片文件压缩 ([ffbb384](https://github.com/StreakingMan/solvable-sheep-game/commit/ffbb38495f2c8c124f6039496f613e6c416d9db4))
### Bug Fixes
* 图片文件大小校验逻辑 ([be0faff](https://github.com/StreakingMan/solvable-sheep-game/commit/be0faff9fdf4aea6a2659325520d68b2f348c656))
* 自定义主题编辑回显 ([ffabf68](https://github.com/StreakingMan/solvable-sheep-game/commit/ffabf6805f81dd03bd070dd09a32e51a51d1138f))
* 音频文件切换为minio外链 ([680811c](https://github.com/StreakingMan/solvable-sheep-game/commit/680811c50cbef3fec80b73407daddcf48847f3bd))
### [1.0.1](https://github.com/StreakingMan/solvable-sheep-game/compare/v1.0.0...v1.0.1) (2022-10-19)
### Bug Fixes
* diy模式得分页再来一次按钮显示 ([c667103](https://github.com/StreakingMan/solvable-sheep-game/commit/c6671038a831fd069b930303aa3bc841b5317571))
* 主题配置表单样式优化 ([5579d5a](https://github.com/StreakingMan/solvable-sheep-game/commit/5579d5a32e87a37c50d33621551dd39f7a0c74e5))
* 主题配置表单样式优化 ([8dec5b6](https://github.com/StreakingMan/solvable-sheep-game/commit/8dec5b6420b256ee6d87689d6790d4da81171913))
* 排行榜滚动定位 ([5353a40](https://github.com/StreakingMan/solvable-sheep-game/commit/5353a4041615589ac57d0cd8ce1ebca49372211c))
* 纯净模式隐藏二维码 ([3913982](https://github.com/StreakingMan/solvable-sheep-game/commit/3913982716d570d11afbeacdad8dd5d6d522eb37))
* 通关成功状态判断,完成游戏后无法点击问题 ([331b96c](https://github.com/StreakingMan/solvable-sheep-game/commit/331b96c8f0717bbb7912984b38cfde8622ea9bc6))
## [1.0.0](https://github.com/StreakingMan/solvable-sheep-game/compare/v0.0.10...v1.0.0) (2022-10-12)
### Features
* 排行榜 ([9682d31](https://github.com/StreakingMan/solvable-sheep-game/commit/9682d31c495d8c7229003e68b79a15430a4ecb68))
### Bug Fixes
* wxQrcode路径 ([cc06bb1](https://github.com/StreakingMan/solvable-sheep-game/commit/cc06bb14e3a89411478dc32e880a49ef692b2e3c))
* 主题ID恢复为一次性交互优化 ([d070c67](https://github.com/StreakingMan/solvable-sheep-game/commit/d070c67d3f49d8dbf89d420b72d02896a2d49508))
* 分数缓存逻辑调整 ([a8a6312](https://github.com/StreakingMan/solvable-sheep-game/commit/a8a6312e2a5d67639f50e185407915d554d28127))
* 图标路径判断问题 ([fc7a880](https://github.com/StreakingMan/solvable-sheep-game/commit/fc7a8806078cf0c1c343830cb3c0a3ba3aa7cbf0))
* 排行榜样式调整 ([639b1a3](https://github.com/StreakingMan/solvable-sheep-game/commit/639b1a3db11d3f21d88e39e8408fadab7dfa4121))
* 自定义主题icons完成度校验 ([01d8561](https://github.com/StreakingMan/solvable-sheep-game/commit/01d85610ebb2f3dcc7da7b2fa038c168414fc49a))
### [0.0.10](https://github.com/StreakingMan/solvable-sheep-game/compare/v0.0.9...v0.0.10) (2022-10-11)

View File

@ -19,36 +19,34 @@
**声明:本项目仅供交流,禁止商用!否则后果自负。基于此项目的二创都是欢迎的,但非二创请不要删除原仓库地址
(啥都不改唯独删除来源我真的会谢 🙄️,请尊重他人劳动成果)**
![qrcode.png](qrcode.png)
<img src="qrcode.png" style="width: 250px;" alt="体验地址二维码">
## Feature
- 弹出:弹出队列左侧第一个,无限次数
- 撤销:撤销上一次操作,无限次数
- 洗牌:哗啦哗啦,无限次数
- 关卡:50 关玩到爽,可直接跳
- 关卡:20 关玩到爽,可直接跳
- 内置主题:金轮<img style="width:36px" src="src/themes/jinlun/images/肌肉金轮1.png" />
骚猪<img style="width:36px" src="src/themes/pdd/images/1.png" />
ikun<img style="width:36px" src="src/themes/ikun/images/kun.png" />(露出黑脚)等
- 自定义主题:自定义图片和音效,快速整活
- 排行榜:皇城 pk
开心就好 😄
![preview.png](preview.png)
![preview2.png](preview2.png)
![previews.png](previews.png)
## Contribution
vite+react 实现,欢迎 star、issue、pr、fork尽量标注原仓库地址
切换主题参考 `src/themes` 下的代码,欢迎整活
## Related Repo
<a href="https://github.com/opendilab" target="_blank">opendilab</a> 的 AI 整活!移步
<a href="https://github.com/opendilab/DI-sheep" target="_blank">DI-sheep深度强化学习 + 羊了个羊</a>
<img style="width:360px" src="https://github.com/opendilab/DI-sheep/raw/master/ui/public/demo.gif" alt="" />
<img style="width:250px" src="https://github.com/opendilab/DI-sheep/raw/master/ui/public/demo.gif" alt="" />
## Todo List
@ -57,21 +55,30 @@ vite+react 实现,欢迎 star、issue、pr、fork尽量标注原仓库地
- [x] UI/UX 优化
- [x] 多主题
- [x] 计时、得分、保存进度机制
- [ ] 排行榜
- [x] 排行榜
- [ ] 性能优化
- [x] BGM/音效
- [ ] ~~点击时的缓冲队列,优化交互动画效果~~
- [x] 该游戏似乎涉嫌抄袭,考证后补充来源说明
- [ ] 桌面应用
- [ ] ~~桌面应用~~
- [x] 路径区分主题
- [x] 主题自定义
- [x] 本地图片、音频配置
## 二次开发
项目的自定义主题功能设计到后台存储Bmob 懒人数据库),如果您只是简单的整活,可能并不需要相关的逻辑。
详细的二次开发说移步这里[DIY 指南](/diy/README.md)
项目的自定义主题功能涉及到后台存储Bmob 懒人数据库),如果您只是简单的整活,可能并不需要相关的逻辑。
详细的二次开发说明请移步这里[DIY 指南](/diy/README.md)
## License
[GNU GENERAL PUBLIC LICENSE Version 3](LICENSE.md)
## 资助
~~由于各种白嫖的静态资源托管、后台服务的免费额度都已用完,目前自费升级了相关套餐。~~
如果您喜欢这个项目,觉得本项目对你有帮助的话,可以扫描下方付款码请我喝杯咖啡 ☕️/~~分摊后台服务费用~~ 😘
2023.5.5 更新Bmob 服务到期,后台服务已下线,相关功能暂时无法使用,如有需要请自行搭建后台服务
![wxQrCodes.png](wxQrCodes.png)

View File

@ -2,6 +2,9 @@
游戏的核心逻辑已经封装到了 `src/components/Game.tsx` ,方便大家魔改, 主题配置的类型声明见 `src/themes/interface.ts`
你可以先通过超酷的[stackblitz](https://stackblitz.com/edit/solvable-sheep-game?file=diy%2Fdiy.theme.json&terminal=dev:diy)
在线体验一番(等待依赖安装完成后,编辑配置将实时更新)再回来看这里的指南。
## 准备工作
### 环境准备
@ -24,10 +27,11 @@ yarn install
## 素材配置
原项目的自定义后台使用了 Bmob 后台DIY 时并不需要相关的逻辑,您只需要改动 `diy` 文件下的文件: 图片以及音频素材复制到 `diy/public` 下, 并配置 `diy/diy.theme.json`
原项目的自定义主题功能使用了 Bmob 后台DIY 时并不需要相关的逻辑,您只需要改动 `diy` 文件下的文件:
图片以及音频素材复制到 `diy/public` 下, 并配置 `diy/diy.theme.json`
文件即可,配置格式见 `src/themes/interface.ts`
以下是配置字段的说明:
以下是`diy.theme.json`配置字段的说明:
- title 标题
- desc 描述
@ -67,9 +71,10 @@ yarn dev:diy
yarn build:diy
```
会在 `diy/diy-dist` 下,生成静态资源,直接将这些文件复制服务器上代理即可。如果嫌麻烦,推荐使用 [vercel](https://vercel.com/) 一键部署(每月免费 100G 流量), 将更改后的项目推到自己的
githubgitlabbitbucket 同样支持)仓库, 使用 github 账号登录 vercel 后导入该项目,构建模版选择 vite 构建命令更改为 `yarn build:diy`
输出地址改为 `diy/diy-dist` 即可 。
会在 `diy/diy-dist` 下生成静态资源,直接将这些文件复制服务器上做代理即可。如果嫌麻烦,推荐使用 [vercel](https://vercel.com/)
一键部署(每月免费 100G 流量), 将更改后的项目推到自己的 githubgitlabbitbucket 同样支持)仓库,
使用 github 账号登录 vercel 后导入该项目,构建模版选择 vite
构建命令更改为 `yarn build:diy` 输出地址改为 `diy/diy-dist` 即可 。见下图:
<img src="./vercel.png" alt="" style="width: 400px"/>
@ -77,7 +82,8 @@ githubgitlabbitbucket 同样支持)仓库, 使用 github 账号登录
## 其他
如果您想体验项目的完整功能,则需要注册一个 [Bmob](https://www.bmobapp.com/) 账号, 注册后新建应用(有一年的白嫖版,免费请求数虽然很客观,但并发数有限制,请根据自己的实际流量
如果您想体验项目的完整功能,则需要注册一个 [Bmob](https://www.bmobapp.com/) 账号,
注册后新建应用(有一年的白嫖版,免费请求数虽然很客观,但并发数有限制,请根据自己的实际流量
选择升级套餐,或者其他存储方案)
新建应用后,去到设置页面拷贝密钥和安全码到项目的 `.env` 文件中
@ -91,7 +97,7 @@ ps: 如果您的项目托管在公共仓库中,请注意保护密钥,本地
`config` 表用来存储自定义配置的 json 字符串,需要新增 `content`
`file` 表则是为了 vercel 流量,将一些默认文件转为 base64 编码存到了数据库中,需要添加三列
![img.png](database-file.png)
`rank` 表,储存排名信息
![img.png](datebase-rank.png)
最后,开发和打包命令分别使用 `yarn dev``yarn build` 即可

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

BIN
diy/datebase-rank.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

View File

@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
content="无限道具版羊了个羊、可以通关的羊了个羊"
content="无限道具版羊了个羊、可以通关的羊了个羊、羊了个羊生成器"
/>
<title>有解的羊了个羊</title>
<script>
@ -213,8 +213,8 @@
'https://github.com/StreakingMan/solvable-sheep-game'
);
a.setAttribute('target', '_self');
a.style.cursor = 'pointer'
a.style.zIndex = '15'
a.style.cursor = 'pointer';
a.style.zIndex = '15';
a.innerText =
'本项目仅供交流禁止商业用途点击查看原github仓库';
document.getElementById('root')?.prepend(a);

View File

@ -1,7 +1,7 @@
{
"name": "solvable-sheep-game",
"private": false,
"version": "0.0.10",
"version": "1.1.0",
"type": "module",
"scripts": {
"dev": "vite",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

BIN
previews.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 372 KiB

View File

@ -5,6 +5,8 @@ import {
LAST_LEVEL_STORAGE_KEY,
LAST_SCORE_STORAGE_KEY,
LAST_TIME_STORAGE_KEY,
PLAYING_THEME_ID_STORAGE_KEY,
resetScoreStorage,
wrapThemeDefaultSounds,
} from './utils';
import { Theme } from './themes/interface';
@ -17,11 +19,6 @@ const ThemeChanger = React.lazy(() => import('./components/ThemeChanger'));
const ConfigDialog = React.lazy(() => import('./components/ConfigDialog'));
const WxQrCode = React.lazy(() => import('./components/WxQrCode'));
// 读取缓存关卡得分
const initLevel = Number(localStorage.getItem(LAST_LEVEL_STORAGE_KEY) || '1');
const initScore = Number(localStorage.getItem(LAST_SCORE_STORAGE_KEY) || '0');
const initTime = Number(localStorage.getItem(LAST_TIME_STORAGE_KEY) || '0');
const App: FC<{ theme: Theme<any> }> = ({ theme: initTheme }) => {
console.log('initTheme', initTheme);
// console.log(JSON.stringify(theme));
@ -29,7 +26,23 @@ const App: FC<{ theme: Theme<any> }> = ({ theme: initTheme }) => {
const [theme, setTheme] = useState<Theme<any>>(initTheme);
const [diyDialogShow, setDiyDialogShow] = useState<boolean>(false);
// 读取缓存关卡得分
const [initLevel, setInitLevel] = useState<number>(
Number(localStorage.getItem(LAST_LEVEL_STORAGE_KEY) || '1')
);
const [initScore, setInitScore] = useState<number>(
Number(localStorage.getItem(LAST_SCORE_STORAGE_KEY) || '0')
);
const [initTime, setInitTime] = useState<number>(
Number(localStorage.getItem(LAST_TIME_STORAGE_KEY) || '0')
);
const changeTheme = (theme: Theme<any>) => {
localStorage.setItem(PLAYING_THEME_ID_STORAGE_KEY, theme.title);
setInitLevel(1);
setInitScore(0);
setInitTime(0);
resetScoreStorage();
wrapThemeDefaultSounds(theme);
domRelatedOptForTheme(theme);
setTheme({ ...theme });
@ -77,7 +90,7 @@ const App: FC<{ theme: Theme<any> }> = ({ theme: initTheme }) => {
<PersonalInfo />
<div className={'flex-spacer'} style={{ minHeight: 52 }} />
<Suspense fallback={<span>Loading</span>}>
{!__DIY__ && <WxQrCode />}
{!__DIY__ && !theme.pure && <WxQrCode />}
</Suspense>
{!__DIY__ && (
<p

View File

@ -146,7 +146,8 @@
input,
select {
flex-grow: 1;
width: 100%;
box-sizing: border-box;
}
input[type='file'] {
@ -228,7 +229,8 @@
.divider {
width: 100%;
height: 0;
border-bottom: 1px solid rgb(0 0 0 / 8%);
border-bottom: 1px solid currentcolor;
opacity: 0.6;
}
@keyframes gradient {
@ -260,3 +262,21 @@
animation: gradient 1s ease infinite;
}
}
@keyframes rotate {
0% {
transform: scale(0.7) rotate(0deg);
}
100% {
transform: scale(0.7) rotate(360deg);
}
}
.qrCode {
transition: 0.5s;
&.uploading {
animation: rotate 0.5s infinite linear;
}
}

View File

@ -1,4 +1,4 @@
import React, { FC, ReactNode, useEffect, useRef, useState } from 'react';
import React, { FC, ReactNode, useEffect, useState } from 'react';
import style from './ConfigDialog.module.scss';
import classNames from 'classnames';
import { Icon, Sound, Theme } from '../themes/interface';
@ -7,13 +7,16 @@ import Bmob from 'hydrogen-js-sdk';
import {
captureElement,
CUSTOM_THEME_FILE_VALIDATE_STORAGE_KEY,
CUSTOM_THEME_ID_STORAGE_KEY,
LAST_CUSTOM_THEME_ID_STORAGE_KEY,
CUSTOM_THEME_STORAGE_KEY,
deleteThemeUnusedSounds,
getFileBase64String,
linkReg,
randomString,
wrapThemeDefaultSounds,
LAST_UPLOAD_TIME_STORAGE_KEY,
canvasToFile,
createCanvasByImgSrc,
} from '../utils';
import { copy } from 'clipboard';
import { CloseIcon } from './CloseIcon';
@ -51,8 +54,6 @@ interface CustomTheme extends Theme<any> {
icons: CustomIcon[];
}
const id = localStorage.getItem(CUSTOM_THEME_ID_STORAGE_KEY);
const ConfigDialog: FC<{
closeMethod: () => void;
previewMethod: (theme: Theme<string>) => void;
@ -60,14 +61,13 @@ const ConfigDialog: FC<{
// 错误提示
const [configError, setConfigError] = useState<string>('');
// 生成链接
const [genLink, setGenLink] = useState<string>(
id ? `${location.origin}?customTheme=${id}` : ''
);
const [genLink, setGenLink] = useState<string>('');
// 主题大对象
const [customTheme, setCustomTheme] = useState<CustomTheme>({
title: '',
sounds: [],
pure: false,
icons: new Array(10).fill(0).map(() => ({
name: randomString(4),
content: '',
@ -146,10 +146,14 @@ const ConfigDialog: FC<{
new Array(10).fill('')
);
// 文件体积校验开关
const initEnableFileSizeValidate = localStorage.getItem(
CUSTOM_THEME_FILE_VALIDATE_STORAGE_KEY
);
const [enableFileSizeValidate, setEnableFileSizeValidate] =
useState<boolean>(
localStorage.getItem(CUSTOM_THEME_FILE_VALIDATE_STORAGE_KEY) !==
'false'
initEnableFileSizeValidate === null
? true
: initEnableFileSizeValidate === 'true'
);
useEffect(() => {
localStorage.setItem(
@ -165,7 +169,7 @@ const ConfigDialog: FC<{
type: 'bgm' | 'background' | 'sound' | 'icon';
file?: File;
idx?: number;
}) => void = ({ type, file, idx }) => {
}) => void = async ({ type, file, idx }) => {
if (!file) return;
switch (type) {
case 'bgm':
@ -183,16 +187,20 @@ const ConfigDialog: FC<{
break;
case 'background':
setBackgroundError('');
if (enableFileSizeValidate && file.size > 80 * 1024) {
return setBackgroundError('请选择80k以内全损画质的图片');
try {
const _file = enableFileSizeValidate
? await canvasToFile({
canvas: await createCanvasByImgSrc({
imgSrc: await getFileBase64String(file),
}),
maxFileSize: 20 * 1024,
})
: file;
const fileBase64 = await getFileBase64String(_file);
updateCustomTheme('background', fileBase64);
} catch (e: any) {
setBackgroundError(e);
}
getFileBase64String(file)
.then((res) => {
updateCustomTheme('background', res);
})
.catch((e) => {
setBackgroundError(e);
});
break;
case 'sound':
setSoundError('');
@ -210,23 +218,27 @@ const ConfigDialog: FC<{
case 'icon':
if (idx == null) return;
setIconErrors(makeIconErrors(idx, ''));
if (enableFileSizeValidate && file.size > 5 * 1024) {
return setIconErrors(
makeIconErrors(idx, '请选择5k以内的图片文件')
try {
const _file = enableFileSizeValidate
? await canvasToFile({
canvas: await createCanvasByImgSrc({
imgSrc: await getFileBase64String(file),
}),
maxFileSize: 4 * 1024,
})
: file;
const fileBase64 = await getFileBase64String(_file);
updateCustomTheme(
'icons',
customTheme.icons.map((icon, _idx) =>
_idx === idx
? { ...icon, content: fileBase64 }
: icon
)
);
} catch (e: any) {
setIconErrors(makeIconErrors(idx, e));
}
getFileBase64String(file)
.then((res) => {
updateCustomTheme(
'icons',
customTheme.icons.map((icon, _idx) =>
_idx === idx ? { ...icon, content: res } : icon
)
);
})
.catch((e) => {
setIconErrors(makeIconErrors(idx, e));
});
break;
}
};
@ -246,12 +258,16 @@ const ConfigDialog: FC<{
// 初始化
useEffect(() => {
const lastId = localStorage.getItem(LAST_CUSTOM_THEME_ID_STORAGE_KEY);
lastId && setGenLink(`${location.origin}?customTheme=${lastId}`);
try {
const configString = localStorage.getItem(CUSTOM_THEME_STORAGE_KEY);
if (configString) {
const parseRes = JSON.parse(configString);
if (typeof parseRes === 'object') {
setCustomTheme(parseRes);
setTimeout(() => {
setCustomTheme(parseRes);
}, 300);
}
}
} catch (e) {
@ -272,6 +288,15 @@ const ConfigDialog: FC<{
const findIconError = iconErrors.find((i) => !!i);
if (findIconError)
return Promise.reject(`图标素材有错误:${findIconError}`);
const findUnfinishedIconIdx = customTheme.icons.findIndex(
(icon) => !icon.content
);
if (findUnfinishedIconIdx !== -1) {
setIconErrors(makeIconErrors(findUnfinishedIconIdx, '请填写链接'));
return Promise.reject(
`${findUnfinishedIconIdx + 1}图标素材未完成`
);
}
return Promise.resolve('');
};
@ -301,6 +326,20 @@ const ConfigDialog: FC<{
if (uploading) return;
if (!enableFileSizeValidate)
return setConfigError('请先开启文件大小校验');
let passTime = Number.MAX_SAFE_INTEGER;
const lastUploadTime = localStorage.getItem(
LAST_UPLOAD_TIME_STORAGE_KEY
);
if (lastUploadTime) {
passTime = Date.now() - Number(lastUploadTime);
}
if (passTime < 1000 * 60 * 15) {
return setConfigError(
`为节省请求数15分钟内只能生成一次二维码还剩大约${
15 - Math.round(passTime / 1000 / 60)
}`
);
}
setUploading(true);
setConfigError('');
validateTheme()
@ -310,19 +349,19 @@ const ConfigDialog: FC<{
const stringify = JSON.stringify(cloneTheme);
localStorage.setItem(CUSTOM_THEME_STORAGE_KEY, stringify);
const query = Bmob.Query('config');
if (id) query.set('id', id);
// Bmob上限 384563
query.set('content', stringify);
query
.save()
.then((res) => {
if (!id) {
localStorage.setItem(
CUSTOM_THEME_ID_STORAGE_KEY,
//@ts-ignore
res.objectId
);
}
localStorage.setItem(
LAST_CUSTOM_THEME_ID_STORAGE_KEY,
//@ts-ignore
res.objectId
);
localStorage.setItem(
LAST_UPLOAD_TIME_STORAGE_KEY,
Date.now().toString()
);
setTimeout(() => {
setGenLink(
`${location.origin}?customTheme=${
@ -334,11 +373,7 @@ const ConfigDialog: FC<{
})
.catch(({ error, code }) => {
setTimeout(() => {
setConfigError(
code === 10007
? '上传数据长度已超过bmob的限制'
: error
);
setConfigError(error);
}, 3000);
})
.finally(() => {
@ -348,19 +383,37 @@ const ConfigDialog: FC<{
});
})
.catch((e) => {
setTimeout(() => {
setConfigError(e);
setUploading(false);
}, 3000);
setConfigError(e);
setUploading(false);
});
};
// 彩蛋
const [pureClickTime, setPureClickTime] = useState<number>(0);
useEffect(() => {
updateCustomTheme(
'pure',
pureClickTime % 5 === 0 && pureClickTime !== 0
);
}, [pureClickTime]);
return (
<div className={classNames(style.dialog)}>
<div className={style.closeBtn} onClick={closeMethod}>
<CloseIcon fill={'#fff'} />
</div>
<h2></h2>
<p style={{ color: 'red' }}>
<a
href="https://github.com/StreakingMan/solvable-sheep-game/blob/master/diy/README.md"
target="_blank"
rel="noopener noreferrer"
>
👉👈
</a>
🙏
</p>
<InputContainer label={'标题'} required>
<input
@ -378,7 +431,7 @@ const ConfigDialog: FC<{
</InputContainer>
<InputContainer label={'BGM'}>
<div className={style.tip}>
80k以下
80k以下使
</div>
<input
type={'file'}
@ -400,7 +453,7 @@ const ConfigDialog: FC<{
</InputContainer>
<InputContainer label={'背景图'}>
<div className={style.tip}>
80k以下
使
</div>
<input
type={'file'}
@ -431,36 +484,43 @@ const ConfigDialog: FC<{
/>
)}
</div>
<div className={'flex-container flex-center flex-no-wrap'}>
<span></span>
<input
type={'checkbox'}
checked={!!customTheme.backgroundBlur}
onChange={(e) =>
updateCustomTheme(
'backgroundBlur',
e.target.checked
)
}
/>
<div className={'flex-spacer'} />
<span></span>
<input
type={'checkbox'}
checked={!!customTheme.dark}
onChange={(e) =>
updateCustomTheme('dark', e.target.checked)
}
/>
<div className={'flex-spacer'} />
<span></span>
<input
type={'color'}
value={customTheme.backgroundColor || '#ffffff'}
onChange={(e) =>
updateCustomTheme('backgroundColor', e.target.value)
}
/>
<div className={'flex-container flex-center flex-wrap'}>
<div className={'flex-spacer flex-container flex-center'}>
<span></span>
<input
type={'checkbox'}
checked={!!customTheme.backgroundBlur}
onChange={(e) =>
updateCustomTheme(
'backgroundBlur',
e.target.checked
)
}
/>
</div>
<div className={'flex-spacer flex-container flex-center'}>
<span></span>
<input
type={'checkbox'}
checked={!!customTheme.dark}
onChange={(e) =>
updateCustomTheme('dark', e.target.checked)
}
/>
</div>
<div className={'flex-spacer flex-container flex-center'}>
<span></span>
<input
type={'color'}
value={customTheme.backgroundColor || '#ffffff'}
onChange={(e) =>
updateCustomTheme(
'backgroundColor',
e.target.value
)
}
/>
</div>
</div>
<div className={style.tip}>
使使
@ -499,7 +559,7 @@ const ConfigDialog: FC<{
onChange={(e) => onNewSoundChange('name', e.target.value)}
/>
<div className={style.tip}>
10k以下
10k以下使
</div>
<input
type={'file'}
@ -523,7 +583,7 @@ const ConfigDialog: FC<{
</InputContainer>
<InputContainer label={'图标素材'} required>
<div className={style.tip}>
5k以下56*56
使
</div>
</InputContainer>
{customTheme.icons.map((icon, idx) => (
@ -555,13 +615,14 @@ const ConfigDialog: FC<{
placeholder={'或者输入https外链'}
value={customTheme.icons[idx].content}
onBlur={(e) => {
if (!linkReg.test(e.target.value))
setIconErrors(
makeIconErrors(
idx,
'请输入https外链'
)
);
setIconErrors(
makeIconErrors(
idx,
linkReg.test(e.target.value)
? ''
: '请输入https外链'
)
);
}}
onChange={(e) =>
updateIcons('content', e.target.value, idx)
@ -618,10 +679,23 @@ const ConfigDialog: FC<{
{genLink && (
<div className={'flex-container flex-center flex-column'}>
<QRCodeCanvas id="qrCode" value={genLink} size={300} />
<QRCodeCanvas
id="qrCode"
value={genLink}
size={300}
className={classNames(
style.qrCode,
uploading && style.uploading
)}
/>
<button
onClick={() =>
captureElement('qrCode', customTheme.title)
captureElement(
'qrCode',
`${customTheme.title}-${localStorage.getItem(
LAST_CUSTOM_THEME_ID_STORAGE_KEY
)}`
)
}
className="primary"
>
@ -634,7 +708,7 @@ const ConfigDialog: FC<{
</div>
)}
<div className={style.tip}>
使mp3外链
使mp3外链
👉
<input
type={'checkbox'}
@ -643,10 +717,15 @@ const ConfigDialog: FC<{
setEnableFileSizeValidate(!e.target.checked)
}
/>
(1M为宜)
(1M为宜)
</div>
{configError && <div className={style.errorTip}>{configError}</div>}
<WxQrCode />
{customTheme.pure && (
<div className={style.tip}>
🎉🎉🎉
</div>
)}
<WxQrCode onClick={() => setPureClickTime(pureClickTime + 1)} />
<div className={'flex-container'}>
<button
className={'primary flex-grow'}
@ -654,17 +733,17 @@ const ConfigDialog: FC<{
>
</button>
<button
className={classNames(
'primary flex-grow',
style.uploadBtn,
uploading && style.uploading
)}
onClick={onGenQrLinkClick}
>
{genLink ? '更新数据' : '生成二维码&链接'}
{uploading ? '...' : ''}
</button>
{/*<button*/}
{/* className={classNames(*/}
{/* 'primary flex-grow',*/}
{/* style.uploadBtn,*/}
{/* uploading && style.uploading*/}
{/* )}*/}
{/* onClick={onGenQrLinkClick}*/}
{/* disabled*/}
{/*>*/}
{/* 生成二维码&链接*/}
{/*</button>*/}
</div>
</div>
);

View File

@ -0,0 +1,133 @@
@use 'sass:math';
// 烟花动画来源 https://codepen.io/yshlin/pen/WNMmQX
$particles: 50;
$width: 500;
$height: 500;
// Create the explosion...
$box-shadow: ();
$box-shadow2: ();
@for $i from 0 through $particles {
$box-shadow: $box-shadow,
math.random($width) -
math.div($width, 2) +
px
math.random($height) -
math.div($height, 1.2) +
px
hsl(math.random(360) 100% 50%);
$box-shadow2: $box-shadow2, 0 0 #fff;
}
@mixin keyframes($animationName) {
@keyframes #{$animationName} {
@content;
}
@keyframes #{$animationName} {
@content;
}
@keyframes #{$animationName} {
@content;
}
@keyframes #{$animationName} {
@content;
}
@keyframes #{$animationName} {
@content;
}
}
@mixin animation-delay($settings) {
animation-delay: $settings;
}
@mixin animation-duration($settings) {
animation-duration: $settings;
}
@mixin animation($settings) {
animation: $settings;
}
@mixin transform($settings) {
transform: $settings;
}
.pyro {
position: absolute;
left: 0;
top: 0;
width: 100%;
}
.pyro > .before,
.pyro > .after {
position: absolute;
width: 10px;
height: 10px;
border-radius: 50%;
box-shadow: $box-shadow2;
@include animation(
(
1s bang ease-out infinite backwards,
1s gravity ease-in infinite backwards,
5s position linear infinite backwards
)
);
}
.pyro > .after {
@include animation-delay((1.25s, 1.25s, 1.25s));
@include animation-duration((1.25s, 1.25s, 6.25s));
}
@include keyframes(bang) {
to {
box-shadow: $box-shadow;
}
}
@include keyframes(gravity) {
to {
@include transform(translateY(200px));
opacity: 0;
}
}
@include keyframes(position) {
0%,
19.9% {
margin-top: 10%;
margin-left: 40%;
}
20%,
39.9% {
margin-top: 40%;
margin-left: 30%;
}
40%,
59.9% {
margin-top: 20%;
margin-left: 70%;
}
60%,
79.9% {
margin-top: 30%;
margin-left: 20%;
}
80%,
99.9% {
margin-top: 30%;
margin-left: 80%;
}
}

View File

@ -0,0 +1,12 @@
import React, { FC } from 'react';
import style from './Fireworks.module.scss';
const Fireworks: FC = () => {
return (
<div className={style.pyro}>
<div className={style.before} />
<div className={style.after} />
</div>
);
};
export default Fireworks;

View File

@ -74,21 +74,6 @@
}
}
.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;

View File

@ -4,17 +4,20 @@ import React, {
useEffect,
useRef,
useState,
Suspense,
} from 'react';
import './Game.scss';
import {
LAST_LEVEL_STORAGE_KEY,
LAST_SCORE_STORAGE_KEY,
LAST_TIME_STORAGE_KEY,
linkReg,
randomString,
resetScoreStorage,
timestampToUsedTimeString,
waitTimeout,
} from '../utils';
import { Icon, Theme } from '../themes/interface';
import Score from './Score';
interface MySymbol {
id: string;
@ -133,8 +136,9 @@ const Symbol: FC<SymbolProps> = ({ x, y, icon, isCover, status, onClick }) => {
style={{ opacity: isCover ? 0.4 : 1 }}
>
{typeof icon.content === 'string' ? (
linkReg.test(icon.content) ||
icon.content.startsWith('/') ? (
icon.content.startsWith('data:') ||
icon.content.startsWith('/') ||
icon.content.startsWith('http') ? (
/*图片地址*/
<img src={icon.content} alt="" />
) : (
@ -167,7 +171,7 @@ const Game: FC<{
Record<MySymbol['id'], number>
>({});
const [finished, setFinished] = useState<boolean>(false);
const [tipText, setTipText] = useState<string>('');
const [success, setSuccess] = useState<boolean>(false);
const [animating, setAnimating] = useState<boolean>(false);
// 音效
@ -323,6 +327,7 @@ const Game: FC<{
// 重开
const restart = () => {
setFinished(false);
setSuccess(false);
setScore(0);
setLevel(1);
setQueue([]);
@ -391,24 +396,26 @@ const Game: FC<{
// 输了
if (updateQueue.length === 7) {
setTipText('失败了');
setFinished(true);
setSuccess(false);
}
if (!updateScene.find((s) => s.status !== 2)) {
// 胜利
// 队列清空了
if (level === maxLevel) {
setTipText('完成挑战');
// 胜利
setFinished(true);
return;
setSuccess(true);
} else {
// 升级
// 通关奖励关卡对应数值分数
setScore(score + level);
setLevel(level + 1);
setQueue([]);
checkCover(makeScene(level + 1, theme.icons));
}
// 升级
// 通关奖励关卡对应数值分数
setScore(score + level);
setLevel(level + 1);
setQueue([]);
checkCover(makeScene(level + 1, theme.icons));
} else {
// 更新队列
setQueue(updateQueue);
checkCover(updateScene);
}
@ -425,9 +432,7 @@ const Game: FC<{
useEffect(() => {
if (finished) {
intervalRef.current && clearInterval(intervalRef.current);
localStorage.setItem(LAST_LEVEL_STORAGE_KEY, '1');
localStorage.setItem(LAST_SCORE_STORAGE_KEY, '0');
localStorage.setItem(LAST_TIME_STORAGE_KEY, '0');
resetScoreStorage();
}
}, [finished]);
// 更新使用时间
@ -488,17 +493,21 @@ const Game: FC<{
<br />
{score}
<br />
{(usedTime / 1000).toFixed(3)}
{timestampToUsedTimeString(usedTime)}
</div>
{/*提示弹窗*/}
{finished && (
<div className="modal">
<h1>{tipText}</h1>
<h1>{score}</h1>
<h1>{(usedTime / 1000).toFixed(3)}</h1>
<button onClick={restart}></button>
</div>
)}
{/*积分、排行榜*/}
<Suspense fallback={<span>rank list</span>}>
{finished && (
<Score
level={level}
time={usedTime}
score={score}
success={success}
pure={theme.pure}
restartMethod={restart}
/>
)}
</Suspense>
{/*bgm*/}
{theme.bgm && (
<button className="bgm-button" onClick={() => setBgmOn(!bgmOn)}>

View File

@ -0,0 +1,89 @@
.modal {
position: fixed;
width: 100vw;
height: 100vh;
backdrop-filter: blur(6px);
background-color: rgb(0 0 0 / 20%);
top: 0;
left: 0;
z-index: 10 !important;
overflow-y: auto;
.inner {
display: flex;
flex-direction: column;
justify-content: center;
min-height: 100%;
width: 100%;
padding: 12px;
max-width: 450px;
box-sizing: border-box;
margin: 0 auto;
gap: 8px;
}
h1,
h2,
h3,
h4 {
margin: 0;
}
}
.nameInput {
background-color: rgb(0 0 0 / 20%);
color: currentcolor;
border: 1px currentcolor dashed;
&::placeholder {
color: currentcolor;
opacity: 0.6;
}
&:focus {
outline: none;
}
}
.rankContainer {
border: 1px solid currentcolor;
padding: 12px;
border-radius: 8px;
min-height: 200px;
background-color: rgb(0 0 0 / 20%);
display: flex;
flex-direction: column;
gap: 8px;
.title {
font-weight: 900;
margin: 0;
}
.list {
display: flex;
flex-direction: column;
max-height: 30vh;
overflow-y: auto;
thead {
position: sticky;
top: 0;
background-color: gray;
}
td.username {
word-break: break-all;
}
}
}
.tip {
font-size: 18px;
text-align: center;
opacity: 0.8;
&.error {
color: crimson;
}
}

297
src/components/Score.tsx Normal file
View File

@ -0,0 +1,297 @@
import React, { FC, Suspense, useEffect, useRef, useState } from 'react';
import style from './Score.module.scss';
import Bmob from 'hydrogen-js-sdk';
import {
PLAYING_THEME_ID_STORAGE_KEY,
randomString,
timestampToUsedTimeString,
USER_ID_STORAGE_KEY,
USER_NAME_STORAGE_KEY,
} from '../utils';
import WxQrCode from './WxQrCode';
const Fireworks = React.lazy(() => import('./Fireworks'));
interface RankInfo {
// id
objectId?: string;
// 综合评分
rating: number;
// 通关数
level: number;
// 游戏得分
score: number;
// 主题id
themeId: string;
// 耗时
time: number;
// 用户昵称
username: string;
// 用户id
userId: string;
}
// 该组件条件渲染
const Score: FC<{
level: number;
score: number;
time: number;
success: boolean;
pure?: boolean;
restartMethod: () => void;
}> = ({ level, score, time, success, restartMethod, pure = false }) => {
const [rankList, setRankList] = useState<RankInfo[]>([]);
const [username, setUsername] = useState<string>(
localStorage.getItem(USER_NAME_STORAGE_KEY) || ''
);
const [userId, setUserId] = useState<string>(
localStorage.getItem(USER_ID_STORAGE_KEY) || ''
);
const usernameInputRef = useRef<HTMLInputElement>(null);
const [tip, setTip] = useState<string>('');
// 综合评分
const rating = Math.max(0, score) * 100 - Math.round(time / 1000);
// 分主题排行
const themeId = localStorage.getItem(PLAYING_THEME_ID_STORAGE_KEY);
const uploadRankInfo = (id?: string) => {
const _userId = localStorage.getItem(USER_ID_STORAGE_KEY);
const _username = localStorage.getItem(USER_NAME_STORAGE_KEY);
if (!themeId || !_userId || !_username) return;
const rankInfo: RankInfo = {
rating,
themeId,
level,
score,
time,
username: _username,
userId: _userId,
};
const query = Bmob.Query('rank');
id && query.set('id', id);
for (const [key, val] of Object.entries(rankInfo)) {
query.set(key, val);
}
query
.save()
.then(() => {
getRankList();
})
.catch((e) => {
console.log(e);
});
};
const getRankList = (cb?: (rankList: RankInfo[]) => void) => {
if (!themeId) return;
const query = Bmob.Query('rank');
query.equalTo('themeId', '==', themeId);
query.order('-rating');
query.limit(50);
query
.find()
.then((res) => {
setRankList(res as any);
cb && cb(res as any);
const _userId = localStorage.getItem(USER_ID_STORAGE_KEY);
if (_userId) {
setTimeout(() => {
const rankEl = document.getElementById(_userId + 'el');
rankEl?.scrollIntoView({
behavior: 'smooth',
block: 'center',
});
}, 1000);
}
})
.catch((e) => {
console.log(e);
});
};
const onConfirmNameClick = () => {
const inputUsername = usernameInputRef.current?.value.trim();
if (!inputUsername) return;
const newUserId = randomString(8);
setUsername(inputUsername);
setUserId(newUserId);
localStorage.setItem(USER_NAME_STORAGE_KEY, inputUsername);
localStorage.setItem(USER_ID_STORAGE_KEY, newUserId);
judgeAndUpload(rankList, newUserId);
};
// 判断是否需要上传记录
const judgeAndUpload = (_rankList: RankInfo[], _userId: string) => {
if (!_userId) return;
if (
_rankList.length < 50 ||
rating > _rankList[_rankList.length - 1].rating
) {
// 榜未满或者分数高于榜上最后一名
// 本次排名
let thisRank = _rankList.findIndex((rank) => rank.rating < rating);
if (thisRank === -1) {
thisRank = _rankList.length + 1;
} else {
thisRank++;
}
// 查找是否曾上榜
const findSelf = _rankList.findIndex(
(rank) => rank.userId === _userId
);
if (findSelf === -1) {
// 新上榜
uploadRankInfo();
setTip(`恭喜上榜!本次排名${thisRank}`);
} else {
if (_rankList[findSelf].rating < rating) {
// 破自己记录
uploadRankInfo(_rankList[findSelf].objectId);
setTip(`个人新高!本次排名${thisRank}`);
} else if (_rankList[findSelf].rating > rating) {
// 没破自己记录
setTip(
`距离你的最高记录${_rankList[findSelf].rating}还差一点~`
);
} else {
setTip(`与你的最高记录${_rankList[findSelf].rating}持平~`);
}
}
} else {
// 未上榜
setTip('本次未上榜');
}
};
useEffect(() => {
if (!__DIY__) {
// 排行榜
getRankList((rankList) =>
judgeAndUpload(
rankList,
localStorage.getItem(USER_ID_STORAGE_KEY) || ''
)
);
}
}, []);
return (
<div className={style.modal}>
<Suspense
fallback={
<span style={{ position: 'absolute' }}>🎆fireworks🎆</span>
}
>
{success && <Fireworks />}
</Suspense>
<div className={style.inner}>
{success ? <h1>🎉</h1> : <h1>😫</h1>}
<table>
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
<tr>
<td>{level}</td>
<td>{timestampToUsedTimeString(time)}</td>
<td>{score}</td>
<td>{rating}</td>
</tr>
</tbody>
</table>
{!__DIY__ && !username && (
<div className={'flex-container flex-center'}>
<input
className={style.nameInput}
ref={usernameInputRef}
maxLength={12}
placeholder={'留下大名进行排行榜pk!'}
/>
<button
className={'primary'}
onClick={onConfirmNameClick}
>
</button>
</div>
)}
{tip && <div>{tip}</div>}
{__DIY__ && (
<button className={'primary'} onClick={restartMethod}>
</button>
)}
{!__DIY__ && (
<div className={style.rankContainer}>
<h1 className={style.title}>TOP 50</h1>
{rankList.length ? (
<div className={style.list}>
<table>
<thead>
<tr>
<th></th>
<th></th>
<th></th>
{/*<th>用时</th>*/}
{/*<th>得分</th>*/}
<th></th>
</tr>
</thead>
<tbody>
{rankList.map((rank, idx) => (
<tr
key={idx}
id={rank.userId + 'el'}
style={{
background:
rank.userId === userId
? 'rgb(0 0 0 / 20%)'
: '',
}}
>
<td>{idx + 1}</td>
<td className={style.username}>
{rank.username}
{rank.userId === userId &&
'(你)'}
</td>
<td>{rank.level}</td>
{/*<td>*/}
{/* {timestampToUsedTimeString(*/}
{/* rank.time*/}
{/* )}*/}
{/*</td>*/}
{/*<td>{rank.score}</td>*/}
<td>{rank.rating}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className={style.tip}>
</div>
)}
<button className={'primary'} onClick={restartMethod}>
</button>
</div>
)}
{!pure && <WxQrCode />}
</div>
</div>
);
};
export default Score;

View File

@ -1,8 +1,9 @@
import React, { FC, useState } from 'react';
import React, { FC, MouseEventHandler, useState } from 'react';
import style from './WxQrCode.module.scss';
import classNames from 'classnames';
const WxQrCode: FC<{ title?: string }> = ({
title = '【广告位招租中】同时如果您喜欢这个项目的话,可以点击扫描下方收款码分摊后台相关费用,感谢~😘',
const WxQrCode: FC<{ title?: string; onClick?: MouseEventHandler }> = ({
title = '如果您喜欢这个项目的话,点击扫描下方收款码请我喝杯咖啡,感谢~😘',
onClick,
}) => {
const [fullScreen, setFullScreen] = useState<Record<number, boolean>>({
0: false,
@ -28,7 +29,7 @@ const WxQrCode: FC<{ title?: string }> = ({
});
};
return (
<div className={style.wxQrCodeContainer}>
<div className={style.wxQrCodeContainer} onClick={onClick}>
<div className={style.wxQrCodeTitle}>{title}</div>
{[1, 5, 8].map((num, idx) => (
<div
@ -41,7 +42,7 @@ const WxQrCode: FC<{ title?: string }> = ({
<span className={style.wxQrCodeItemTitle}> {num}</span>
<img
alt={''}
src={`/wxQrCode${num}.jpg`}
src={`/wxQrcode${num}.jpg`}
className={style.wxQrCodeItemImage}
onClick={() => onImageClick(idx)}
/>

View File

@ -5,9 +5,10 @@ import './styles/global.scss';
import './styles/utils.scss';
import Bmob from 'hydrogen-js-sdk';
import {
DEFAULT_BGM_STORAGE_KEY,
domRelatedOptForTheme,
parsePathCustomThemeId,
PLAYING_THEME_ID_STORAGE_KEY,
resetScoreStorage,
wrapThemeDefaultSounds,
} from './utils';
import { getDefaultTheme } from './themes/default';
@ -33,6 +34,21 @@ const errorTip = (tip: string) => {
// 加载成功后数据转换runtime以及转场
const successTrans = (theme: Theme<any>) => {
// 如果上次玩的不是这个主题,清除缓存分数
const lastPlayTheme = localStorage.getItem(PLAYING_THEME_ID_STORAGE_KEY);
if (
!lastPlayTheme ||
![customThemeIdFromPath, theme.title].includes(lastPlayTheme)
) {
resetScoreStorage();
}
// 缓存当前主题id
localStorage.setItem(
PLAYING_THEME_ID_STORAGE_KEY,
customThemeIdFromPath || theme.title
);
wrapThemeDefaultSounds(theme);
setTimeout(() => {
@ -57,54 +73,37 @@ Bmob.initialize(
import.meta.env.VITE_BMOB_SECCODE
);
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);
});
// 请求主题
if (customThemeIdFromPath) {
const storageTheme = localStorage.getItem(customThemeIdFromPath);
if (storageTheme) {
try {
const customTheme = JSON.parse(storageTheme);
successTrans(customTheme);
} catch (e) {
errorTip('主题配置解析失败');
}
} else {
successTrans(getDefaultTheme());
Bmob.Query('config')
.get(customThemeIdFromPath)
.then((res) => {
const { content, increment } = res as any;
localStorage.setItem(customThemeIdFromPath, content);
try {
const customTheme = JSON.parse(content);
successTrans(customTheme);
} catch (e) {
errorTip('主题配置解析失败');
}
// 统计访问次数
increment('visitNum');
// @ts-ignore
res.save();
})
.catch(({ error }) => {
errorTip(error);
});
}
};
// 音效资源请求
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(({ error }) => {
errorTip(error);
});
} else {
loadTheme();
successTrans(getDefaultTheme());
}

View File

@ -1,9 +1,4 @@
import { Theme } from '../interface';
import {
DEFAULT_BGM_STORAGE_KEY,
DEFAULT_CLICK_SOUND_STORAGE_KEY,
DEFAULT_TRIPLE_SOUND_STORAGE_KEY,
} from '../../utils';
const icons = <const>[
`🎨`,
@ -25,6 +20,7 @@ export const getDefaultTheme: () => Theme<DefaultSoundNames> = () => {
title: '有解的羊了个羊',
desc: '真的可以通关~',
dark: true,
maxLevel: 20,
backgroundColor: '#8dac85',
icons: icons.map((icon) => ({
name: icon,
@ -35,16 +31,13 @@ export const getDefaultTheme: () => Theme<DefaultSoundNames> = () => {
sounds: [
{
name: 'button-click',
src:
localStorage.getItem(DEFAULT_CLICK_SOUND_STORAGE_KEY) || '',
src: 'https://minio.streakingman.com/solvable-sheep-game/sound-button-click.mp3',
},
{
name: 'triple',
src:
localStorage.getItem(DEFAULT_TRIPLE_SOUND_STORAGE_KEY) ||
'',
src: 'https://minio.streakingman.com/solvable-sheep-game/sound-triple.mp3',
},
],
bgm: localStorage.getItem(DEFAULT_BGM_STORAGE_KEY) || '',
bgm: 'https://minio.streakingman.com/solvable-sheep-game/sound-disco.mp3',
};
};

View File

@ -1,18 +1,26 @@
import { Theme } from './themes/interface';
import { getDefaultTheme } from './themes/default';
// local
export const LAST_LEVEL_STORAGE_KEY = 'lastLevel';
export const LAST_SCORE_STORAGE_KEY = 'lastScore';
export const LAST_TIME_STORAGE_KEY = 'lastTime';
export const CUSTOM_THEME_ID_STORAGE_KEY = 'customThemeId';
export const LAST_CUSTOM_THEME_ID_STORAGE_KEY = 'lastCustomThemeId';
export const LAST_UPLOAD_TIME_STORAGE_KEY = 'lastUploadTime';
export const CUSTOM_THEME_STORAGE_KEY = 'customTheme';
export const CUSTOM_THEME_FILE_VALIDATE_STORAGE_KEY = 'customThemeFileValidate';
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 USER_NAME_STORAGE_KEY = 'username';
export const USER_ID_STORAGE_KEY = 'userId';
export const PLAYING_THEME_ID_STORAGE_KEY = 'playingThemeId';
export const linkReg = /^(https|data):+/;
export const resetScoreStorage = () => {
localStorage.setItem(LAST_LEVEL_STORAGE_KEY, '1');
localStorage.setItem(LAST_SCORE_STORAGE_KEY, '0');
localStorage.setItem(LAST_TIME_STORAGE_KEY, '0');
};
export const randomString: (len: number) => string = (len) => {
const pool = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
let res = '';
@ -141,3 +149,126 @@ export const getFileBase64String: (file: File) => Promise<string> = (
};
});
};
export const timestampToUsedTimeString: (time: number) => string = (time) => {
try {
const hours = Math.floor(time / (1000 * 60 * 60));
const minutes = Math.floor(
(time - 1000 * 60 * 60 * hours) / (1000 * 60)
);
const seconds = (
(time - 1000 * 60 * 60 * hours - 1000 * 60 * minutes) /
1000
).toFixed(3);
if (hours) {
return `${hours}小时${minutes}${seconds}`;
} else if (minutes) {
return `${minutes}${seconds}`;
} else {
return `${seconds}`;
}
} catch (e) {
return '时间转换出错';
}
};
export const dataURLToFile: (dataURL: string, filename?: string) => File = (
// #endregion dataURLToFile
dataURL,
filename
) => {
const isDataURL = dataURL.startsWith('data:');
if (!isDataURL) throw new Error('Data URL 错误');
const _fileName = filename || new Date().getTime().toString();
const mimeType = dataURL.match(/^data:([^;]+);/)?.[1] || '';
// base64转二进制
const binaryString = atob(dataURL.split(',')[1]);
let binaryStringLength = binaryString.length;
const bytes = new Uint8Array(binaryStringLength);
while (binaryStringLength--) {
bytes[binaryStringLength] = binaryString.charCodeAt(binaryStringLength);
}
return new File([bytes], _fileName, { type: mimeType });
};
interface drawImgSrcInCanvasParams {
imgSrc: string;
canvas: HTMLCanvasElement;
scale?: number;
}
export const drawImgSrcInCanvas: (
params: drawImgSrcInCanvasParams
) => Promise<void> = async ({ imgSrc, canvas, scale = 1 }) => {
if (scale < 0) throw new Error('scale不能小于0');
const ctx = canvas.getContext('2d');
if (!ctx || !(ctx instanceof CanvasRenderingContext2D)) {
throw new Error('Failed to get 2D context');
}
const img = document.createElement('img');
img.setAttribute('src', imgSrc);
return new Promise((resolve, reject) => {
img.onload = () => {
const width = img.width * scale;
const height = img.height * scale;
canvas.width = width;
canvas.height = height;
ctx.drawImage(img, 0, 0, width, height);
resolve();
};
img.onerror = () => {
reject(new Error('图片加载失败'));
};
});
};
interface createCanvasByImgSrcParams {
imgSrc: HTMLImageElement['src'];
scale?: number;
}
export const createCanvasByImgSrc: (
params: createCanvasByImgSrcParams
) => Promise<HTMLCanvasElement> = async ({ imgSrc, scale = 1 }) => {
if (scale < 0) throw new Error('scale不能小于0');
const canvas = document.createElement('canvas');
await drawImgSrcInCanvas({
imgSrc,
canvas,
scale,
});
return canvas;
};
interface CanvasToFileParams {
canvas: HTMLCanvasElement;
fileName?: string;
maxFileSize?: number;
}
export const canvasToFile: (
params: CanvasToFileParams
) => Promise<File> = async ({ canvas, fileName, maxFileSize }) => {
// #endregion canvasToFile
const MIME_TYPE = 'image/png';
const dataURL = canvas.toDataURL(MIME_TYPE);
const _fileName = fileName || new Date().getTime().toString();
const genFile = dataURLToFile(dataURL, _fileName);
// 判断是否需要压缩
if (maxFileSize && genFile.size > maxFileSize) {
let scale = Math.sqrt(maxFileSize / genFile.size);
if (scale > 0.9) scale = 0.9;
// TODO 暂时通过canvas绘制缩放图像进行递归压缩后续考虑其他方式
// TODO 不断生成canvas, 调研内存是否会泄漏
const _canvas = await createCanvasByImgSrc({
imgSrc: dataURL,
scale,
});
return canvasToFile({
canvas: _canvas,
fileName: _fileName,
maxFileSize,
});
} else {
return genFile;
}
};

BIN
wxQrCodes.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB