feat: 排行榜

This commit is contained in:
streakingman 2022-10-12 21:21:19 +08:00
parent 01d85610eb
commit 9682d31c49
10 changed files with 553 additions and 26 deletions

View File

@ -5,6 +5,7 @@ import {
LAST_LEVEL_STORAGE_KEY,
LAST_SCORE_STORAGE_KEY,
LAST_TIME_STORAGE_KEY,
PLAYING_THEME_ID_STORAGE_KEY,
wrapThemeDefaultSounds,
} from './utils';
import { Theme } from './themes/interface';
@ -30,6 +31,7 @@ const App: FC<{ theme: Theme<any> }> = ({ theme: initTheme }) => {
const [diyDialogShow, setDiyDialogShow] = useState<boolean>(false);
const changeTheme = (theme: Theme<any>) => {
sessionStorage.setItem(PLAYING_THEME_ID_STORAGE_KEY, theme.title);
wrapThemeDefaultSounds(theme);
domRelatedOptForTheme(theme);
setTheme({ ...theme });

View File

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

View File

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

View File

@ -74,21 +74,6 @@
}
}
.modal {
position: fixed;
width: 100vw;
height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
backdrop-filter: blur(10px);
background-color: rgb(0 0 0 / 10%);
top: 0;
left: 0;
z-index: 10 !important;
}
.bgm-button {
position: fixed;
left: 8px;

View File

@ -4,6 +4,7 @@ import React, {
useEffect,
useRef,
useState,
Suspense,
} from 'react';
import './Game.scss';
import {
@ -11,9 +12,11 @@ import {
LAST_SCORE_STORAGE_KEY,
LAST_TIME_STORAGE_KEY,
randomString,
timestampToUsedTimeString,
waitTimeout,
} from '../utils';
import { Icon, Theme } from '../themes/interface';
import Score from './Score';
interface MySymbol {
id: string;
@ -488,17 +491,20 @@ const Game: FC<{
<br />
{score}
<br />
{(usedTime / 1000).toFixed(3)}
{timestampToUsedTimeString(usedTime)}
</div>
{/*提示弹窗*/}
{finished && (
<div className="modal">
<h1>{tipText}</h1>
<h1>{score}</h1>
<h1>{(usedTime / 1000).toFixed(3)}</h1>
<button onClick={restart}></button>
</div>
)}
{/*积分、排行榜*/}
<Suspense fallback={<span>rank list</span>}>
{finished && (
<Score
level={level}
time={usedTime}
score={score}
success={level === maxLevel}
restartMethod={restart}
/>
)}
</Suspense>
{/*bgm*/}
{theme.bgm && (
<button className="bgm-button" onClick={() => setBgmOn(!bgmOn)}>

View File

@ -0,0 +1,85 @@
.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;
}
}
}
.tip {
font-size: 18px;
text-align: center;
opacity: 0.8;
&.error {
color: crimson;
}
}

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

@ -0,0 +1,266 @@
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;
restartMethod: () => void;
}> = ({ level, score, time, success, restartMethod }) => {
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 = sessionStorage.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);
})
.catch((e) => {
console.log(e);
});
};
const onConfirmNameClick = () => {
const inputUsername = usernameInputRef.current?.value;
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>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}
placeholder={'留下大名进行排行榜pk!'}
/>
<button
className={'primary'}
onClick={onConfirmNameClick}
>
</button>
</div>
)}
<div>{tip}</div>
{!__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}>
<td>{idx + 1}</td>
<td>
{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>
)}
<WxQrCode />
</div>
</div>
);
};
export default Score;

View File

@ -8,6 +8,7 @@ import {
DEFAULT_BGM_STORAGE_KEY,
domRelatedOptForTheme,
parsePathCustomThemeId,
PLAYING_THEME_ID_STORAGE_KEY,
wrapThemeDefaultSounds,
} from './utils';
import { getDefaultTheme } from './themes/default';
@ -33,6 +34,11 @@ const errorTip = (tip: string) => {
// 加载成功后数据转换runtime以及转场
const successTrans = (theme: Theme<any>) => {
sessionStorage.setItem(
PLAYING_THEME_ID_STORAGE_KEY,
customThemeIdFromPath || theme.title
);
wrapThemeDefaultSounds(theme);
setTimeout(() => {
@ -72,7 +78,7 @@ const loadTheme = () => {
Bmob.Query('config')
.get(customThemeIdFromPath)
.then((res) => {
const { content } = res as any;
const { content, increment } = res as any;
localStorage.setItem(customThemeIdFromPath, content);
try {
const customTheme = JSON.parse(content);
@ -80,6 +86,10 @@ const loadTheme = () => {
} catch (e) {
errorTip('主题配置解析失败');
}
// 统计访问次数
increment('visitNum');
// @ts-ignore
res.save();
})
.catch(({ error }) => {
errorTip(error);

View File

@ -25,6 +25,7 @@ export const getDefaultTheme: () => Theme<DefaultSoundNames> = () => {
title: '有解的羊了个羊',
desc: '真的可以通关~',
dark: true,
maxLevel: 5,
backgroundColor: '#8dac85',
icons: icons.map((icon) => ({
name: icon,

View File

@ -1,6 +1,7 @@
import { Theme } from './themes/interface';
import { getDefaultTheme } from './themes/default';
// local
export const LAST_LEVEL_STORAGE_KEY = 'lastLevel';
export const LAST_SCORE_STORAGE_KEY = 'lastScore';
export const LAST_TIME_STORAGE_KEY = 'lastTime';
@ -11,6 +12,10 @@ export const CUSTOM_THEME_FILE_VALIDATE_STORAGE_KEY = 'customThemeFileValidate';
export const DEFAULT_BGM_STORAGE_KEY = 'defaultBgm';
export const DEFAULT_TRIPLE_SOUND_STORAGE_KEY = 'defaultTripleSound';
export const DEFAULT_CLICK_SOUND_STORAGE_KEY = 'defaultClickSound';
export const USER_NAME_STORAGE_KEY = 'username';
export const USER_ID_STORAGE_KEY = 'userId';
// session
export const PLAYING_THEME_ID_STORAGE_KEY = 'playingThemeId';
export const linkReg = /^(https|data):+/;
@ -142,3 +147,25 @@ export const getFileBase64String: (file: File) => Promise<string> = (
};
});
};
export const timestampToUsedTimeString: (time: number) => string = (time) => {
try {
const hours = Math.floor(time / (1000 * 60 * 60));
const minutes = Math.floor(
(time - 1000 * 60 * 60 * hours) / (1000 * 60)
);
const seconds = (
(time - 1000 * 60 * 60 * hours - 1000 * 60 * minutes) /
1000
).toFixed(3);
if (hours) {
return `${hours}小时${minutes}${seconds}`;
} else if (minutes) {
return `${minutes}${seconds}`;
} else {
return `${seconds}`;
}
} catch (e) {
return '时间转换出错';
}
};