更新功能、刷新不立即解散房间、交互动画优化
This commit is contained in:
3630
pnpm-lock.yaml
generated
3630
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
3
pnpm-workspace.yaml
Normal file
3
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,3 @@
|
||||
allowBuilds:
|
||||
'@parcel/watcher': true
|
||||
electron-winstaller: true
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }) },
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user