更新功能、刷新不立即解散房间、交互动画优化

This commit is contained in:
李琦
2026-08-15 09:15:06 +08:00
parent cd1ab90013
commit 7c56d1e658
6 changed files with 3363 additions and 917 deletions

View File

@@ -4,7 +4,7 @@
// emit('score', n)、emit('end', {score}),带关卡的游戏另有 emit('progress', 解锁到的关卡)
// 组队游戏(meta.coop,如饥荒):开始面板变状态机——模式选择/更衣室/联机房间
import { ref, computed, shallowRef, watch, onMounted, onBeforeUnmount } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useRoute, useRouter, onBeforeRouteLeave } from 'vue-router'
import http, { toast } from '../api/http'
import { useUserStore } from '../stores/user'
import { useStarveCoopStore } from '../stores/starveCoop'
@@ -54,10 +54,22 @@ const buyingSkin = ref('') // 正在购买的皮肤(防连点)
const joinCode = ref('') // 加入房间的邀请码输入
const coopBusy = ref(false) // 连接/建房中
const coopPlaying = ref(false) // 当前对局是否联机局(结束时不删单人存档)
// ---- 饥荒页内顶部信息条:悬浮小条,自动隐藏,鼠标靠近顶部展示 ----
const floatHead = computed(() => code === 'starve')
const headShow = ref(false)
function onHeadMouseMove(e) {
if (!floatHead.value) return
headShow.value = e.clientY <= 130
}
const playHeadClasses = computed(() => ({
'play-head-float': floatHead.value,
'play-head-hidden': floatHead.value && !headShow.value,
}))
// ---- Mod 装载(注册表标记 mods 的游戏,如饥荒;勾选记本地) ----
const modsAvail = meta?.mods ? modMeta() : []
const selMods = ref([])
const showModModal = ref(false)
try {
selMods.value = JSON.parse(localStorage.getItem('mods_' + code) || '[]').filter((id) => modsAvail.some((m) => m.id === id))
} catch { selMods.value = [] }
@@ -67,6 +79,10 @@ function toggleMod(id) {
else selMods.value.push(id)
localStorage.setItem('mods_' + code, JSON.stringify(selMods.value))
}
function clearMods() {
selMods.value = []
localStorage.setItem('mods_' + code, JSON.stringify([]))
}
// 勾选组合的得分系数(难度 mod 相乘,夹在 0.5~1.5)
const modFactor = computed(() => {
let f = 1
@@ -356,6 +372,11 @@ function startCoopGame() {
}
// 房间座位视图数据(补上空位)
const roomSeats = computed(() => coopStore.roomState?.seats || [])
const overlayClasses = computed(() => ({
scrollable: levelsMeta || (coopable && ['skins', 'solo', 'room'].includes(coopStage)),
room: coopable && coopStage === 'room' && coopStore.inRoom,
'room-entry': coopable && coopStage === 'room' && !coopStore.inRoom,
}))
const allReady = computed(() => {
const occ = roomSeats.value.filter((s) => s.occupied)
return occ.length >= 1 && occ.every((s) => s.ready)
@@ -456,17 +477,58 @@ function again() {
// 从全局邀请弹窗跳转而来:自动连 ws 并加入房间
async function tryPendingJoin() {
if (!coopable) return
const pendingCode = sessionStorage.getItem('pending_join_code')
let pendingCode = sessionStorage.getItem('pending_join_code')
if (pendingCode) {
sessionStorage.removeItem('pending_join_code')
sessionStorage.removeItem('pending_join_game')
} else {
pendingCode = localStorage.getItem('starve_last_room_code')
if (!pendingCode) return
}
if (!pendingCode) return
sessionStorage.removeItem('pending_join_code')
sessionStorage.removeItem('pending_join_game')
coopBusy.value = true
coopStage.value = 'room'
try {
if (coopStore.inRoom) coopStore.leaveRoom()
await coopStore.connect()
coopStage.value = 'room'
joinCode.value = pendingCode
coopStore.joinRoom(pendingCode)
// 等待服务端自动重挂(30秒断线保护);重挂成功则无需再发 join_room
await new Promise((resolve) => {
if (coopStore.inRoom) return resolve(true)
const t = setTimeout(() => {
stopReattach?.()
resolve(false)
}, 800)
const stopReattach = watch(() => coopStore.inRoom, (v) => {
if (v) {
clearTimeout(t)
stopReattach?.()
resolve(true)
}
})
})
if (!coopStore.inRoom) coopStore.joinRoom(pendingCode)
const ok = await new Promise((resolve) => {
if (coopStore.inRoom) return resolve(true)
const t = setTimeout(() => {
stop?.()
resolve(false)
}, 3000)
const stop = watch(() => coopStore.inRoom, (v) => {
if (v) {
clearTimeout(t)
stop?.()
resolve(true)
}
})
})
if (!ok) {
toast('自动回房失败,请手动创建或加入房间', 'info')
try { localStorage.removeItem('starve_last_room_code') } catch (e) {}
}
} catch {
toast('联机服务连接失败,请稍后再试')
} finally {
@@ -474,7 +536,20 @@ async function tryPendingJoin() {
}
}
// 正常导航离开(不是刷新)时清掉“自动回房”记忆;刷新不会触发路由离开守卫,因此记忆会保留
onBeforeRouteLeave(() => {
try { localStorage.removeItem('starve_last_room_code') } catch (e) {}
})
// 兜底:房间状态里只要有邀请码,就写入自动回房记忆
watch(() => coopStore.roomState?.code, (code) => {
if (code) {
try { localStorage.setItem('starve_last_room_code', code) } catch (e) {}
}
})
onMounted(() => {
window.addEventListener('mousemove', onHeadMouseMove)
// 宽屏游戏(如饥荒):给 body 打标解除全局 1200px 限宽
if (meta?.wide) document.body.classList.add('wide-page')
load()
@@ -486,6 +561,7 @@ onMounted(() => {
onBeforeUnmount(() => {
document.body.classList.remove('wide-page')
window.removeEventListener('nlg:pending-join', tryPendingJoin)
window.removeEventListener('mousemove', onHeadMouseMove)
clearInterval(inviteTickTimer)
gameRef.value?.stop()
// 离开游玩页断开联机连接(房间自动退出)
@@ -496,7 +572,7 @@ onBeforeUnmount(() => {
<template>
<div v-if="detail" class="play-wrap" :class="{ 'play-fill': meta?.wide }">
<!-- 顶部信息条 -->
<div class="play-head panel">
<div class="play-head panel" :class="playHeadClasses">
<button class="btn btn-ghost btn-sm" @click="router.back()">← 返回</button>
<GameIcon class="g-icon" :code="code" :icon="detail.game.icon" :size="34" />
<div class="g-info">
@@ -513,7 +589,7 @@ onBeforeUnmount(() => {
<div class="stage panel">
<component :is="gameComp" v-if="gameComp" ref="gameRef" @score="onScore" @end="onEnd" @progress="onProgress" @save="onSave" />
<!-- 开始遮罩:带关卡的游戏显示关卡选择;组队游戏显示模式选择状态机;其余显示开始按钮 -->
<div v-if="phase === 'ready'" class="overlay" :class="{ scrollable: levelsMeta || (coopable && ['skins', 'solo', 'room'].includes(coopStage)) }">
<div v-if="phase === 'ready'" class="overlay" :class="overlayClasses">
<!-- ============ 组队游戏(如饥荒):模式选择 / 单人 / 更衣室 / 房间 ============ -->
<template v-if="coopable">
<!-- 1. 模式选择 -->
@@ -566,6 +642,11 @@ onBeforeUnmount(() => {
<span v-if="m.factor !== 1" class="mp-factor" :class="{ up: m.factor > 1 }">×{{ m.factor }}</span>
</label>
</div>
<button v-if="modsAvail.length" class="btn btn-ghost mod-trigger" @click="showModModal = true">
🧩 Mod 装载
<span v-if="selMods.length" class="mod-trigger-count">{{ selMods.length }} 个已选</span>
<span v-if="modFactor !== 1" class="mod-trigger-factor" :class="{ up: modFactor > 1 }">得分 ×{{ modFactor }}</span>
</button>
<template v-if="saveable && saveInfo.exists">
<div class="save-card">
<b>发现存档</b>
@@ -654,6 +735,11 @@ onBeforeUnmount(() => {
<span v-if="m.factor !== 1" class="mp-factor" :class="{ up: m.factor > 1 }">×{{ m.factor }}</span>
</label>
</div>
<button v-if="coopStore.isHost && modsAvail.length" class="btn btn-ghost mod-trigger" @click="showModModal = true">
🧩 本局 Mod(房主设定)
<span v-if="selMods.length" class="mod-trigger-count">{{ selMods.length }} 个已选</span>
<span v-if="modFactor !== 1" class="mod-trigger-factor" :class="{ up: modFactor > 1 }">得分 ×{{ modFactor }}</span>
</button>
<!-- 房主邀请好友:在线好友一键发弹窗邀请,离线好友发私聊兜底 -->
<div v-if="coopStore.isHost && friendList.length" class="friend-invite">
<div class="fi-head">邀请好友({{ friendList.length }})· 同一人 1 分钟内不可重复</div>
@@ -775,7 +861,40 @@ onBeforeUnmount(() => {
<span v-else class="prop-buy">💰{{ p.price }}</span>
</button>
</div>
<!-- Mod 选择模态框:卡片多选 -->
<div v-if="showModModal" class="modal-mask" @click.self="showModModal = false">
<div class="mod-modal panel">
<button class="bm-close" @click="showModModal = false">✕</button>
<h3>选择 Mod</h3>
<p class="text-dim" style="font-size: 12px; margin-top: -4px">
新开局时生效 · 已选 {{ selMods.length }} 个{{ modFactor !== 1 ? ` · 得分 ×${modFactor}` : '' }}
</p>
<div class="mod-card-grid">
<div
v-for="m in modsAvail"
:key="m.id"
class="mod-card"
:class="{ on: selMods.includes(m.id) }"
@click="toggleMod(m.id)"
>
<div class="mod-card-check">{{ selMods.includes(m.id) ? '✓' : '' }}</div>
<div class="mod-card-name">{{ m.name }}</div>
<div class="mod-card-desc">{{ m.desc }}</div>
<div class="mod-card-foot">
<span class="mod-card-tag">{{ m.tag }}</span>
<span v-if="m.factor !== 1" class="mod-card-factor" :class="{ up: m.factor > 1 }">得分 ×{{ m.factor }}</span>
</div>
</div>
</div>
<div class="ov-btns">
<button class="btn btn-ghost" @click="clearMods">清空</button>
<button class="btn" @click="showModModal = false">确认</button>
</div>
</div>
</div>
</div>
<!-- 未拥有提示 -->
<div v-else-if="phase === 'locked'" class="locked panel">
<div style="font-size: 50px">🔒</div>
@@ -1324,4 +1443,218 @@ onBeforeUnmount(() => {
font-size: 12px;
color: var(--text-dim, #9a9ac4);
}
/* ---- Mod 改为卡片多选模态框:隐藏旧的平铺列表,触发按钮 + 卡片模态框 ---- */
.mod-pick {
display: none;
}
.mod-trigger {
width: min(420px, 92%);
justify-content: center;
gap: 8px;
}
.mod-trigger-count {
font-size: 12px;
color: var(--primary-2);
font-weight: 700;
}
.mod-trigger-factor {
font-size: 12px;
font-weight: 700;
color: #67d18f;
}
.mod-trigger-factor.up {
color: #e0a63c;
}
.modal-mask {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(3px);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
padding: 20px;
}
.mod-modal {
position: relative;
width: min(680px, 100%);
max-height: 84vh;
overflow: auto;
padding: 20px 18px 18px;
}
.bm-close {
position: absolute;
top: 10px;
right: 12px;
border: 0;
background: transparent;
color: var(--text-dim);
font-size: 16px;
cursor: pointer;
}
.bm-close:hover {
color: var(--text);
}
.mod-card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 10px;
margin: 14px 0;
}
.mod-card {
position: relative;
background: var(--bg-card);
border: 1.5px solid var(--border);
border-radius: 12px;
padding: 12px;
cursor: pointer;
transition: all 0.15s;
display: flex;
flex-direction: column;
gap: 6px;
}
.mod-card:hover {
border-color: var(--primary);
transform: translateY(-2px);
}
.mod-card.on {
border-color: var(--accent);
box-shadow: 0 0 12px color-mix(in srgb, var(--accent) 35%, transparent);
background: color-mix(in srgb, var(--accent) 10%, var(--bg-card));
}
.mod-card-check {
position: absolute;
top: 8px;
right: 8px;
width: 22px;
height: 22px;
border-radius: 50%;
border: 2px solid var(--border);
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 800;
color: #fff;
}
.mod-card.on .mod-card-check {
background: var(--accent);
border-color: var(--accent);
}
.mod-card-name {
font-weight: 800;
font-size: 14px;
padding-right: 26px;
}
.mod-card-desc {
font-size: 12px;
color: var(--text-dim);
line-height: 1.5;
}
.mod-card-foot {
display: flex;
align-items: center;
gap: 6px;
margin-top: auto;
}
.mod-card-tag {
font-size: 11px;
padding: 2px 8px;
border-radius: 999px;
border: 1px solid var(--border);
color: var(--text-dim);
}
.mod-card-factor {
font-size: 11px;
font-weight: 700;
color: #67d18f;
margin-left: auto;
}
.mod-card-factor.up {
color: #e0a63c;
}
/* ---- 邀请好友移到右侧悬浮栏,不挤占中间 ---- */
.friend-invite {
position: absolute;
right: 16px;
top: 16px;
bottom: 16px;
width: 260px;
max-height: none;
height: auto;
z-index: 6;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
}
@media (max-width: 900px) {
.friend-invite {
position: static;
width: min(440px, 96%);
height: auto;
max-height: 220px;
box-shadow: none;
}
}
.overlay.room {
padding-right: 280px;
}
@media (max-width: 900px) {
.overlay.room {
padding-right: 20px;
}
}
/* ---- 饥荒页内顶部信息条:悬浮小条,自动隐藏,鼠标靠近顶部展示 ---- */
.play-head.play-head-float {
position: fixed;
top: 40px;
left: 12px;
right: 12px;
z-index: 149;
transition: transform 0.25s ease;
margin: 0;
}
.play-head.play-head-float.play-head-hidden {
transform: translateY(calc(-100% - 40px));
}
/* ---- 创建/加入房间入口:卡片式排版 ---- */
.overlay.room-entry .room-entry-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 14px;
padding: 16px 18px;
}
.overlay.room-entry .ov-btns {
width: min(360px, 92%);
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 14px;
padding: 14px 16px;
justify-content: center;
}
.overlay.room-entry .ov-btns .btn-lg {
width: 100%;
}
.overlay.room-entry .join-row {
width: min(360px, 92%);
justify-content: center;
}
.overlay.room-entry .join-row {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 12px 14px;
gap: 10px;
}
.overlay.room-entry .join-input {
width: 180px;
height: 38px;
}
/* 选择组队(创建/加入)和房间面板:垂直居中,不要贴顶 */
.overlay.room,
.overlay.room-entry {
justify-content: center;
}
</style>