2225 lines
71 KiB
Vue
2225 lines
71 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, filterSkins } 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 displayName = computed(() => (code === 'starve' ? '年糕求生' : detail.value?.game?.name || '游戏'))
|
||
const showManual = ref(false)
|
||
const showSettings = ref(false)
|
||
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 savesList = ref([]) // [{id, name, day, score, updated_at}]
|
||
const savesMax = ref(12)
|
||
const activeSaveId = ref(null) // 本局正在写入的存档 id
|
||
const pendingSaveName = ref('') // 新开局待写入的存档名
|
||
const showSaveNameModal = ref(false)
|
||
const saveNameInput = ref('')
|
||
const saveBusy = ref(false)
|
||
|
||
function formatSaveTime(ts) {
|
||
if (!ts) return ''
|
||
const d = new Date((ts < 1e12 ? ts * 1000 : ts))
|
||
if (Number.isNaN(d.getTime())) return ''
|
||
const p = (n) => String(n).padStart(2, '0')
|
||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
|
||
}
|
||
|
||
async function refreshSavesList() {
|
||
if (!saveable) return
|
||
try {
|
||
const res = await http.get(`/games/${code}/saves`)
|
||
savesList.value = res?.list || []
|
||
savesMax.value = res?.max || 12
|
||
} catch {
|
||
savesList.value = []
|
||
}
|
||
}
|
||
|
||
// ---- 组队联机 + 皮肤更衣室(注册表标记 coop 的游戏才启用,如饥荒) ----
|
||
const coopable = !!meta?.coop
|
||
const coopStore = useStarveCoopStore()
|
||
const coopStage = ref('mode') // 开始面板子状态:mode 模式选择 / solo 单人 / skins 更衣室 / room 联机房间
|
||
const skins = ref([]) // 后端皮肤列表 [{code,name,price,owned}]
|
||
const skinTab = ref('all') // 更衣室筛选:all 全部 / original 饥荒原版 / featured 特色角色
|
||
const filteredSkins = computed(() => filterSkins(skins.value, skinTab.value))
|
||
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 touchMode = ref(localStorage.getItem('starve_touch_mode') || 'both')
|
||
function saveTouchMode(mode) {
|
||
touchMode.value = mode
|
||
localStorage.setItem('starve_touch_mode', mode)
|
||
window.dispatchEvent(new CustomEvent('starve-touch-mode', { detail: mode }))
|
||
}
|
||
const isMobile = computed(() => typeof window !== 'undefined' && ('ontouchstart' in window || navigator.maxTouchPoints > 0))
|
||
const pageLandscape = ref(false)
|
||
function syncPageLandscape() {
|
||
if (typeof window === 'undefined') return
|
||
const vw = window.innerWidth
|
||
const vh = window.innerHeight
|
||
const portrait = vh > vw
|
||
const phoneLike = Math.min(vw, vh) <= 820
|
||
const on = floatHead.value && isMobile.value && phoneLike && portrait
|
||
const nextW = `${vh}px`
|
||
const nextH = `${vw}px`
|
||
const sizeChanged = on && (
|
||
document.documentElement.style.getPropertyValue('--pl-w') !== nextW ||
|
||
document.documentElement.style.getPropertyValue('--pl-h') !== nextH
|
||
)
|
||
const changed = on !== pageLandscape.value
|
||
pageLandscape.value = on
|
||
document.body.classList.toggle('starve-page-landscape', on)
|
||
if (on) {
|
||
document.documentElement.style.setProperty('--pl-w', nextW)
|
||
document.documentElement.style.setProperty('--pl-h', nextH)
|
||
} else {
|
||
document.documentElement.style.removeProperty('--pl-w')
|
||
document.documentElement.style.removeProperty('--pl-h')
|
||
}
|
||
if (changed || sizeChanged) {
|
||
requestAnimationFrame(() => window.dispatchEvent(new Event('nlg:starve-relayout')))
|
||
}
|
||
}
|
||
function openManual() {
|
||
showManual.value = true
|
||
if (phase.value === 'playing') gameRef.value?.setPaused?.(true)
|
||
}
|
||
function toggleHeadNav() {
|
||
headShow.value = !headShow.value
|
||
window.dispatchEvent(new CustomEvent('nlg:starve-nav-toggle'))
|
||
}
|
||
function closeManual() {
|
||
showManual.value = false
|
||
if (phase.value === 'playing') gameRef.value?.setPaused?.(false)
|
||
}
|
||
function openSettings() {
|
||
showSettings.value = true
|
||
if (phase.value === 'playing') gameRef.value?.setPaused?.(true)
|
||
}
|
||
function closeSettings() {
|
||
showSettings.value = false
|
||
if (phase.value === 'playing') gameRef.value?.setPaused?.(false)
|
||
}
|
||
function togglePause() {
|
||
gameRef.value?.togglePause?.()
|
||
}
|
||
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
|
||
})
|
||
const hasSaves = computed(() => savesList.value.length > 0)
|
||
|
||
// 背包中指定道具的数量
|
||
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) {
|
||
await refreshSavesList()
|
||
}
|
||
// 组队游戏:拉取皮肤商城列表(选中的皮肤若未拥有则回退默认)
|
||
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}=从云存档继续;{saveName}=新开局命名
|
||
function startGame(arg = 1) {
|
||
const level = typeof arg === 'number' ? arg : 1
|
||
const save = typeof arg === 'object' && arg?.save ? arg.save : null
|
||
const saveName = typeof arg === 'object' && arg?.saveName ? String(arg.saveName).trim() : ''
|
||
liveScore.value = save?.score || 0
|
||
result.value = null
|
||
coopPlaying.value = false
|
||
phase.value = 'playing'
|
||
startAt.value = Date.now()
|
||
if (save && arg?.saveId) activeSaveId.value = arg.saveId
|
||
else if (!save) {
|
||
activeSaveId.value = null
|
||
pendingSaveName.value = saveName || '冒险'
|
||
}
|
||
// 等游戏组件挂载后启动(组件为异步 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 openNewSaveModal() {
|
||
if (savesList.value.length >= savesMax.value) {
|
||
toast(`最多 ${savesMax.value} 个存档,请先删除旧档`)
|
||
return
|
||
}
|
||
saveNameInput.value = `冒险 ${savesList.value.length + 1}`
|
||
showSaveNameModal.value = true
|
||
}
|
||
|
||
function confirmNewSave() {
|
||
const name = saveNameInput.value.trim() || '冒险'
|
||
showSaveNameModal.value = false
|
||
startGame({ saveName: name })
|
||
}
|
||
|
||
// 从指定云存档继续
|
||
async function continueSaveById(id) {
|
||
if (saveBusy.value) return
|
||
saveBusy.value = true
|
||
try {
|
||
const row = await http.get(`/games/${code}/saves/${id}`)
|
||
let snap = null
|
||
try {
|
||
snap = JSON.parse(row.data)
|
||
} catch {
|
||
snap = null
|
||
}
|
||
if (!snap) {
|
||
toast('存档损坏')
|
||
return
|
||
}
|
||
startGame({ save: snap, saveId: row.id })
|
||
} catch {
|
||
// 拦截器已提示
|
||
} finally {
|
||
saveBusy.value = false
|
||
}
|
||
}
|
||
|
||
// 删除指定存档
|
||
async function deleteSaveById(id) {
|
||
if (saveBusy.value) return
|
||
if (!confirm('确定删除这份存档?此操作不可恢复。')) return
|
||
saveBusy.value = true
|
||
try {
|
||
await http.delete(`/games/${code}/saves/${id}`)
|
||
if (activeSaveId.value === id) activeSaveId.value = null
|
||
await refreshSavesList()
|
||
toast('已删除存档')
|
||
} catch {
|
||
//
|
||
} finally {
|
||
saveBusy.value = false
|
||
}
|
||
}
|
||
|
||
// 游戏内自动/手动存档:新建或覆盖当前 activeSaveId
|
||
async function onSave(snap) {
|
||
if (!saveable || !snap || coopPlaying.value) return
|
||
const body = {
|
||
id: activeSaveId.value || 0,
|
||
name: pendingSaveName.value || '冒险',
|
||
day: snap.day || 1,
|
||
score: snap.score || 0,
|
||
data: JSON.stringify(snap),
|
||
}
|
||
try {
|
||
const res = await http.post(`/games/${code}/saves`, body)
|
||
if (res?.id) {
|
||
activeSaveId.value = res.id
|
||
pendingSaveName.value = res.name || pendingSaveName.value
|
||
}
|
||
await refreshSavesList()
|
||
} 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
|
||
}
|
||
|
||
// 游戏结束回调:单人只删本局这份存档(永久死亡);联机局不动单人存档
|
||
async function onEnd(payload) {
|
||
finalScore.value = payload?.score ?? liveScore.value
|
||
phase.value = 'over'
|
||
if (saveable && !coopPlaying.value && activeSaveId.value) {
|
||
const id = activeSaveId.value
|
||
activeSaveId.value = null
|
||
pendingSaveName.value = ''
|
||
await http.delete(`/games/${code}/saves/${id}`).catch(() => {})
|
||
await refreshSavesList()
|
||
}
|
||
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(() => {
|
||
// 宽屏游戏(如饥荒):给 body 打标解除全局 1200px 限宽
|
||
if (meta?.wide) document.body.classList.add('wide-page')
|
||
load()
|
||
window.addEventListener('nlg:open-settings', openSettings)
|
||
window.addEventListener('nlg:open-manual', openManual)
|
||
if (floatHead.value) {
|
||
syncPageLandscape()
|
||
window.addEventListener('resize', syncPageLandscape)
|
||
window.addEventListener('orientationchange', syncPageLandscape)
|
||
window.visualViewport?.addEventListener('resize', syncPageLandscape)
|
||
}
|
||
if (coopable) {
|
||
window.addEventListener('nlg:pending-join', tryPendingJoin)
|
||
tryPendingJoin()
|
||
}
|
||
})
|
||
onBeforeUnmount(() => {
|
||
document.body.classList.remove('wide-page')
|
||
document.body.classList.remove('starve-page-landscape')
|
||
document.documentElement.style.removeProperty('--pl-w')
|
||
document.documentElement.style.removeProperty('--pl-h')
|
||
window.removeEventListener('nlg:open-manual', openManual)
|
||
window.removeEventListener('nlg:open-settings', openSettings)
|
||
window.removeEventListener('nlg:pending-join', tryPendingJoin)
|
||
window.removeEventListener('resize', syncPageLandscape)
|
||
window.removeEventListener('orientationchange', syncPageLandscape)
|
||
window.visualViewport?.removeEventListener('resize', syncPageLandscape)
|
||
clearInterval(inviteTickTimer)
|
||
gameRef.value?.stop()
|
||
// 离开游玩页断开联机连接(房间自动退出)
|
||
if (coopable) coopStore.disconnect()
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<div v-if="detail" class="play-wrap" :class="{ 'play-fill': meta?.wide, 'starve-ui': code === 'starve' }">
|
||
<button v-if="floatHead" class="head-toggle" @click="toggleHeadNav">
|
||
{{ headShow ? '▲ 收起' : '☰ 年糕求生' }}
|
||
</button>
|
||
<!-- 顶部信息条 -->
|
||
<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>{{ displayName }}</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>
|
||
<button class="btn btn-ghost btn-sm" @click="openManual">📖 手册</button>
|
||
<button class="btn btn-ghost btn-sm" @click="openSettings">⚙️ 设置</button>
|
||
<button v-if="phase === 'playing'" class="btn btn-ghost btn-sm" @click="togglePause">⏸ 暂停</button>
|
||
</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>{{ displayName }}</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>
|
||
<div class="ov-btns">
|
||
<button class="btn btn-ghost btn-sm" @click="openManual">📖 操作手册</button>
|
||
<button class="btn btn-ghost btn-sm" @click="openSettings">⚙️ 设置</button>
|
||
</div>
|
||
<button class="btn btn-ghost" @click="coopStage = 'skins'">
|
||
更衣室 · {{ skins.length || 45 }} 款人物皮肤
|
||
</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 && hasSaves ? '(新建冒险时生效)' : '(可选)' }}</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>
|
||
<div v-if="saveable && hasSaves" class="save-list">
|
||
<div class="save-list-head">
|
||
<b>我的存档</b>
|
||
<span class="text-dim">{{ savesList.length }}/{{ savesMax }}</span>
|
||
</div>
|
||
<div
|
||
v-for="s in savesList"
|
||
:key="s.id"
|
||
class="save-card save-row"
|
||
>
|
||
<div class="save-row-main">
|
||
<b>{{ s.name || '冒险' }}</b>
|
||
<span>第 {{ s.day }} 天 · 得分 {{ s.score }}</span>
|
||
<span v-if="s.updated_at" class="save-time">{{ formatSaveTime(s.updated_at) }}</span>
|
||
</div>
|
||
<div class="save-row-btns">
|
||
<button class="btn btn-sm" :disabled="saveBusy" @click="continueSaveById(s.id)">继续</button>
|
||
<button class="btn btn-ghost btn-sm" :disabled="saveBusy" @click="deleteSaveById(s.id)">删除</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="ov-btns">
|
||
<button class="btn btn-lg" :disabled="saveBusy" @click="openNewSaveModal">新的冒险</button>
|
||
</div>
|
||
<button class="btn btn-ghost btn-sm" @click="coopStage = 'mode'">← 返回</button>
|
||
</template>
|
||
<!-- 3. 更衣室:分类 Tab + 立绘网格(已解锁可穿、未解锁积分购买) -->
|
||
<template v-else-if="coopStage === 'skins'">
|
||
<h3>更衣室</h3>
|
||
<p class="text-dim" style="font-size: 12px">点击已解锁的皮肤穿上;未解锁的用积分购买 · 余额 {{ userStore.user?.points ?? 0 }}</p>
|
||
<div class="skin-tabs">
|
||
<button class="skin-tab" :class="{ on: skinTab === 'all' }" @click="skinTab = 'all'">全部</button>
|
||
<button class="skin-tab" :class="{ on: skinTab === 'original' }" @click="skinTab = 'original'">饥荒原版</button>
|
||
<button class="skin-tab" :class="{ on: skinTab === 'featured' }" @click="skinTab = 'featured'">特色角色</button>
|
||
</div>
|
||
<div class="skin-grid" :class="{ original: skinTab === 'original' }">
|
||
<div
|
||
v-for="s in filteredSkins"
|
||
: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>{{ displayName }}</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>
|
||
<div class="ov-btns">
|
||
<button class="btn btn-ghost btn-sm" @click="openManual">📖 操作手册</button>
|
||
<button class="btn btn-ghost btn-sm" @click="openSettings">⚙️ 设置</button>
|
||
</div>
|
||
<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">
|
||
<div v-if="hasSaves" class="save-list">
|
||
<div class="save-list-head">
|
||
<b>我的存档</b>
|
||
<span class="text-dim">{{ savesList.length }}/{{ savesMax }}</span>
|
||
</div>
|
||
<div v-for="s in savesList" :key="s.id" class="save-card save-row">
|
||
<div class="save-row-main">
|
||
<b>{{ s.name || '冒险' }}</b>
|
||
<span>第 {{ s.day }} 天 · 得分 {{ s.score }}</span>
|
||
<span v-if="s.updated_at" class="save-time">{{ formatSaveTime(s.updated_at) }}</span>
|
||
</div>
|
||
<div class="save-row-btns">
|
||
<button class="btn btn-sm" :disabled="saveBusy" @click="continueSaveById(s.id)">继续</button>
|
||
<button class="btn btn-ghost btn-sm" :disabled="saveBusy" @click="deleteSaveById(s.id)">删除</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<button class="btn btn-lg" :disabled="saveBusy" @click="openNewSaveModal">新的冒险</button>
|
||
</template>
|
||
<button v-else class="btn btn-lg" @click="startGame()">开始游戏</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 v-if="showSaveNameModal" class="modal-mask" @click.self="showSaveNameModal = false">
|
||
<div class="save-name-modal panel">
|
||
<button class="bm-close" @click="showSaveNameModal = false">✕</button>
|
||
<h3>新建存档</h3>
|
||
<p class="text-dim" style="font-size: 12px">给这次冒险起个名字(最多 32 字)</p>
|
||
<input
|
||
v-model="saveNameInput"
|
||
class="save-name-input"
|
||
maxlength="32"
|
||
placeholder="存档名称"
|
||
@keyup.enter="confirmNewSave"
|
||
/>
|
||
<div class="ov-btns" style="margin-top: 14px">
|
||
<button class="btn btn-lg" @click="confirmNewSave">开始冒险</button>
|
||
<button class="btn btn-ghost" @click="showSaveNameModal = false">取消</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 操作手册 -->
|
||
<div v-if="showManual" class="modal-mask" @click.self="closeManual">
|
||
<div class="mod-modal panel manual-modal">
|
||
<button class="bm-close" @click="closeManual">✕</button>
|
||
<h3>操作手册</h3>
|
||
<p class="text-dim" style="font-size: 12px; line-height: 1.6; text-align: left">
|
||
{{ meta.controls }}<br />
|
||
移动端:点击地面移动{{ touchMode === 'both' || touchMode === 'joystick' ? ',也可使用左下角摇杆' : '' }};点击目标会自动攻击/采集。<br />
|
||
快捷键:WASD 移动 · 空格采集 · F 攻击 · E 一键交互 · C 合成 · B 背包 · M 地图 · P/Esc 暂停。
|
||
</p>
|
||
<div class="ov-btns">
|
||
<button class="btn" @click="closeManual">知道了</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 设置 -->
|
||
<div v-if="showSettings" class="modal-mask" @click.self="closeSettings">
|
||
<div class="mod-modal panel settings-modal">
|
||
<button class="bm-close" @click="closeSettings">✕</button>
|
||
<h3>设置</h3>
|
||
<div class="setting-block">
|
||
<b>移动端移动方式</b>
|
||
<div class="setting-options">
|
||
<button class="chip" :class="{ on: touchMode === 'both' }" @click="saveTouchMode('both')">点击 + 摇杆</button>
|
||
<button class="chip" :class="{ on: touchMode === 'tap' }" @click="saveTouchMode('tap')">仅点击</button>
|
||
<button class="chip" :class="{ on: touchMode === 'joystick' }" @click="saveTouchMode('joystick')">仅摇杆</button>
|
||
</div>
|
||
<p class="text-dim" style="font-size: 12px">默认都支持;摇杆未选中时自动隐藏。</p>
|
||
</div>
|
||
<div class="setting-block">
|
||
<b>PC 快捷键</b>
|
||
<ul class="shortcut-list">
|
||
<li><span>WASD / 方向键</span>移动</li>
|
||
<li><span>空格</span>采集 / 交互</li>
|
||
<li><span>F</span>攻击</li>
|
||
<li><span>1~9</span>使用物品栏</li>
|
||
<li><span>C</span>合成</li>
|
||
<li><span>M</span>地图</li>
|
||
<li><span>P / Esc</span>暂停 / 继续</li>
|
||
</ul>
|
||
</div>
|
||
<div class="ov-btns">
|
||
<button class="btn" @click="closeSettings">完成</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-list {
|
||
width: min(460px, 92%);
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
max-height: 240px;
|
||
overflow: auto;
|
||
text-align: left;
|
||
}
|
||
.save-list-head {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 0 4px;
|
||
color: var(--text);
|
||
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 16px;
|
||
}
|
||
.save-row {
|
||
flex-direction: row;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
}
|
||
.save-row-main {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
min-width: 0;
|
||
}
|
||
.save-row-btns {
|
||
display: flex;
|
||
gap: 6px;
|
||
flex-shrink: 0;
|
||
}
|
||
.save-card b {
|
||
color: var(--primary-2);
|
||
font-size: 14px;
|
||
}
|
||
.save-time {
|
||
font-size: 11px;
|
||
color: var(--text-dim, #888);
|
||
}
|
||
.save-name-modal {
|
||
width: min(360px, 92%);
|
||
position: relative;
|
||
padding: 20px 22px 18px;
|
||
text-align: center;
|
||
}
|
||
.save-name-input {
|
||
width: 100%;
|
||
margin-top: 10px;
|
||
padding: 10px 12px;
|
||
border-radius: 8px;
|
||
border: 1px solid var(--border);
|
||
background: var(--bg, #1a1520);
|
||
color: var(--text);
|
||
font-size: 14px;
|
||
box-sizing: border-box;
|
||
}
|
||
.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);
|
||
}
|
||
/* ---- 更衣室:分类 Tab + 皮肤网格 ---- */
|
||
.skin-tabs {
|
||
display: flex;
|
||
justify-content: center;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
margin: 6px 0 4px;
|
||
}
|
||
.skin-tab {
|
||
padding: 5px 14px;
|
||
border-radius: 999px;
|
||
border: 1.5px solid var(--border);
|
||
background: var(--bg-card);
|
||
color: var(--text);
|
||
font-size: 13px;
|
||
cursor: pointer;
|
||
}
|
||
.skin-tab:hover {
|
||
border-color: var(--primary);
|
||
}
|
||
.skin-tab.on {
|
||
border-color: var(--accent);
|
||
color: var(--accent);
|
||
box-shadow: 0 0 8px color-mix(in srgb, var(--accent) 35%, transparent);
|
||
}
|
||
.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-grid.original {
|
||
grid-template-columns: repeat(5, minmax(72px, 1fr));
|
||
width: min(640px, 92%);
|
||
}
|
||
.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;
|
||
}
|
||
|
||
/* 二级导航:吸顶小按钮手动展开/收起,不随鼠标弹出 */
|
||
.head-toggle {
|
||
position: fixed;
|
||
top: 2px;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
z-index: 160;
|
||
padding: 4px 12px;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
border-radius: 999px;
|
||
border: 1px solid var(--border);
|
||
background: var(--bg-panel);
|
||
color: var(--text);
|
||
cursor: pointer;
|
||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.35);
|
||
}
|
||
/* 手册/设置弹窗 */
|
||
.manual-modal,
|
||
.settings-modal {
|
||
width: min(560px, 100%);
|
||
text-align: left;
|
||
}
|
||
.setting-block {
|
||
background: var(--bg-card);
|
||
border: 1px solid var(--border);
|
||
border-radius: 12px;
|
||
padding: 12px 14px;
|
||
margin: 10px 0;
|
||
}
|
||
.setting-options {
|
||
display: flex;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
margin-top: 10px;
|
||
}
|
||
.shortcut-list {
|
||
margin: 8px 0 0;
|
||
padding-left: 18px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
font-size: 13px;
|
||
}
|
||
.shortcut-list span {
|
||
display: inline-block;
|
||
min-width: 96px;
|
||
color: var(--primary-2);
|
||
font-weight: 700;
|
||
}
|
||
|
||
/* ---- 年糕求生 UI:游戏内弹窗/面板统一手绘木纸风格 ---- */
|
||
.play-wrap.starve-ui .modal-mask {
|
||
background: rgba(10, 7, 4, 0.62);
|
||
}
|
||
.play-wrap.starve-ui .mod-modal,
|
||
.play-wrap.starve-ui .modal-mask .mod-modal {
|
||
background: linear-gradient(180deg, #2b2115, #17120b);
|
||
border: 2px solid #b89a5c;
|
||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.6);
|
||
color: #f4ead2;
|
||
}
|
||
.play-wrap.starve-ui .btn-ghost {
|
||
background: rgba(42, 32, 18, 0.9);
|
||
border-color: #6f5b38;
|
||
color: #f4ead2;
|
||
}
|
||
.play-wrap.starve-ui .play-head {
|
||
background: linear-gradient(180deg, #2b2115, #17120b);
|
||
border: 2px solid #b89a5c;
|
||
}
|
||
.play-wrap.starve-ui .stat-chip {
|
||
background: rgba(42, 32, 18, 0.9);
|
||
border-color: #6f5b38;
|
||
}
|
||
.play-wrap.starve-ui .head-toggle {
|
||
background: #2b2115;
|
||
border-color: #b89a5c;
|
||
color: #f4ead2;
|
||
}
|
||
|
||
.play-wrap.starve-ui .mod-card {
|
||
background: rgba(42, 32, 18, 0.9);
|
||
border-color: #6f5b38;
|
||
}
|
||
.play-wrap.starve-ui .mod-card:hover {
|
||
border-color: #e8bc5a;
|
||
}
|
||
.play-wrap.starve-ui .btn {
|
||
border-radius: 10px;
|
||
border-color: #b89a5c;
|
||
background: linear-gradient(180deg, #5a4327, #3a2b17);
|
||
color: #f4ead2;
|
||
}
|
||
.play-wrap.starve-ui .mode-card {
|
||
background: rgba(42, 32, 18, 0.92);
|
||
border-color: #6f5b38;
|
||
border-radius: 14px;
|
||
}
|
||
.play-wrap.starve-ui .mode-card:hover {
|
||
border-color: #e8bc5a;
|
||
}
|
||
.play-wrap.starve-ui .skin-tab {
|
||
border-radius: 0;
|
||
border: 1.6px solid #6f5b38;
|
||
background: linear-gradient(180deg, #3a2b17, #241a10);
|
||
color: #f4ead2;
|
||
font-family: "Ma Shan Zheng", "KaiTi", serif;
|
||
letter-spacing: 1px;
|
||
box-shadow: inset 0 0 0 2px #1d1409;
|
||
}
|
||
.play-wrap.starve-ui .skin-tab:hover {
|
||
border-color: #e8bc5a;
|
||
color: #fff2c0;
|
||
}
|
||
.play-wrap.starve-ui .skin-tab.on {
|
||
border-color: #e8bc5a;
|
||
color: #f4d68a;
|
||
background: linear-gradient(180deg, #5a4327, #2b1d10);
|
||
box-shadow: inset 0 0 0 2px #1d1409, 0 0 10px rgba(232, 188, 90, 0.35);
|
||
}
|
||
.play-wrap.starve-ui .join-row,
|
||
.play-wrap.starve-ui .overlay.room-entry .ov-btns {
|
||
background: rgba(42, 32, 18, 0.9);
|
||
border-color: #6f5b38;
|
||
}
|
||
.play-wrap.starve-ui .join-input {
|
||
background: #17120b;
|
||
border-color: #b89a5c;
|
||
color: #f4ead2;
|
||
}
|
||
.play-wrap.starve-ui .mod-modal h3 {
|
||
color: #f4ead2;
|
||
}
|
||
|
||
|
||
/* 更强的饥荒主题:羊皮纸/木箱/铜扣 */
|
||
.play-wrap.starve-ui .mod-modal,
|
||
.play-wrap.starve-ui .manual-modal,
|
||
.play-wrap.starve-ui .settings-modal {
|
||
background:
|
||
radial-gradient(circle at 12% 10%, rgba(232, 188, 90, 0.16), transparent 32%),
|
||
repeating-linear-gradient(90deg, rgba(0,0,0,0.06) 0 2px, transparent 2px 14px),
|
||
linear-gradient(180deg, #33261a, #17120b);
|
||
border: 2px solid #b89a5c;
|
||
box-shadow:
|
||
inset 0 0 0 4px #241a10,
|
||
0 12px 40px rgba(0, 0, 0, 0.6);
|
||
color: #f4ead2;
|
||
}
|
||
.play-wrap.starve-ui .mod-modal h3,
|
||
.play-wrap.starve-ui .manual-modal h3,
|
||
.play-wrap.starve-ui .settings-modal h3 {
|
||
font-family: "Ma Shan Zheng", "KaiTi", serif;
|
||
letter-spacing: 2px;
|
||
color: #f4d68a;
|
||
text-shadow: 2px 2px 0 #17120b;
|
||
}
|
||
.play-wrap.starve-ui .btn,
|
||
.play-wrap.starve-ui .btn-ghost {
|
||
position: relative;
|
||
font-family: "Ma Shan Zheng", "KaiTi", serif;
|
||
letter-spacing: 1px;
|
||
border: 1.6px solid #b89a5c;
|
||
background:
|
||
linear-gradient(180deg, rgba(255,255,255,0.08), transparent 45%),
|
||
linear-gradient(180deg, #5a4327, #2b1d10);
|
||
box-shadow: inset 0 0 0 2px #1d1409, 0 3px 0 #0d0906;
|
||
text-shadow: 1px 1px 0 #0d0906;
|
||
}
|
||
.play-wrap.starve-ui .btn:hover,
|
||
.play-wrap.starve-ui .btn-ghost:hover {
|
||
border-color: #f0c04a;
|
||
color: #fff2c0;
|
||
}
|
||
.play-wrap.starve-ui .mode-card {
|
||
background:
|
||
radial-gradient(circle at 50% 0%, rgba(232, 188, 90, 0.18), transparent 45%),
|
||
linear-gradient(180deg, #302213, #17120b);
|
||
box-shadow: inset 0 0 0 3px #1d1409, 0 6px 16px rgba(0,0,0,0.35);
|
||
font-family: "Ma Shan Zheng", "KaiTi", serif;
|
||
}
|
||
.play-wrap.starve-ui .overlay.room-entry .ov-btns,
|
||
.play-wrap.starve-ui .join-row {
|
||
background:
|
||
linear-gradient(180deg, rgba(232,188,90,0.08), transparent),
|
||
linear-gradient(180deg, #302213, #17120b);
|
||
box-shadow: inset 0 0 0 2px #1d1409;
|
||
}
|
||
.play-wrap.starve-ui .mod-modal,
|
||
.play-wrap.starve-ui .manual-modal,
|
||
.play-wrap.starve-ui .settings-modal,
|
||
.play-wrap.starve-ui .mode-card,
|
||
.play-wrap.starve-ui .mod-card,
|
||
.play-wrap.starve-ui .join-row,
|
||
.play-wrap.starve-ui .join-input {
|
||
border-radius: 0;
|
||
image-rendering: pixelated;
|
||
}
|
||
.play-wrap.starve-ui .bm-close {
|
||
width: 30px;
|
||
height: 30px;
|
||
padding: 0;
|
||
border: 2px solid #b89a5c;
|
||
background: #2b2115;
|
||
color: #f4ead2;
|
||
font-size: 14px;
|
||
line-height: 1;
|
||
}
|
||
.play-wrap.starve-ui .mod-card-check {
|
||
border-radius: 0;
|
||
}
|
||
</style>
|