Files
nl-game/src/views/GamePlay.vue

1661 lines
53 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
// 游玩页:统一的游戏容器(开始遮罩/关卡选择 → 游戏进行 + 道具栏 → 结算遮罩)
// 与游戏组件的约定:组件暴露 start(opts)/stop/useProp 方法,
// emit('score', n)、emit('end', {score}),带关卡的游戏另有 emit('progress', 解锁到的关卡)
// 组队游戏meta.coop如饥荒开始面板变状态机——模式选择/更衣室/联机房间
import { ref, computed, shallowRef, watch, onMounted, onBeforeUnmount } from 'vue'
import { useRoute, useRouter, onBeforeRouteLeave } from 'vue-router'
import http, { toast } from '../api/http'
import { useUserStore } from '../stores/user'
import { useStarveCoopStore } from '../stores/starveCoop'
import { gameRegistry } from '../games'
import { renderSkinPreview } from '../games/starveSkins'
import { modMeta } from '../games/starve/mods/registry'
import LevelSelect from '../components/LevelSelect.vue'
import GameIcon from '../components/GameIcon.vue'
import PixelAvatar from '../components/PixelAvatar.vue'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const code = route.params.code
const meta = gameRegistry[code] // 注册表信息(组件、道具、操作说明、关卡配置)
const gameComp = shallowRef(null) // 动态加载的游戏组件
const gameRef = ref(null) // 游戏组件实例引用
const detail = ref(null) // {game, owned, best_score}
const phase = ref('loading') // loading / locked / ready / playing / over
const liveScore = ref(0) // 实时得分
const finalScore = ref(0) // 结束得分
const result = ref(null) // 结算响应
const useDouble = ref(false) // 是否使用双倍积分卡
const bag = ref([]) // 我的道具背包
const catalog = ref([]) // 道具商城目录(道具栏一键购买用)
const startAt = ref(0) // 本局开始时间(算时长)
const settling = ref(false)
const buyingProp = ref('') // 正在购买的道具码(防连点)
// ---- 关卡进度(带 levels 配置的游戏才启用) ----
const levelsMeta = meta?.levels || null // {total, names?}
const progress = ref({ level: 1, friends: [] }) // 我的进度 + 好友进度
// ---- 云存档(注册表标记 saveable 的游戏才启用,如饥荒) ----
const saveable = !!meta?.saveable
const saveInfo = ref({ exists: false }) // {exists, day, score, data}
// ---- 组队联机 + 皮肤更衣室(注册表标记 coop 的游戏才启用,如饥荒) ----
const coopable = !!meta?.coop
const coopStore = useStarveCoopStore()
const coopStage = ref('mode') // 开始面板子状态mode 模式选择 / solo 单人 / skins 更衣室 / room 联机房间
const skins = ref([]) // 后端皮肤列表 [{code,name,price,owned}]
const mySkin = ref(localStorage.getItem('skin_' + code) || 'wilson') // 我选中的皮肤(记本地)
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 = [] }
function toggleMod(id) {
const i = selMods.value.indexOf(id)
if (i >= 0) selMods.value.splice(i, 1)
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
modsAvail.forEach((m) => { if (selMods.value.includes(m.id)) f *= m.factor })
return Math.round(Math.max(0.5, Math.min(1.5, f)) * 100) / 100
})
// 存档里记录的 mod 名单(继续冒险时只读展示,以存档为准)
const saveModNames = computed(() => {
if (!saveInfo.value?.exists || !saveInfo.value.data) return []
try {
const ids = JSON.parse(saveInfo.value.data).mods || []
return modsAvail.filter((m) => ids.includes(m.id)).map((m) => m.name)
} catch { return [] }
})
// 背包中指定道具的数量
function propCount(propCode) {
return bag.value.find((p) => p.code === propCode)?.quantity || 0
}
// 局内道具栏:本游戏支持的全部局内道具(没库存也展示,可一键购买)
// 双倍卡在开始面板、复活卡在结算面板单独处理
const inGameProps = computed(() =>
(meta?.supports || [])
.filter((c) => !['double_points', 'revive'].includes(c))
.map((c) => {
const item = catalog.value.find((p) => p.code === c)
if (!item) return null
return { ...item, quantity: propCount(c) }
})
.filter(Boolean)
)
// 结算面板是否可复活
const canRevive = computed(() => meta?.supports.includes('revive') && propCount('revive') > 0)
// 加载游戏详情、背包、道具目录与关卡进度
async function load() {
if (!meta) {
toast('该游戏暂未上线')
router.replace('/')
return
}
const [d, props, shop] = await Promise.all([
http.get(`/games/${code}`),
http.get('/user/props'),
http.get('/shop/props'),
])
detail.value = d
bag.value = props
catalog.value = shop
// 已拥有或在VIP周免批次内都可进入playable 由后端计算)
if (!d.playable) {
phase.value = 'locked'
return
}
// 带关卡的游戏:读取我上次玩到第几关 + 好友进度
if (levelsMeta) {
progress.value = await http.get(`/games/${code}/progress`)
}
// 支持存档的游戏:读取云存档(有档则开始面板显示「继续冒险」)
if (saveable) {
saveInfo.value = await http.get(`/games/${code}/save`).catch(() => ({ exists: false }))
}
// 组队游戏:拉取皮肤商城列表(选中的皮肤若未拥有则回退默认)
if (coopable) {
skins.value = await http.get(`/games/${code}/skins`).catch(() => [])
const mine = skins.value.find((s) => s.code === mySkin.value)
if (mine && !mine.owned) mySkin.value = skins.value.find((s) => s.owned)?.code || 'wilson'
}
// 异步加载游戏组件
const mod = await meta.loader()
gameComp.value = mod.default
phase.value = 'ready'
}
// 开始本局:数字=起始关卡(关卡选择面板传入);{save}=从云存档继续
function startGame(arg = 1) {
const level = typeof arg === 'number' ? arg : 1
const save = typeof arg === 'object' && arg?.save ? arg.save : null
liveScore.value = save?.score || 0
result.value = null
coopPlaying.value = false
phase.value = 'playing'
startAt.value = Date.now()
// 等游戏组件挂载后启动(组件为异步 chunk逐帧重试直到实例就绪避免只等一帧的竞态
const deadline = performance.now() + 3000
const tryStart = () => {
// 新开局带上勾选的 mod续档时以存档内记录为准组件侧处理
if (gameRef.value?.start) gameRef.value.start({ level, save, skin: mySkin.value, mods: save ? undefined : [...selMods.value] })
else if (performance.now() < deadline) requestAnimationFrame(tryStart)
}
requestAnimationFrame(tryStart)
}
// 从云存档继续(解析失败则按新开一局处理)
function continueSave() {
let snap = null
try {
snap = JSON.parse(saveInfo.value.data)
} catch (e) {
snap = null
}
startGame(snap ? { save: snap } : 1)
}
// 放弃存档重新开始:先删服务端存档再开新局
async function restartFresh() {
await http.delete(`/games/${code}/save`).catch(() => {})
saveInfo.value = { exists: false }
startGame()
}
// 游戏内自动/手动存档:转存服务端(快照由游戏组件序列化)
function onSave(snap) {
if (!saveable || !snap) return
saveInfo.value = { exists: true, day: snap.day, score: snap.score, data: JSON.stringify(snap) }
http.post(`/games/${code}/save`, { day: snap.day || 1, score: snap.score || 0, data: JSON.stringify(snap) }).catch(() => {})
}
// 游戏内过关:上报解锁的新关卡(只增不减,后端同样兜底)
function onProgress(newLevel) {
if (!levelsMeta || newLevel <= progress.value.level) return
progress.value.level = newLevel
http.post(`/games/${code}/progress`, { level: newLevel }).catch(() => {})
}
// 游戏组件实时报分
function onScore(n) {
liveScore.value = n
}
// 游戏结束回调:单人存档游戏死亡即删档(复刻原版永久死亡);联机局不动单人存档
function onEnd(payload) {
finalScore.value = payload?.score ?? liveScore.value
phase.value = 'over'
if (saveable && !coopPlaying.value) {
saveInfo.value = { exists: false }
http.delete(`/games/${code}/save`).catch(() => {})
}
coopPlaying.value = false
}
// =====================================================================
// 组队联机 + 更衣室coopable 游戏专属)
// =====================================================================
// 皮肤立绘预览(离屏渲染 dataURL模块级缓存
function skinPreview(c) {
return renderSkinPreview(c, 72)
}
// 选择皮肤:已拥有才能选中;记本地 + 在房间时同步给座位
function pickSkin(s) {
if (!s.owned) return
mySkin.value = s.code
localStorage.setItem('skin_' + code, s.code)
if (coopStore.inRoom) coopStore.setSkin(s.code)
}
// 购买皮肤:扣积分 → 标记已拥有并自动穿上
async function buySkin(s) {
if (buyingSkin.value || s.owned) return
buyingSkin.value = s.code
try {
const res = await http.post(`/games/${code}/skins/buy`, { skin_code: s.code })
userStore.setPoints(res.balance)
s.owned = true
toast(`已解锁「${s.name}」(-${s.price} 积分)`, 'success')
pickSkin(s)
} catch (e) {
// 拦截器已提示
} finally {
buyingSkin.value = ''
}
}
// 进入组队:建立联机连接后进入房间面板
async function enterCoop() {
if (coopBusy.value) return
coopBusy.value = true
try {
await coopStore.connect()
coopStage.value = 'room'
} catch (e) {
toast('联机服务连接失败,请稍后再试')
} finally {
coopBusy.value = false
}
}
// 建房/加入前确保连接可用(断线回退到本面板后再操作时自动重连)
async function ensureCoopConn() {
try {
await coopStore.connect()
return true
} catch (e) {
toast('联机服务连接失败,请稍后再试')
return false
}
}
async function createRoom() {
if (await ensureCoopConn()) coopStore.createRoom()
}
async function joinRoom() {
if (!joinCode.value.trim()) {
toast('请输入 6 位邀请码', 'info')
return
}
if (await ensureCoopConn()) coopStore.joinRoom(joinCode.value)
}
function leaveRoom() {
coopStore.leaveRoom()
}
// ---- 房主邀请好友:在线推弹窗、离线落私聊;同一人 60 秒冷却 ----
const INVITE_CD_MS = 60_000
const friendList = ref([])
const inviteUntil = ref({}) // user_id -> 可再次邀请的时间戳(ms)
const nowTick = ref(Date.now())
let inviteTickTimer = null
function startInviteTick() {
if (inviteTickTimer) return
inviteTickTimer = setInterval(() => {
nowTick.value = Date.now()
const next = { ...inviteUntil.value }
let any = false
for (const k of Object.keys(next)) {
if (next[k] <= nowTick.value) delete next[k]
else any = true
}
inviteUntil.value = next
if (!any) {
clearInterval(inviteTickTimer)
inviteTickTimer = null
}
}, 1000)
}
function inviteLeft(uid) {
const left = Math.ceil(((inviteUntil.value[uid] || 0) - nowTick.value) / 1000)
return left > 0 ? left : 0
}
async function loadFriends() {
try {
friendList.value = await http.get('/friends')
} catch (e) { /* 拦截器已提示 */ }
}
function inviteFriend(uid) {
const left = inviteLeft(uid)
if (left > 0) {
toast(`${left} 秒后再邀请该好友`, 'info')
return
}
coopStore.send('invite', { user_id: uid })
inviteUntil.value = { ...inviteUntil.value, [uid]: Date.now() + INVITE_CD_MS }
startInviteTick()
toast('邀请已发送', 'success')
}
// 进入房间面板(成为房主)时拉取好友列表
watch(
() => coopStore.inRoom && coopStore.isHost,
(host) => { if (host) loadFriends() },
{ immediate: true }
)
// 入座成功后自动上报当前皮肤(座位立绘用)
watch(() => coopStore.inRoom, (v) => {
if (v) coopStore.setSkin(mySkin.value)
})
// 房主开始 → 后端广播 status=playing → 双端同时进入联机对局
watch(() => coopStore.roomState?.status, (s, old) => {
if (s === 'playing' && old !== 'playing' && phase.value !== 'playing') startCoopGame()
})
function startCoopGame() {
liveScore.value = 0
result.value = null
coopPlaying.value = true
phase.value = 'playing'
startAt.value = Date.now()
const coopCtx = {
isHost: coopStore.isHost,
mySeat: coopStore.mySeat,
players: coopStore.activePlayers,
send: (type, data) => coopStore.send(type, data),
onMessage: (cb) => coopStore.setGameHandler(cb),
offMessage: () => coopStore.clearGameHandler(),
}
const deadline = performance.now() + 3000
const tryStart = () => {
// 房主决定本局 mod 集合,客机等待全量快照对齐
if (gameRef.value?.start) gameRef.value.start({ coop: coopCtx, skin: mySkin.value, mods: coopStore.isHost ? [...selMods.value] : [] })
else if (performance.now() < deadline) requestAnimationFrame(tryStart)
}
requestAnimationFrame(tryStart)
}
// 房间座位视图数据(补上空位)
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)
})
// 使用局内道具:先扣库存再作用到游戏
async function useProp(propCode) {
try {
const res = await http.post('/props/use', { prop_code: propCode, game_code: code })
// 更新本地库存
const item = bag.value.find((p) => p.code === propCode)
if (item) item.quantity = res.remain
const applied = gameRef.value?.useProp(propCode)
if (!applied) toast('当前场景无法使用该道具', 'info')
else toast('道具已生效!', 'success')
} catch (e) {
// 拦截器已提示
}
}
// 道具栏点击:有库存直接用;没库存则一键购买后立即使用(积分不足由拦截器提示)
async function clickProp(p) {
if (p.quantity > 0) {
useProp(p.code)
return
}
if (buyingProp.value) return
buyingProp.value = p.code
try {
await http.post('/shop/buy', { item_type: 2, item_id: p.id, quantity: 1 })
await userStore.refresh()
bag.value = await http.get('/user/props')
toast(`已购买「${p.name}」(-${p.price} 积分)`, 'success')
await useProp(p.code)
} catch (e) {
// 拦截器已提示
} finally {
buyingProp.value = ''
}
}
// 复活:扣复活卡并让游戏原地复活继续
async function revive() {
try {
const res = await http.post('/props/use', { prop_code: 'revive', game_code: code })
const item = bag.value.find((p) => p.code === 'revive')
if (item) item.quantity = res.remain
phase.value = 'playing'
gameRef.value?.useProp('revive')
toast('复活成功,继续加油!', 'success')
} catch (e) {
// 拦截器已提示
}
}
// 结算:上报得分换积分
async function settle() {
if (settling.value) return
settling.value = true
try {
const res = await http.post(`/games/${code}/score`, {
score: finalScore.value,
duration: Math.round((Date.now() - startAt.value) / 1000),
use_double: useDouble.value,
})
result.value = res
userStore.setPoints(res.balance)
if (res.is_new_high) toast('🎉 刷新个人纪录!', 'success')
useDouble.value = false
// 双倍卡已消耗,刷新背包
bag.value = await http.get('/user/props')
// 刷新最高分显示
detail.value.best_score = Math.max(detail.value.best_score, finalScore.value)
} catch (e) {
// 拦截器已提示
} finally {
settling.value = false
}
}
// 再来一局:带关卡的游戏回到关卡选择,存档/组队游戏回开始面板,其余直接重开
function again() {
result.value = null
if (coopable) {
// 联机局结束后后端会把房间打回 waiting只要还在房 / 本局是联机,就回房间面板(不要回模式首页)
const backToRoom = coopStore.inRoom || coopPlaying.value
coopPlaying.value = false
coopStage.value = backToRoom ? 'room' : 'mode'
phase.value = 'ready'
// 房间还在但 WS 断了:尝试重连,方便房主直接再开一局
if (backToRoom && !coopStore.connected) {
coopStore.connect().catch(() => toast('联机服务连接失败,请重新进入组队', 'info'))
}
} else if (levelsMeta || saveable) phase.value = 'ready'
else startGame()
}
// 从全局邀请弹窗跳转而来:自动连 ws 并加入房间
async function tryPendingJoin() {
if (!coopable) return
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
// 等待服务端自动重挂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 {
coopBusy.value = false
}
}
// 正常导航离开(不是刷新)时清掉“自动回房”记忆;刷新不会触发路由离开守卫,因此记忆会保留
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()
if (coopable) {
window.addEventListener('nlg:pending-join', tryPendingJoin)
tryPendingJoin()
}
})
onBeforeUnmount(() => {
document.body.classList.remove('wide-page')
window.removeEventListener('nlg:pending-join', tryPendingJoin)
window.removeEventListener('mousemove', onHeadMouseMove)
clearInterval(inviteTickTimer)
gameRef.value?.stop()
// 离开游玩页断开联机连接(房间自动退出)
if (coopable) coopStore.disconnect()
})
</script>
<template>
<div v-if="detail" class="play-wrap" :class="{ 'play-fill': meta?.wide }">
<!-- 顶部信息条 -->
<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">
<b>{{ detail.game.name }}</b>
<span class="text-dim" style="font-size: 12px">{{ meta.controls }}</span>
</div>
<div class="g-stats">
<span class="stat-chip">本局 <b class="text-accent">{{ liveScore }}</b></span>
<span class="stat-chip">最高 <b class="text-primary">{{ detail.best_score }}</b></span>
<span class="stat-chip">💰 {{ userStore.user?.points ?? 0 }}</span>
</div>
</div>
<!-- 游戏区域 -->
<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="overlayClasses">
<!-- ============ 组队游戏如饥荒模式选择 / 单人 / 更衣室 / 房间 ============ -->
<template v-if="coopable">
<!-- 1. 模式选择 -->
<template v-if="coopStage === 'mode'">
<div class="ov-icon"><GameIcon :code="code" :icon="detail.game.icon" :size="56" /></div>
<h3>{{ detail.game.name }}</h3>
<p class="text-dim ov-desc">{{ detail.game.description }}</p>
<div class="mode-cards">
<button class="mode-card" @click="coopStage = 'solo'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="8" r="3.4" />
<path d="M5.5 20c.6-4 3.2-6 6.5-6s5.9 2 6.5 6" />
</svg>
<b>单人冒险</b>
<i>云存档续玩 · 死亡删档</i>
</button>
<button class="mode-card" :disabled="coopBusy" @click="enterCoop">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<circle cx="8.5" cy="8.5" r="2.8" />
<circle cx="16" cy="9.5" r="2.4" />
<path d="M3.5 19c.5-3.2 2.6-4.8 5-4.8s4.5 1.6 5 4.8" />
<path d="M14.5 18.5c.4-2.6 2-3.9 3.9-3.9 1.4 0 2.7.8 3.4 2.4" />
</svg>
<b>{{ coopBusy ? '连接中…' : '组队联机' }}</b>
<i>2~4 人共享同一世界</i>
</button>
</div>
<button class="btn btn-ghost" @click="coopStage = 'skins'">
更衣室 · 30 款人物皮肤
</button>
</template>
<!-- 2. 单人面板继续存档 / 新的冒险 -->
<template v-else-if="coopStage === 'solo'">
<div class="ov-icon"><GameIcon :code="code" :icon="detail.game.icon" :size="48" /></div>
<h3>单人冒险</h3>
<label v-if="meta.supports.includes('double_points') && propCount('double_points') > 0" class="double-check">
<input v-model="useDouble" type="checkbox" />
使用 双倍积分卡 {{ propCount('double_points') }} 本局积分翻倍
</label>
<!-- Mod 装载新开局生效继续冒险以存档内记录为准 -->
<div v-if="modsAvail.length" class="mod-pick">
<div class="mp-head">
<b>Mod 装载{{ saveable && saveInfo.exists ? '(重新开始时生效)' : '(可选)' }}</b>
<span v-if="modFactor !== 1" class="mp-total" :class="{ up: modFactor > 1 }">得分 ×{{ modFactor }}</span>
</div>
<label v-for="m in modsAvail" :key="m.id" class="mp-item" :class="{ on: selMods.includes(m.id) }">
<input type="checkbox" :checked="selMods.includes(m.id)" @change="toggleMod(m.id)" />
<span class="mp-name">{{ m.name }}</span>
<span class="mp-desc">{{ m.desc }}</span>
<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>
<span v-if="saveModNames.length" class="save-mods">Mod{{ saveModNames.join('') }}</span>
<span> {{ saveInfo.day }} · 得分 {{ saveInfo.score }}</span>
</div>
<div class="ov-btns">
<button class="btn btn-lg" @click="continueSave">继续冒险</button>
<button class="btn btn-ghost" @click="restartFresh">放弃存档重新开始</button>
</div>
</template>
<button v-else class="btn btn-lg" @click="startGame()">新的冒险</button>
<button class="btn btn-ghost btn-sm" @click="coopStage = 'mode'"> 返回</button>
</template>
<!-- 3. 更衣室30 款皮肤立绘网格已解锁可穿未解锁积分购买 -->
<template v-else-if="coopStage === 'skins'">
<h3>更衣室</h3>
<p class="text-dim" style="font-size: 12px">点击已解锁的皮肤穿上未解锁的用积分购买 · 余额 {{ userStore.user?.points ?? 0 }}</p>
<div class="skin-grid">
<div
v-for="s in skins"
:key="s.code"
class="skin-cell"
:class="{ on: mySkin === s.code, locked: !s.owned }"
:title="s.owned ? '点击穿上' : `${s.price} 积分解锁`"
@click="s.owned ? pickSkin(s) : null"
>
<img :src="skinPreview(s.code)" alt="" />
<span class="sk-name">{{ s.name }}</span>
<span v-if="mySkin === s.code" class="sk-tag on-tag">已穿上</span>
<button
v-else-if="!s.owned"
class="sk-buy"
:disabled="buyingSkin === s.code"
@click.stop="buySkin(s)"
>
{{ buyingSkin === s.code ? '购买中…' : s.price + ' 积分' }}
</button>
<span v-else class="sk-tag">已拥有</span>
</div>
</div>
<button class="btn btn-ghost btn-sm" @click="coopStage = 'mode'"> 返回</button>
</template>
<!-- 4. 联机房间建房/邀请码加入 座位面板 -->
<template v-else-if="coopStage === 'room'">
<template v-if="!coopStore.inRoom">
<h3>组队联机</h3>
<p class="text-dim" style="font-size: 12px">创建房间把邀请码发给好友或输入好友的邀请码加入2~4 </p>
<div class="ov-btns">
<button class="btn btn-lg" @click="createRoom">创建房间</button>
</div>
<div class="join-row">
<input v-model="joinCode" class="join-input" maxlength="6" placeholder="输入 6 位邀请码" @keyup.enter="joinRoom" />
<button class="btn" @click="joinRoom">加入</button>
</div>
<button class="btn btn-ghost btn-sm" @click="coopStage = 'mode'"> 返回</button>
</template>
<template v-else>
<h3>联机房间</h3>
<div class="invite-row">
邀请码 <b class="invite-code">{{ coopStore.roomState.code }}</b>
<span class="text-dim" style="font-size: 12px">发给好友加入</span>
</div>
<div class="seat-grid">
<div v-for="s in roomSeats" :key="s.index" class="seat-cell" :class="{ empty: !s.occupied, offline: s.occupied && !s.online }">
<template v-if="s.occupied">
<img class="seat-fig" :src="skinPreview(s.skin || 'wilson')" alt="" />
<b class="seat-name">{{ s.name }}<template v-if="s.user_id === coopStore.roomState.host_id">房主</template></b>
<span class="seat-state" :class="{ ok: s.ready }">{{ !s.online ? '掉线' : s.ready ? '已准备' : '未准备' }}</span>
</template>
<template v-else>
<span class="seat-empty-mark">空位</span>
</template>
</div>
</div>
<!-- 房主可为本局勾选 mod随世界快照同步给全员 -->
<div v-if="coopStore.isHost && modsAvail.length" class="mod-pick compact">
<div class="mp-head">
<b>本局 Mod房主设定</b>
<span v-if="modFactor !== 1" class="mp-total" :class="{ up: modFactor > 1 }">得分 ×{{ modFactor }}</span>
</div>
<label v-for="m in modsAvail" :key="m.id" class="mp-item" :class="{ on: selMods.includes(m.id) }">
<input type="checkbox" :checked="selMods.includes(m.id)" @change="toggleMod(m.id)" />
<span class="mp-name">{{ m.name }}</span>
<span class="mp-desc">{{ m.desc }}</span>
<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>
<div class="fi-list">
<button
v-for="f in friendList"
:key="f.user_id"
type="button"
class="fi-item"
:class="{ off: !f.online, sent: inviteLeft(f.user_id) > 0 }"
:disabled="inviteLeft(f.user_id) > 0"
:title="inviteLeft(f.user_id) > 0
? `${inviteLeft(f.user_id)} 秒后可再邀请`
: (f.online ? '点击发送弹窗邀请' : '好友离线:将发送私聊邀请')"
@click="inviteFriend(f.user_id)"
>
<span class="fi-avatar"><PixelAvatar :code="f.avatar || 'px:01'" :size="22" /></span>
<span class="fi-name">{{ f.nickname }}</span>
<span class="fi-dot" :class="{ on: f.online }"></span>
<span class="fi-act">{{
inviteLeft(f.user_id) > 0
? `${inviteLeft(f.user_id)}s`
: (f.online ? '邀请' : '留言')
}}</span>
</button>
</div>
</div>
<div class="ov-btns">
<button v-if="coopStore.isHost" class="btn btn-lg" :disabled="!allReady" :title="allReady ? '' : '等待全员准备'" @click="coopStore.startGame()">开始冒险</button>
<button
v-else
class="btn"
@click="coopStore.setReady(!roomSeats[coopStore.mySeat]?.ready)"
>{{ roomSeats[coopStore.mySeat]?.ready ? '取消准备' : '准备' }}</button>
<button class="btn btn-ghost" @click="coopStage = 'skins'">更衣室</button>
<button class="btn btn-ghost" @click="leaveRoom">离开房间</button>
</div>
</template>
</template>
</template>
<!-- ============ 普通游戏原有开始面板 ============ -->
<template v-else>
<div class="ov-icon"><GameIcon :code="code" :icon="detail.game.icon" :size="levelsMeta ? 40 : 56" /></div>
<h3>{{ detail.game.name }}</h3>
<p v-if="!levelsMeta" class="text-dim ov-desc">{{ detail.game.description }}</p>
<p class="text-dim" style="font-size: 12px">🎮 {{ meta.controls }}</p>
<label v-if="meta.supports.includes('double_points') && propCount('double_points') > 0" class="double-check">
<input v-model="useDouble" type="checkbox" />
使用 双倍积分卡 {{ propCount('double_points') }} 本局积分翻倍
</label>
<!-- 关卡选择读取上次进度展示好友都在哪一关 -->
<LevelSelect
v-if="levelsMeta"
:total="levelsMeta.total"
:names="levelsMeta.names"
:my-level="progress.level"
:friends="progress.friends"
@pick="startGame"
/>
<!-- 存档游戏有档显示继续 / 重新开始无档显示新的冒险 -->
<template v-else-if="saveable && saveInfo.exists">
<div class="save-card">
<b>发现存档</b>
<span> {{ saveInfo.day }} · 得分 {{ saveInfo.score }}</span>
</div>
<div class="ov-btns">
<button class="btn btn-lg" @click="continueSave">继续冒险</button>
<button class="btn btn-ghost" @click="restartFresh">放弃存档重新开始</button>
</div>
</template>
<button v-else class="btn btn-lg" @click="startGame()">{{ saveable ? '新的冒险' : '开始游戏' }}</button>
</template>
</div>
<!-- 结算遮罩 -->
<div v-if="phase === 'over'" class="overlay">
<template v-if="!result">
<div class="ov-icon">💀</div>
<h3>本局结束</h3>
<p class="final">得分 <b>{{ finalScore }}</b></p>
<div class="ov-btns">
<button v-if="canRevive" class="btn btn-accent" @click="revive">
使用复活卡继续 {{ propCount('revive') }}
</button>
<button class="btn" :disabled="settling" @click="settle">{{ settling ? '结算中' : '领取积分' }}</button>
</div>
</template>
<template v-else>
<div class="ov-icon">{{ result.is_new_high ? '🏆' : '🎉' }}</div>
<h3>{{ result.is_new_high ? '新纪录!' : '结算完成' }}</h3>
<p class="final">得分 <b>{{ finalScore }}</b></p>
<p class="got">
获得积分 <b class="text-primary">+{{ result.points_gained }}</b>
<span v-if="result.doubled" class="tag tag-accent">已双倍</span>
</p>
<p v-if="result.capped" class="text-dim" style="font-size: 12px">今日该游戏积分已达上限明天再来吧</p>
<p class="text-dim" style="font-size: 12px">当前余额 💰{{ result.balance }}</p>
<div class="ov-btns">
<button class="btn" @click="again">再来一局</button>
<button class="btn btn-ghost" @click="router.push('/')">返回大厅</button>
</div>
</template>
</div>
</div>
<!-- 局内道具栏支持的道具全部展示没库存的点击直接花积分买一张并立即生效 -->
<div v-if="phase === 'playing' && inGameProps.length" class="prop-bar panel">
<span class="text-dim" style="font-size: 12px">局内道具</span>
<button
v-for="p in inGameProps"
:key="p.code"
class="prop-btn"
:class="{ empty: !p.quantity }"
:disabled="buyingProp === p.code"
:title="p.description + (p.quantity ? '' : `(点击花 ${p.price} 积分购买并立即使用)`)"
@click="clickProp(p)"
>
<span class="prop-icon">{{ p.icon }}</span>
<span class="prop-name">{{ p.name }}</span>
<span v-if="p.quantity" class="prop-count">×{{ p.quantity }}</span>
<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>
<h3>尚未拥有该游戏</h3>
<p class="text-dim">前往游戏中心用积分购买后即可游玩</p>
<button class="btn" @click="router.push('/center')">去游戏中心</button>
</div>
</template>
<style scoped>
.play-wrap {
display: flex;
flex-direction: column;
gap: 12px;
}
.play-head {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
}
.g-icon {
font-size: 30px;
}
.g-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.g-stats {
margin-left: auto;
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.stat-chip {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 999px;
padding: 4px 12px;
font-size: 12px;
}
.stage {
position: relative;
display: flex;
justify-content: center;
padding: 14px;
min-height: 300px;
}
.stage :deep(canvas) {
max-width: 100%;
height: auto;
border-radius: 10px;
display: block;
}
/* 宽屏游戏meta.wide整页视口高度内自适应游戏区吃满剩余高度 */
.play-wrap.play-fill {
height: 100%;
min-height: 0;
}
.play-fill .stage {
flex: 1;
min-height: 0;
padding: 10px;
overflow: hidden;
}
.overlay {
position: absolute;
inset: 0;
background: color-mix(in srgb, var(--bg) 82%, transparent);
backdrop-filter: blur(4px);
border-radius: var(--radius);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
text-align: center;
padding: 20px;
z-index: 5;
}
/* 关卡选择内容较高,允许滚动 */
.overlay.scrollable {
justify-content: flex-start;
overflow-y: auto;
}
.ov-icon {
font-size: 52px;
filter: drop-shadow(var(--glow));
}
.ov-desc {
max-width: 420px;
font-size: 13px;
}
/* 存档信息卡(存档游戏的开始面板) */
.save-card {
display: flex;
flex-direction: column;
gap: 4px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 12px 26px;
}
.save-card b {
color: var(--primary-2);
font-size: 14px;
}
.save-mods {
color: #b48ad8;
}
/* ---- Mod 装载勾选列表 ---- */
.mod-pick {
width: min(460px, 92%);
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 10px 14px;
display: flex;
flex-direction: column;
gap: 6px;
text-align: left;
}
.mod-pick.compact {
width: min(420px, 96%);
max-height: 200px;
overflow: auto;
}
/* 房主邀请好友栏 */
.friend-invite {
width: min(440px, 96%);
max-height: 220px;
overflow: auto;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 10px;
text-align: left;
box-sizing: border-box;
flex-shrink: 0;
}
.fi-head {
font-size: 12px;
font-weight: 700;
color: var(--text-dim);
}
.fi-list {
display: flex;
flex-direction: column;
gap: 6px;
width: 100%;
}
.fi-item {
display: grid;
grid-template-columns: 28px minmax(0, 1fr) 10px auto;
align-items: center;
column-gap: 10px;
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: 10px;
background: transparent;
color: var(--text);
cursor: pointer;
font-size: 13px;
transition: background 0.15s, border-color 0.15s;
width: 100%;
box-sizing: border-box;
text-align: left;
}
.fi-item:hover:not(:disabled) {
background: var(--bg-panel);
border-color: var(--primary);
}
.fi-item.off {
opacity: 0.65;
}
.fi-item.sent {
cursor: default;
opacity: 0.75;
}
.fi-item:disabled {
cursor: not-allowed;
}
.fi-avatar {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
flex-shrink: 0;
}
.fi-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: left;
font-weight: 600;
}
.fi-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #888;
justify-self: center;
}
.fi-dot.on {
background: #67d18f;
box-shadow: 0 0 6px rgba(103, 209, 143, 0.6);
}
.fi-act {
font-size: 12px;
color: var(--primary);
white-space: nowrap;
min-width: 2.5em;
text-align: right;
}
.mp-head {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 13px;
}
.mp-total {
font-size: 12px;
font-weight: 700;
color: #67d18f;
}
.mp-total.up {
color: #e0a63c;
}
.mp-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
padding: 4px 6px;
border-radius: 8px;
cursor: pointer;
border: 1px solid transparent;
}
.mp-item:hover {
background: rgba(255, 255, 255, 0.04);
}
.mp-item.on {
border-color: var(--primary);
background: rgba(124, 92, 255, 0.08);
}
.mp-item input {
accent-color: var(--primary);
}
.mp-name {
font-weight: 700;
white-space: nowrap;
}
.mp-desc {
flex: 1;
color: var(--text-dim);
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mp-factor {
font-size: 12px;
font-weight: 700;
color: #67d18f;
}
.mp-factor.up {
color: #e0a63c;
}
.save-card span {
font-size: 13px;
color: var(--text-dim, #9a9ac4);
}
.double-check {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--primary-2);
cursor: pointer;
background: var(--bg-card);
padding: 8px 14px;
border-radius: 10px;
border: 1px solid var(--border);
}
.final {
font-size: 15px;
}
.final b {
font-size: 30px;
color: var(--accent);
}
.got {
font-size: 14px;
}
.got b {
font-size: 22px;
}
.ov-btns {
display: flex;
gap: 10px;
margin-top: 8px;
flex-wrap: wrap;
justify-content: center;
}
.prop-bar {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 16px;
flex-wrap: wrap;
}
.prop-btn {
display: flex;
align-items: center;
gap: 6px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 999px;
padding: 6px 14px;
cursor: pointer;
color: var(--text);
font-size: 13px;
transition: all 0.15s;
}
.prop-btn:hover {
border-color: var(--primary);
box-shadow: var(--glow);
}
.prop-icon {
font-size: 16px;
}
.prop-count {
color: var(--primary-2);
font-weight: 700;
}
/* 无库存道具:暗显 + 价格标签 */
.prop-btn.empty {
opacity: 0.75;
border-style: dashed;
}
.prop-buy {
color: var(--accent);
font-size: 11px;
font-weight: 700;
}
.locked {
text-align: center;
padding: 60px 20px;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
}
/* ---- 组队游戏:模式选择卡片 ---- */
.mode-cards {
display: flex;
gap: 14px;
margin: 6px 0;
flex-wrap: wrap;
justify-content: center;
}
.mode-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
width: 180px;
padding: 18px 14px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 14px;
color: var(--text);
cursor: pointer;
transition: all 0.18s;
}
.mode-card:hover {
border-color: var(--primary);
box-shadow: var(--glow);
transform: translateY(-2px);
}
.mode-card svg {
width: 34px;
height: 34px;
color: var(--primary-2);
}
.mode-card b {
font-size: 15px;
}
.mode-card i {
font-style: normal;
font-size: 11px;
color: var(--text-dim, #9a9ac4);
}
/* ---- 更衣室:皮肤网格 ---- */
.skin-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
gap: 10px;
width: min(720px, 92%);
max-height: 46vh;
overflow-y: auto;
padding: 6px;
}
.skin-cell {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 10px 6px 8px;
background: var(--bg-card);
border: 1.5px solid var(--border);
border-radius: 12px;
cursor: pointer;
transition: all 0.15s;
}
.skin-cell:hover {
border-color: var(--primary);
}
.skin-cell.on {
border-color: var(--accent);
box-shadow: 0 0 10px color-mix(in srgb, var(--accent) 40%, transparent);
}
.skin-cell.locked {
cursor: default;
}
.skin-cell.locked img {
filter: grayscale(0.85) brightness(0.75);
}
.skin-cell img {
width: 56px;
height: 56px;
image-rendering: auto;
}
.sk-name {
font-size: 11px;
line-height: 1.2;
text-align: center;
}
.sk-tag {
font-size: 10px;
color: var(--text-dim, #9a9ac4);
}
.sk-tag.on-tag {
color: var(--accent);
font-weight: 700;
}
.sk-buy {
font-size: 10px;
padding: 2px 8px;
border-radius: 999px;
border: 1px solid var(--accent);
background: transparent;
color: var(--accent);
cursor: pointer;
}
.sk-buy:hover {
background: var(--accent);
color: #fff;
}
/* ---- 联机房间 ---- */
.join-row {
display: flex;
gap: 8px;
align-items: center;
}
.join-input {
width: 150px;
padding: 9px 12px;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--bg-card);
color: var(--text);
font-size: 14px;
letter-spacing: 3px;
text-transform: uppercase;
text-align: center;
}
.invite-row {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
}
.invite-code {
font-size: 22px;
letter-spacing: 4px;
color: var(--accent);
background: var(--bg-card);
border: 1px dashed var(--accent);
border-radius: 8px;
padding: 3px 12px;
}
.seat-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 140px));
gap: 10px;
justify-content: center;
width: min(640px, 92%);
}
.seat-cell {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 12px 8px;
background: var(--bg-card);
border: 1.5px solid var(--border);
border-radius: 12px;
min-height: 120px;
justify-content: center;
}
.seat-cell.empty {
border-style: dashed;
opacity: 0.6;
}
.seat-cell.offline {
opacity: 0.55;
}
.seat-fig {
width: 54px;
height: 54px;
}
.seat-name {
font-size: 12px;
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.seat-state {
font-size: 11px;
color: var(--text-dim, #9a9ac4);
}
.seat-state.ok {
color: var(--primary-2);
font-weight: 700;
}
.seat-empty-mark {
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>