1661 lines
53 KiB
Vue
1661 lines
53 KiB
Vue
<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>
|