Compare commits
113 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09e43ae02a | ||
|
|
c5bd3f01d9 | ||
|
|
be0faff9fd | ||
|
|
cebf64847f | ||
|
|
ffbb38495f | ||
|
|
e553e5360f | ||
|
|
d234bd06a8 | ||
|
|
680811c50c | ||
|
|
ffabf6805f | ||
|
|
58bff1ce98 | ||
|
|
cd5501ebea | ||
|
|
331b96c8f0 | ||
|
|
c6671038a8 | ||
|
|
5579d5a32e | ||
|
|
8dec5b6420 | ||
|
|
3913982716 | ||
|
|
5353a40416 | ||
|
|
fd1d429f59 | ||
|
|
81503973a7 | ||
|
|
acb5083e22 | ||
|
|
639b1a3db1 | ||
|
|
a8a6312e2a | ||
|
|
9682d31c49 | ||
|
|
01d85610eb | ||
|
|
d070c67d3f | ||
|
|
fc7a880607 | ||
|
|
47caa7ccf9 | ||
|
|
3fa7059272 | ||
|
|
cc06bb14e3 | ||
|
|
a3264ec959 | ||
|
|
0f0072585b | ||
|
|
f6c25eb96a | ||
|
|
0ca17d699e | ||
|
|
2d7de338fa | ||
|
|
1890cc5a6f | ||
|
|
9f613ade82 | ||
|
|
b7482fa209 | ||
|
|
84fa8483d9 | ||
|
|
557a6a705a | ||
|
|
f08730e584 | ||
|
|
d0b04bcd9e | ||
|
|
88acee5bc5 | ||
|
|
34e89fe232 | ||
|
|
e6449f9d8c | ||
|
|
657422eb4f | ||
|
|
eaa56b0070 | ||
|
|
89eab211e4 | ||
|
|
c9cc9114c3 | ||
|
|
66f7e48166 | ||
|
|
6a91e0bab2 | ||
|
|
23dfa5135b | ||
|
|
bac732a42b | ||
|
|
e867a927b3 | ||
|
|
e4c6a2d584 | ||
|
|
5e29a1e55a | ||
|
|
b0cc1f279f | ||
|
|
9b626ea4a6 | ||
|
|
c0de917641 | ||
|
|
90852ce91d | ||
|
|
0afeac0f87 | ||
|
|
e7ae319ecf | ||
|
|
f8c969f75b | ||
|
|
ad01eb2dbb | ||
|
|
b158f2346c | ||
|
|
ae7dab752c | ||
|
|
0127253fdd | ||
|
|
a32cb04bb3 | ||
|
|
e75f375044 | ||
|
|
f694d9af72 | ||
|
|
4d5fd90f43 | ||
|
|
b2ab534aa5 | ||
|
|
f36c99b385 | ||
|
|
f6681cabb0 | ||
|
|
e35ddfa44e | ||
|
|
e287398572 | ||
|
|
75a02be9f4 | ||
|
|
b687d53733 | ||
|
|
982a2bd342 | ||
|
|
72228b7f35 | ||
|
|
4b09bd08c7 | ||
|
|
3a3ea6037f | ||
|
|
57224e8015 | ||
|
|
f8e27e70f3 | ||
|
|
9275049c07 | ||
|
|
ba5afc9571 | ||
|
|
ca7d267e21 | ||
|
|
0eaf5333f6 | ||
|
|
c578b89703 | ||
|
|
a5e1ea1004 | ||
|
|
85954f72b2 | ||
|
|
7f20ba12ea | ||
|
|
f0e2caf7a7 | ||
|
|
aa2d11a096 | ||
|
|
dc3033d0d2 | ||
|
|
91af5edf47 | ||
|
|
81c96fd653 | ||
|
|
2afb29b5bd | ||
|
|
7e2990a2b6 | ||
|
|
7368725b52 | ||
|
|
cc8a0cabfc | ||
|
|
504fb9221a | ||
|
|
90d0c91669 | ||
|
|
d2be6ca8a5 | ||
|
|
30bb7a0277 | ||
|
|
8b3960e976 | ||
|
|
b3b5737e5c | ||
|
|
dd78308485 | ||
|
|
d1d7c6b2f0 | ||
|
|
43c017f19b | ||
|
|
78ab02e371 | ||
|
|
7188b26806 | ||
|
|
8562ea4761 | ||
|
|
ea8c9777d3 |
2
.env
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
VITE_BMOB_SECRETKEY=yourBmobAppSecretKey
|
||||
VITE_BMOB_SECCODE=youBmobAppSecurityCode
|
||||
|
|
@ -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
|
|
@ -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
0
.husky/pre-commit
Executable file → Normal 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'],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -9,4 +9,8 @@ module.exports = {
|
|||
'stylelint-config-standard-scss',
|
||||
'stylelint-config-prettier-scss',
|
||||
],
|
||||
rules: {
|
||||
// 后续统一
|
||||
'selector-class-pattern': '^[a-zA-Z0-9-_]+$',
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
module.exports = {
|
||||
scripts: {
|
||||
postbump: 'yarn build && git add dist/*',
|
||||
// postbump: 'yarn build && git add dist/*',
|
||||
},
|
||||
};
|
||||
|
|
|
|||
153
CHANGELOG.md
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
46
README.md
|
|
@ -16,37 +16,69 @@
|
|||
坑爹的小游戏(本来玩法挺有意思的,非得恶心人),根本无解(99.99%无解),气的我自己写了个 demo,
|
||||
扫二维码或<a href="https://solvable-sheep-game.streakingman.com/" target="_blank">pc 浏览器体验</a>
|
||||
|
||||

|
||||
**声明:本项目仅供交流,禁止商用!否则后果自负。基于此项目的二创都是欢迎的,但非二创请不要删除原仓库地址
|
||||
(啥都不改唯独删除来源我真的会谢 🙄️,请尊重他人劳动成果)**
|
||||
|
||||
<img src="qrcode.png" style="width: 250px;" alt="体验地址二维码">
|
||||
|
||||
## Feature
|
||||
|
||||
- 弹出:弹出队列左侧第一个,无限次数
|
||||
- 撤销:撤销上一次操作,无限次数
|
||||
- 洗牌:哗啦哗啦,无限次数
|
||||
- 关卡:50 关玩到爽,可直接跳
|
||||
- 关卡:20 关玩到爽,可直接跳
|
||||
- 内置主题:金轮<img style="width:36px" src="src/themes/jinlun/images/肌肉金轮1.png" />、
|
||||
骚猪<img style="width:36px" src="src/themes/pdd/images/1.png" />、
|
||||
ikun<img style="width:36px" src="src/themes/ikun/images/kun.png" />(露出黑脚)等
|
||||
- 自定义主题:自定义图片和音效,快速整活
|
||||
- 排行榜:皇城 pk
|
||||
|
||||
开心就好 😄
|
||||
|
||||

|
||||

|
||||
|
||||
## Contribution
|
||||
|
||||
vite+react 实现,欢迎 star、issue、pr、fork(尽量标注原仓库地址)
|
||||
|
||||
切换主题参考 `src/themes` 下的代码,欢迎整活
|
||||
## Related Repo
|
||||
|
||||
<a href="https://github.com/opendilab" target="_blank">opendilab</a> 的 AI 整活!移步
|
||||
<a href="https://github.com/opendilab/DI-sheep" target="_blank">DI-sheep:深度强化学习 + 羊了个羊</a>
|
||||
|
||||
<img style="width: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 服务到期,后台服务已下线,相关功能暂时无法使用,如有需要请自行搭建后台服务
|
||||
|
||||

|
||||
|
|
|
|||
103
diy/README.md
Normal 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 流量), 将更改后的项目推到自己的 github(gitlab,bitbucket 同样支持)仓库,
|
||||
使用 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` 表,储存排名信息
|
||||

|
||||
|
||||
最后,开发和打包命令分别使用 `yarn dev` 和 `yarn build` 即可
|
||||
BIN
diy/datebase-rank.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
100
diy/diy.theme.json
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
After Width: | Height: | Size: 2.0 KiB |
BIN
diy/public/10.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
diy/public/2.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
diy/public/3.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
diy/public/4.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
diy/public/5.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
diy/public/6.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
diy/public/7.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
diy/public/8.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
diy/public/9.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
diy/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
diy/public/sound-button-click.mp3
Normal file
BIN
diy/public/sound-disco.mp3
Normal file
BIN
diy/public/sound-shift.mp3
Normal file
BIN
diy/public/sound-triple.mp3
Normal file
BIN
diy/public/sound-undo.mp3
Normal file
BIN
diy/public/sound-wash.mp3
Normal file
BIN
diy/vercel.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
206
index.html
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
BIN
preview.png
|
Before Width: | Height: | Size: 245 KiB |
BIN
previews.png
Normal file
|
After Width: | Height: | Size: 372 KiB |
|
|
@ -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 |
BIN
public/wxQrcode1.jpg
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
public/wxQrcode5.jpg
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
public/wxQrcode8.jpg
Normal file
|
After Width: | Height: | Size: 34 KiB |
110
src/App.css
|
|
@ -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
|
|
@ -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;
|
||||
}
|
||||
551
src/App.tsx
|
|
@ -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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
24
src/components/CloseIcon.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
282
src/components/ConfigDialog.module.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
752
src/components/ConfigDialog.tsx
Normal 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;
|
||||
133
src/components/Fireworks.module.scss
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
@use 'sass:math';
|
||||
|
||||
// 烟花动画,来源 https://codepen.io/yshlin/pen/WNMmQX
|
||||
|
||||
$particles: 50;
|
||||
$width: 500;
|
||||
$height: 500;
|
||||
|
||||
// Create the explosion...
|
||||
$box-shadow: ();
|
||||
$box-shadow2: ();
|
||||
@for $i from 0 through $particles {
|
||||
$box-shadow: $box-shadow,
|
||||
math.random($width) -
|
||||
math.div($width, 2) +
|
||||
px
|
||||
math.random($height) -
|
||||
math.div($height, 1.2) +
|
||||
px
|
||||
hsl(math.random(360) 100% 50%);
|
||||
$box-shadow2: $box-shadow2, 0 0 #fff;
|
||||
}
|
||||
@mixin keyframes($animationName) {
|
||||
@keyframes #{$animationName} {
|
||||
@content;
|
||||
}
|
||||
|
||||
@keyframes #{$animationName} {
|
||||
@content;
|
||||
}
|
||||
|
||||
@keyframes #{$animationName} {
|
||||
@content;
|
||||
}
|
||||
|
||||
@keyframes #{$animationName} {
|
||||
@content;
|
||||
}
|
||||
|
||||
@keyframes #{$animationName} {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin animation-delay($settings) {
|
||||
animation-delay: $settings;
|
||||
}
|
||||
|
||||
@mixin animation-duration($settings) {
|
||||
animation-duration: $settings;
|
||||
}
|
||||
|
||||
@mixin animation($settings) {
|
||||
animation: $settings;
|
||||
}
|
||||
|
||||
@mixin transform($settings) {
|
||||
transform: $settings;
|
||||
}
|
||||
|
||||
.pyro {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pyro > .before,
|
||||
.pyro > .after {
|
||||
position: absolute;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
box-shadow: $box-shadow2;
|
||||
@include animation(
|
||||
(
|
||||
1s bang ease-out infinite backwards,
|
||||
1s gravity ease-in infinite backwards,
|
||||
5s position linear infinite backwards
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
.pyro > .after {
|
||||
@include animation-delay((1.25s, 1.25s, 1.25s));
|
||||
@include animation-duration((1.25s, 1.25s, 6.25s));
|
||||
}
|
||||
|
||||
@include keyframes(bang) {
|
||||
to {
|
||||
box-shadow: $box-shadow;
|
||||
}
|
||||
}
|
||||
|
||||
@include keyframes(gravity) {
|
||||
to {
|
||||
@include transform(translateY(200px));
|
||||
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@include keyframes(position) {
|
||||
0%,
|
||||
19.9% {
|
||||
margin-top: 10%;
|
||||
margin-left: 40%;
|
||||
}
|
||||
|
||||
20%,
|
||||
39.9% {
|
||||
margin-top: 40%;
|
||||
margin-left: 30%;
|
||||
}
|
||||
|
||||
40%,
|
||||
59.9% {
|
||||
margin-top: 20%;
|
||||
margin-left: 70%;
|
||||
}
|
||||
|
||||
60%,
|
||||
79.9% {
|
||||
margin-top: 30%;
|
||||
margin-left: 20%;
|
||||
}
|
||||
|
||||
80%,
|
||||
99.9% {
|
||||
margin-top: 30%;
|
||||
margin-left: 80%;
|
||||
}
|
||||
}
|
||||
12
src/components/Fireworks.tsx
Normal file
|
|
@ -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;
|
||||
73
src/components/FixedAnimateScalePanel.module.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
36
src/components/FixedAnimateScalePanel.tsx
Normal 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
|
|
@ -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
|
|
@ -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;
|
||||
38
src/components/Info.module.scss
Normal 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
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
103
src/components/PersonalInfo.module.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
78
src/components/PersonalInfo.tsx
Normal file
89
src/components/Score.module.scss
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
.modal {
|
||||
position: fixed;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
backdrop-filter: blur(6px);
|
||||
background-color: rgb(0 0 0 / 20%);
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 10 !important;
|
||||
overflow-y: auto;
|
||||
|
||||
.inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
min-height: 100%;
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
max-width: 450px;
|
||||
box-sizing: border-box;
|
||||
margin: 0 auto;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4 {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.nameInput {
|
||||
background-color: rgb(0 0 0 / 20%);
|
||||
color: currentcolor;
|
||||
border: 1px currentcolor dashed;
|
||||
|
||||
&::placeholder {
|
||||
color: currentcolor;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.rankContainer {
|
||||
border: 1px solid currentcolor;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
min-height: 200px;
|
||||
background-color: rgb(0 0 0 / 20%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
.title {
|
||||
font-weight: 900;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 30vh;
|
||||
overflow-y: auto;
|
||||
|
||||
thead {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background-color: gray;
|
||||
}
|
||||
|
||||
td.username {
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tip {
|
||||
font-size: 18px;
|
||||
text-align: center;
|
||||
opacity: 0.8;
|
||||
|
||||
&.error {
|
||||
color: crimson;
|
||||
}
|
||||
}
|
||||
297
src/components/Score.tsx
Normal file
|
|
@ -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;
|
||||
93
src/components/ThemeChanger.module.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
94
src/components/ThemeChanger.tsx
Normal 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;
|
||||
63
src/components/Title.module.scss
Normal 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
|
|
@ -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>}
|
||||
</>
|
||||
);
|
||||
};
|
||||
64
src/components/WxQrCode.module.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
55
src/components/WxQrCode.tsx
Normal 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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
109
src/main.tsx
|
|
@ -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
|
|
@ -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
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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',
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
};
|
||||
|
|
|
|||
BIN
src/themes/ikun/images/kun.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
src/themes/ikun/images/坤舞1.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
src/themes/ikun/images/坤舞2.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
src/themes/ikun/images/坤舞3.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
src/themes/ikun/images/坤舞4.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
src/themes/ikun/images/坤舞5.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
src/themes/ikun/images/坤舞6.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
src/themes/ikun/images/坤舞7.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
src/themes/ikun/images/尖叫鸡.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
src/themes/ikun/images/篮球.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
64
src/themes/ikun/index.tsx
Normal 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,
|
||||
};
|
||||
BIN
src/themes/ikun/sounds/bgm.mp3
Normal file
BIN
src/themes/ikun/sounds/music.mp3
Normal file
BIN
src/themes/ikun/sounds/个人练习生.mp3
Normal file
BIN
src/themes/ikun/sounds/你干嘛哎呦.mp3
Normal file
BIN
src/themes/ikun/sounds/全民制作人大家好.mp3
Normal file
BIN
src/themes/ikun/sounds/篮球击地.mp3
Normal file
BIN
src/themes/ikun/sounds/鸡你太美.mp3
Normal 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>;
|
||||
}
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 1.7 KiB |