mirror of
https://ghproxy.com/https://github.com/StreakingMan/solvable-sheep-game
synced 2025-06-01 18:03:58 +08:00
Compare commits
29 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
09e43ae02a | ||
![]() |
c5bd3f01d9 | ||
![]() |
be0faff9fd | ||
![]() |
cebf64847f | ||
![]() |
ffbb38495f | ||
![]() |
e553e5360f | ||
![]() |
d234bd06a8 | ||
![]() |
680811c50c | ||
![]() |
ffabf6805f | ||
![]() |
58bff1ce98 | ||
![]() |
cd5501ebea | ||
![]() |
331b96c8f0 | ||
![]() |
c6671038a8 | ||
![]() |
5579d5a32e | ||
![]() |
8dec5b6420 | ||
![]() |
3913982716 | ||
![]() |
5353a40416 | ||
![]() |
fd1d429f59 | ||
![]() |
81503973a7 | ||
![]() |
acb5083e22 | ||
![]() |
639b1a3db1 | ||
![]() |
a8a6312e2a | ||
![]() |
9682d31c49 | ||
![]() |
01d85610eb | ||
![]() |
d070c67d3f | ||
![]() |
fc7a880607 | ||
![]() |
47caa7ccf9 | ||
![]() |
3fa7059272 | ||
![]() |
cc06bb14e3 |
0
.husky/commit-msg
Executable file → Normal file
0
.husky/commit-msg
Executable file → Normal file
0
.husky/pre-commit
Executable file → Normal file
0
.husky/pre-commit
Executable file → Normal file
43
CHANGELOG.md
43
CHANGELOG.md
|
@ -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)
|
||||
|
||||
|
||||
|
|
29
README.md
29
README.md
|
@ -19,36 +19,34 @@
|
|||
**声明:本项目仅供交流,禁止商用!否则后果自负。基于此项目的二创都是欢迎的,但非二创请不要删除原仓库地址
|
||||
(啥都不改唯独删除来源我真的会谢 🙄️,请尊重他人劳动成果)**
|
||||
|
||||

|
||||
<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
|
||||
|
||||
开心就好 😄
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
## 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 服务到期,后台服务已下线,相关功能暂时无法使用,如有需要请自行搭建后台服务
|
||||
|
||||

|
||||
|
|
|
@ -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 流量), 将更改后的项目推到自己的
|
||||
github(gitlab,bitbucket 同样支持)仓库, 使用 github 账号登录 vercel 后导入该项目,构建模版选择 vite, 构建命令更改为 `yarn build:diy`
|
||||
输出地址改为 `diy/diy-dist` 即可 。
|
||||
会在 `diy/diy-dist` 下生成静态资源,直接将这些文件复制服务器上做代理即可。如果嫌麻烦,推荐使用 [vercel](https://vercel.com/)
|
||||
一键部署(每月免费 100G 流量), 将更改后的项目推到自己的 github(gitlab,bitbucket 同样支持)仓库,
|
||||
使用 github 账号登录 vercel 后导入该项目,构建模版选择 vite,
|
||||
构建命令更改为 `yarn build:diy` 输出地址改为 `diy/diy-dist` 即可 。见下图:
|
||||
|
||||
<img src="./vercel.png" alt="" style="width: 400px"/>
|
||||
|
||||
|
@ -77,7 +82,8 @@ github(gitlab,bitbucket 同样支持)仓库, 使用 github 账号登录
|
|||
|
||||
## 其他
|
||||
|
||||
如果您想体验项目的完整功能,则需要注册一个 [Bmob](https://www.bmobapp.com/) 账号, 注册后新建应用(有一年的白嫖版,免费请求数虽然很客观,但并发数有限制,请根据自己的实际流量
|
||||
如果您想体验项目的完整功能,则需要注册一个 [Bmob](https://www.bmobapp.com/) 账号,
|
||||
注册后新建应用(有一年的白嫖版,免费请求数虽然很客观,但并发数有限制,请根据自己的实际流量
|
||||
选择升级套餐,或者其他存储方案)
|
||||
|
||||
新建应用后,去到设置页面拷贝密钥和安全码到项目的 `.env` 文件中
|
||||
|
@ -91,7 +97,7 @@ ps: 如果您的项目托管在公共仓库中,请注意保护密钥,本地
|
|||
|
||||
`config` 表用来存储自定义配置的 json 字符串,需要新增 `content` 列
|
||||
|
||||
`file` 表则是为了 vercel 流量,将一些默认文件转为 base64 编码存到了数据库中,需要添加三列
|
||||

|
||||
`rank` 表,储存排名信息
|
||||

|
||||
|
||||
最后,开发和打包命令分别使用 `yarn dev` 和 `yarn build` 即可
|
||||
|
|
Binary file not shown.
Before Width: | Height: | Size: 58 KiB |
BIN
diy/datebase-rank.png
Normal file
BIN
diy/datebase-rank.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 56 KiB |
|
@ -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);
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "solvable-sheep-game",
|
||||
"private": false,
|
||||
"version": "0.0.10",
|
||||
"version": "1.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
|
BIN
preview.png
BIN
preview.png
Binary file not shown.
Before Width: | Height: | Size: 212 KiB |
BIN
preview2.png
BIN
preview2.png
Binary file not shown.
Before Width: | Height: | Size: 27 KiB |
BIN
previews.png
Normal file
BIN
previews.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 372 KiB |
25
src/App.tsx
25
src/App.tsx
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
133
src/components/Fireworks.module.scss
Normal file
133
src/components/Fireworks.module.scss
Normal 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%;
|
||||
}
|
||||
}
|
12
src/components/Fireworks.tsx
Normal file
12
src/components/Fireworks.tsx
Normal 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;
|
|
@ -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;
|
||||
|
|
|
@ -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)}>
|
||||
|
|
89
src/components/Score.module.scss
Normal file
89
src/components/Score.module.scss
Normal 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
297
src/components/Score.tsx
Normal 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;
|
|
@ -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)}
|
||||
/>
|
||||
|
|
93
src/main.tsx
93
src/main.tsx
|
@ -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());
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
};
|
||||
};
|
||||
|
|
139
src/utils.ts
139
src/utils.ts
|
@ -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
BIN
wxQrCodes.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 138 KiB |
Loading…
Reference in New Issue
Block a user