Compare commits

...

113 Commits

Author SHA1 Message Date
streakingman
09e43ae02a chore(release): 1.1.0 2023-05-05 00:44:31 +08:00
streakingman
c5bd3f01d9 chore: 停用分享说明 2023-05-05 00:44:02 +08:00
streakingman
be0faff9fd fix: 图片文件大小校验逻辑 2023-05-05 00:33:21 +08:00
streakingman
cebf64847f chore: 服务到期说明 2023-05-05 00:20:14 +08:00
streakingman
ffbb38495f feat: 本地图片文件压缩 2023-05-04 23:52:21 +08:00
streakingman
e553e5360f chore: update diy README.md 2023-01-17 22:02:14 +08:00
StreakingMan
d234bd06a8 1 binaries uploaded 2023-01-17 21:03:05 +08:00
streakingman
680811c50c fix: 音频文件切换为minio外链 2022-11-10 07:57:54 +08:00
streakingman
ffabf6805f fix: 自定义主题编辑回显 2022-11-04 10:11:12 +08:00
streakingman
58bff1ce98 revert: 默认主题关卡数 2022-10-19 23:56:13 +08:00
streakingman
cd5501ebea chore(release): 1.0.1 2022-10-19 23:49:34 +08:00
streakingman
331b96c8f0 fix: 通关成功状态判断,完成游戏后无法点击问题 2022-10-19 23:49:17 +08:00
streakingman
c6671038a8 fix: diy模式得分页再来一次按钮显示 2022-10-14 20:21:12 +08:00
streakingman
5579d5a32e fix: 主题配置表单样式优化 2022-10-13 22:06:04 +08:00
streakingman
8dec5b6420 fix: 主题配置表单样式优化 2022-10-13 20:27:44 +08:00
streakingman
3913982716 fix: 纯净模式隐藏二维码 2022-10-13 20:07:10 +08:00
streakingman
5353a40416 fix: 排行榜滚动定位 2022-10-13 12:44:47 +08:00
streakingman
fd1d429f59 chore(release): 1.0.0 2022-10-13 00:51:35 +08:00
streakingman
81503973a7 docs: diy指南排名表配置 2022-10-13 00:43:29 +08:00
streakingman
acb5083e22 docs: update README.md 2022-10-12 23:47:40 +08:00
streakingman
639b1a3db1 fix: 排行榜样式调整 2022-10-12 23:44:47 +08:00
streakingman
a8a6312e2a fix: 分数缓存逻辑调整 2022-10-12 23:02:30 +08:00
streakingman
9682d31c49 feat: 排行榜 2022-10-12 21:21:19 +08:00
streakingman
01d85610eb fix: 自定义主题icons完成度校验 2022-10-12 00:13:49 +08:00
streakingman
d070c67d3f fix: 主题ID恢复为一次性,交互优化 2022-10-11 23:40:54 +08:00
streakingman
fc7a880607 fix: 图标路径判断问题 2022-10-11 22:51:38 +08:00
streakingman
47caa7ccf9 docs: update README.md 2022-10-11 21:51:06 +08:00
streakingman
3fa7059272 chore: 文档完善、错别字更正 2022-10-11 21:42:47 +08:00
streakingman
cc06bb14e3 fix: wxQrcode路径 2022-10-11 18:57:41 +08:00
streakingman
a3264ec959 chore(release): 0.0.10 2022-10-11 18:50:37 +08:00
streakingman
0f0072585b fix: diy模式的图片路径 2022-10-11 18:50:03 +08:00
streakingman
f6c25eb96a chore: 取消dist维护 2022-10-11 18:39:56 +08:00
streakingman
0ca17d699e chore: update todolist 2022-10-11 18:34:07 +08:00
streakingman
2d7de338fa feat: 本地文件配置存储 2022-10-11 18:31:59 +08:00
streakingman
1890cc5a6f refactor: 自定义主题表单布局重构 2022-10-11 04:59:08 +08:00
streakingman
9f613ade82 chore: 乞讨~ 2022-10-11 02:27:46 +08:00
streakingman
b7482fa209 feat: 计时、最大关卡配置 2022-10-10 03:39:46 +08:00
streakingman
84fa8483d9 chore: 提示面板封装 2022-10-09 23:11:58 +08:00
streakingman
557a6a705a docs: diy指南vercel配置更正 2022-10-09 16:15:47 +08:00
streakingman
f08730e584 docs: diy指南,Bmob配置说明 2022-10-09 00:42:20 +08:00
streakingman
d0b04bcd9e docs: diy指南补充 2022-10-09 00:01:42 +08:00
streakingman
88acee5bc5 docs: diy指南 2022-10-08 23:49:41 +08:00
streakingman
34e89fe232 feat: diy模式区分入口 2022-10-08 22:51:41 +08:00
streakingman
e6449f9d8c chore: 组件拆分动态引入 2022-10-08 21:01:01 +08:00
streakingman
657422eb4f fix: diy按钮位置调整 2022-10-05 12:20:03 +08:00
streakingman
eaa56b0070 feat: 暂时关闭二维码生成功能 2022-10-05 11:42:17 +08:00
streakingman
89eab211e4 fix: 交互优化 2022-10-04 16:48:23 +08:00
streakingman
c9cc9114c3 feat: 配置上传时间间隔限制 2022-10-04 15:39:11 +08:00
streakingman
66f7e48166 chore: 请求错误提示 2022-10-04 15:18:36 +08:00
streakingman
6a91e0bab2 chore(release): 0.0.9 2022-10-04 14:28:06 +08:00
streakingman
23dfa5135b chore: update todo list. resolve #5 2022-10-04 14:27:20 +08:00
streakingman
bac732a42b Merge branch 'refactor'
# Conflicts:
#	src/App.tsx
2022-10-04 14:24:17 +08:00
streakingman
e867a927b3 fix: 项目链接 2022-10-04 14:20:45 +08:00
streakingman
e4c6a2d584 fix: 默认音效填充 2022-10-04 14:08:33 +08:00
streakingman
5e29a1e55a refactor: 主题初始化、资源缓存、UI交互等重构 2022-10-04 13:57:33 +08:00
streakingman
b0cc1f279f chore: 删除无用资源 2022-10-03 21:50:07 +08:00
streakingman
9b626ea4a6 chore: bgm使用外链 2022-10-03 16:57:03 +08:00
streakingman
c0de917641 feat: 缓存自定义主题 2022-10-02 21:50:43 +08:00
streakingman
90852ce91d chore: 组件初步拆分 2022-10-02 21:44:38 +08:00
streakingman
0afeac0f87 feat: 关卡进度缓存 2022-09-29 23:53:02 +08:00
streakingman
e7ae319ecf fix: 禁止图片拖动 2022-09-27 19:30:27 +08:00
streakingman
f8c969f75b docs: update README.md todo list 2022-09-27 19:11:40 +08:00
streakingman
ad01eb2dbb fix: 防止音频配置错误时阻塞主流程 2022-09-26 23:40:50 +08:00
streakingman
b158f2346c chore(release): 0.0.8 2022-09-26 22:36:59 +08:00
streakingman
ae7dab752c feat(自定义主题): 背景图片配置 2022-09-26 22:36:07 +08:00
streakingman
0127253fdd feat(自定义主题): 纯净模式 2022-09-26 21:40:15 +08:00
streakingman
a32cb04bb3 docs: 图片样式 2022-09-26 15:15:11 +08:00
streakingman
e75f375044 docs: add related repo, close #9 2022-09-26 15:11:45 +08:00
streakingman
f694d9af72 fix(自定义主题): bgm链接判断 2022-09-26 00:15:58 +08:00
streakingman
4d5fd90f43 chore(release): 0.0.7 2022-09-25 04:42:55 +08:00
streakingman
b2ab534aa5 docs: update README.md 2022-09-25 04:42:39 +08:00
streakingman
f36c99b385 feat: 生成二维码 2022-09-25 04:19:11 +08:00
streakingman
f6681cabb0 feat: 接入Bmob储存自定义配置 2022-09-25 02:56:32 +08:00
streakingman
e35ddfa44e feat: 自定义主题 2022-09-24 23:29:35 +08:00
streakingman
e287398572 chore(release): 0.0.6 2022-09-23 22:19:01 +08:00
streakingman
75a02be9f4 docs: todo update 2022-09-23 22:18:46 +08:00
streakingman
b687d53733 feat: 根据路径区分主题 2022-09-23 22:07:36 +08:00
streakingman
982a2bd342 docs: 黑脚位置调整 2022-09-23 21:06:17 +08:00
streakingman
72228b7f35 chore(release): 0.0.5 2022-09-22 21:18:53 +08:00
streakingman
4b09bd08c7 feat(themes): ow主题 2022-09-22 21:18:39 +08:00
streakingman
3a3ea6037f fix(themes): 骚猪主题音效增益 2022-09-22 20:30:33 +08:00
streakingman
57224e8015 fix: 入队顺序维护 2022-09-22 20:01:51 +08:00
streakingman
f8e27e70f3 Merge branch 'themes/pdd' 2022-09-22 14:22:39 +08:00
streakingman
9275049c07 chore(release): 0.0.4 2022-09-22 14:18:47 +08:00
streakingman
ba5afc9571 chore: 来源声明 2022-09-22 14:18:23 +08:00
裸奔狂甩丁丁
ca7d267e21
Merge pull request #10 from StreakingMan/themes/pdd
Themes/pdd
2022-09-22 13:47:04 +08:00
streakingman
0eaf5333f6 chore: bgm只剪一小段 2022-09-22 13:44:14 +08:00
streakingman
c578b89703 feat(themes): 骚猪主题音效 2022-09-22 13:27:41 +08:00
streakingman
a5e1ea1004 Merge branch 'master' into themes/pdd
# Conflicts:
#	src/App.tsx
2022-09-22 12:47:05 +08:00
streakingman
85954f72b2 chore(release): 0.0.3 2022-09-22 10:49:45 +08:00
streakingman
7f20ba12ea chore: ikun主题音效压缩 2022-09-22 10:49:20 +08:00
streakingman
f0e2caf7a7 fix: bgm加载时机调整 2022-09-22 10:09:06 +08:00
streakingman
aa2d11a096 chore: 音效文件压缩 2022-09-22 10:08:33 +08:00
streakingman
dc3033d0d2 chore: pdd bgm压缩 2022-09-22 09:35:36 +08:00
streakingman
91af5edf47 chore: bgm压缩 2022-09-22 09:27:14 +08:00
streakingman
81c96fd653 Revert "chore: bgm切换为外链"
This reverts commit 2afb29b5bd.
2022-09-22 09:25:45 +08:00
streakingman
2afb29b5bd chore: bgm切换为外链 2022-09-22 02:43:04 +08:00
streakingman
7e2990a2b6 chore: 备案 2022-09-22 02:34:18 +08:00
streakingman
7368725b52 fix(themes): pdd主题图片压缩,bgm使用外链 2022-09-22 01:49:22 +08:00
streakingman
cc8a0cabfc Merge branch 'master' into themes/pdd 2022-09-22 01:48:12 +08:00
streakingman
504fb9221a chore: 图片压缩 2022-09-22 01:08:12 +08:00
裸奔狂甩丁丁
90d0c91669
Merge pull request #8 from z-jeff953/master
feat: 优化洗牌算法为O(n)的洗牌算法
2022-09-21 23:39:20 +08:00
streakingman
d2be6ca8a5 feat(themes): 骚猪主题图片补充 2022-09-21 19:28:17 +08:00
streakingman
30bb7a0277 feat(themes): 骚猪主题 2022-09-21 19:27:29 +08:00
zjeff
8b3960e976 feat: 优化洗牌算法为O(n)的洗牌算法 2022-09-21 19:17:16 +08:00
streakingman
b3b5737e5c docs: 二次开发简单说明 2022-09-21 17:30:33 +08:00
streakingman
dd78308485 docs: 二次开发简单说明 2022-09-21 17:28:57 +08:00
streakingman
d1d7c6b2f0 fix: host判断 2022-09-21 10:11:40 +08:00
streakingman
43c017f19b docs: 增加声明 2022-09-21 10:10:13 +08:00
streakingman
78ab02e371 docs: update README.md 2022-09-20 16:58:05 +08:00
streakingman
7188b26806 chore(release): 0.0.2 2022-09-20 16:49:56 +08:00
streakingman
8562ea4761 feat(themes): ikun主题 2022-09-20 16:49:30 +08:00
streakingman
ea8c9777d3 build: 首次打包 2022-09-19 20:41:58 +08:00
147 changed files with 6513 additions and 744 deletions

2
.env Normal file
View File

@ -0,0 +1,2 @@
VITE_BMOB_SECRETKEY=yourBmobAppSecretKey
VITE_BMOB_SECCODE=youBmobAppSecurityCode

View File

@ -24,5 +24,8 @@ module.exports = {
],
rules: {
'prettier/prettier': 'error',
'@typescript-eslint/no-explicit-any': 0,
'@typescript-eslint/ban-ts-comment': 0,
'@typescript-eslint/no-non-null-assertion': 0,
},
};

3
.gitignore vendored
View File

@ -8,7 +8,8 @@ pnpm-debug.log*
lerna-debug.log*
node_modules
# dist
dist
diy-dist
dist-ssr
*.local

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

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

View File

@ -5,7 +5,7 @@
*/
module.exports = {
'*.{[tj]s,[tj]sx,[cm]js}': ['eslint --fix'],
'*.json': ['prettier --write'],
'*.{css,scss}': ['stylelint --fix'],
'!(dist/**/*)*.{[tj]s,[tj]sx,[cm]js}': ['eslint --fix'],
'!(dist/**/*)*.json': ['prettier --write'],
'!(dist/**/*)*.{css,scss}': ['stylelint --fix'],
};

View File

@ -9,4 +9,8 @@ module.exports = {
'stylelint-config-standard-scss',
'stylelint-config-prettier-scss',
],
rules: {
// 后续统一
'selector-class-pattern': '^[a-zA-Z0-9-_]+$',
},
};

View File

@ -1,5 +1,5 @@
module.exports = {
scripts: {
postbump: 'yarn build && git add dist/*',
// postbump: 'yarn build && git add dist/*',
},
};

View File

@ -2,6 +2,159 @@
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)
### Features
* diy模式区分入口 ([34e89fe](https://github.com/StreakingMan/solvable-sheep-game/commit/34e89fe2328637fea5dc8b1f7f388defe720ebc9))
* 暂时关闭二维码生成功能 ([eaa56b0](https://github.com/StreakingMan/solvable-sheep-game/commit/eaa56b00700c4f50efe0e2a4536240dffc73f33f))
* 本地文件配置存储 ([2d7de33](https://github.com/StreakingMan/solvable-sheep-game/commit/2d7de338faafc76c55cf9f90b84f2b6672460255))
* 计时、最大关卡配置 ([b7482fa](https://github.com/StreakingMan/solvable-sheep-game/commit/b7482fa209db447a92772644579aca75dfc6de79))
* 配置上传时间间隔限制 ([c9cc911](https://github.com/StreakingMan/solvable-sheep-game/commit/c9cc9114c32a5f1bd9e66aa089d9309a07588d54))
### Bug Fixes
* diy按钮位置调整 ([657422e](https://github.com/StreakingMan/solvable-sheep-game/commit/657422eb4fce3d169e594578e890cb15dbdc2e4c))
* diy模式的图片路径 ([0f00725](https://github.com/StreakingMan/solvable-sheep-game/commit/0f0072585b9af704c92fcef3e1ef5faef477f2e6))
* 交互优化 ([89eab21](https://github.com/StreakingMan/solvable-sheep-game/commit/89eab211e49652198e1a54f288aeb378728e3a87))
### [0.0.9](https://github.com/StreakingMan/solvable-sheep-game/compare/v0.0.8...v0.0.9) (2022-10-04)
### Features
* 关卡进度缓存 ([0afeac0](https://github.com/StreakingMan/solvable-sheep-game/commit/0afeac0f8716bef43d67dfb999de31ac4fed8f01))
* 缓存自定义主题 ([c0de917](https://github.com/StreakingMan/solvable-sheep-game/commit/c0de917641bd8ed08a5a4f1fda9c774770d879ec))
### Bug Fixes
* 防止音频配置错误时阻塞主流程 ([ad01eb2](https://github.com/StreakingMan/solvable-sheep-game/commit/ad01eb2dbb3519388c64ffe0cda8dddc457f0ce7))
* 禁止图片拖动 ([e7ae319](https://github.com/StreakingMan/solvable-sheep-game/commit/e7ae319ecfddb4f0726355e9003d9f9f5e0c304a))
* 默认音效填充 ([e4c6a2d](https://github.com/StreakingMan/solvable-sheep-game/commit/e4c6a2d584648b35735ede614a6f8683521e012e))
* 项目链接 ([e867a92](https://github.com/StreakingMan/solvable-sheep-game/commit/e867a927b3e2faa831f8c462af059874bb59db5b))
### [0.0.8](https://github.com/StreakingMan/solvable-sheep-game/compare/v0.0.7...v0.0.8) (2022-09-26)
### Features
* **自定义主题:** 纯净模式 ([0127253](https://github.com/StreakingMan/solvable-sheep-game/commit/0127253fddaa4236b14e6ff7306a7b9f5c840c49))
* **自定义主题:** 背景图片配置 ([ae7dab7](https://github.com/StreakingMan/solvable-sheep-game/commit/ae7dab752c067641b92890e3ec547e6ecd2f6b0c))
### Bug Fixes
* **自定义主题:** bgm链接判断 ([f694d9a](https://github.com/StreakingMan/solvable-sheep-game/commit/f694d9af726337360cffa348e1079c56ef6b15f5))
### [0.0.7](https://github.com/StreakingMan/solvable-sheep-game/compare/v0.0.6...v0.0.7) (2022-09-24)
### Features
* 接入Bmob储存自定义配置 ([f6681ca](https://github.com/StreakingMan/solvable-sheep-game/commit/f6681cabb0a6090848456352facd2f36772e506e))
* 生成二维码 ([f36c99b](https://github.com/StreakingMan/solvable-sheep-game/commit/f36c99b3858a2913b4383ba4dd58d144405f79ac))
* 自定义主题 ([e35ddfa](https://github.com/StreakingMan/solvable-sheep-game/commit/e35ddfa44e0e7ef87f5cec48b9c1404109c14584))
### [0.0.6](https://github.com/StreakingMan/solvable-sheep-game/compare/v0.0.5...v0.0.6) (2022-09-23)
### Features
* 根据路径区分主题 ([b687d53](https://github.com/StreakingMan/solvable-sheep-game/commit/b687d537332b1b51a7c40bf286d8858ed68f3c58))
### [0.0.5](https://github.com/StreakingMan/solvable-sheep-game/compare/v0.0.4...v0.0.5) (2022-09-22)
### Features
* **themes:** ow主题 ([4b09bd0](https://github.com/StreakingMan/solvable-sheep-game/commit/4b09bd08c7b6e4ef9f275c5e5d4af964ad245664))
### Bug Fixes
* **themes:** 骚猪主题音效增益 ([3a3ea60](https://github.com/StreakingMan/solvable-sheep-game/commit/3a3ea6037f549045008ddbda3dc5b45043f126a1))
* 入队顺序维护 ([57224e8](https://github.com/StreakingMan/solvable-sheep-game/commit/57224e8015ed17bc72a243eabffd0a4edff0850c))
### [0.0.4](https://github.com/StreakingMan/solvable-sheep-game/compare/v0.0.3...v0.0.4) (2022-09-22)
### Features
* **themes:** 骚猪主题 ([30bb7a0](https://github.com/StreakingMan/solvable-sheep-game/commit/30bb7a0277bfe83b9c6493b4fa98fa7baabcf06a))
* **themes:** 骚猪主题图片补充 ([d2be6ca](https://github.com/StreakingMan/solvable-sheep-game/commit/d2be6ca8a5223745029bcfccf54109056a007844))
* **themes:** 骚猪主题音效 ([c578b89](https://github.com/StreakingMan/solvable-sheep-game/commit/c578b897031b2b5dbcbda0fb5c2c38804636b50c))
### Bug Fixes
* **themes:** pdd主题图片压缩bgm使用外链 ([7368725](https://github.com/StreakingMan/solvable-sheep-game/commit/7368725b5217705a780461d79e3eaf5340e9ebba))
### [0.0.3](https://github.com/StreakingMan/solvable-sheep-game/compare/v0.0.2...v0.0.3) (2022-09-22)
### Features
* 优化洗牌算法为O(n)的洗牌算法 ([8b3960e](https://github.com/StreakingMan/solvable-sheep-game/commit/8b3960e9762441246de2320252aceca505076e2c))
### Bug Fixes
* bgm加载时机调整 ([f0e2caf](https://github.com/StreakingMan/solvable-sheep-game/commit/f0e2caf7a7124981eef6bc4ff9ed7b459893c102))
* host判断 ([d1d7c6b](https://github.com/StreakingMan/solvable-sheep-game/commit/d1d7c6b2f06e3d67a4edf6cabc14a3fa419d5d93))
### [0.0.2](https://github.com/StreakingMan/solvable-sheep-game/compare/v0.0.1...v0.0.2) (2022-09-20)
### Features
* **themes:** ikun主题 ([8562ea4](https://github.com/StreakingMan/solvable-sheep-game/commit/8562ea4761e5d0d66c69de060d15664e1a582b99))
### 0.0.1 (2022-09-19)

View File

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

103
diy/README.md Normal file
View File

@ -0,0 +1,103 @@
# DIY 指南
游戏的核心逻辑已经封装到了 `src/components/Game.tsx` ,方便大家魔改, 主题配置的类型声明见 `src/themes/interface.ts`
你可以先通过超酷的[stackblitz](https://stackblitz.com/edit/solvable-sheep-game?file=diy%2Fdiy.theme.json&terminal=dev:diy)
在线体验一番(等待依赖安装完成后,编辑配置将实时更新)再回来看这里的指南。
## 准备工作
### 环境准备
安装以下内容
- [git](https://git-scm.com/)
- [node](https://nodejs.org/en/)
### 克隆仓库
直接克隆本仓库或者 fork 后,安装项目依赖
```shell
git clone https://github.com/StreakingMan/solvable-sheep-game.git
cd solvable-sheep-game
npm install -g yarn
yarn install
```
## 素材配置
原项目的自定义主题功能使用了 Bmob 后台DIY 时并不需要相关的逻辑,您只需要改动 `diy` 文件下的文件:
图片以及音频素材复制到 `diy/public` 下, 并配置 `diy/diy.theme.json`
文件即可,配置格式见 `src/themes/interface.ts`
以下是`diy.theme.json`配置字段的说明:
- title 标题
- desc 描述
- bgm 背景音乐文件相对于 `diy/public` 的路径
- dark 深色模式(标题为亮色)
- background 背景图片文件相对于 `diy/public` 的路径
- backgroundBlur 背景图片是否添加毛玻璃效果
- backgroundColor 背景颜色 CSS 色值
- pure 纯净模式DIY 时已开启
- maxLevel 最大关卡数,默认 50
- sounds 音效数组
- name 名称
- src 音效文件相对于 `diy/public` 的路径
- icons 图标数组
- name 名称,三连判断的依据
- content 图片文件相对于 `diy/public` 的路径
- clickSound 点击音效的 `name`
- triple 三连音效的 `name`
- operateSoundMap 操作音效
- shift 弹出音效的 `name`
- undo 撤销音效的 `name`
- wash 洗牌音效的 `name`
配置完成后调试运行,点击链接即可
```shell
yarn dev:diy
# ➜ Local: http://localhost:5556/
```
## 构建发布
运行命令
```shell
yarn build:diy
```
会在 `diy/diy-dist` 下生成静态资源,直接将这些文件复制服务器上做代理即可。如果嫌麻烦,推荐使用 [vercel](https://vercel.com/)
一键部署(每月免费 100G 流量), 将更改后的项目推到自己的 githubgitlabbitbucket 同样支持)仓库,
使用 github 账号登录 vercel 后导入该项目,构建模版选择 vite
构建命令更改为 `yarn build:diy` 输出地址改为 `diy/diy-dist` 即可 。见下图:
<img src="./vercel.png" alt="" style="width: 400px"/>
导入后项目有更新会自动构建,并且会生成 vercel 的二级链接(也可以自定义域名)。
## 其他
如果您想体验项目的完整功能,则需要注册一个 [Bmob](https://www.bmobapp.com/) 账号,
注册后新建应用(有一年的白嫖版,免费请求数虽然很客观,但并发数有限制,请根据自己的实际流量
选择升级套餐,或者其他存储方案)
新建应用后,去到设置页面拷贝密钥和安全码到项目的 `.env` 文件中
- VITE_BMOB_SECRETKEY=设置->应用密钥->SecretKey
- VITE_BMOB_SECCODE=设置->安全验证->Api 安全码
ps: 如果您的项目托管在公共仓库中,请注意保护密钥,本地使用 `.env.local` 进行配置
应用创建后,点击【云数据库】,创建两个表 `config``file`
`config` 表用来存储自定义配置的 json 字符串,需要新增 `content`
`rank` 表,储存排名信息
![img.png](datebase-rank.png)
最后,开发和打包命令分别使用 `yarn dev``yarn build` 即可

BIN
diy/datebase-rank.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

100
diy/diy.theme.json Normal file
View File

@ -0,0 +1,100 @@
{
"title": "标题",
"desc": "描述",
"bgm": "/sound-disco.mp3",
"dark": true,
"background": "",
"backgroundBlur": false,
"backgroundColor": "#8dac85",
"pure": true,
"maxLevel": 50,
"icons": [
{
"name": "1",
"content": "/1.png",
"clickSound": "button-click",
"tripleSound": "triple"
},
{
"name": "2",
"content": "/2.png",
"clickSound": "button-click",
"tripleSound": "triple"
},
{
"name": "3",
"content": "/3.png",
"clickSound": "button-click",
"tripleSound": "triple"
},
{
"name": "4",
"content": "/4.png",
"clickSound": "button-click",
"tripleSound": "triple"
},
{
"name": "5",
"content": "/5.png",
"clickSound": "button-click",
"tripleSound": "triple"
},
{
"name": "6",
"content": "/6.png",
"clickSound": "button-click",
"tripleSound": "triple"
},
{
"name": "7",
"content": "/7.png",
"clickSound": "button-click",
"tripleSound": "triple"
},
{
"name": "8",
"content": "/8.png",
"clickSound": "button-click",
"tripleSound": "triple"
},
{
"name": "9",
"content": "/9.png",
"clickSound": "button-click",
"tripleSound": "triple"
},
{
"name": "10",
"content": "/10.png",
"clickSound": "button-click",
"tripleSound": "triple"
}
],
"sounds": [
{
"name": "sound-undo",
"src": "/sound-undo.mp3"
},
{
"name": "sound-shift",
"src": "/sound-shift.mp3"
},
{
"name": "sound-wash",
"src": "/sound-wash.mp3"
},
{
"name": "button-click",
"src": "/sound-button-click.mp3"
},
{
"name": "triple",
"src": "/sound-triple.mp3"
}
],
"operateSoundMap": {
"shift": "sound-shift",
"undo": "sound-undo",
"wash": "sound-wash"
}
}

19
diy/diy.vite.config.ts Normal file
View File

@ -0,0 +1,19 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
root: 'diy',
publicDir: './public',
build: {
outDir: 'diy-dist',
},
define: {
__DIY__: true,
},
server: {
host: true,
port: 5556,
},
});

38
diy/index.html Normal file
View File

@ -0,0 +1,38 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="./public/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
content="无限道具版羊了个羊、可以通关的羊了个羊"
/>
<title>有解的羊了个羊</title>
<style>
html,
body {
margin: 0;
padding: 0;
background-color: white;
transition: background-color 0.3s 0.4s;
color: rgb(0 0 0 / 60%);
}
a {
color: currentColor;
text-decoration: inherit;
}
#root {
transition: opacity 0.5s;
}
</style>
</head>
<body>
<div id="root"></div>
<script>
// vite没有global手动声明
var global = global || window;
</script>
<script type="module" src="./main.tsx"></script>
</body>
</html>

15
diy/main.tsx Normal file
View File

@ -0,0 +1,15 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from '../src/App';
import '../src/styles/global.scss';
import '../src/styles/utils.scss';
import { domRelatedOptForTheme } from '../src/utils';
import theme from './diy.theme.json';
domRelatedOptForTheme(theme);
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<App theme={theme} />
</React.StrictMode>
);

BIN
diy/public/1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

BIN
diy/public/10.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

BIN
diy/public/2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

BIN
diy/public/3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

BIN
diy/public/4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

BIN
diy/public/5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

BIN
diy/public/6.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

BIN
diy/public/7.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

BIN
diy/public/8.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

BIN
diy/public/9.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

BIN
diy/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

BIN
diy/public/sound-disco.mp3 Normal file

Binary file not shown.

BIN
diy/public/sound-shift.mp3 Normal file

Binary file not shown.

BIN
diy/public/sound-triple.mp3 Normal file

Binary file not shown.

BIN
diy/public/sound-undo.mp3 Normal file

Binary file not shown.

BIN
diy/public/sound-wash.mp3 Normal file

Binary file not shown.

BIN
diy/vercel.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

View File

@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
content="无限道具版羊了个羊、可以通关的羊了个羊"
content="无限道具版羊了个羊、可以通关的羊了个羊、羊了个羊生成器"
/>
<title>有解的羊了个羊</title>
<script>
@ -19,13 +19,207 @@
s.parentNode.insertBefore(hm, s);
})();
</script>
<style>
html,
body {
margin: 0;
padding: 0;
background-color: white;
transition: background-color 0.3s 0.4s;
color: rgb(0 0 0 / 60%);
}
a {
color: currentColor;
text-decoration: inherit;
}
#root {
transition: opacity 0.5s;
}
#loading {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 32px;
overflow: visible;
}
@keyframes move {
0% {
left: 0;
top: 0;
height: 50%;
width: 50%;
}
12.5% {
left: 0;
top: 0;
height: 50%;
width: 100%;
}
25% {
left: 50%;
top: 0;
height: 50%;
width: 50%;
}
37.5% {
left: 50%;
top: 0;
height: 100%;
width: 50%;
}
50% {
left: 50%;
top: 50%;
height: 50%;
width: 50%;
}
62.5% {
left: 0;
top: 50%;
height: 50%;
width: 100%;
}
75% {
left: 0;
top: 50%;
height: 50%;
width: 50%;
}
87.5% {
left: 0;
top: 0;
height: 100%;
width: 50%;
}
}
@keyframes wave {
0% {
transform: translateY(30px);
}
100% {
transform: translateY(-30px);
}
}
.loadingBlock {
position: absolute;
transition: 0.6s;
border-radius: 12px;
left: 0;
top: 0;
height: 50%;
width: 50%;
animation: move 1s infinite ease-in-out;
}
.loadingBlockContainer {
width: 64px;
height: 64px;
position: relative;
}
#loading.error .loadingBlock,
#loading.success .loadingBlock {
animation-play-state: paused;
}
.loadingBlock1 {
background-color: #8dac8588;
}
#loading.error .loadingBlock1 {
transform: rotate(75deg) translateX(30px);
animation: wave 1s infinite alternate;
}
#loading.success .loadingBlock1 {
transform: rotate(75deg) translateX(200px) scale(4);
transition-delay: 0.1s;
}
.loadingBlock2 {
background-color: #8dac8566;
animation-delay: 0.375s;
}
#loading.error .loadingBlock2 {
transform: rotate(175deg) translateX(10px);
}
#loading.success .loadingBlock2 {
transform: rotate(175deg) translateX(200px) scale(2);
transition-delay: 0.05s;
}
.loadingBlock3 {
background-color: #8dac8544;
animation-delay: 0.75s;
}
#loading.error .loadingBlock3 {
transform: rotate(225deg) translateX(20px);
}
#loading.success .loadingBlock3 {
transform: rotate(225deg) translateX(200px) scale(3);
}
#loadingTips {
font-size: 0.8em;
line-height: 1.8;
text-align: center;
transition: 0.3s;
}
#loading.success #loadingTips {
transform: translateY(300px);
opacity: 0;
}
#backHomeTip {
visibility: hidden;
}
</style>
</head>
<body>
<div id="root"></div>
<div id="root">
<!--数据加载提示在react渲染之前做-->
<div id="loading" class="loading">
<div class="loadingBlockContainer">
<div class="loadingBlock loadingBlock1"></div>
<div class="loadingBlock loadingBlock2"></div>
<div class="loadingBlock loadingBlock3"></div>
</div>
<div id="loadingTips">
<span id="loadingText">加载中...</span><br />
<span id="backHomeTip">
稍后再试或
<a href="/" style="color: #646cff"> 返回首页 </a>
</span>
</div>
</div>
</div>
<script>
// vite没有global手动声明
var global = global || window;
</script>
<script type="module" src="/src/main.tsx"></script>
<script
async
src="//busuanzi.ibruce.info/busuanzi/2.3/busuanzi.pure.mini.js"
></script>
<script async>
// 如果您基于此项目二创,可以删除以下代码
// 否则请标明原仓库地址
setTimeout(() => {
const { hostname } = location;
if (
hostname !== 'localhost' &&
!hostname.endsWith('streakingman.com')
) {
const a = document.createElement('a');
a.setAttribute(
'href',
'https://github.com/StreakingMan/solvable-sheep-game'
);
a.setAttribute('target', '_self');
a.style.cursor = 'pointer';
a.style.zIndex = '15';
a.innerText =
'本项目仅供交流禁止商业用途点击查看原github仓库';
document.getElementById('root')?.prepend(a);
}
}, 5000);
</script>
</body>
</html>

View File

@ -1,17 +1,23 @@
{
"name": "solvable-sheep-game",
"private": false,
"version": "0.0.1",
"version": "1.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"dev:diy": "vite --config diy/diy.vite.config.ts",
"build": "tsc && vite build",
"build:diy": "tsc && vite build --config diy/diy.vite.config.ts",
"preview": "vite preview",
"prepare": "husky install",
"release:first": "standard-version -- --first-release",
"release": "standard-version --commit-all"
},
"dependencies": {
"classnames": "^2.3.2",
"clipboard": "^2.0.11",
"hydrogen-js-sdk": "^2.3.10",
"qrcode.react": "^3.1.0",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
@ -30,6 +36,7 @@
"husky": "^8.0.1",
"lint-staged": "^13.0.3",
"prettier": "^2.7.1",
"sass": "^1.55.0",
"standard-version": "^9.5.0",
"stylelint": "^14.11.0",
"stylelint-config-prettier-scss": "^0.0.1",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 245 KiB

BIN
previews.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 372 KiB

View File

@ -1,5 +0,0 @@
<svg height="32" aria-hidden="true" viewBox="0 0 16 16" version="1.1" width="32" data-view-component="true"
class="octicon octicon-mark-github v-align-middle">
<path fill-rule="evenodd"
d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
</svg>

Before

Width:  |  Height:  |  Size: 788 B

Binary file not shown.

BIN
public/wxQrcode1.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

BIN
public/wxQrcode5.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

BIN
public/wxQrcode8.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

View File

@ -1,110 +0,0 @@
#root {
text-align: center;
width: 100%;
max-width: 500px;
margin: 0 auto;
}
.app {
width: 100%;
margin: 0 auto;
}
.scene-container {
width: 100%;
padding-bottom: 100%;
position: relative;
margin: 10% 0;
}
.scene-inner {
position: absolute;
left: 0;
right: 0;
bottom: 0;
top: 0;
overflow: visible;
font-size: 28px;
}
.symbol {
width: 12.5%;
padding-bottom: 12.5%;
position: absolute;
transition: 150ms;
left: 0;
top: 0;
border-radius: 8px;
}
.symbol-inner {
position: absolute;
left: 0;
right: 0;
bottom: 0;
top: 0;
display: flex;
justify-content: center;
align-items: center;
border-radius: 8px;
border: 2px solid #444;
transition: 0.3s;
overflow: hidden;
user-select: none;
}
.symbol-inner img {
width: 100%;
height: 100%;
object-fit: contain;
}
.queue-container {
border-radius: 8px;
width: 100%;
padding-bottom: 15%;
border: 2px solid gray;
margin-bottom: 16px;
}
.flex-container {
display: flex;
gap: 8px;
}
.flex-center {
justify-content: center;
align-items: center;
}
.flex-grow {
flex-grow: 1;
}
.flex-between {
justify-content: space-between;
align-items: center;
}
.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;
}
.bgm-button {
position: fixed;
left: 0;
top: 0;
padding: 4px;
width: 36px;
height: 36px;
}

21
src/App.scss Normal file
View File

@ -0,0 +1,21 @@
#root {
text-align: center;
width: 100%;
max-width: 500px;
margin: 0 auto;
position: relative;
>:not(.background) {
z-index: 1;
}
}
.background {
position: fixed;
left: 0;
top: 0;
width: 100vw;
height: 100vh;
object-fit: cover;
z-index: 0;
}

View File

@ -1,457 +1,130 @@
import React, {
FC,
MouseEventHandler,
useEffect,
useRef,
useState,
} from 'react';
import React, { FC, useEffect, useState, Suspense } from 'react';
import './App.scss';
import {
domRelatedOptForTheme,
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';
import Game from './components/Game';
import { BeiAn } from './components/BeiAn';
import { Title } from './components/Title';
import { PersonalInfo } from './components/PersonalInfo';
import { Info } from './components/Info';
const ThemeChanger = React.lazy(() => import('./components/ThemeChanger'));
const ConfigDialog = React.lazy(() => import('./components/ConfigDialog'));
const WxQrCode = React.lazy(() => import('./components/WxQrCode'));
import './App.css';
import { GithubIcon } from './GithubIcon';
import { randomString, waitTimeout } from './utils';
import { defaultTheme } from './themes/default';
import { Icon, Theme } from './themes/interface';
import { fishermanTheme } from './themes/fisherman';
import { jinlunTheme } from './themes/jinlun';
const App: FC<{ theme: Theme<any> }> = ({ theme: initTheme }) => {
console.log('initTheme', initTheme);
// console.log(JSON.stringify(theme));
// 主题
const themes = [defaultTheme, fishermanTheme, jinlunTheme];
const [theme, setTheme] = useState<Theme<any>>(initTheme);
const [diyDialogShow, setDiyDialogShow] = useState<boolean>(false);
// 最大关卡
const maxLevel = 50;
interface MySymbol {
id: string;
status: number; // 0->1->2
isCover: boolean;
x: number;
y: number;
icon: Icon;
}
type Scene = MySymbol[];
// 8*8网格 4*4->8*8
const makeScene: (level: number, icons: Icon[]) => Scene = (level, icons) => {
const curLevel = Math.min(maxLevel, level);
const iconPool = icons.slice(0, 2 * curLevel);
const offsetPool = [0, 25, -25, 50, -50].slice(0, 1 + curLevel);
const scene: Scene = [];
const range = [
[2, 6],
[1, 6],
[1, 7],
[0, 7],
[0, 8],
][Math.min(4, curLevel - 1)];
const randomSet = (icon: Icon) => {
const offset =
offsetPool[Math.floor(offsetPool.length * Math.random())];
const row =
range[0] + Math.floor((range[1] - range[0]) * Math.random());
const column =
range[0] + Math.floor((range[1] - range[0]) * Math.random());
scene.push({
isCover: false,
status: 0,
icon,
id: randomString(4),
x: column * 100 + offset,
y: row * 100 + offset,
});
};
// 大于5级别增加icon池
let compareLevel = curLevel;
while (compareLevel > 0) {
iconPool.push(
...iconPool.slice(0, Math.min(10, 2 * (compareLevel - 5)))
);
compareLevel -= 5;
}
for (const icon of iconPool) {
for (let i = 0; i < 6; i++) {
randomSet(icon);
}
}
return scene;
};
// 洗牌
const washScene: (level: number, scene: Scene) => Scene = (level, scene) => {
const updateScene = scene.slice().sort(() => Math.random() - 0.5);
const offsetPool = [0, 25, -25, 50, -50].slice(0, 1 + level);
const range = [
[2, 6],
[1, 6],
[1, 7],
[0, 7],
[0, 8],
][Math.min(4, level - 1)];
const randomSet = (symbol: MySymbol) => {
const offset =
offsetPool[Math.floor(offsetPool.length * Math.random())];
const row =
range[0] + Math.floor((range[1] - range[0]) * Math.random());
const column =
range[0] + Math.floor((range[1] - range[0]) * Math.random());
symbol.x = column * 100 + offset;
symbol.y = row * 100 + offset;
symbol.isCover = false;
};
for (const symbol of updateScene) {
if (symbol.status !== 0) continue;
randomSet(symbol);
}
return updateScene;
};
interface SymbolProps extends MySymbol {
onClick: MouseEventHandler;
}
const Symbol: FC<SymbolProps> = ({ x, y, icon, isCover, status, onClick }) => {
return (
<div
className="symbol"
style={{
transform: `translateX(${x}%) translateY(${y}%)`,
backgroundColor: isCover ? '#999' : 'white',
opacity: status < 2 ? 1 : 0,
}}
onClick={onClick}
>
<div
className="symbol-inner"
style={{ opacity: isCover ? 0.5 : 1 }}
>
{typeof icon.content === 'string' ? (
<i>{icon.content}</i>
) : (
icon.content
)}
</div>
</div>
// 读取缓存关卡得分
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 App: FC = () => {
const [curTheme, setCurTheme] = useState<Theme<any>>(defaultTheme);
const [scene, setScene] = useState<Scene>(makeScene(1, curTheme.icons));
const [level, setLevel] = useState<number>(1);
const [queue, setQueue] = useState<MySymbol[]>([]);
const [sortedQueue, setSortedQueue] = useState<
Record<MySymbol['id'], number>
>({});
const [finished, setFinished] = useState<boolean>(false);
const [tipText, setTipText] = useState<string>('');
const [animating, setAnimating] = useState<boolean>(false);
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 });
};
// 音效
const soundRefMap = useRef<Record<string, HTMLAudioElement>>({});
const previewTheme = (_theme: Theme<any>) => {
const theme = JSON.parse(JSON.stringify(_theme));
wrapThemeDefaultSounds(theme);
domRelatedOptForTheme(theme);
setTheme(theme);
};
// 第一次点击时播放bgm
const bgmRef = useRef<HTMLAudioElement>(null);
const [bgmOn, setBgmOn] = useState<boolean>(false);
const [once, setOnce] = useState<boolean>(false);
// 生产环境才统计
useEffect(() => {
if (!bgmRef.current) return;
if (bgmOn) {
bgmRef.current.volume = 0.5;
bgmRef.current.play();
} else {
bgmRef.current?.pause();
if (__DIY__) return;
console.log(import.meta.env.MODE);
if (import.meta.env.PROD) {
const busuanziScript = document.createElement('script');
busuanziScript.src =
'//busuanzi.ibruce.info/busuanzi/2.3/busuanzi.pure.mini.js';
document.getElementById('root')?.appendChild(busuanziScript);
}
}, [bgmOn]);
// 主题切换
useEffect(() => {
restart();
}, [curTheme]);
// 队列区排序
useEffect(() => {
const cache: Record<string, MySymbol[]> = {};
for (const symbol of queue) {
if (cache[symbol.icon.name]) {
cache[symbol.icon.name].push(symbol);
} else {
cache[symbol.icon.name] = [symbol];
}
}
const temp = [];
for (const symbols of Object.values(cache)) {
temp.push(...symbols);
}
const updateSortedQueue: typeof sortedQueue = {};
let x = 50;
for (const symbol of temp) {
updateSortedQueue[symbol.id] = x;
x += 100;
}
setSortedQueue(updateSortedQueue);
}, [queue]);
// 初始化覆盖状态
useEffect(() => {
checkCover(scene);
}, []);
// 向后检查覆盖
const checkCover = (scene: Scene) => {
const updateScene = scene.slice();
for (let i = 0; i < updateScene.length; i++) {
// 当前item对角坐标
const cur = updateScene[i];
cur.isCover = false;
if (cur.status !== 0) continue;
const { x: x1, y: y1 } = cur;
const x2 = x1 + 100,
y2 = y1 + 100;
for (let j = i + 1; j < updateScene.length; j++) {
const compare = updateScene[j];
if (compare.status !== 0) continue;
// 两区域有交集视为选中
// 两区域不重叠情况取反即为交集
const { x, y } = compare;
if (!(y + 100 <= y1 || y >= y2 || x + 100 <= x1 || x >= x2)) {
cur.isCover = true;
break;
}
}
}
setScene(updateScene);
};
// 弹出
const pop = () => {
if (!queue.length) return;
const updateQueue = queue.slice();
const symbol = updateQueue.shift();
if (!symbol) return;
const find = scene.find((s) => s.id === symbol.id);
if (find) {
setQueue(updateQueue);
find.status = 0;
find.x = 100 * Math.floor(8 * Math.random());
find.y = 700;
checkCover(scene);
}
};
// 撤销
const undo = () => {
if (!queue.length) return;
const updateQueue = queue.slice();
const symbol = updateQueue.pop();
if (!symbol) return;
const find = scene.find((s) => s.id === symbol.id);
if (find) {
setQueue(updateQueue);
find.status = 0;
checkCover(scene);
}
};
// 洗牌
const wash = () => {
checkCover(washScene(level, scene));
};
// 加大难度
const levelUp = () => {
if (level >= maxLevel) {
return;
}
setFinished(false);
setLevel(level + 1);
setQueue([]);
checkCover(makeScene(level + 1, curTheme.icons));
};
// 重开
const restart = () => {
setFinished(false);
setLevel(1);
setQueue([]);
checkCover(makeScene(1, curTheme.icons));
};
// 点击item
const clickSymbol = async (idx: number) => {
if (finished || animating) return;
if (!once) {
setBgmOn(true);
setOnce(true);
}
const updateScene = scene.slice();
const symbol = updateScene[idx];
if (symbol.isCover || symbol.status !== 0) return;
symbol.status = 1;
// 点击音效
if (soundRefMap.current) {
console.log(soundRefMap.current, symbol.icon);
soundRefMap.current[symbol.icon.clickSound].currentTime = 0;
soundRefMap.current[symbol.icon.clickSound].play();
}
let updateQueue = queue.slice();
updateQueue.push(symbol);
setQueue(updateQueue);
checkCover(updateScene);
setAnimating(true);
await waitTimeout(150);
const filterSame = updateQueue.filter((sb) => sb.icon === symbol.icon);
// 三连了
if (filterSame.length === 3) {
updateQueue = updateQueue.filter((sb) => sb.icon !== symbol.icon);
for (const sb of filterSame) {
const find = updateScene.find((i) => i.id === sb.id);
if (find) {
find.status = 2;
// 三连音效
if (soundRefMap.current) {
soundRefMap.current[
symbol.icon.tripleSound
].currentTime = 0;
soundRefMap.current[symbol.icon.tripleSound].play();
}
}
}
}
// 输了
if (updateQueue.length === 7) {
setTipText('失败了');
setFinished(true);
}
if (!updateScene.find((s) => s.status !== 2)) {
// 胜利
if (level === maxLevel) {
setTipText('完成挑战');
setFinished(true);
return;
}
// 升级
setLevel(level + 1);
setQueue([]);
checkCover(makeScene(level + 1, curTheme.icons));
} else {
setQueue(updateQueue);
checkCover(updateScene);
}
setAnimating(false);
};
return (
<>
<h2>(DEMO)</h2>
<h6>
<GithubIcon />
</h6>
<h3 className="flex-container flex-center">
:
<select
onChange={(e) =>
setCurTheme(themes[Number(e.target.value)])
}
>
{themes.map((t, idx) => (
<option key={t.name} value={idx}>
{t.name}
</option>
))}
</select>
Level: {level}
</h3>
<div className="app">
<div className="scene-container">
<div className="scene-inner">
{scene.map((item, idx) => (
<Symbol
key={item.id}
{...item}
x={
item.status === 0
? item.x
: item.status === 1
? sortedQueue[item.id]
: -1000
}
y={item.status === 0 ? item.y : 895}
onClick={() => clickSymbol(idx)}
/>
))}
</div>
</div>
</div>
<div className="queue-container flex-container flex-center" />
<div className="flex-container flex-between">
<button className="flex-grow" onClick={pop}>
</button>
<button className="flex-grow" onClick={undo}>
</button>
<button className="flex-grow" onClick={wash}>
</button>
<button className="flex-grow" onClick={levelUp}>
</button>
{/*<button onClick={test}>测试</button>*/}
</div>
<p>
<span id="busuanzi_container_site_pv">
访<span id="busuanzi_value_site_pv"></span>
</span>
</p>
{finished && (
<div className="modal">
<h1>{tipText}</h1>
<button onClick={restart}></button>
</div>
)}
{/*bgm*/}
<button className="bgm-button" onClick={() => setBgmOn(!bgmOn)}>
{bgmOn ? '🔊' : '🔈'}
<audio ref={bgmRef} loop src="/sound-disco.mp3" />
</button>
{/*音效*/}
{curTheme.sounds.map((sound) => (
<audio
key={sound.name}
ref={(ref) => {
if (ref) soundRefMap.current[sound.name] = ref;
{theme.background && (
<img
alt="background"
src={theme.background}
className="background"
style={{
filter: theme.backgroundBlur ? 'blur(8px)' : 'none',
}}
src={sound.src}
/>
))}
)}
<Title title={theme.title} desc={theme.desc} />
<Game
key={theme.title}
theme={theme}
initLevel={initLevel}
initScore={initScore}
initTime={initTime}
/>
<PersonalInfo />
<div className={'flex-spacer'} style={{ minHeight: 52 }} />
<Suspense fallback={<span>Loading</span>}>
{!__DIY__ && !theme.pure && <WxQrCode />}
</Suspense>
{!__DIY__ && (
<p
style={{
textAlign: 'center',
fontSize: 10,
opacity: 0.5,
}}
>
<span id="busuanzi_container_site_pv">
访
<span id="busuanzi_value_site_pv" />
</span>
<br />
<BeiAn />
</p>
)}
{!__DIY__ && !theme.pure && (
<>
<Info />
<ThemeChanger
changeTheme={changeTheme}
onDiyClick={() => setDiyDialogShow(true)}
/>
<Suspense fallback={<span>Loading</span>}>
{diyDialogShow && (
<ConfigDialog
closeMethod={() => setDiyDialogShow(false)}
previewMethod={previewTheme}
/>
)}
</Suspense>
</>
)}
</>
);
};

View File

@ -1,28 +0,0 @@
import React, { FC } from 'react';
export const GithubIcon: FC = () => {
return (
<a
href="https://github.com/StreakingMan/solvable-sheep-game"
target="_blank"
rel="noreferrer"
>
@StreakingMan
<svg
height="24"
aria-hidden="true"
viewBox="0 0 16 16"
version="1.1"
width="24"
data-view-component="true"
className="octicon octicon-mark-github v-align-middle"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"
/>
</svg>
</a>
);
};

13
src/components/BeiAn.tsx Normal file
View File

@ -0,0 +1,13 @@
import React, { FC } from 'react';
export const BeiAn: FC = () => {
return (
<a
href="https://beian.miit.gov.cn/"
target="_blank"
rel="noopener noreferrer nofollow"
>
ICP备17007857号-2
</a>
);
};

View File

@ -0,0 +1,24 @@
import React, { FC, MouseEventHandler } from 'react';
export const CloseIcon: FC<{ fill: string; onClick?: MouseEventHandler }> = ({
fill,
onClick,
}) => {
return (
<svg
width="13"
height="14"
viewBox="0 0 13 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
onClick={onClick}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M4.9498 7.04945L0 11.9993L1.41421 13.4135L6.36401 8.46367L11.3138 13.4135L12.728 11.9993L7.77823 7.04945L12.7279 2.09976L11.3137 0.685547L6.36401 5.63524L1.41432 0.685547L0.0001055 2.09976L4.9498 7.04945Z"
fill={fill}
/>
</svg>
);
};

View File

@ -0,0 +1,282 @@
@keyframes show {
0% {
transform: translateX(-50%) translateY(-100%);
opacity: 0;
}
100% {
transform: translateX(-50%) translateY(0);
opacity: 1;
}
}
@keyframes shake {
0% {
transform: translateX(0);
}
30% {
transform: translateX(30px);
}
80% {
transform: translateX(-30px);
}
100% {
transform: translateX(0);
}
}
.soundItem {
height: 30px;
position: relative;
.inner {
height: 30px;
min-width: 48px;
margin-left: 42px;
background-color: #888;
line-height: 30px;
padding: 0 12px;
border-bottom-right-radius: 15px;
border-top-right-radius: 15px;
font-size: 14px;
color: white;
position: relative;
z-index: 2;
display: flex;
gap: 8px;
align-items: center;
justify-content: space-between;
}
svg {
cursor: pointer;
transition: 0.3s;
&:hover {
transform: rotate(180deg);
}
}
audio {
height: 30px;
width: 100px;
position: absolute;
left: 0;
z-index: 1;
}
}
.dialog {
text-align: left;
overflow-y: auto;
color: rgb(255 255 255 / 87%);
background-color: #242424;
z-index: 20 !important;
position: fixed;
left: 50%;
top: 0;
width: calc(100% - 32px);
max-width: 500px;
bottom: 0;
animation: ease-in-out show 0.3s both;
transition: 0.3s;
padding: 16px;
display: flex;
flex-flow: column nowrap !important;
gap: 8px;
overflow-x: hidden;
h2 {
text-align: center;
margin-top: -36px;
}
@media screen and (min-width: 768px) {
margin: 36px 0;
border-radius: 16px;
box-shadow: 0 19px 38px rgb(0 0 0 / 30%), 0 15px 12px rgb(0 0 0 / 22%);
}
@media (prefers-color-scheme: light) {
color: #213547;
background-color: #fff;
}
.errorTip {
color: crimson;
animation: shake 0.2s 2;
}
.closeBtn {
position: sticky;
background-color: rgb(0 0 0 / 30%);
border-radius: 4px;
left: calc(100% - 36px);
top: 0;
width: 36px;
height: 36px;
min-height: 36px;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(5px);
cursor: pointer;
svg {
transition: 0.3s;
}
&:hover {
svg {
transform: rotate(180deg);
}
}
}
}
.inputContainer {
word-break: keep-all;
audio {
height: 30px;
}
input,
select {
width: 100%;
box-sizing: border-box;
}
input[type='file'] {
padding: 4px 12px;
}
input[type='checkbox'] {
flex-grow: 0;
width: 24px;
height: 24px;
}
input[type='color'] {
padding: 0 2px;
flex-grow: 0;
width: 30px;
height: 30px;
}
.label {
min-width: 74px;
opacity: 0.7;
font-weight: 600;
}
.imgPreview {
height: 30px;
border: 1px dashed rgb(0 0 0 / 30%);
min-width: 30px;
object-fit: contain;
}
&.required .label {
&::after {
content: '*';
color: red;
}
}
}
.tip {
font-size: 12px;
opacity: 0.6;
word-break: break-all;
}
.iconInput {
display: flex;
flex-direction: column;
gap: 4px;
flex-grow: 1;
&Group {
display: flex;
flex-wrap: nowrap;
align-items: center;
gap: 8px;
input {
width: 100%;
max-width: 100%;
box-sizing: border-box;
}
input[type='file'] {
padding: 3px 12px;
}
.iconPreview {
height: 109px;
width: 109px;
border: 1px dashed rgb(0 0 0 / 30%);
object-fit: cover;
flex-basis: 109px;
}
}
}
.divider {
width: 100%;
height: 0;
border-bottom: 1px solid currentcolor;
opacity: 0.6;
}
@keyframes gradient {
0% {
background-position: 100% 50%;
}
50% {
background-position: 0 50%;
}
100% {
background-position: 100% 50%;
}
}
.uploadBtn {
background-image: linear-gradient(
-45deg,
#ee7752,
#e73c7e,
#23a6d5,
#23d5ab
);
background-size: 400% 400%;
background-position: 100% 50%;
&.uploading {
animation: gradient 1s ease infinite;
}
}
@keyframes rotate {
0% {
transform: scale(0.7) rotate(0deg);
}
100% {
transform: scale(0.7) rotate(360deg);
}
}
.qrCode {
transition: 0.5s;
&.uploading {
animation: rotate 0.5s infinite linear;
}
}

View File

@ -0,0 +1,752 @@
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';
import { QRCodeCanvas } from 'qrcode.react';
import Bmob from 'hydrogen-js-sdk';
import {
captureElement,
CUSTOM_THEME_FILE_VALIDATE_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';
import WxQrCode from './WxQrCode';
const InputContainer: FC<{
label: string;
required?: boolean;
children?: ReactNode;
}> = ({ label, children, required }) => {
return (
<>
<div className={style.divider} />
<div
className={classNames(
'flex-container flex-center flex-no-wrap',
style.inputContainer,
required && style.required
)}
>
<span className={style.label}>{label}</span>
<div className={'flex-container flex-column flex-grow'}>
{children}
</div>
</div>
</>
);
};
interface CustomIcon extends Icon {
content: string;
}
interface CustomTheme extends Theme<any> {
icons: CustomIcon[];
}
const ConfigDialog: FC<{
closeMethod: () => void;
previewMethod: (theme: Theme<string>) => void;
}> = ({ closeMethod, previewMethod }) => {
// 错误提示
const [configError, setConfigError] = useState<string>('');
// 生成链接
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: '',
clickSound: '',
tripleSound: '',
})),
});
function updateCustomTheme(key: keyof CustomTheme, value: any) {
if (['sounds', 'icons'].includes(key)) {
if (Array.isArray(value)) {
setCustomTheme({
...customTheme,
[key]: [...value],
});
} else {
setCustomTheme({
...customTheme,
[key]: [...customTheme[key as 'sounds' | 'icons'], value],
});
}
} else {
setCustomTheme({
...customTheme,
[key]: value,
});
}
}
useEffect(() => {
console.log(customTheme);
}, [customTheme]);
// 音效
const [newSound, setNewSound] = useState<Sound>({ name: '', src: '' });
const [soundError, setSoundError] = useState<string>('');
const onNewSoundChange = (key: keyof Sound, value: string) => {
setNewSound({
...newSound,
[key]: value,
});
};
const onAddNewSoundClick = () => {
setSoundError('');
let error = '';
if (!linkReg.test(newSound.src)) error = '请输入https链接';
if (!newSound.name) error = '请输入音效名称';
if (customTheme.sounds.find((s) => s.name === newSound.name))
error = '名称已存在';
if (error) {
setSoundError(error);
} else {
updateCustomTheme('sounds', newSound);
setNewSound({ name: '', src: '' });
}
};
const onDeleteSoundClick = (idx: number) => {
const deleteSoundName = customTheme.sounds[idx].name;
const findIconUseIdx = customTheme.icons.findIndex(
({ clickSound, tripleSound }) =>
[clickSound, tripleSound].includes(deleteSoundName)
);
if (findIconUseIdx !== -1) {
return setSoundError(
`${findIconUseIdx + 1}项图标有使用该音效,请取消后再删除`
);
}
const newSounds = customTheme.sounds.slice();
newSounds.splice(idx, 1);
updateCustomTheme('sounds', newSounds);
};
// 本地文件选择
const [bgmError, setBgmError] = useState<string>('');
const [backgroundError, setBackgroundError] = useState<string>('');
const [iconErrors, setIconErrors] = useState<string[]>(
new Array(10).fill('')
);
// 文件体积校验开关
const initEnableFileSizeValidate = localStorage.getItem(
CUSTOM_THEME_FILE_VALIDATE_STORAGE_KEY
);
const [enableFileSizeValidate, setEnableFileSizeValidate] =
useState<boolean>(
initEnableFileSizeValidate === null
? true
: initEnableFileSizeValidate === 'true'
);
useEffect(() => {
localStorage.setItem(
CUSTOM_THEME_FILE_VALIDATE_STORAGE_KEY,
enableFileSizeValidate + ''
);
}, [enableFileSizeValidate]);
const makeIconErrors = (idx: number, error: string) =>
new Array(10)
.fill('')
.map((item, _idx) => (idx === _idx ? error : iconErrors[_idx]));
const onFileChange: (props: {
type: 'bgm' | 'background' | 'sound' | 'icon';
file?: File;
idx?: number;
}) => void = async ({ type, file, idx }) => {
if (!file) return;
switch (type) {
case 'bgm':
setBgmError('');
if (enableFileSizeValidate && file.size > 80 * 1024) {
return setBgmError('请选择80k以内全损音质的文件');
}
getFileBase64String(file)
.then((res) => {
updateCustomTheme('bgm', res);
})
.catch((e) => {
setBgmError(e);
});
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);
}
break;
case 'sound':
setSoundError('');
if (enableFileSizeValidate && file.size > 10 * 1024) {
return setSoundError('请选择10k以内的音频文件');
}
getFileBase64String(file)
.then((res) => {
onNewSoundChange('src', res);
})
.catch((e) => {
setSoundError(e);
});
break;
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
)
);
} catch (e: any) {
setIconErrors(makeIconErrors(idx, e));
}
break;
}
};
// 图标更新
const updateIcons = (key: keyof CustomIcon, value: string, idx: number) => {
const newIcons = customTheme.icons.map((icon, _idx) =>
_idx === idx
? {
...icon,
[key]: value,
}
: icon
);
updateCustomTheme('icons', newIcons);
};
// 初始化
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') {
setTimeout(() => {
setCustomTheme(parseRes);
}, 300);
}
}
} catch (e) {
console.log(e);
}
}, []);
// 校验主题
const validateTheme: () => Promise<string> = async () => {
// 校验
if (!customTheme.title) return Promise.reject('请输入标题');
if (customTheme.bgm && !linkReg.test(customTheme.bgm))
return Promise.reject('bgm请输入https链接');
if (customTheme.background && !linkReg.test(customTheme.background))
return Promise.reject('背景图请输入https链接');
if (!customTheme.maxLevel || customTheme.maxLevel < 5)
return Promise.reject('请输入大于5的关卡数');
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('');
};
// 预览
const onPreviewClick = () => {
setConfigError('');
validateTheme()
.then(() => {
const cloneTheme = JSON.parse(JSON.stringify(customTheme));
wrapThemeDefaultSounds(cloneTheme);
previewMethod(cloneTheme);
localStorage.setItem(
CUSTOM_THEME_STORAGE_KEY,
JSON.stringify(customTheme)
);
closeMethod();
})
.catch((e) => {
setConfigError(e);
});
};
// 生成二维码和链接
const [uploading, setUploading] = useState<boolean>(false);
const onGenQrLinkClick = () => {
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()
.then(() => {
const cloneTheme = JSON.parse(JSON.stringify(customTheme));
deleteThemeUnusedSounds(cloneTheme);
const stringify = JSON.stringify(cloneTheme);
localStorage.setItem(CUSTOM_THEME_STORAGE_KEY, stringify);
const query = Bmob.Query('config');
query.set('content', stringify);
query
.save()
.then((res) => {
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=${
/*@ts-ignore*/
res.objectId || id
}`
);
}, 3000);
})
.catch(({ error, code }) => {
setTimeout(() => {
setConfigError(error);
}, 3000);
})
.finally(() => {
setTimeout(() => {
setUploading(false);
}, 3000);
});
})
.catch((e) => {
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
placeholder={'请输入标题'}
value={customTheme.title}
onChange={(e) => updateCustomTheme('title', e.target.value)}
/>
</InputContainer>
<InputContainer label={'描述'}>
<input
placeholder={'请输入描述'}
value={customTheme.desc || ''}
onChange={(e) => updateCustomTheme('desc', e.target.value)}
/>
</InputContainer>
<InputContainer label={'BGM'}>
<div className={style.tip}>
80k以下使
</div>
<input
type={'file'}
accept={'.mp3'}
onChange={(e) =>
onFileChange({
type: 'bgm',
file: e.target.files?.[0],
})
}
/>
{bgmError && <div className={style.errorTip}>{bgmError}</div>}
<input
placeholder={'或者输入https外链'}
value={customTheme.bgm || ''}
onChange={(e) => updateCustomTheme('bgm', e.target.value)}
/>
{customTheme.bgm && <audio src={customTheme.bgm} controls />}
</InputContainer>
<InputContainer label={'背景图'}>
<div className={style.tip}>
使
</div>
<input
type={'file'}
accept={'.jpg,.png,.gif'}
onChange={(e) =>
onFileChange({
type: 'background',
file: e.target.files?.[0],
})
}
/>
{backgroundError && (
<div className={style.errorTip}>{backgroundError}</div>
)}
<div className={'flex-container flex-center'}>
<input
placeholder={'或者输入https外链'}
value={customTheme.background || ''}
onChange={(e) =>
updateCustomTheme('background', e.target.value)
}
/>
{customTheme.background && (
<img
alt="加载失败"
src={customTheme.background}
className={style.imgPreview}
/>
)}
</div>
<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}>
使使
</div>
</InputContainer>
<InputContainer label={'关卡数'} required>
<input
type={'number'}
placeholder={'最低5关最高...理论上无限'}
value={customTheme.maxLevel || ''}
onChange={(e) =>
updateCustomTheme('maxLevel', Number(e.target.value))
}
/>
</InputContainer>
<InputContainer label={'音效素材'}>
<div className={'flex-container flex-left-center'}>
{customTheme.sounds.map((sound, idx) => {
return (
<div key={sound.name} className={style.soundItem}>
<audio src={sound.src} controls />
<div className={style.inner}>
<span>{sound.name}</span>
<CloseIcon
fill={'#fff'}
onClick={() => onDeleteSoundClick(idx)}
/>
</div>
</div>
);
})}
</div>
<input
placeholder={'输入音效名称'}
value={newSound.name}
onChange={(e) => onNewSoundChange('name', e.target.value)}
/>
<div className={style.tip}>
10k以下使
</div>
<input
type={'file'}
accept={'.mp3'}
onChange={(e) =>
onFileChange({
type: 'sound',
file: e.target.files?.[0],
})
}
/>
<input
placeholder={'或者输入https外链'}
value={newSound.src}
onChange={(e) => onNewSoundChange('src', e.target.value)}
/>
{soundError && (
<div className={style.errorTip}>{soundError}</div>
)}
<button onClick={onAddNewSoundClick}></button>
</InputContainer>
<InputContainer label={'图标素材'} required>
<div className={style.tip}>
使
</div>
</InputContainer>
{customTheme.icons.map((icon, idx) => (
<div key={icon.name} className={style.iconInputGroup}>
<img
alt=""
className={style.iconPreview}
src={icon.content}
/>
<div className={style.iconInput}>
<input
type={'file'}
accept={'.jpg,.png,.gif'}
onChange={(e) =>
onFileChange({
type: 'icon',
file: e.target.files?.[0],
idx,
})
}
/>
<div
className={
'flex-container flex-center flex-no-wrap'
}
style={{ wordBreak: 'keep-all' }}
>
<input
placeholder={'或者输入https外链'}
value={customTheme.icons[idx].content}
onBlur={(e) => {
setIconErrors(
makeIconErrors(
idx,
linkReg.test(e.target.value)
? ''
: '请输入https外链'
)
);
}}
onChange={(e) =>
updateIcons('content', e.target.value, idx)
}
/>
{iconErrors[idx] && (
<div className={style.errorTip}>
{iconErrors[idx]}
</div>
)}
</div>
<div className={'flex-container'}>
<select
className={'flex-grow'}
value={customTheme.icons[idx].clickSound}
onChange={(e) =>
updateIcons(
'clickSound',
e.target.value,
idx
)
}
>
<option value=""></option>
{customTheme.sounds.map((sound) => (
<option key={sound.name} value={sound.name}>
{sound.name}
</option>
))}
</select>
<select
className={'flex-grow'}
value={customTheme.icons[idx].tripleSound}
onChange={(e) =>
updateIcons(
'tripleSound',
e.target.value,
idx
)
}
>
<option value=""></option>
{customTheme.sounds.map((sound) => (
<option key={sound.name} value={sound.name}>
{sound.name}
</option>
))}
</select>
</div>
</div>
</div>
))}
{/*<InputContainer label={'操作音效'}></InputContainer>*/}
{genLink && (
<div className={'flex-container flex-center flex-column'}>
<QRCodeCanvas
id="qrCode"
value={genLink}
size={300}
className={classNames(
style.qrCode,
uploading && style.uploading
)}
/>
<button
onClick={() =>
captureElement(
'qrCode',
`${customTheme.title}-${localStorage.getItem(
LAST_CUSTOM_THEME_ID_STORAGE_KEY
)}`
)
}
className="primary"
>
</button>
<div style={{ fontSize: 12 }}>{genLink}</div>
<button onClick={() => copy(genLink)} className="primary">
</button>
</div>
)}
<div className={style.tip}>
使mp3外链
👉
<input
type={'checkbox'}
checked={!enableFileSizeValidate}
onChange={(e) =>
setEnableFileSizeValidate(!e.target.checked)
}
/>
(1M为宜)
</div>
{configError && <div className={style.errorTip}>{configError}</div>}
{customTheme.pure && (
<div className={style.tip}>
🎉🎉🎉
</div>
)}
<WxQrCode onClick={() => setPureClickTime(pureClickTime + 1)} />
<div className={'flex-container'}>
<button
className={'primary flex-grow'}
onClick={onPreviewClick}
>
</button>
{/*<button*/}
{/* className={classNames(*/}
{/* 'primary flex-grow',*/}
{/* style.uploadBtn,*/}
{/* uploading && style.uploading*/}
{/* )}*/}
{/* onClick={onGenQrLinkClick}*/}
{/* disabled*/}
{/*>*/}
{/* 生成二维码&链接*/}
{/*</button>*/}
</div>
</div>
);
};
export default ConfigDialog;

View File

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

View File

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

View File

@ -0,0 +1,73 @@
.panel {
position: fixed;
width: 36px;
height: 36px;
border-radius: 18px;
transition: 0.3s;
padding: 16px;
background-color: rgb(0 0 0/ 50%);
color: white;
overflow: hidden;
backdrop-filter: blur(8px);
box-sizing: border-box;
cursor: pointer;
font-size: 14px;
> * {
opacity: 0;
}
&.leftBottom {
left: 8px;
bottom: 8px;
}
.closeBtn {
position: absolute;
border-radius: 8px;
background-color: rgb(0 0 0/20%);
width: 36px;
height: 36px;
right: 0;
top: 0;
display: flex;
align-items: center;
justify-content: center;
transform: scale(0);
color: white;
cursor: pointer;
user-select: none;
svg {
transition: 0.3s;
}
&:hover {
svg {
transform: rotate(180deg);
}
}
}
&.open {
height: 100px;
border-radius: 8px;
cursor: default;
z-index: 5 !important;
@media screen and (max-width: 500px) {
width: calc(100% - 16px);
}
@media screen and (min-width: 501px) {
width: 500px;
}
> * {
opacity: 1;
}
.closeBtn {
transform: scale(1);
}
}
}

View File

@ -0,0 +1,36 @@
import React, { FC, ReactNode, useState } from 'react';
import style from './FixedAnimateScalePanel.module.scss';
import classNames from 'classnames';
import { CloseIcon } from './CloseIcon';
export const FixedAnimateScalePanel: FC<{
children: ReactNode;
className?: string;
openClassName?: string;
closeClassName?: string;
initOpen?: boolean;
}> = ({
children,
className,
openClassName,
closeClassName,
initOpen = false,
}) => {
const [open, setOpen] = useState<boolean>(initOpen);
return (
<div
onClick={() => !open && setOpen(true)}
className={classNames(
style.panel,
open && style.open,
className,
open ? openClassName : closeClassName
)}
>
{children}
<div className={style.closeBtn} onClick={() => setOpen(false)}>
<CloseIcon fill={'#888'} />
</div>
</div>
);
};

99
src/components/Game.scss Normal file
View File

@ -0,0 +1,99 @@
.game {
width: 100%;
margin: 0 auto;
padding-top: 10%;
padding-bottom: 2.5%;
}
.scene {
&-container {
width: 100%;
padding-bottom: 112.5%;
position: relative;
}
&-inner {
position: absolute;
left: 0;
right: 0;
bottom: 0;
top: 0;
overflow: visible;
font-size: 28px;
}
}
.symbol {
width: 12.5%;
padding-bottom: 12.5%;
position: absolute;
transition: 150ms;
left: 0;
top: 0;
border-radius: 8px;
&-inner {
position: absolute;
left: 0;
right: 0;
bottom: 0;
top: 0;
display: flex;
justify-content: center;
align-items: center;
border-radius: 8px;
border: 2px solid #444;
transition: 0.3s;
overflow: hidden;
user-select: none;
img {
width: 100%;
height: 100%;
object-fit: cover;
-webkit-user-drag: none;
}
}
}
.queue-container {
padding-bottom: 18.75%;
margin-bottom: 16px;
position: relative;
&::after {
content: '';
position: absolute;
left: 3.125%;
right: 3.125%;
top: 0;
bottom: 0;
border-radius: 12px;
background-color: rgb(0 0 0 / 16%);
border: 8px solid rgb(0 0 0 / 8%);
}
}
.bgm-button {
position: fixed;
left: 8px;
top: 8px;
padding: 4px;
width: 36px;
height: 36px;
cursor: pointer;
}
.zhenghuo-button {
width: 100%;
margin-top: 8px;
}
.level {
font-size: 1.5em;
font-weight: 900;
line-height: 1.4;
padding: 12px 0;
text-shadow: 4px 6px 2px rgb(0 0 0 / 20%);
font-family: Menlo, Monaco, 'Courier New', monospace, serif;
}

532
src/components/Game.tsx Normal file
View File

@ -0,0 +1,532 @@
import React, {
FC,
MouseEventHandler,
useEffect,
useRef,
useState,
Suspense,
} from 'react';
import './Game.scss';
import {
LAST_LEVEL_STORAGE_KEY,
LAST_SCORE_STORAGE_KEY,
LAST_TIME_STORAGE_KEY,
randomString,
resetScoreStorage,
timestampToUsedTimeString,
waitTimeout,
} from '../utils';
import { Icon, Theme } from '../themes/interface';
import Score from './Score';
interface MySymbol {
id: string;
status: number; // 0->1->2 正常->队列中->三连
isCover: boolean;
x: number;
y: number;
icon: Icon;
}
type Scene = MySymbol[];
// 随机位置、偏移量
const randomPositionOffset: (
offsetPool: number[],
range: number[]
) => { offset: number; row: number; column: number } = (offsetPool, range) => {
const offset = offsetPool[Math.floor(offsetPool.length * Math.random())];
const row = range[0] + Math.floor((range[1] - range[0]) * Math.random());
const column = range[0] + Math.floor((range[1] - range[0]) * Math.random());
return { offset, row, column };
};
// 制作场景8*8虚拟网格 4*4->8*8
const sceneRanges = [
[2, 6],
[1, 6],
[1, 7],
[0, 7],
[0, 8],
];
const offsets = [0, 25, -25, 50, -50];
const makeScene: (level: number, icons: Icon[]) => Scene = (level, icons) => {
// 初始图标x2
const iconPool = icons.slice(0, 2 * level);
const offsetPool = offsets.slice(0, 1 + level);
const scene: Scene = [];
// 网格范围,随等级由中心扩满
const range = sceneRanges[Math.min(4, level - 1)];
// 在范围内随机摆放图标
const randomSet = (icon: Icon) => {
const { offset, row, column } = randomPositionOffset(offsetPool, range);
scene.push({
isCover: false,
status: 0,
icon,
id: randomString(6),
x: column * 100 + offset,
y: row * 100 + offset,
});
};
// 每间隔5级别增加icon池
let compareLevel = level;
while (compareLevel > 0) {
iconPool.push(
...iconPool.slice(0, Math.min(10, 2 * (compareLevel - 5)))
);
compareLevel -= 5;
}
// icon池中每个生成六张卡片
for (const icon of iconPool) {
for (let i = 0; i < 6; i++) {
randomSet(icon);
}
}
return scene;
};
// o(n) 时间复杂度的洗牌算法
const fastShuffle: <T = any>(arr: T[]) => T[] = (arr) => {
const res = arr.slice();
for (let i = 0; i < res.length; i++) {
const idx = (Math.random() * res.length) >> 0;
[res[i], res[idx]] = [res[idx], res[i]];
}
return res;
};
// 洗牌
const washScene: (level: number, scene: Scene) => Scene = (level, scene) => {
// 打乱顺序
const updateScene = fastShuffle(scene);
const offsetPool = offsets.slice(0, 1 + level);
const range = sceneRanges[Math.min(4, level - 1)];
// 重新设置位置
const randomSet = (symbol: MySymbol) => {
const { offset, row, column } = randomPositionOffset(offsetPool, range);
symbol.x = column * 100 + offset;
symbol.y = row * 100 + offset;
symbol.isCover = false;
};
// 仅对仍在牌堆中的进行重置
for (const symbol of updateScene) {
if (symbol.status !== 0) continue;
randomSet(symbol);
}
return updateScene;
};
// icon对应的组件
interface SymbolProps extends MySymbol {
onClick: MouseEventHandler;
}
const Symbol: FC<SymbolProps> = ({ x, y, icon, isCover, status, onClick }) => {
return (
<div
className="symbol"
style={{
transform: `translateX(${x}%) translateY(${y}%)`,
backgroundColor: isCover ? '#999' : 'white',
opacity: status < 2 ? 1 : 0,
}}
onClick={onClick}
>
<div
className="symbol-inner"
style={{ opacity: isCover ? 0.4 : 1 }}
>
{typeof icon.content === 'string' ? (
icon.content.startsWith('data:') ||
icon.content.startsWith('/') ||
icon.content.startsWith('http') ? (
/*图片地址*/
<img src={icon.content} alt="" />
) : (
/*字符表情*/
<i>{icon.content}</i>
)
) : (
/*ReactNode*/
icon.content
)}
</div>
</div>
);
};
const Game: FC<{
theme: Theme<any>;
initLevel?: number;
initScore?: number;
initTime?: number;
}> = ({ theme, initLevel = 1, initScore = 0, initTime = 0 }) => {
const maxLevel = theme.maxLevel || 50;
const [scene, setScene] = useState<Scene>(
makeScene(initLevel, theme.icons)
);
const [level, setLevel] = useState<number>(initLevel);
const [score, setScore] = useState<number>(initScore);
const [queue, setQueue] = useState<MySymbol[]>([]);
const [sortedQueue, setSortedQueue] = useState<
Record<MySymbol['id'], number>
>({});
const [finished, setFinished] = useState<boolean>(false);
const [success, setSuccess] = useState<boolean>(false);
const [animating, setAnimating] = useState<boolean>(false);
// 音效
const soundRefMap = useRef<Record<string, HTMLAudioElement>>({});
// 第一次点击时播放bgm
const bgmRef = useRef<HTMLAudioElement>(null);
const [bgmOn, setBgmOn] = useState<boolean>(false);
const [once, setOnce] = useState<boolean>(false);
useEffect(() => {
if (!bgmRef.current) return;
if (bgmOn) {
bgmRef.current.volume = 0.5;
bgmRef.current.play().then();
} else {
bgmRef.current.pause();
}
}, [bgmOn]);
// 关卡缓存
useEffect(() => {
localStorage.setItem(LAST_LEVEL_STORAGE_KEY, level.toString());
localStorage.setItem(LAST_SCORE_STORAGE_KEY, score.toString());
localStorage.setItem(LAST_TIME_STORAGE_KEY, usedTime.toString());
}, [level]);
// 队列区排序
useEffect(() => {
const cache: Record<string, MySymbol[]> = {};
// 加上索引避免以id字典序来排
const idx = 0;
for (const symbol of queue) {
if (cache[idx + symbol.icon.name]) {
cache[idx + symbol.icon.name].push(symbol);
} else {
cache[idx + symbol.icon.name] = [symbol];
}
}
const temp = [];
for (const symbols of Object.values(cache)) {
temp.push(...symbols);
}
const updateSortedQueue: typeof sortedQueue = {};
let x = 50;
for (const symbol of temp) {
updateSortedQueue[symbol.id] = x;
x += 100;
}
setSortedQueue(updateSortedQueue);
}, [queue]);
// 初始化覆盖状态
useEffect(() => {
checkCover(scene);
}, []);
// 向后检查覆盖
const checkCover = (scene: Scene) => {
const updateScene = scene.slice();
for (let i = 0; i < updateScene.length; i++) {
// 当前item对角坐标
const cur = updateScene[i];
cur.isCover = false;
if (cur.status !== 0) continue;
const { x: x1, y: y1 } = cur;
const x2 = x1 + 100,
y2 = y1 + 100;
for (let j = i + 1; j < updateScene.length; j++) {
const compare = updateScene[j];
if (compare.status !== 0) continue;
// 两区域有交集视为选中
// 两区域不重叠情况取反即为交集
const { x, y } = compare;
if (!(y + 100 <= y1 || y >= y2 || x + 100 <= x1 || x >= x2)) {
cur.isCover = true;
break;
}
}
}
setScene(updateScene);
};
// 弹出
const popTime = useRef(0);
const pop = () => {
if (!queue.length) return;
const updateQueue = queue.slice();
const symbol = updateQueue.shift();
setScore(score - 1);
if (!symbol) return;
const find = scene.find((s) => s.id === symbol.id);
if (find) {
setQueue(updateQueue);
find.status = 0;
find.x = 100 * (popTime.current % 7);
popTime.current++;
find.y = 800;
checkCover(scene);
// 音效
if (soundRefMap.current?.['sound-shift']) {
soundRefMap.current['sound-shift'].currentTime = 0;
soundRefMap.current['sound-shift'].play().then();
}
}
};
// 撤销
const undo = () => {
if (!queue.length) return;
setScore(score - 1);
const updateQueue = queue.slice();
const symbol = updateQueue.pop();
if (!symbol) return;
const find = scene.find((s) => s.id === symbol.id);
if (find) {
setQueue(updateQueue);
find.status = 0;
checkCover(scene);
// 音效
if (soundRefMap.current?.['sound-undo']) {
soundRefMap.current['sound-undo'].currentTime = 0;
soundRefMap.current['sound-undo'].play().then();
}
}
};
// 洗牌
const wash = () => {
setScore(score - 1);
checkCover(washScene(level, scene));
// 音效
if (soundRefMap.current?.['sound-wash']) {
soundRefMap.current['sound-wash'].currentTime = 0;
soundRefMap.current['sound-wash'].play().then();
}
};
// 加大难度,该方法由玩家点击下一关触发
const levelUp = () => {
if (level >= maxLevel) {
return;
}
// 跳关扣关卡对应数值的分
setScore(score - level);
setFinished(false);
setLevel(level + 1);
setQueue([]);
checkCover(makeScene(level + 1, theme.icons));
};
// 重开
const restart = () => {
setFinished(false);
setSuccess(false);
setScore(0);
setLevel(1);
setQueue([]);
checkCover(makeScene(1, theme.icons));
setUsedTime(0);
startTimer(true);
};
// 点击item
const clickSymbol = async (idx: number) => {
if (finished || animating) return;
// 第一次点击时播放bgm开启计时
if (!once) {
setBgmOn(true);
setOnce(true);
startTimer();
}
const updateScene = scene.slice();
const symbol = updateScene[idx];
if (symbol.isCover || symbol.status !== 0) return;
symbol.status = 1;
// 点击音效
if (soundRefMap.current?.[symbol.icon.clickSound]) {
soundRefMap.current[symbol.icon.clickSound].currentTime = 0;
soundRefMap.current[symbol.icon.clickSound].play().then();
}
// 将点击项目加入队列
let updateQueue = queue.slice();
updateQueue.push(symbol);
setQueue(updateQueue);
checkCover(updateScene);
// 动画锁 150ms
setAnimating(true);
await waitTimeout(150);
// 查找当前队列中与点击项相同的
const filterSame = updateQueue.filter((sb) => sb.icon === symbol.icon);
// 后续状态判断
// 三连了
if (filterSame.length === 3) {
// 三连一次+3分
setScore(score + 3);
updateQueue = updateQueue.filter((sb) => sb.icon !== symbol.icon);
for (const sb of filterSame) {
const find = updateScene.find((i) => i.id === sb.id);
if (find) {
find.status = 2;
// 三连音效
if (soundRefMap.current?.[symbol.icon.tripleSound]) {
soundRefMap.current[
symbol.icon.tripleSound
].currentTime = 0;
soundRefMap.current[symbol.icon.tripleSound]
.play()
.then();
}
}
}
}
// 输了
if (updateQueue.length === 7) {
setFinished(true);
setSuccess(false);
}
if (!updateScene.find((s) => s.status !== 2)) {
// 队列清空了
if (level === maxLevel) {
// 胜利
setFinished(true);
setSuccess(true);
} else {
// 升级
// 通关奖励关卡对应数值分数
setScore(score + level);
setLevel(level + 1);
setQueue([]);
checkCover(makeScene(level + 1, theme.icons));
}
} else {
// 更新队列
setQueue(updateQueue);
checkCover(updateScene);
}
setAnimating(false);
};
// 计时相关
const [startTime, setStartTime] = useState<number>(0);
const [now, setNow] = useState<number>(0);
const [usedTime, setUsedTime] = useState<number>(initTime);
const intervalRef = useRef<NodeJS.Timeout | null>(null);
// 结束时重置计时器和关卡信息
useEffect(() => {
if (finished) {
intervalRef.current && clearInterval(intervalRef.current);
resetScoreStorage();
}
}, [finished]);
// 更新使用时间
useEffect(() => {
if (startTime && now) setUsedTime(now - startTime);
}, [now]);
// 计时器
const startTimer = (restart?: boolean) => {
setStartTime(Date.now() - (restart ? 0 : initTime));
setNow(Date.now());
intervalRef.current && clearInterval(intervalRef.current);
intervalRef.current = setInterval(() => {
setNow(Date.now());
}, 10);
};
return (
<>
<div className="game">
<div className="scene-container">
<div className="scene-inner">
{scene.map((item, idx) => (
<Symbol
key={item.id}
{...item}
x={
item.status === 0
? item.x
: item.status === 1
? sortedQueue[item.id]
: -1000
}
y={item.status === 0 ? item.y : 945}
onClick={() => clickSymbol(idx)}
/>
))}
</div>
</div>
</div>
<div className="queue-container" />
<div className="flex-container flex-between">
<button className="flex-grow" onClick={pop}>
</button>
<button className="flex-grow" onClick={undo}>
</button>
<button className="flex-grow" onClick={wash}>
</button>
<button className="flex-grow" onClick={levelUp}>
</button>
</div>
<div className="level">
{level}/{maxLevel}
{scene.filter((i) => i.status === 0).length}
<br />
{score}
<br />
{timestampToUsedTimeString(usedTime)}
</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)}>
{bgmOn ? '🔊' : '🔈'}
<audio ref={bgmRef} loop src={theme.bgm} />
</button>
)}
{/*音效*/}
{theme.sounds.map((sound) => (
<audio
key={sound.name}
ref={(ref) => {
if (ref) soundRefMap.current[sound.name] = ref;
}}
src={sound.src}
/>
))}
</>
);
};
export default Game;

View File

@ -0,0 +1,38 @@
.info {
left: 8px;
bottom: 8px;
display: flex;
justify-content: center;
flex-direction: column;
p {
margin: 0;
transition: 0.6s;
}
a {
color: #747bff;
}
.icon {
position: absolute;
left: 14px;
top: 2px;
font-size: 24px;
font-weight: 900;
transition: 0.2s;
opacity: 1;
}
&.open {
height: 120px;
@media screen and (max-width: 500px) {
width: calc(100% - 70px);
}
.icon {
transform: scale(0);
}
}
}

58
src/components/Info.tsx Normal file
View File

@ -0,0 +1,58 @@
import React, { FC } from 'react';
import style from './Info.module.scss';
import { FixedAnimateScalePanel } from './FixedAnimateScalePanel';
export const Info: FC = () => {
return (
<FixedAnimateScalePanel
className={style.info}
openClassName={style.open}
>
<div className={style.icon}>i</div>
<p>
bgm素材
<a
href="https://www.bilibili.com/video/BV1zs411S7sz/"
target="_blank"
rel="noreferrer"
>
DISCO
</a>
<a
href="https://music.163.com/#/song?id=135022"
target="_blank"
rel="noreferrer"
>
</a>
<a
href="https://y.qq.com/n/ryqq/songDetail/0020Nusb3QJGn9"
target="_blank"
rel="noreferrer"
>
</a>
</p>
<p>
-{'>'}-{'>'}
<a
href="https://play.google.com/store/apps/details?id=tile.master.connect.matching.game"
target="_blank"
rel="noreferrer"
>
3 Tiles
</a>
-{'>'}
<a
href="https://www.bilibili.com/video/BV1zT411N7RT"
target="_blank"
rel="noreferrer"
>
</a>
</p>
<p></p>
</FixedAnimateScalePanel>
);
};

View File

@ -0,0 +1,103 @@
@keyframes gradient {
0% {
background-position: 0 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0 50%;
}
}
.personalInfo {
right: 8px;
top: 8px;
animation: gradient 4s ease infinite;
background-image: linear-gradient(
-45deg,
#ee775288,
#e73c7e88,
#23a6d588,
#23d5ab88
);
background-size: 400% 400%;
background-position: 0 0;
transition: 0.4s;
z-index: 9;
* {
transition: 0.6s;
}
a {
text-decoration: underline;
color: white;
font-weight: 900;
}
.github {
&Icon {
position: absolute;
right: 6px;
top: 6px;
cursor: pointer;
opacity: 1 !important;
}
&Link {
position: absolute;
right: -196px;
top: 26px;
}
}
.bilibili {
&Icon {
position: absolute;
right: -100px;
top: 10px;
}
&Link {
position: absolute;
right: -196px;
top: 16px;
}
}
&.open {
@media screen and (max-width: 500px) {
width: calc(100% - 60px) !important;
}
.github {
&Icon {
right: calc(100% - 70px);
top: 18px;
width: 36px;
height: 36px;
}
&Link {
right: calc(100% - 200px);
top: 26px;
}
}
.bilibili {
&Icon {
height: 36px;
right: 26px;
top: 50px;
}
&Link {
right: 110px;
top: 58px;
}
}
}
}

File diff suppressed because one or more lines are too long

View File

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

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

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

View File

@ -0,0 +1,93 @@
@keyframes gradient {
0% {
background-position: 0 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0 50%;
}
}
.container {
position: fixed;
right: 8px;
bottom: 8px;
transition: 0.3s;
color: white;
backdrop-filter: blur(8px);
box-sizing: border-box;
cursor: pointer;
user-select: none;
line-height: 52px;
width: 52px;
height: 52px;
overflow: visible;
.square {
width: 52px;
height: 52px;
border-radius: 8px;
background: rgb(0 0 0/ 40%);
position: absolute;
right: 0;
top: 0;
transition: 0.4s;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: contain;
}
}
.diy {
width: 52px;
font-weight: 900;
font-size: 18px;
background-image: linear-gradient(
45deg,
#ee7752,
#e73c7e,
#23a6d5,
#23d5ab
);
background-size: 400% 400%;
background-position: 0 0;
animation: gradient 6s ease infinite;
}
&.open {
.diy {
width: 86px;
animation: gradient 2s ease infinite;
}
}
}
.adv {
position: fixed;
right: 8px;
bottom: 8px;
transition: 0.5s;
height: 280px;
backdrop-filter: blur(10px);
border-radius: 8px;
background-color: rgb(0 0 0 / 30%);
transform-origin: right bottom;
display: flex;
align-items: center;
box-sizing: border-box;
padding: 8px;
@media screen and (max-width: 500px) {
width: calc(100vw - 16px);
}
@media screen and (min-width: 501px) {
width: 500px;
}
}

View File

@ -0,0 +1,94 @@
import React, { FC, useState } from 'react';
import style from './ThemeChanger.module.scss';
import classNames from 'classnames';
import { fishermanTheme } from '../themes/fisherman';
import { jinlunTheme } from '../themes/jinlun';
import { ikunTheme } from '../themes/ikun';
import { pddTheme } from '../themes/pdd';
import { getDefaultTheme } from '../themes/default';
import { Theme } from '../themes/interface';
import WxQrCode from './WxQrCode';
const BuiltinThemes = [
getDefaultTheme(),
fishermanTheme,
jinlunTheme,
ikunTheme,
pddTheme,
];
const ThemeChanger: FC<{
changeTheme: (theme: Theme<any>) => void;
onDiyClick: () => void;
}> = ({ changeTheme, onDiyClick }) => {
const [open, setOpen] = useState(false);
return (
<>
<div className={classNames(style.container, open && style.open)}>
{BuiltinThemes.map((theme, idx) => (
<div
className={classNames(style.square)}
key={theme.title}
style={{
opacity: open ? 1 : 0.3,
transform: open
? `translateY(-${110 * (idx + 1)}%)`
: '',
}}
onClick={() => {
setOpen(false);
changeTheme(theme);
}}
>
{typeof theme.icons[0].content === 'string' ? (
theme.icons[0].content.startsWith('http') ? (
/*图片外链*/
<img src={theme.icons[0].content} alt="" />
) : (
/*字符表情*/
<i>{theme.icons[0].content}</i>
)
) : (
/*ReactNode*/
theme.icons[0].content
)}
</div>
))}
<div
className={classNames(style.square, style.diy)}
onClick={() => {
setOpen(false);
onDiyClick();
}}
style={{
transform: open
? `translateY(-${110 * 6}%)`
: 'translateX(-110%)',
}}
>
{open ? '点我整活' : 'DIY!'}
</div>
<div
onClick={() => setOpen(!open)}
className={classNames(style.square)}
>
{open ? '收起' : '更多'}
</div>
</div>
<div
className={style.adv}
style={{
opacity: open ? 1 : 0.3,
transform: open
? `translateY(-400px) scale(1)`
: 'translateY(0) scale(0)',
}}
>
<WxQrCode />
</div>
</>
);
};
export default ThemeChanger;

View File

@ -0,0 +1,63 @@
@keyframes jump {
0% {
transform: translateY(0);
}
10% {
transform: translateY(-50%);
}
20% {
transform: translateY(0);
}
100% {
transform: translateY(0);
}
}
@keyframes scale {
0% {
transform: scale(1);
}
100% {
transform: scale(1.05);
}
}
.title {
margin: 0;
line-height: 1.15;
font-size: 2rem;
padding-top: 1rem;
animation: scale 0.27s infinite alternate;
z-index: 1;
* {
transition: 0.3s;
}
.item {
display: inline-block;
animation-name: jump;
animation-iteration-count: infinite;
animation-duration: 3s;
animation-play-state: running;
text-shadow: 4px 6px 2px rgb(0 0 0 / 20%);
}
}
.title,
.description {
text-align: center;
}
.description {
margin: 0;
line-height: 1.5;
font-size: 1rem;
font-weight: 400;
padding-top: 0.5rem;
}

24
src/components/Title.tsx Normal file
View File

@ -0,0 +1,24 @@
import React, { FC } from 'react';
import style from './Title.module.scss';
export const Title: FC<{ title: string; desc?: string }> = ({
title,
desc,
}) => {
return (
<>
<h1 className={style.title}>
{[...title].map((str, i) => (
<span
className={style.item}
style={{ animationDelay: i / 10 + 's' }}
key={`${i}`}
>
{str}
</span>
))}
</h1>
{desc && <h2 className={style.description}>{desc}</h2>}
</>
);
};

View File

@ -0,0 +1,64 @@
.wxQrCode {
&Container {
display: flex;
gap: 16px;
flex-wrap: wrap;
padding: 12px;
border-radius: 12px;
border: 1px solid currentcolor;
}
&Title {
opacity: 0.8;
width: 100%;
font-size: 14px;
}
&Item {
display: flex;
flex: 1 1 0;
gap: 12px;
flex-direction: column;
align-items: center;
transition: 0.3s;
&Title {
font-weight: 600;
font-size: 16px;
}
&Image {
width: 100%;
transition: 0.3s;
cursor: pointer;
z-index: 1;
transform-origin: bottom;
&:hover {
z-index: 10;
transform: scale(1.1);
box-shadow: 0 19px 38px rgb(0 0 0 / 30%),
0 15px 12px rgb(0 0 0 / 22%);
}
}
&.fullScreen .wxQrCodeItemImage {
z-index: 10;
transform: scale(3);
box-shadow: 0 19px 38px rgb(0 0 0 / 30%),
0 15px 12px rgb(0 0 0 / 22%);
}
&:nth-child(2).fullScreen .wxQrCodeItemImage {
transform-origin: left bottom;
}
&:nth-child(3).fullScreen .wxQrCodeItemImage {
transform-origin: center bottom;
}
&:nth-child(4).fullScreen .wxQrCodeItemImage {
transform-origin: right bottom;
}
}
}

View File

@ -0,0 +1,55 @@
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 = '如果您喜欢这个项目的话,点击扫描下方收款码请我喝杯咖啡,感谢~😘',
onClick,
}) => {
const [fullScreen, setFullScreen] = useState<Record<number, boolean>>({
0: false,
1: false,
2: false,
});
const onImageClick = (idx: number) => {
setFullScreen({
0: false,
1: false,
2: false,
[idx]: !fullScreen[idx],
});
const clickListener: EventListener = (e) => {
// @ts-ignore
if (e.target?.className !== style.wxQrCodeItemImage) {
setFullScreen({ 0: false, 1: false, 2: false });
}
window.removeEventListener('click', clickListener);
};
setTimeout(() => {
window.addEventListener('click', clickListener);
});
};
return (
<div className={style.wxQrCodeContainer} onClick={onClick}>
<div className={style.wxQrCodeTitle}>{title}</div>
{[1, 5, 8].map((num, idx) => (
<div
key={num}
className={classNames(
style.wxQrCodeItem,
fullScreen[idx] && style.fullScreen
)}
>
<span className={style.wxQrCodeItemTitle}> {num}</span>
<img
alt={''}
src={`/wxQrcode${num}.jpg`}
className={style.wxQrCodeItemImage}
onClick={() => onImageClick(idx)}
/>
</div>
))}
</div>
);
};
export default WxQrCode;

View File

@ -1,78 +0,0 @@
:root {
font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
font-size: 16px;
line-height: 24px;
font-weight: 400;
color-scheme: light dark;
color: rgb(255 255 255 / 87%);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizelegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-size-adjust: 100%;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
padding: 0 32px;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
select {
border: 2px solid gray;
border-radius: 4px;
padding: 4px 8px;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
word-break: keep-all;
outline: none;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #fff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}

View File

@ -1,10 +1,109 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
import './styles/global.scss';
import './styles/utils.scss';
import Bmob from 'hydrogen-js-sdk';
import {
domRelatedOptForTheme,
parsePathCustomThemeId,
PLAYING_THEME_ID_STORAGE_KEY,
resetScoreStorage,
wrapThemeDefaultSounds,
} from './utils';
import { getDefaultTheme } from './themes/default';
import { Theme } from './themes/interface';
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>
// react渲染
const render = (theme: Theme<any>) => {
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<App theme={theme} />
</React.StrictMode>
);
};
// 错误提示
const errorTip = (tip: string) => {
setTimeout(() => {
document.getElementById('loading')?.classList.add('error');
document.getElementById('loadingText')!.innerText = tip;
document.getElementById('backHomeTip')!.style.visibility = 'visible';
}, 600);
};
// 加载成功后数据转换runtime以及转场
const successTrans = (theme: Theme<any>) => {
// 如果上次玩的不是这个主题,清除缓存分数
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(() => {
domRelatedOptForTheme(theme);
const root = document.getElementById('root');
root!.style.opacity = '0';
document.getElementById('loading')?.classList.add('success');
setTimeout(() => {
render(theme);
root!.style.opacity = '1';
}, 600);
}, 500);
};
// 从url初始化主题
const customThemeIdFromPath = parsePathCustomThemeId(location.href);
// Bmob初始化
// @ts-ignore
Bmob.initialize(
import.meta.env.VITE_BMOB_SECRETKEY,
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('主题配置解析失败');
}
} 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 {
successTrans(getDefaultTheme());
}

43
src/styles/global.scss Normal file
View File

@ -0,0 +1,43 @@
body {
padding: 0 32px;
-webkit-tap-highlight-color: transparent;
font-synthesis: none;
text-rendering: optimizelegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-size-adjust: 100%;
}
#root {
text-align: center;
width: 100%;
max-width: 450px;
margin: 0 auto;
min-height: 100vh;
display: flex;
flex-direction: column;
}
button {
color: white;
border: none;
padding: 8px 16px;
border-radius: 8px;
background-color: #3338;
cursor: pointer;
&.primary {
background-color: #747bff;
}
}
input,
select {
border: 1px dashed rgb(0 0 0 / 30%);
border-radius: 4px;
padding: 8px 12px;
&::placeholder {
color: rgb(0 0 0 / 30%);
}
}

40
src/styles/utils.scss Normal file
View File

@ -0,0 +1,40 @@
.flex-container {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.flex-no-wrap {
flex-wrap: nowrap;
}
.flex-center {
justify-content: center;
align-items: center;
}
.flex-left-center {
justify-content: flex-start;
align-items: center;
}
.flex-row {
flex-direction: row;
}
.flex-column {
flex-direction: column;
}
.flex-grow {
flex-grow: 1;
}
.flex-between {
justify-content: space-between;
align-items: center;
}
.flex-spacer {
flex: 1 1 auto;
}

View File

@ -15,26 +15,29 @@ const icons = <const>[
export type DefaultSoundNames = 'button-click' | 'triple';
import soundButtonClickUrl from './sounds/sound-button-click.mp3';
import soundTripleUrl from './sounds/sound-triple.mp3';
export const defaultSounds: Theme<DefaultSoundNames>['sounds'] = [
{
name: 'button-click',
src: soundButtonClickUrl,
},
{
name: 'triple',
src: soundTripleUrl,
},
];
export const defaultTheme: Theme<DefaultSoundNames> = {
name: '默认',
icons: icons.map((icon) => ({
name: icon,
content: icon,
clickSound: 'button-click',
tripleSound: 'triple',
})),
sounds: defaultSounds,
export const getDefaultTheme: () => Theme<DefaultSoundNames> = () => {
return {
title: '有解的羊了个羊',
desc: '真的可以通关~',
dark: true,
maxLevel: 20,
backgroundColor: '#8dac85',
icons: icons.map((icon) => ({
name: icon,
content: icon,
clickSound: 'button-click',
tripleSound: 'triple',
})),
sounds: [
{
name: 'button-click',
src: 'https://minio.streakingman.com/solvable-sheep-game/sound-button-click.mp3',
},
{
name: 'triple',
src: 'https://minio.streakingman.com/solvable-sheep-game/sound-triple.mp3',
},
],
bgm: 'https://minio.streakingman.com/solvable-sheep-game/sound-disco.mp3',
};
};

View File

@ -1,7 +1,7 @@
// 钓鱼佬主题
import React from 'react';
import { Theme } from '../interface';
import { DefaultSoundNames, defaultSounds } from '../default';
import { DefaultSoundNames } from '../default';
const imagesUrls = import.meta.glob('./images/*.png', {
import: 'default',
@ -16,12 +16,12 @@ const fishes = Object.entries(imagesUrls).map(([key, value]) => ({
}));
export const fishermanTheme: Theme<DefaultSoundNames> = {
name: '钓鱼佬',
title: '🐟鱼了个鱼🐟',
icons: fishes.map(({ name, content }) => ({
name,
content,
clickSound: 'button-click',
tripleSound: 'triple',
})),
sounds: defaultSounds,
sounds: [],
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

64
src/themes/ikun/index.tsx Normal file
View File

@ -0,0 +1,64 @@
import React from 'react';
import { Theme } from '../interface';
import niganma from './sounds/你干嘛哎呦.mp3';
import dajiahao from './sounds/全民制作人大家好.mp3';
import jntm from './sounds/鸡你太美.mp3';
import music from './sounds/music.mp3';
import lianxisheng from './sounds/个人练习生.mp3';
import boom from './sounds/篮球击地.mp3';
import bgm from './sounds/bgm.mp3';
type SoundNames =
| '你干嘛'
| '鸡你太美'
| '全民制作人大家好'
| 'music'
| '个人练习生'
| '篮球击地';
const pictureSoundMap: Record<string, SoundNames> = {
['kun']: '全民制作人大家好',
['坤舞1']: '篮球击地',
['坤舞2']: '个人练习生',
['坤舞3']: '篮球击地',
['坤舞4']: '你干嘛',
['坤舞5']: '个人练习生',
['坤舞6']: '鸡你太美',
['坤舞7']: 'music',
['尖叫鸡']: '鸡你太美',
['篮球']: '篮球击地',
};
const sounds: { name: SoundNames; src: string }[] = [
{ name: '你干嘛', src: niganma },
{ name: '鸡你太美', src: jntm },
{ name: '全民制作人大家好', src: dajiahao },
{ name: 'music', src: music },
{ name: '个人练习生', src: lianxisheng },
{ name: '篮球击地', src: boom },
];
const imagesUrls = import.meta.glob('./images/*.png', {
import: 'default',
eager: true,
});
const icons = Object.entries(imagesUrls).map(([key, value]) => ({
name: key.slice(9, -4),
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
content: <img src={value} alt="" />,
}));
export const ikunTheme: Theme<SoundNames> = {
title: '🐔鸡了个鸡🐔',
bgm,
icons: icons.map(({ name, content }) => ({
name,
content,
clickSound: pictureSoundMap[name],
tripleSound: '鸡你太美',
})),
sounds,
};

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -7,13 +7,24 @@ export interface Icon<T = string> {
tripleSound: T;
}
interface Sound<T = string> {
export interface Sound<T = string> {
name: T;
src: string;
}
type Operation = 'shift' | 'undo' | 'wash';
export interface Theme<SoundNames> {
name: string;
title: string;
desc?: string;
bgm?: string;
background?: string;
backgroundColor?: string;
backgroundBlur?: boolean;
dark?: boolean;
pure?: boolean;
maxLevel?: number;
icons: Icon<SoundNames>[];
sounds: Sound<SoundNames>[];
operateSoundMap?: Record<Operation, SoundNames>;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Some files were not shown because too many files have changed in this diff Show More