Compare commits

..

No commits in common. "master" and "v1.0.1" have entirely different histories.

12 changed files with 127 additions and 232 deletions

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

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

View File

@ -2,20 +2,6 @@
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)

View File

@ -76,9 +76,7 @@ vite+react 实现,欢迎 star、issue、pr、fork尽量标注原仓库地
## 资助
~~由于各种白嫖的静态资源托管、后台服务的免费额度都已用完,目前自费升级了相关套餐。~~
如果您喜欢这个项目,觉得本项目对你有帮助的话,可以扫描下方付款码请我喝杯咖啡 ☕️/~~分摊后台服务费用~~ 😘
2023.5.5 更新Bmob 服务到期,后台服务已下线,相关功能暂时无法使用,如有需要请自行搭建后台服务
由于各种白嫖的静态资源托管、后台服务的免费额度都已用完,目前自费升级了相关套餐。
如果您喜欢这个项目,觉得本项目对你有帮助的话,可以扫描下方付款码请我喝杯咖啡 ☕️/分摊后台服务费用~ 😘
![wxQrCodes.png](wxQrCodes.png)

View File

@ -2,9 +2,6 @@
游戏的核心逻辑已经封装到了 `src/components/Game.tsx` ,方便大家魔改, 主题配置的类型声明见 `src/themes/interface.ts`
你可以先通过超酷的[stackblitz](https://stackblitz.com/edit/solvable-sheep-game?file=diy%2Fdiy.theme.json&terminal=dev:diy)
在线体验一番(等待依赖安装完成后,编辑配置将实时更新)再回来看这里的指南。
## 准备工作
### 环境准备
@ -97,6 +94,9 @@ ps: 如果您的项目托管在公共仓库中,请注意保护密钥,本地
`config` 表用来存储自定义配置的 json 字符串,需要新增 `content`
`file` 表则是为了节省 vercel 流量,将一些默认文件转为 base64 字符串存到了数据库中,需要添加三列
![img.png](database-file.png)
`rank` 表,储存排名信息
![img.png](datebase-rank.png)

BIN
diy/database-file.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

View File

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

View File

@ -15,8 +15,6 @@ import {
randomString,
wrapThemeDefaultSounds,
LAST_UPLOAD_TIME_STORAGE_KEY,
canvasToFile,
createCanvasByImgSrc,
} from '../utils';
import { copy } from 'clipboard';
import { CloseIcon } from './CloseIcon';
@ -146,14 +144,10 @@ const ConfigDialog: FC<{
new Array(10).fill('')
);
// 文件体积校验开关
const initEnableFileSizeValidate = localStorage.getItem(
CUSTOM_THEME_FILE_VALIDATE_STORAGE_KEY
);
const [enableFileSizeValidate, setEnableFileSizeValidate] =
useState<boolean>(
initEnableFileSizeValidate === null
? true
: initEnableFileSizeValidate === 'true'
localStorage.getItem(CUSTOM_THEME_FILE_VALIDATE_STORAGE_KEY) !==
'false'
);
useEffect(() => {
localStorage.setItem(
@ -169,7 +163,7 @@ const ConfigDialog: FC<{
type: 'bgm' | 'background' | 'sound' | 'icon';
file?: File;
idx?: number;
}) => void = async ({ type, file, idx }) => {
}) => void = ({ type, file, idx }) => {
if (!file) return;
switch (type) {
case 'bgm':
@ -187,20 +181,16 @@ const ConfigDialog: FC<{
break;
case 'background':
setBackgroundError('');
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);
if (enableFileSizeValidate && file.size > 80 * 1024) {
return setBackgroundError('请选择80k以内全损画质的图片');
}
getFileBase64String(file)
.then((res) => {
updateCustomTheme('background', res);
})
.catch((e) => {
setBackgroundError(e);
});
break;
case 'sound':
setSoundError('');
@ -218,27 +208,23 @@ const ConfigDialog: FC<{
case 'icon':
if (idx == null) return;
setIconErrors(makeIconErrors(idx, ''));
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
)
if (enableFileSizeValidate && file.size > 5 * 1024) {
return setIconErrors(
makeIconErrors(idx, '请选择5k以内的图片文件')
);
} 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;
}
};
@ -265,9 +251,7 @@ const ConfigDialog: FC<{
if (configString) {
const parseRes = JSON.parse(configString);
if (typeof parseRes === 'object') {
setTimeout(() => {
setCustomTheme(parseRes);
}, 300);
setCustomTheme(parseRes);
}
}
} catch (e) {
@ -373,7 +357,11 @@ const ConfigDialog: FC<{
})
.catch(({ error, code }) => {
setTimeout(() => {
setConfigError(error);
setConfigError(
code === 10007
? '上传数据长度已超过bmob的限制'
: error
);
}, 3000);
})
.finally(() => {
@ -403,17 +391,6 @@ const ConfigDialog: FC<{
<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
@ -431,7 +408,7 @@ const ConfigDialog: FC<{
</InputContainer>
<InputContainer label={'BGM'}>
<div className={style.tip}>
80k以下使
80k以下
</div>
<input
type={'file'}
@ -453,7 +430,7 @@ const ConfigDialog: FC<{
</InputContainer>
<InputContainer label={'背景图'}>
<div className={style.tip}>
使
80k以下
</div>
<input
type={'file'}
@ -559,7 +536,7 @@ const ConfigDialog: FC<{
onChange={(e) => onNewSoundChange('name', e.target.value)}
/>
<div className={style.tip}>
10k以下使
10k以下
</div>
<input
type={'file'}
@ -583,7 +560,7 @@ const ConfigDialog: FC<{
</InputContainer>
<InputContainer label={'图标素材'} required>
<div className={style.tip}>
使
5k以下56*56
</div>
</InputContainer>
{customTheme.icons.map((icon, idx) => (
@ -708,7 +685,7 @@ const ConfigDialog: FC<{
</div>
)}
<div className={style.tip}>
使mp3外链
使mp3外链
👉
<input
type={'checkbox'}
@ -733,17 +710,16 @@ const ConfigDialog: FC<{
>
</button>
{/*<button*/}
{/* className={classNames(*/}
{/* 'primary flex-grow',*/}
{/* style.uploadBtn,*/}
{/* uploading && style.uploading*/}
{/* )}*/}
{/* onClick={onGenQrLinkClick}*/}
{/* disabled*/}
{/*>*/}
{/* 生成二维码&链接*/}
{/*</button>*/}
<button
className={classNames(
'primary flex-grow',
style.uploadBtn,
uploading && style.uploading
)}
onClick={onGenQrLinkClick}
>
&
</button>
</div>
</div>
);

View File

@ -2,7 +2,7 @@ import React, { FC, MouseEventHandler, useState } from 'react';
import style from './WxQrCode.module.scss';
import classNames from 'classnames';
const WxQrCode: FC<{ title?: string; onClick?: MouseEventHandler }> = ({
title = '如果您喜欢这个项目的话,点击扫描下方收款码请我喝杯咖啡,感谢~😘',
title = '如果您喜欢这个项目的话,可以点击扫描下方收款码分担后台相关费用(或请我喝杯咖啡,感谢~😘',
onClick,
}) => {
const [fullScreen, setFullScreen] = useState<Record<number, boolean>>({

View File

@ -5,7 +5,11 @@ 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,
@ -73,37 +77,58 @@ Bmob.initialize(
import.meta.env.VITE_BMOB_SECCODE
);
// 请求主题
if (customThemeIdFromPath) {
const storageTheme = localStorage.getItem(customThemeIdFromPath);
if (storageTheme) {
try {
const customTheme = JSON.parse(storageTheme);
successTrans(customTheme);
} catch (e) {
errorTip('主题配置解析失败');
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);
});
}
} 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);
});
successTrans(getDefaultTheme());
}
};
// 音效资源请求
if (!localStorage.getItem(DEFAULT_BGM_STORAGE_KEY)) {
const query = Bmob.Query('file');
query.equalTo('type', '==', 'default');
query
.find()
.then((results) => {
for (const file of results as any) {
localStorage.setItem(file.name, file.base64);
}
loadTheme();
})
.catch(({ error }) => {
errorTip(error);
});
} else {
successTrans(getDefaultTheme());
loadTheme();
}

View File

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

View File

@ -9,6 +9,9 @@ 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';
@ -171,104 +174,3 @@ 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;
}
};