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

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

3630
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

3
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,3 @@
allowBuilds:
'@parcel/watcher': true
electron-winstaller: true

View File

@@ -1,7 +1,7 @@
<script setup>
// 顶部导航栏站点名、主导航SVG 图标在上 + 文字在下的竖排布局)、消息入口(带未读角标)、积分、用户菜单
// 小屏≤900px不再收进汉堡抽屉而是隐藏文字仅留图标title 悬浮提示文字)
import { computed } from 'vue'
import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useUserStore } from '../stores/user'
import { useThemeStore } from '../stores/theme'
@@ -33,6 +33,17 @@ const msgBadge = computed(() => {
return n > 99 ? '99+' : n
})
// 仅饥荒游玩页:导航变悬浮小条,默认隐藏,鼠标靠近顶部再展示
const navFloat = computed(() => route.path.startsWith('/play/starve'))
const navShow = ref(true)
watch(navFloat, (v) => { navShow.value = !v }, { immediate: true })
function onNavMouseMove(e) {
if (!navFloat.value) return
navShow.value = e.clientY <= 60
}
onMounted(() => window.addEventListener('mousemove', onNavMouseMove))
onBeforeUnmount(() => window.removeEventListener('mousemove', onNavMouseMove))
// 当前路由是否命中某导航
function isActive(path) {
return route.path === path || (path !== '/' && route.path.startsWith(path))
@@ -46,7 +57,7 @@ function logout() {
</script>
<template>
<header class="nav">
<header class="nav" :class="{ 'nav-float': navFloat, 'nav-hidden': navFloat && !navShow }">
<div class="nav-inner">
<router-link to="/" class="brand">
<span class="brand-icon"><NavIcon name="lobby" :size="26" /></span>
@@ -287,4 +298,53 @@ function logout() {
font-size: 15px;
}
}
/* ---- 仅饥荒游玩页:悬浮小导航,自动隐藏,鼠标靠近顶部展示 ---- */
.nav.nav-float {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 150;
height: 40px;
transition: transform 0.25s ease;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18);
}
.nav.nav-float.nav-hidden {
transform: translateY(-100%);
}
.nav-float .nav-inner {
max-width: none;
height: 40px;
padding: 0 10px;
gap: 10px;
}
.nav-float .brand {
font-size: 14px;
gap: 6px;
}
.nav-float .brand-name,
.nav-float .link-text,
.nav-float .nickname {
display: none;
}
.nav-float .links {
gap: 0;
}
.nav-float .link {
flex-direction: row;
padding: 4px 8px;
gap: 4px;
}
.nav-float .link-icon {
width: 18px;
height: 18px;
}
.nav-float .points {
font-size: 13px;
}
.nav-float .avatar {
width: 26px;
height: 26px;
}
</style>

View File

@@ -123,7 +123,7 @@ function drawHair(c, skin, headY) {
}
// 手持工具(在前臂末端坐标系内绘制)
function drawTool(c, toolCode) {
function drawToolLegacy(c, toolCode) {
if (!toolCode) return
c.save()
c.translate(0, 11)
@@ -183,6 +183,221 @@ function drawTool(c, toolCode) {
c.restore()
}
// 增强手持物:更大、轮廓更清楚、材质区分更明显(斧/镐/铲/矛/触手/火把)
function drawToolEnhanced(c, toolCode) {
if (!toolCode) return
c.save()
c.translate(0, 10)
c.rotate(-0.45)
c.lineJoin = 'round'
c.lineCap = 'round'
const wood = '#8a5a2b'
const dark = '#1d1409'
const iron = '#9aa0a8'
if (toolCode === 'shovel') {
// 木柄 + 宽铲头
c.strokeStyle = wood
c.lineWidth = 3.2
c.beginPath(); c.moveTo(0, 6); c.lineTo(0, -16); c.stroke()
c.beginPath()
c.moveTo(-6, -16)
c.quadraticCurveTo(0, -28, 6, -16)
c.lineTo(4, -12)
c.quadraticCurveTo(0, -15, -4, -12)
c.closePath()
fs(c, iron, dark, 1.4)
c.fillStyle = 'rgba(255,255,255,0.35)'
c.beginPath(); c.moveTo(-2, -16); c.quadraticCurveTo(0, -22, 2, -16); c.closePath(); c.fill()
} else if (toolCode === 'tentaclespike') {
c.strokeStyle = '#6b4a7e'
c.lineWidth = 3.6
c.beginPath(); c.moveTo(0, 8); c.quadraticCurveTo(3, -10, 0, -24); c.stroke()
c.fillStyle = '#e8e2d4'
;[[0, -10], [1, -16], [-1, -21]].forEach(([x, y]) => {
c.beginPath(); c.moveTo(x, y); c.lineTo(x + 5, y - 2); c.lineTo(x + 1, y - 6); c.closePath(); c.fill()
})
} else if (toolCode === 'torch') {
c.strokeStyle = wood
c.lineWidth = 3.2
c.beginPath(); c.moveTo(0, 6); c.lineTo(0, -14); c.stroke()
c.strokeStyle = '#c9a06a'
c.lineWidth = 1.4
c.beginPath(); c.moveTo(-2, -8); c.lineTo(2, -6); c.moveTo(-2, -4); c.lineTo(2, -2); c.stroke()
c.beginPath()
c.moveTo(0, -22)
c.quadraticCurveTo(7, -15, 0, -10)
c.quadraticCurveTo(-7, -15, 0, -22)
fs(c, '#f0923c', '#b5541e', 1.2)
c.beginPath()
c.moveTo(0, -19)
c.quadraticCurveTo(4, -15, 0, -12)
c.quadraticCurveTo(-4, -15, 0, -19)
fs(c, '#ffd76a', '#e8a03c', 1)
} else if (toolCode === 'axe') {
c.strokeStyle = wood
c.lineWidth = 3.2
c.beginPath(); c.moveTo(0, 6); c.lineTo(0, -15); c.stroke()
c.strokeStyle = '#c9a06a'
c.lineWidth = 1.2
c.beginPath(); c.moveTo(-2, -11); c.lineTo(2, -9); c.moveTo(-2, -7); c.lineTo(2, -5); c.stroke()
c.beginPath()
c.moveTo(0, -15); c.lineTo(11, -11); c.lineTo(10, -3); c.lineTo(4, -6); c.lineTo(0, -8)
c.closePath()
fs(c, '#b8bec6', dark, 1.4)
c.fillStyle = 'rgba(255,255,255,0.4)'
c.beginPath(); c.moveTo(1, -14); c.lineTo(9, -10.4); c.lineTo(8, -5.5); c.lineTo(3, -8); c.closePath(); c.fill()
} else if (toolCode === 'pick') {
c.strokeStyle = wood
c.lineWidth = 3.2
c.beginPath(); c.moveTo(0, 6); c.lineTo(0, -14); c.stroke()
c.beginPath()
c.moveTo(-9, -13)
c.quadraticCurveTo(0, -22, 9, -13)
c.quadraticCurveTo(5, -15, 0, -15)
c.quadraticCurveTo(-5, -15, -9, -13)
c.closePath()
fs(c, '#5c6470', dark, 1.4)
c.fillStyle = 'rgba(255,255,255,0.28)'
c.beginPath(); c.moveTo(-6, -14); c.quadraticCurveTo(0, -20, 6, -14); c.quadraticCurveTo(0, -17, -6, -14); c.closePath(); c.fill()
} else if (toolCode === 'spear') {
c.strokeStyle = wood
c.lineWidth = 3
c.beginPath(); c.moveTo(0, 8); c.lineTo(0, -24); c.stroke()
c.strokeStyle = '#c9a06a'
c.lineWidth = 1.4
c.beginPath(); c.moveTo(-2.4, -13); c.lineTo(2.4, -11); c.moveTo(-2.4, -9); c.lineTo(2.4, -7); c.stroke()
c.beginPath()
c.moveTo(-3.4, -23); c.lineTo(0, -32); c.lineTo(3.4, -23)
c.closePath()
fs(c, '#c3c8d0', dark, 1.4)
c.fillStyle = 'rgba(255,255,255,0.45)'
c.beginPath(); c.moveTo(0, -30); c.lineTo(1.6, -24); c.lineTo(-1.6, -24); c.closePath(); c.fill()
}
c.restore()
}
// 皮肤差异化头饰/面饰:同一发型也靠头饰/面纹区分
function drawSkinAccessory(c, skin, headY) {
const code = skin.code || ''
c.save()
c.lineJoin = 'round'
c.lineCap = 'round'
if (code === 'flame' || code === 'lava') {
// 护目镜
c.strokeStyle = '#1d1409'; c.lineWidth = 1.6
c.beginPath(); c.arc(-3, headY - 3.4, 3.4, 0, 7); c.stroke()
c.beginPath(); c.arc(3, headY - 3.4, 3.4, 0, 7); c.stroke()
c.fillStyle = code === 'lava' ? '#ff9a3c' : '#f0923c'
c.fill()
c.strokeStyle = '#1d1409'
c.beginPath(); c.moveTo(-6.2, headY - 4); c.lineTo(-0.8, headY - 4.6); c.moveTo(6.2, headY - 4); c.lineTo(0.8, headY - 4.6); c.stroke()
} else if (code === 'ocean' || code === 'navy') {
// 水手/军官帽
c.fillStyle = code === 'ocean' ? '#f4f8fa' : '#2c3e68'
c.strokeStyle = '#1d1409'; c.lineWidth = 1.4
c.beginPath(); c.ellipse(0, headY - 8, 10, 3.6, 0, Math.PI, 0); c.fill(); c.stroke()
c.fillStyle = code === 'ocean' ? '#2f6d8e' : '#c9a03c'
c.fillRect(-10, headY - 8.8, 20, 2)
c.fillStyle = '#1d1409'
c.beginPath(); c.arc(0, headY - 12, 2.4, 0, 7); c.fill()
} else if (code === 'forest' || code === 'moss' || code === 'swamp') {
// 叶片/芦苇头环
c.fillStyle = code === 'swamp' ? '#4a5c2e' : code === 'moss' ? '#6a7a3d' : '#3f6b35'
c.strokeStyle = '#1d1409'; c.lineWidth = 1.2
;[[-7, headY - 7], [-3, headY - 10], [2, headY - 10.5], [7, headY - 7]].forEach(([hx, hy]) => {
c.beginPath(); c.ellipse(hx, hy, 3.6, 1.8, hx * 0.35, 0, 7); c.fill(); c.stroke()
})
} else if (code === 'honey' || code === 'gold') {
// 蜜糖/金币发夹
c.fillStyle = code === 'honey' ? '#f0c040' : '#e0b83a'
c.strokeStyle = '#8e6a14'; c.lineWidth = 1.2
c.beginPath(); c.arc(6.5, headY - 5, 2.6, 0, 7); c.fill(); c.stroke()
c.fillStyle = '#fff'
c.beginPath(); c.arc(6.5, headY - 5.6, 0.9, 0, 7); c.fill()
} else if (code === 'dusk' || code === 'plum') {
// 魔术师小礼帽 / 贵妇小帽
c.fillStyle = code === 'dusk' ? '#3a2a52' : '#8a4a7e'
c.strokeStyle = '#1d1409'; c.lineWidth = 1.4
c.beginPath(); c.rect(-6, headY - 12, 12, 4); c.fill(); c.stroke()
c.beginPath(); c.rect(-4, headY - 16, 8, 4.5); c.fill(); c.stroke()
c.fillStyle = '#e8c14d'; c.fillRect(-6, headY - 10, 12, 1.6)
} else if (code === 'mint' || code === 'sky') {
// 圆框眼镜 / 飞行风镜
c.strokeStyle = code === 'mint' ? '#3f5548' : '#4a668c'; c.lineWidth = 1.5
c.beginPath(); c.arc(-3.2, headY - 2.6, 2.8, 0, 7); c.stroke()
c.beginPath(); c.arc(3.2, headY - 2.6, 2.8, 0, 7); c.stroke()
c.beginPath(); c.moveTo(-0.6, headY - 2.6); c.lineTo(0.6, headY - 2.6); c.stroke()
} else if (code === 'ash' || code === 'rust') {
// 头巾
c.fillStyle = code === 'ash' ? '#8a8a94' : '#a05a2a'
c.strokeStyle = '#1d1409'; c.lineWidth = 1.4
c.beginPath(); c.moveTo(-8.6, headY - 6); c.quadraticCurveTo(0, headY - 14, 8.6, headY - 6); c.quadraticCurveTo(0, headY - 9, -8.6, headY - 6); c.closePath(); c.fill(); c.stroke()
c.beginPath(); c.moveTo(8, headY - 6); c.lineTo(11, headY + 2); c.lineTo(8.6, headY + 3); c.closePath(); c.fill(); c.stroke()
} else if (code === 'rose') {
// 蔷薇发饰
c.fillStyle = '#d86a8a'
c.strokeStyle = '#7e3348'; c.lineWidth = 1.1
;[[0, 2.2], [1.8, 1.4], [1.8, -1.4], [0, -2.2], [-1.8, -1.4], [-1.8, 1.4]].forEach(([px, py]) => {
c.beginPath(); c.arc(6.8 + px, headY - 5 + py, 2.1, 0, 7); c.fill(); c.stroke()
})
} else if (code === 'sand') {
// 旅人头巾
c.fillStyle = '#c9a86a'
c.strokeStyle = '#1d1409'; c.lineWidth = 1.3
c.beginPath(); c.moveTo(-8.4, headY - 7); c.quadraticCurveTo(0, headY - 15, 8.4, headY - 7); c.quadraticCurveTo(0, headY - 8.5, -8.4, headY - 7); c.closePath(); c.fill(); c.stroke()
c.fillStyle = '#a8854e'
c.fillRect(-8.4, headY - 9.6, 16.8, 2.4)
} else if (code === 'blizzard' || code === 'glacier') {
// 毛绒兜帽
c.fillStyle = code === 'blizzard' ? '#dfe6f0' : '#b8d8e8'
c.strokeStyle = '#1d1409'; c.lineWidth = 1.4
c.beginPath(); c.moveTo(-8.6, headY - 4); c.quadraticCurveTo(-9, headY - 14, 0, headY - 14); c.quadraticCurveTo(9, headY - 14, 8.6, headY - 4); c.quadraticCurveTo(0, headY - 7, -8.6, headY - 4); c.closePath(); c.fill(); c.stroke()
} else if (code === 'crow') {
// 鸦羽
c.fillStyle = '#2a2534'
c.strokeStyle = '#1d1409'; c.lineWidth = 1.2
c.beginPath(); c.moveTo(7, headY - 7); c.quadraticCurveTo(14, headY - 16, 12, headY - 2); c.quadraticCurveTo(9, headY - 5, 7, headY - 3); c.closePath(); c.fill(); c.stroke()
} else if (code === 'sunset') {
// 吟游者羽帽
c.fillStyle = '#e8823c'
c.strokeStyle = '#1d1409'; c.lineWidth = 1.3
c.beginPath(); c.ellipse(0, headY - 8, 9, 3.2, 0, Math.PI, 0); c.fill(); c.stroke()
c.fillStyle = '#f8d8a8'
c.beginPath(); c.moveTo(6, headY - 9); c.quadraticCurveTo(12, headY - 16, 11, headY - 4); c.quadraticCurveTo(8, headY - 7, 6, headY - 6); c.closePath(); c.fill(); c.stroke()
} else if (code === 'thorn') {
// 荆棘头冠
c.strokeStyle = '#54682c'; c.lineWidth = 1.4
c.beginPath(); c.moveTo(-7, headY - 6); c.quadraticCurveTo(0, headY - 12, 7, headY - 6); c.stroke()
;[[-4, headY - 8], [0, headY - 10], [4, headY - 8]].forEach(([hx, hy]) => {
c.beginPath(); c.moveTo(hx, hy); c.lineTo(hx, hy - 4); c.stroke()
})
} else if (code === 'shadow' || code === 'void') {
// 半脸面具
c.fillStyle = code === 'shadow' ? '#1e1828' : '#141020'
c.strokeStyle = '#1d1409'; c.lineWidth = 1.1
c.beginPath(); c.moveTo(-6, headY - 6); c.quadraticCurveTo(0, headY - 2, 6, headY - 6); c.quadraticCurveTo(3, headY + 2, 0, headY + 3); c.quadraticCurveTo(-3, headY + 2, -6, headY - 6); c.closePath(); c.fill(); c.stroke()
c.fillStyle = code === 'shadow' ? '#8e5cf0' : '#5a4a9e'
c.beginPath(); c.arc(0, headY - 1.5, 1.6, 0, 7); c.fill()
} else if (code === 'blood') {
// 血月战纹
c.strokeStyle = '#8e1c24'; c.lineWidth = 1.5
c.beginPath(); c.moveTo(-6.5, headY - 2); c.lineTo(-3.5, headY + 2); c.stroke()
c.beginPath(); c.moveTo(-3, headY - 2); c.lineTo(-6, headY + 2); c.stroke()
} else if (code === 'willow' || code === 'wendy') {
// 发饰
c.fillStyle = code === 'willow' ? '#8a2f22' : '#c96a8a'
c.strokeStyle = '#1d1409'; c.lineWidth = 1.1
c.beginPath(); c.arc(-6.8, headY - 3, 2.2, 0, 7); c.fill(); c.stroke()
} else if (code === 'king') {
// 金冠已在发型中体现;这里补红披肩扣
c.fillStyle = '#e8c14d'
c.strokeStyle = '#8e6a14'; c.lineWidth = 1
c.beginPath(); c.arc(0, -14, 1.6, 0, 7); c.fill(); c.stroke()
}
c.restore()
}
// 通用人物绘制:以脚底 (0,0) 为原点,头顶约 -32px
// pose = { dir, walking, walkT, swing, breathe, toolCode, ghost, time, act, fall }
// act: 动作分化chop 横劈 / mine 上凿下砸 / dig 下铲 / attack 突刺 / eat 抬手进食swing 1→0 驱动
@@ -271,6 +486,8 @@ export function drawPlayerFig(c, skin, pose = {}) {
fs(c, skin.skinTone, '#1d1409', 1.8)
// 发型
drawHair(c, skin, headY)
// 头饰/面饰差异化
drawSkinAccessory(c, skin, headY)
// 眼睛(竖点)+ 嘴
c.strokeStyle = '#1d1409'
c.lineWidth = 1.8
@@ -303,7 +520,7 @@ export function drawPlayerFig(c, skin, pose = {}) {
c.moveTo(0, 0)
c.lineTo(0, 11)
c.stroke()
drawTool(c, toolCode)
drawToolEnhanced(c, toolCode)
c.restore()
c.restore()
}
@@ -318,6 +535,20 @@ export function renderSkinPreview(code, size = 72) {
cv.width = size
cv.height = size
const c = cv.getContext('2d')
// 每个皮肤给不同底色光晕,卡片预览不再长得一样
c.save()
c.fillStyle = skin.vestColor || '#a33d3d'
c.globalAlpha = 0.18
c.beginPath()
c.arc(size / 2, size / 2, size * 0.48, 0, 7)
c.fill()
c.globalAlpha = 1
c.strokeStyle = skin.hairColor || '#15100c'
c.lineWidth = 1.5
c.beginPath()
c.arc(size / 2, size / 2, size * 0.46, 0, 7)
c.stroke()
c.restore()
c.translate(size / 2, size * 0.86)
const scale = size / 52
c.scale(scale, scale)

View File

@@ -70,6 +70,9 @@ export const useStarveCoopStore = defineStore('starveCoop', {
switch (type) {
case 'room_state':
this.roomState = data
if (data?.code) {
try { localStorage.setItem('starve_last_room_code', data.code) } catch (e) {}
}
gameHandler?.(type, data)
break
case 'starve_input':
@@ -81,6 +84,7 @@ export const useStarveCoopStore = defineStore('starveCoop', {
case 'room_closed':
this.closedReason = data.reason || '房间已解散'
this.roomState = null
try { localStorage.removeItem('starve_last_room_code') } catch (e) {}
gameHandler?.('room_closed', data)
toast(this.closedReason, 'info')
break
@@ -101,6 +105,7 @@ export const useStarveCoopStore = defineStore('starveCoop', {
leaveRoom() {
this.send('leave_room')
this.roomState = null
try { localStorage.removeItem('starve_last_room_code') } catch (e) {}
},
setReady(ready) { this.send('ready', { ready }) },
setSkin(code) { this.send('skin', { skin: code }) },

View File

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