Compare commits

...

17 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
16 changed files with 333 additions and 175 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,32 @@
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)

View File

@ -76,7 +76,9 @@ vite+react 实现,欢迎 star、issue、pr、fork尽量标注原仓库地
## 资助
由于各种白嫖的静态资源托管、后台服务的免费额度都已用完,目前自费升级了相关套餐。
如果您喜欢这个项目,觉得本项目对你有帮助的话,可以扫描下方付款码请我喝杯咖啡 ☕️/分摊后台服务费用~ 😘
~~由于各种白嫖的静态资源托管、后台服务的免费额度都已用完,目前自费升级了相关套餐。~~
如果您喜欢这个项目,觉得本项目对你有帮助的话,可以扫描下方付款码请我喝杯咖啡 ☕️/~~分摊后台服务费用~~ 😘
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)
在线体验一番(等待依赖安装完成后,编辑配置将实时更新)再回来看这里的指南。
## 准备工作
### 环境准备
@ -68,8 +71,8 @@ yarn dev:diy
yarn build:diy
```
会在 `diy/diy-dist` 下生成静态资源,直接将这些文件复制服务器上做代理即可。如果嫌麻烦,推荐使用 [vercel](https://vercel.com/)
一键部署(每月免费 100G 流量), 将更改后的项目推到自己的 githubgitlabbitbucket 同样支持)仓库,
会在 `diy/diy-dist` 下生成静态资源,直接将这些文件复制服务器上做代理即可。如果嫌麻烦,推荐使用 [vercel](https://vercel.com/)
一键部署(每月免费 100G 流量), 将更改后的项目推到自己的 githubgitlabbitbucket 同样支持)仓库,
使用 github 账号登录 vercel 后导入该项目,构建模版选择 vite
构建命令更改为 `yarn build:diy` 输出地址改为 `diy/diy-dist` 即可 。见下图:
@ -79,7 +82,7 @@ yarn build:diy
## 其他
如果您想体验项目的完整功能,则需要注册一个 [Bmob](https://www.bmobapp.com/) 账号,
如果您想体验项目的完整功能,则需要注册一个 [Bmob](https://www.bmobapp.com/) 账号,
注册后新建应用(有一年的白嫖版,免费请求数虽然很客观,但并发数有限制,请根据自己的实际流量
选择升级套餐,或者其他存储方案)
@ -94,9 +97,6 @@ ps: 如果您的项目托管在公共仓库中,请注意保护密钥,本地
`config` 表用来存储自定义配置的 json 字符串,需要新增 `content`
`file` 表则是为了节省 vercel 流量,将一些默认文件转为 base64 字符串存到了数据库中,需要添加三列
![img.png](database-file.png)
`rank` 表,储存排名信息
![img.png](datebase-rank.png)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

View File

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

View File

@ -90,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'] {

View File

@ -15,6 +15,8 @@ import {
randomString,
wrapThemeDefaultSounds,
LAST_UPLOAD_TIME_STORAGE_KEY,
canvasToFile,
createCanvasByImgSrc,
} from '../utils';
import { copy } from 'clipboard';
import { CloseIcon } from './CloseIcon';
@ -65,6 +67,7 @@ const ConfigDialog: FC<{
const [customTheme, setCustomTheme] = useState<CustomTheme>({
title: '',
sounds: [],
pure: false,
icons: new Array(10).fill(0).map(() => ({
name: randomString(4),
content: '',
@ -143,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(
@ -162,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':
@ -180,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('');
@ -207,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;
}
};
@ -250,7 +265,9 @@ const ConfigDialog: FC<{
if (configString) {
const parseRes = JSON.parse(configString);
if (typeof parseRes === 'object') {
setCustomTheme(parseRes);
setTimeout(() => {
setCustomTheme(parseRes);
}, 300);
}
}
} catch (e) {
@ -356,11 +373,7 @@ const ConfigDialog: FC<{
})
.catch(({ error, code }) => {
setTimeout(() => {
setConfigError(
code === 10007
? '上传数据长度已超过bmob的限制'
: error
);
setConfigError(error);
}, 3000);
})
.finally(() => {
@ -375,12 +388,32 @@ const ConfigDialog: FC<{
});
};
// 彩蛋
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
@ -398,7 +431,7 @@ const ConfigDialog: FC<{
</InputContainer>
<InputContainer label={'BGM'}>
<div className={style.tip}>
80k以下
80k以下使
</div>
<input
type={'file'}
@ -420,7 +453,7 @@ const ConfigDialog: FC<{
</InputContainer>
<InputContainer label={'背景图'}>
<div className={style.tip}>
80k以下
使
</div>
<input
type={'file'}
@ -451,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}>
使使
@ -519,7 +559,7 @@ const ConfigDialog: FC<{
onChange={(e) => onNewSoundChange('name', e.target.value)}
/>
<div className={style.tip}>
10k以下
10k以下使
</div>
<input
type={'file'}
@ -543,7 +583,7 @@ const ConfigDialog: FC<{
</InputContainer>
<InputContainer label={'图标素材'} required>
<div className={style.tip}>
5k以下56*56
使
</div>
</InputContainer>
{customTheme.icons.map((icon, idx) => (
@ -668,7 +708,7 @@ const ConfigDialog: FC<{
</div>
)}
<div className={style.tip}>
使mp3外链
使mp3外链
👉
<input
type={'checkbox'}
@ -680,7 +720,12 @@ const ConfigDialog: FC<{
(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'}
@ -688,16 +733,17 @@ const ConfigDialog: FC<{
>
</button>
<button
className={classNames(
'primary flex-grow',
style.uploadBtn,
uploading && style.uploading
)}
onClick={onGenQrLinkClick}
>
&
</button>
{/*<button*/}
{/* className={classNames(*/}
{/* 'primary flex-grow',*/}
{/* style.uploadBtn,*/}
{/* uploading && style.uploading*/}
{/* )}*/}
{/* onClick={onGenQrLinkClick}*/}
{/* disabled*/}
{/*>*/}
{/* 生成二维码&链接*/}
{/*</button>*/}
</div>
</div>
);

View File

@ -171,6 +171,7 @@ const Game: FC<{
Record<MySymbol['id'], number>
>({});
const [finished, setFinished] = useState<boolean>(false);
const [success, setSuccess] = useState<boolean>(false);
const [animating, setAnimating] = useState<boolean>(false);
// 音效
@ -326,6 +327,7 @@ const Game: FC<{
// 重开
const restart = () => {
setFinished(false);
setSuccess(false);
setScore(0);
setLevel(1);
setQueue([]);
@ -395,21 +397,25 @@ const Game: FC<{
// 输了
if (updateQueue.length === 7) {
setFinished(true);
setSuccess(false);
}
if (!updateScene.find((s) => s.status !== 2)) {
// 胜利
// 队列清空了
if (level === maxLevel) {
// 胜利
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);
}
@ -496,7 +502,8 @@ const Game: FC<{
level={level}
time={usedTime}
score={score}
success={level === maxLevel}
success={success}
pure={theme.pure}
restartMethod={restart}
/>
)}

View File

@ -37,8 +37,9 @@ const Score: FC<{
score: number;
time: number;
success: boolean;
pure?: boolean;
restartMethod: () => void;
}> = ({ level, score, time, success, restartMethod }) => {
}> = ({ level, score, time, success, restartMethod, pure = false }) => {
const [rankList, setRankList] = useState<RankInfo[]>([]);
const [username, setUsername] = useState<string>(
localStorage.getItem(USER_NAME_STORAGE_KEY) || ''
@ -97,7 +98,10 @@ const Score: FC<{
if (_userId) {
setTimeout(() => {
const rankEl = document.getElementById(_userId + 'el');
rankEl?.scrollIntoView({ behavior: 'smooth' });
rankEl?.scrollIntoView({
behavior: 'smooth',
block: 'center',
});
}, 1000);
}
})
@ -219,7 +223,13 @@ const Score: FC<{
</div>
)}
<div>{tip}</div>
{tip && <div>{tip}</div>}
{__DIY__ && (
<button className={'primary'} onClick={restartMethod}>
</button>
)}
{!__DIY__ && (
<div className={style.rankContainer}>
@ -278,7 +288,7 @@ const Score: FC<{
</button>
</div>
)}
<WxQrCode />
{!pure && <WxQrCode />}
</div>
</div>
);

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

View File

@ -5,11 +5,7 @@ import './styles/global.scss';
import './styles/utils.scss';
import Bmob from 'hydrogen-js-sdk';
import {
DEFAULT_BGM_STORAGE_KEY,
domRelatedOptForTheme,
LAST_LEVEL_STORAGE_KEY,
LAST_SCORE_STORAGE_KEY,
LAST_TIME_STORAGE_KEY,
parsePathCustomThemeId,
PLAYING_THEME_ID_STORAGE_KEY,
resetScoreStorage,
@ -77,58 +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, 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 (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>[
`🎨`,
@ -36,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

@ -9,9 +9,6 @@ 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';
@ -174,3 +171,104 @@ export const timestampToUsedTimeString: (time: number) => string = (time) => {
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;
}
};