Commit 3053be55 authored by 陈冲's avatar 陈冲

feat: 提交

parent 4a6831ac
......@@ -11,7 +11,7 @@ use socketioxide::extract::{Data, SocketRef};
use socketioxide::socket::DisconnectReason;
use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use std::time::{Duration, Instant};
lazy_static::lazy_static! {
/// 可以有多个管理员,但不要用同一个账户登录,否则后面的没效果
......@@ -26,11 +26,16 @@ static GAME_ROUND_ID: AtomicU64 = AtomicU64::new(0);
const ROOM_ENTRY_LIMIT: usize = 20;
///游戏结束排序最大用户数
const ROOM_SCORE_RANK_LIMIT: usize = 10;
const GAME_START_COUNTDOWN_SECS: u64 = 3;
const GAME_RESULT_WAIT_SECS: u64 = 2;
const GAME_RUNNING_SECS: u64 = 60;
#[derive(Clone, Default)]
struct RoomPlayer {
/// 成绩分数,具体含义由前端和游戏逻辑自行定义,后端只负责存储和排序。
score: i64,
/// 客户端上一次提交的累计分数,用于重连后继续累计而不是覆盖旧分数。
last_client_score: i64,
// /// wechat原始token用于记录
// wechat: String,
/// 是否在线,离线的玩家不允许提交分数,但保留在排行榜里。
......@@ -66,6 +71,8 @@ struct RoomState {
status: RoomStatus,
players: HashMap<String, RoomPlayer>, // 玩家列表,key 是 userid
next_entry_order: usize,
current_game_id: Option<String>,
round_started_at: Option<Instant>,
}
impl RoomState {
......@@ -140,6 +147,36 @@ impl RoomState {
.map(|index| json!(index + 1))
}
fn player_rank(&self, userid: &str) -> Option<usize> {
self.full_score_ranking()
.iter()
.position(|player| player.wechat == userid)
.map(|index| index + 1)
}
fn remaining_running_secs(&self) -> u64 {
let Some(started_at) = self.round_started_at else {
return GAME_RUNNING_SECS;
};
let elapsed = started_at.elapsed().as_secs();
let play_elapsed = elapsed.saturating_sub(GAME_START_COUNTDOWN_SECS);
GAME_RUNNING_SECS.saturating_sub(play_elapsed)
}
fn reconnect_payload(&self, userid: &str) -> Option<Value> {
let player = self.players.get(userid)?;
Some(json!({
"reconnect": true,
"status": self.status.as_str(),
"gameId": self.current_game_id,
"score": player.score,
"rank": self.player_rank(userid),
"remainingSeconds": self.remaining_running_secs(),
}))
}
fn current_ranking_payload(&self) -> Vec<Value> {
let current_ranking = if self.status == RoomStatus::Ended {
self.score_ranking()
......@@ -164,6 +201,8 @@ impl RoomState {
self.status = RoomStatus::Closed;
self.players.clear();
self.next_entry_order = 0;
self.current_game_id = None;
self.round_started_at = None;
}
}
......@@ -337,8 +376,8 @@ async fn sleep_room_status(round_id: u64, secs: u64, expected_status: RoomStatus
fn spawn_game_timeout(round_id: u64, secs: u64) {
tokio::spawn(async move {
let start_countdown_secs = 3;
let result_wait_secs = 2;
let start_countdown_secs = GAME_START_COUNTDOWN_SECS;
let result_wait_secs = GAME_RESULT_WAIT_SECS;
let running_secs = secs.saturating_sub(start_countdown_secs + result_wait_secs);
if !sleep_room_status(round_id, start_countdown_secs, RoomStatus::Running).await {
......@@ -470,8 +509,32 @@ async fn handle_room_command(
if let Ok(mut state) = ROOM_STATE.lock() {
let already_joined = state.players.contains_key(userid);
if already_joined {
if let Some(player) = state.players.get_mut(userid) {
player.online = true;
if !nickname.is_empty() {
player.nickname = nickname.to_string();
}
if !avatar.is_empty() {
player.avatar = avatar.to_string();
}
}
let reconnect_payload = state.reconnect_payload(userid);
let should_resume_game = state.status == RoomStatus::Running;
drop(state);
emit_msgpack(&socket, evt, &err_resp("您已经进入游戏中", Some(json!(1))));
if let Some(mut player) = CLIENT_PLAYER_MAP.get_mut(userid) {
player.0 = 1;
}
emit_msgpack(&socket, evt, &ok_resp(reconnect_payload.clone()));
if should_resume_game {
if let Some(game_id) = reconnect_payload
.as_ref()
.and_then(|payload| payload.get("gameId"))
.and_then(|value| value.as_str())
{
emit_msgpack(&socket, "game_start_result", &ok_resp(Some(json!(game_id))));
}
}
return;
}
if state.status != RoomStatus::Start {
......@@ -499,6 +562,7 @@ async fn handle_room_command(
})
.or_insert(RoomPlayer {
score: 0,
last_client_score: 0,
online: true,
entry_order,
nickname: nickname.to_string(),
......@@ -538,12 +602,17 @@ async fn handle_room_command(
}
state.status = RoomStatus::Running;
state.current_game_id = Some(game_index.to_string());
state.round_started_at = Some(Instant::now());
let round_id = GAME_ROUND_ID.fetch_add(1, Ordering::SeqCst) + 1;
// let snapshot = state.snapshot();
drop(state);
//通知所有玩家倒计时60秒,实际上还有3秒倒计时,以前2秒统计结果,所以是65
spawn_game_timeout(round_id, 65);
spawn_game_timeout(
round_id,
GAME_START_COUNTDOWN_SECS + GAME_RUNNING_SECS + GAME_RESULT_WAIT_SECS,
);
broadcast_msgpack_all_player(evt, &ok_resp(Some(json!(game_index))));
//通知管理员
emit_msgpack(&socket, evt, &ok_resp(Some(json!(game_index))));
......@@ -576,7 +645,13 @@ async fn handle_room_command(
}*/
else if state.players.contains_key(userid) {
let player = state.players.get_mut(userid).unwrap();
player.score = score;
let score_delta = if score >= player.last_client_score {
score - player.last_client_score
} else {
score
};
player.score += score_delta.max(0);
player.last_client_score = score;
// player.wechat = wechat.to_string();
player.online = true;
drop(state);
......@@ -608,14 +683,26 @@ async fn handle_room_command(
if wechat.is_empty() {
return;
}
if let Ok(state) = ROOM_STATE.lock() {
let (score, rank) = if let Ok(state) = ROOM_STATE.lock() {
//防止客户端提交的状态还在Running中
if !matches!(state.status, RoomStatus::Submit | RoomStatus::Running) {
drop(state);
return;
}
let server_score = state
.players
.get(userid)
.map(|player| player.score)
.unwrap_or(score);
let server_rank = state
.player_rank(userid)
.map(|rank| rank as i64)
.unwrap_or(rank);
drop(state);
}
(server_score, server_rank)
} else {
(score, rank)
};
let game = Game {
wechat_id: wechat.to_string(),
nickname: nickname.to_string(),
......@@ -749,10 +836,11 @@ pub async fn on_connect(_io: SocketIo, socket: SocketRef, Data(data): Data<Value
return;
}
match CLIENT_PLAYER_MAP.entry(userid.clone()) {
Entry::Occupied(_) => {
// 如果当前用户已经存在
_ = socket.disconnect();
return;
Entry::Occupied(mut entry) => {
// 同一玩家重连时用新 socket 接管,避免网络抖动后无法回到本局。
let old_socket = entry.get().1.clone();
_ = old_socket.disconnect();
entry.insert((0, socket.clone()));
}
Entry::Vacant(entry) => {
entry.insert((0, socket.clone()));
......@@ -781,7 +869,7 @@ pub async fn on_connect(_io: SocketIo, socket: SocketRef, Data(data): Data<Value
});
socket.on_disconnect({
move |_socket: SocketRef, _reason: DisconnectReason| async move {
move |socket: SocketRef, _reason: DisconnectReason| async move {
if !is_create_user {
if is_admin {
CLIENT_ADMIN_MAP.remove(&userid);
......@@ -793,9 +881,19 @@ pub async fn on_connect(_io: SocketIo, socket: SocketRef, Data(data): Data<Value
}
}
} else {
let should_remove = CLIENT_PLAYER_MAP
.get(&userid)
.map(|player| player.1.id == socket.id)
.unwrap_or(false);
if !should_remove {
return;
}
CLIENT_PLAYER_MAP.remove(&userid);
if let Ok(mut state) = ROOM_STATE.lock() {
if state.status != RoomStatus::Start {
if let Some(player) = state.players.get_mut(&userid) {
player.online = false;
}
return;
}
if state.players.remove(&userid).is_none() {
......
......@@ -23,7 +23,7 @@ defineEmits<{
</Transition>
<div class="img-logo"></div>
<div class="play-content">
<div>当前击鼓次数</div>
<div>当前击鼓积分</div>
<div>{{ tick }}</div>
<div>当前排名: &nbsp;&nbsp;<label>{{ rank }}</label>&nbsp;</div>
</div>
......
......@@ -31,7 +31,7 @@ onMounted(() => {
<div class="score-content">
<div>{{ nickname }}</div>
<div>您的成绩</div>
<div>击鼓次数(次)</div>
<div>击鼓积分</div>
<div>{{ tick }}</div>
<div>最终排名</div>
<div><label>{{ rank }}</label></div>
......
......@@ -25,7 +25,7 @@ onMounted(() => {
<div class="h5-page game-stage">
<div class="img-logo"></div>
<div class="bg-bottom"></div>
<div class="img-score">
<!-- <div class="img-score">
<div class="score-avatar"
:style="`background: url(${avatar});border-radius:66px;width:132px;height:132px;`"></div>
<div class="score-content">
......@@ -40,7 +40,7 @@ onMounted(() => {
<div class="btn-group">
<div class="btn-replay" @click="$emit('replay')"></div>
<div class="btn-back" @click="$emit('back')"></div>
</div>
</div> -->
</div>
</template>
......@@ -58,7 +58,7 @@ onMounted(() => {
left: var(--stage-viewport-left, 0);
width: var(--stage-viewport-width, 750px);
height: 479px;
background: v-bind('imageUrls.bg2') center/cover;
background: v-bind('imageUrls.bg') center/cover;
opacity: 0.6;
}
......@@ -72,96 +72,4 @@ onMounted(() => {
transform: translateX(-50%);
}
.img-score {
position: absolute;
top: 450px;
left: 50%;
width: 465px;
height: 731px;
background: v-bind('imageUrls.scoreBg') center/cover;
transform: translateX(-50%) scale(1.2);
}
.score-avatar {
position: absolute;
top: -50px;
left: 50%;
width: 60px;
height: 60px;
background: v-bind('imageUrls.avatar') center/cover;
transform: translateX(-50%);
}
.score-content {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: calc(100% - 100px);
font-size: 28pt;
text-align: center;
}
.score-content>div:nth-child(1) {
color: #E00000;
font-size: 32pt;
font-weight: bold;
}
.score-content>div:nth-child(2) {
margin-top: 20px;
font-size: 32pt;
}
.score-content>div:nth-child(3) {
margin-top: 40px;
}
.score-content>div:nth-child(4) {
margin-top: 30px;
color: #E00000;
font-size: 32pt;
/* font-weight: bold; */
transform: scale(1.5);
}
.score-content>div:nth-child(5) {
margin-top: 40px;
}
.score-content>div:nth-child(6) {
margin-top: 20px;
color: #E00000;
font-size: 32pt;
font-weight: bold;
}
.score-content>div:nth-child(6) label {
color: #E00000;
font-size: 42pt;
margin: 0 10px;
}
.btn-group {
position: absolute;
top: 1320px;
left: 50%;
display: flex;
gap: 40px;
transform: translateX(-50%);
}
.btn-replay,
.btn-back {
width: 315px;
height: 100px;
}
.btn-replay {
background: v-bind('imageUrls.replay') center/cover;
}
.btn-back {
background: v-bind('imageUrls.back') center/cover;
}
</style>
......@@ -5,7 +5,6 @@
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>小游戏-大屏</title>
<style>body{background: #FA6047;}</style>
</head>
<body>
<div id="app"></div>
......
......@@ -10,7 +10,7 @@ const props = withDefaults(defineProps<{
width: 1920,
height: 1080,
mode: 'contain',
background: '#FA6047',
// background: '#FA6047',
})
const scale = ref(1)
......
......@@ -2,8 +2,12 @@
import { $save } from '@/commons/utils.ts';
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import DesignStage from '@/components/DesignStage.vue'
import { playGame1Music } from '@/commons/music'
// import { playGame1Music } from '@/commons/music'
const emit = defineEmits<{
loginSuccess: []
}>()
const router = useRouter()
const username = ref('')
const password = ref('')
......@@ -28,7 +32,7 @@ async function login() {
}
const loginHandler = async () => {
playGame1Music()
// playGame1Music()
const { state, msg, data } = await login()
if (state === 0) {
......@@ -38,45 +42,148 @@ const loginHandler = async () => {
for (let i in data) {
$save(i, data[i]);
}
await router.replace('/game1')
emit('loginSuccess');
}
onMounted(() => {
playGame1Music()
// playGame1Music()
})
</script>
<template>
<DesignStage>
<form>
<table>
<tbody>
<tr>
<td>用户名</td>
<td><input v-model="username" type="text" /></td>
</tr>
<tr>
<td>密码</td>
<td><input v-model="password" type="password" /></td>
</tr>
<tr>
<td colspan="2" align="center">
<input type="button" value="登录" @click="loginHandler" />
</td>
</tr>
</tbody>
</table>
<div class="login-page">
<form class="login-panel" @submit.prevent="loginHandler">
<div class="panel-header">
<h1>管理登录</h1>
</div>
<label class="field">
<span>用户名</span>
<input v-model="username" type="text" autocomplete="username" placeholder="请输入用户名" />
</label>
<label class="field">
<span>密码</span>
<input v-model="password" type="password" autocomplete="current-password" placeholder="请输入密码" />
</label>
<button class="login-button" type="submit">登录</button>
</form>
</DesignStage>
</div>
</template>
<style scoped>
table {
.login-page {
position: absolute;
left: 50%;
top: 50%;
margin-left: -150px;
margin-top: -100px;
transform: scale(3);
z-index: 1;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
background: rgba(0, 0, 0, 0.8);
}
.login-panel {
box-sizing: border-box;
width: 420px;
padding: 42px 44px 38px;
position: relative;
z-index: 1;
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 16px;
background: rgba(0, 0, 0, 0.42);
backdrop-filter: blur(8px);
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.34);
color: #fff;
}
.panel-header {
margin-bottom: 34px;
text-align: center;
}
h1 {
margin: 0;
font-size: 34px;
line-height: 1.2;
font-weight: 800;
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.28);
}
.field {
display: block;
margin-bottom: 20px;
}
.field span {
display: block;
margin-bottom: 8px;
color: rgba(255, 255, 255, 0.88);
font-size: 15px;
font-weight: 700;
}
.field input {
box-sizing: border-box;
width: 100%;
height: 52px;
padding: 0 16px;
border: 1px solid rgba(255, 255, 255, 0.34);
border-radius: 12px;
outline: none;
background: rgba(255, 255, 255, 0.84);
color: #17304f;
font-size: 16px;
font-weight: 600;
transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
}
.field input::placeholder {
color: rgba(23, 48, 79, 0.48);
}
.field input:focus {
border-color: rgba(255, 223, 158, 0.95);
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 0 0 4px rgba(255, 223, 158, 0.22);
}
.login-button {
width: 100%;
height: 54px;
margin-top: 10px;
border: 0;
border-radius: 12px;
cursor: pointer;
background: #ffdf9e;
color: #7d1f00;
font-size: 18px;
font-weight: 800;
box-shadow: 0 12px 28px rgba(125, 31, 0, 0.24);
transition: transform 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
}
.login-button:hover {
transform: translateY(-2px);
background: #ffe8b8;
box-shadow: 0 16px 34px rgba(125, 31, 0, 0.3);
}
.login-button:active {
transform: translateY(0);
}
@media (max-width: 520px) {
.login-panel {
width: calc(100% - 32px);
padding: 34px 24px 30px;
}
h1 {
font-size: 30px;
}
}
</style>
<script setup lang="ts">
import { cssAssetUrl } from '@/commons/assets.ts';
import { $read } from '@/commons/utils';
import router from '@/router';
import { ref } from 'vue';
import Login from './Login.vue';
const imageUrls = {
bg1: cssAssetUrl('main-bg1.png'),
logo: cssAssetUrl('game1/logo.png'),
title1: cssAssetUrl('main-title.png'),
}
const list = ref(['击鼓齐福', '聚宝接福',
'马上有福', '策马迎福', '圈彩纳福', '福运当头'
])
const show_login = ref(false);
const item_index = ref(0);
const gameHandler = async (index: number) => {
item_index.value = index;
let token = $read('token', '')
if (!token) {
//需要登录
show_login.value = true;
return;
}
await router.replace(`/game${index + 1}`);
}
const loginSuccessHandler = () => {
show_login.value = false;
gameHandler(item_index.value);
}
</script>
<template>
<Login v-if="show_login" @loginSuccess="loginSuccessHandler" />
<div class="bg">
<div class="img-logo"></div>
<div class="img-title1"></div>
<div class="rule-container">
<div>
<div class="rule-title">游戏规则</div>
<div class="rule-text">
扫码即玩,大屏实时同步。每局结束大屏公布前10名。主持人邀请获奖用户,凭获奖界面作为凭证,上台领奖。,核对后发放实物奖品。奖品不折现、不替换,逾期作废。
</div>
</div>
</div>
<div class="game-container">
<div v-for="item, index in list" @click="gameHandler(index)">
<div class="card">
<div class="img">
<div></div>
</div>
<div class="btn">{{ item }}</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
......@@ -30,9 +67,8 @@ const imageUrls = {
flex-direction: column;
overflow: hidden;
position: relative;
}
.img-logo {
.img-logo {
background: v-bind('imageUrls.logo');
background-size: cover;
width: 428px;
......@@ -40,52 +76,110 @@ const imageUrls = {
position: absolute;
left: 37px;
top: 82px;
}
}
.img-title1 {
.img-title1 {
background: v-bind('imageUrls.title1') center / cover no-repeat;
width: 1024px;
height: 143px;
margin: 0 auto;
position: relative;
top: 50px;
}
}
.rule-container {
.rule-container {
box-sizing: border-box;
width: 638px;
min-height: 162px;
border-radius: 12px;
background:
linear-gradient(90deg, rgba(124, 77, 28, 0.74), rgba(136, 122, 76, 0.72)),
rgba(124, 91, 48, 0.52);
backdrop-filter: blur(18px) saturate(0.9);
-webkit-backdrop-filter: blur(18px) saturate(0.9);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.18),
inset 0 -1px 0 rgba(0, 0, 0, 0.08);
color: white;
padding: 27px 34px 24px;
position: absolute;
left: 0;
top: 0;
left: 50%;
top: 341px;
transform: translateX(-50%);
font-weight: 700;
text-shadow:
0 2px 0 rgba(0, 0, 0, 0.65),
1px 0 0 rgba(0, 0, 0, 0.45),
-1px 0 0 rgba(0, 0, 0, 0.45),
0 -1px 0 rgba(0, 0, 0, 0.45);
}
-1px -1px 0 #000,
1px -1px 0 #000,
-1px 1px 0 #000,
1px 1px 0 #000;
background: rgba(0, 0, 0, 0.25);
backdrop-filter: blur(5px);
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.1);
.rule-title {
>div {
padding: 27px 34px 24px;
background: rgba(0, 0, 0, 0.15);
border-radius: 12px;
}
.rule-title {
margin-bottom: 12px;
font-size: 22px;
line-height: 1.2;
text-align: center;
}
}
.rule-text {
.rule-text {
font-size: 16px;
line-height: 1.58;
}
}
.game-container {
display: flex;
justify-content: space-between;
flex-direction: row;
width: 1220px;
height: 250px;
position: absolute;
left: 50%;
bottom: 250px;
transform: translateX(-50%);
.card {
padding: 10px;
cursor: pointer;
.img {
width: 150px;
height: 150px;
background: white;
border-radius: 20px;
position: relative;
transition: transform 0.2s ease 0.1s;
>div {
width: calc(100% - 20px);
height: calc(100% - 20px);
border: 1px solid red;
border-radius: 20px;
position: absolute;
left: 10px;
top: 10px;
}
}
.btn {
width: 150px;
height: 56px;
line-height: 56px;
background: #FFDF9E;
text-align: center;
color: #7D1F00;
font-weight: bold;
border-radius: 15px;
margin-top: 20px;
}
&:hover .img {
transform: scale(1.1);
}
}
}
}
</style>
......@@ -148,6 +148,7 @@ const { startGame, createRoom, backRoom } = useAdminGameSocket({
onRelogin: async () => {
$remove_socket_storage();
await router.replace('/')
debugger
}
})
......
import { createRouter, createWebHashHistory } from 'vue-router'
import { $read } from '@/commons/utils'
import Main from '@/pages/Main.vue'
import Login from '@/pages/Login.vue'
import Game1 from '@/pages/game1/Game.vue'
import Game6 from '@/pages/game6/Game.vue'
import Game4 from '@/pages/game4/Game4.vue'
......@@ -19,11 +18,6 @@ const router = createRouter({
component: Main,
},
{
path: '/login',
name: 'Login',
component: Login,
},
{
path: '/game1',
name: 'Game1',
component: Game1,
......@@ -58,7 +52,7 @@ router.beforeEach((to) => {
const token = $read('token', '')
if (to.meta.requiresAuth && !token) {
return '/login'
return '/'
}
return true
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment