年糕生存游戏

This commit is contained in:
李琦
2026-08-15 16:46:17 +08:00
parent e70b7aa3d7
commit eca29aecc8
6 changed files with 854 additions and 25 deletions

View File

@@ -33,16 +33,16 @@ const msgBadge = computed(() => {
return n > 99 ? '99+' : n
})
// 仅饥荒游玩页:导航变悬浮小条,默认隐藏,鼠标靠近顶部再展示
// 仅饥荒游玩页:导航变悬浮小条,默认隐藏;由 GamePlay 的吸顶按钮统一展开/收起
const navFloat = computed(() => route.path.startsWith('/play/starve'))
const navShow = ref(true)
watch(navFloat, (v) => { navShow.value = !v }, { immediate: true })
function onNavMouseMove(e) {
function onStarveNavToggle() {
if (!navFloat.value) return
navShow.value = e.clientY <= 60
navShow.value = !navShow.value
}
onMounted(() => window.addEventListener('mousemove', onNavMouseMove))
onBeforeUnmount(() => window.removeEventListener('mousemove', onNavMouseMove))
onMounted(() => window.addEventListener('nlg:starve-nav-toggle', onStarveNavToggle))
onBeforeUnmount(() => window.removeEventListener('nlg:starve-nav-toggle', onStarveNavToggle))
// 当前路由是否命中某导航
function isActive(path) {

View File

@@ -35,6 +35,8 @@ const emit = defineEmits(['score', 'end', 'save', 'progress'])
const canvas = ref(null)
const clockCv = ref(null)
const mapCv = ref(null)
const fullMapCv = ref(null)
const showFullMap = ref(false)
// ---- HUD 响应式状态 ----
const playing = ref(false)
@@ -46,6 +48,7 @@ const hunger = ref(CAP.hunger)
const san = ref(CAP.san)
const temp = ref(20)
const day = ref(1)
const cursorMode = ref('normal')
const seasonTag = ref({ name: '秋', dayIn: 1, len: 20, tint: '#c98548', code: 'autumn' })
const tips = ref([])
const hint = ref('')
@@ -661,6 +664,48 @@ function onCanvasClick(ev) {
if (ent) return setMyTarget({ kind: 'ent', id: ent.id })
setMyTarget({ kind: 'walk', x: wxp, y: wyp })
}
function updateCursor(ev) {
if (!playing.value || !canvas.value) {
cursorMode.value = 'normal'
return
}
const rect = canvas.value.getBoundingClientRect()
const wxp = ((ev.clientX - rect.left) / rect.width) * canvas.value.width + camX
const wyp = ((ev.clientY - rect.top) / rect.height) * canvas.value.height + camY
let mon = null
let md = Infinity
monsters.forEach((m) => {
const r = m.kind === 'deerclops' ? 70 : m.kind === 'treeguard' ? 50 : m.kind === 'beefalo' ? 36 : 28
const d = Math.hypot(m.x - wxp, m.y - wyp)
if (d < r && d < md) { md = d; mon = m }
})
if (mon) {
cursorMode.value = (mon.kind === 'pig' || mon.kind === 'beefalo' || modRt.mobKinds[mon.kind]?.friendly) ? 'hand' : 'attack'
return
}
const f = fires.find((f2) => Math.hypot(f2.x - wxp, f2.y - wyp) < 36)
if (f) {
cursorMode.value = 'hand'
return
}
let ent = null
let ed = Infinity
entities.forEach((e) => {
if (e.deco && e.type !== 'stump') return
if (e.stub || e.solid) return
const d = Math.hypot(e.x - wxp, e.y - wyp)
if (d < Math.max(26, e.size * 0.9) && d < ed) { ed = d; ent = e }
})
if (ent) {
const attackable = ent.hp > 0 && (ent.mobile || ent.nest || ent.lurk)
cursorMode.value = attackable ? 'attack' : 'hand'
return
}
cursorMode.value = 'normal'
}
function onCanvasLeave() {
cursorMode.value = 'normal'
}
// 设置本地目标:客机同时上报主机
function setMyTarget(t) {
player.target = resolveTarget(t)
@@ -1388,6 +1433,9 @@ function setPaused(v) {
function openManualFromGame() {
window.dispatchEvent(new CustomEvent('nlg:open-manual'))
}
function openSettingsFromGame() {
window.dispatchEvent(new CustomEvent('nlg:open-settings'))
}
function togglePause() {
setPaused(!paused.value)
}
@@ -2361,7 +2409,8 @@ function draw() {
}
}
ctx.restore()
drawClock()
drawClockV2()
if (showFullMap.value) drawFullMap()
}
function drawEntWrap(e, env) {
const sx = e.x - camX
@@ -2535,8 +2584,242 @@ function drawNight(W, H) {
})
ctx.drawImage(layer, 0, 0)
}
// 无限接近原版:外木圈 + 三段昼夜盘 + 中心羊皮纸日数盘 + 黑针从中心指向边缘
function drawClockV2() {
const c = clockCv.value?.getContext('2d')
if (!c) return
const S = 112
c.clearRect(0, 0, S, S)
const cx2 = S / 2
const cy2 = S / 2
const R = S / 2 - 5
const [duskAt, nightAt] = daySplit(seasonNow.code)
// 外层木圈
c.beginPath()
c.arc(cx2, cy2, R, 0, 7)
c.fillStyle = '#6b4a2c'
c.fill()
c.strokeStyle = '#1d1409'
c.lineWidth = 3
c.stroke()
c.beginPath()
c.arc(cx2, cy2, R * 0.94, 0, 7)
c.strokeStyle = '#8a6238'
c.lineWidth = 2
c.stroke()
// 昼夜三段扇区
const segs = [
[0, duskAt / DAY_LEN, '#f0c94a'],
[duskAt / DAY_LEN, nightAt / DAY_LEN, '#d88a3e'],
[nightAt / DAY_LEN, 1, '#2a2742'],
]
segs.forEach(([a, b, col]) => {
c.beginPath()
c.moveTo(cx2, cy2)
c.arc(cx2, cy2, R * 0.82, -Math.PI / 2 + a * Math.PI * 2, -Math.PI / 2 + b * Math.PI * 2)
c.closePath()
c.fillStyle = col
c.fill()
c.strokeStyle = 'rgba(20,14,8,0.75)'
c.lineWidth = 1.4
c.stroke()
})
// 外圈 16 段刻度
for (let i = 0; i < 16; i++) {
const a = -Math.PI / 2 + (i / 16) * Math.PI * 2
const long = i % 4 === 0
const r1 = R * 0.94
const r2 = long ? R * 0.82 : R * 0.88
c.beginPath()
c.moveTo(cx2 + Math.cos(a) * r1, cy2 + Math.sin(a) * r1)
c.lineTo(cx2 + Math.cos(a) * r2, cy2 + Math.sin(a) * r2)
c.strokeStyle = 'rgba(20,14,8,0.9)'
c.lineWidth = long ? 2.2 : 1.1
c.stroke()
}
// 中心羊皮纸盘
c.beginPath()
c.arc(cx2, cy2, R * 0.46, 0, 7)
c.fillStyle = '#eadfc4'
c.fill()
c.strokeStyle = '#3a2a17'
c.lineWidth = 2
c.stroke()
c.beginPath()
c.arc(cx2, cy2, R * 0.46, 0, 7)
c.strokeStyle = seasonTag.value.tint
c.lineWidth = 2
c.stroke()
c.fillStyle = '#2b1d10'
c.font = 'bold 16px "Ma Shan Zheng", serif'
c.textAlign = 'center'
c.textBaseline = 'middle'
c.fillText(`${day.value}`, cx2, cy2 - 6)
c.font = 'bold 11px "Ma Shan Zheng", serif'
c.fillStyle = seasonTag.value.tint
c.fillText(`${seasonTag.value.name} ${seasonTag.value.dayIn}/${seasonTag.value.len}`, cx2, cy2 + 10)
// 原版黑针:从中心向外,尖端略过中心盘与外圈之间
const ang = -Math.PI / 2 + (dayTime / DAY_LEN) * Math.PI * 2
c.save()
c.translate(cx2, cy2)
c.rotate(ang)
c.beginPath()
c.moveTo(0, -2.6)
c.lineTo(R * 0.92, -0.8)
c.lineTo(R * 0.92, 0.8)
c.lineTo(0, 2.6)
c.closePath()
c.fillStyle = '#17120b'
c.fill()
c.strokeStyle = '#f4ead2'
c.lineWidth = 1.4
c.stroke()
// 指针尖端圆点
const tipIcon = dayTime >= nightAt || dayTime < 0 ? '#c9d4e8' : '#f0c94a'
c.beginPath()
c.arc(R * 0.92, 0, 2.8, 0, 7)
c.fillStyle = tipIcon
c.fill()
c.strokeStyle = '#17120b'
c.lineWidth = 1.2
c.stroke()
c.restore()
// 中心铜轴
c.beginPath()
c.arc(cx2, cy2, 4, 0, 7)
c.fillStyle = '#b89a5c'
c.fill()
c.strokeStyle = '#1d1409'
c.lineWidth = 1.4
c.stroke()
}
// 原版风格罗盘时钟:外圈木纹时刻环 + 昼夜扇区 + 中心天数季节盘 + 日月指针
function drawClockV2Legacy() {
const c = clockCv.value?.getContext('2d')
if (!c) return
const S = 104
c.clearRect(0, 0, S, S)
const cx2 = S / 2
const cy2 = S / 2
const R = S / 2 - 6
const [duskAt, nightAt] = daySplit(seasonNow.code)
// 外圈木纹底
c.beginPath()
c.arc(cx2, cy2, R, 0, 7)
c.fillStyle = '#4a341f'
c.fill()
c.strokeStyle = '#1d1409'
c.lineWidth = 3
c.stroke()
// 木纹环线
c.strokeStyle = 'rgba(31,20,9,0.55)'
c.lineWidth = 1.4
;[0.82, 0.9, 0.96].forEach((k) => {
c.beginPath()
c.arc(cx2, cy2, R * k, 0, 7)
c.stroke()
})
// 昼夜扇区(黄 / 橙 / 深蓝)
const segs = [
[0, duskAt / DAY_LEN, '#e8bc5a'],
[duskAt / DAY_LEN, nightAt / DAY_LEN, '#c97b3a'],
[nightAt / DAY_LEN, 1, '#25223d'],
]
segs.forEach(([a, b, col]) => {
c.beginPath()
c.moveTo(cx2, cy2)
c.arc(cx2, cy2, R * 0.78, -Math.PI / 2 + a * Math.PI * 2, -Math.PI / 2 + b * Math.PI * 2)
c.closePath()
c.fillStyle = col
c.fill()
c.strokeStyle = 'rgba(20,14,8,0.65)'
c.lineWidth = 1.2
c.stroke()
})
// 16 段时刻刻度:整点长刻度
for (let i = 0; i < 16; i++) {
const a = -Math.PI / 2 + (i / 16) * Math.PI * 2
const long = i % 4 === 0
const r1 = R * 0.96
const r2 = long ? R * 0.82 : R * 0.88
c.beginPath()
c.moveTo(cx2 + Math.cos(a) * r1, cy2 + Math.sin(a) * r1)
c.lineTo(cx2 + Math.cos(a) * r2, cy2 + Math.sin(a) * r2)
c.strokeStyle = 'rgba(20,14,8,0.85)'
c.lineWidth = long ? 2 : 1
c.stroke()
}
// 指针:当前时刻,针头挂太阳/月亮
const ang = -Math.PI / 2 + (dayTime / DAY_LEN) * Math.PI * 2
c.save()
c.translate(cx2, cy2)
c.rotate(ang)
c.strokeStyle = '#f4ead2'
c.lineWidth = 3
c.beginPath()
c.moveTo(0, 0)
c.lineTo(R * 0.86, 0)
c.stroke()
const icon = dayTime >= nightAt || dayTime < 0 ? 'moon' : 'sun'
c.fillStyle = icon === 'sun' ? '#f0c04a' : '#dfe8f0'
c.strokeStyle = '#1d1409'
c.lineWidth = 1.4
if (icon === 'sun') {
c.beginPath(); c.arc(R * 0.74, 0, 5, 0, 7); c.fill(); c.stroke()
} else {
c.beginPath(); c.arc(R * 0.74, 0, 5, 0, 7); c.fill(); c.stroke()
c.fillStyle = '#25223d'
c.beginPath(); c.arc(R * 0.76, 1, 3.4, 0, 7); c.fill()
}
c.restore()
// 中心铜轴,原版罗盘指针的固定点
c.beginPath()
c.arc(cx2, cy2, 4.4, 0, 7)
c.fillStyle = '#b89a5c'
c.fill()
c.strokeStyle = '#1d1409'
c.lineWidth = 1.4
c.stroke()
// 中心季节盘
c.beginPath()
c.arc(cx2, cy2, R * 0.5, 0, 7)
c.fillStyle = '#f4ead2'
c.fill()
c.strokeStyle = seasonTag.value.tint
c.lineWidth = 3
c.stroke()
c.fillStyle = '#3a2d18'
c.font = 'bold 16px "Ma Shan Zheng", serif'
c.textAlign = 'center'
c.textBaseline = 'middle'
c.fillText(`${day.value}`, cx2, cy2 - 7)
c.font = 'bold 11px "Ma Shan Zheng", serif'
c.fillStyle = seasonTag.value.tint
c.fillText(`${seasonTag.value.name} ${seasonTag.value.dayIn}/${seasonTag.value.len}`, cx2, cy2 + 10)
// 顶部小提环
c.strokeStyle = '#3a2a17'
c.lineWidth = 3
c.beginPath()
c.moveTo(cx2 - 6, cy2 - R)
c.lineTo(cx2 - 4, cy2 - R - 7)
c.lineTo(cx2 + 4, cy2 - R - 7)
c.lineTo(cx2 + 6, cy2 - R)
c.stroke()
}
// 右上角昼夜时钟16 段刻度 + 季节配色与标签)
function drawClock() {
function drawClockLegacy() {
const c = clockCv.value?.getContext('2d')
if (!c) return
const S = 92
@@ -2600,10 +2883,11 @@ function drawClock() {
c.stroke()
}
// 小地图2Hz 更新):群系底色 + 战争迷雾 + 标记
function drawMinimap() {
const c = mapCv.value?.getContext('2d')
if (!c || !tiles || !explored) return
const s = 5
function drawMapTo(cv, s) {
if (!cv || !tiles || !explored) return
const c = cv.getContext('2d')
// s 由调用方传入
// s 为像素缩放
c.fillStyle = '#0b0906'
c.fillRect(0, 0, GW * s, GH * s)
for (let ty = 0; ty < GH; ty++) {
@@ -2627,7 +2911,33 @@ function drawMinimap() {
if (e.struct && explored[tileIdx(e.x, e.y)]) dot(e.x, e.y, e.type === 'pighouse' ? '#e8a8a0' : '#7fd8f0', 2)
})
monsters.forEach((m) => { if (m.kind === 'deerclops') dot(m.x, m.y, '#e04840', 3.4) })
players.forEach((p) => { if (!p.dead || coop) dot(p.x, p.y, p === player ? '#fff' : '#ffe89a', p === player ? 3 : 2.4) })
players.forEach((p) => {
if (!p.dead || coop) {
const mx = (p.x / TILE) * s
const my = (p.y / TILE) * s
const r = p === player ? (s >= 8 ? 7 : 4.5) : (s >= 8 ? 5.5 : 3.2)
c.fillStyle = p === player ? '#ffffff' : '#ffe89a'
c.strokeStyle = '#1d1409'
c.lineWidth = s >= 8 ? 3 : 1.5
c.beginPath()
c.arc(mx, my, r, 0, 7)
c.fill()
c.stroke()
if (p === player && s >= 8) {
c.strokeStyle = 'rgba(255,255,255,0.7)'
c.lineWidth = 2
c.beginPath()
c.arc(mx, my, r + 5 + Math.sin(time * 4) * 2, 0, 7)
c.stroke()
}
}
})
}
function drawMinimap() {
drawMapTo(mapCv.value, 3)
}
function drawFullMap() {
drawMapTo(fullMapCv.value, 12)
}
// =====================================================================
@@ -2911,8 +3221,8 @@ function freshClass(f) {
</script>
<template>
<div class="starve-wrap">
<canvas ref="canvas" class="starve-canvas" @click="onCanvasClick"></canvas>
<div class="starve-wrap" :class="'cur-' + cursorMode">
<canvas ref="canvas" class="starve-canvas" @click="onCanvasClick" @mousemove="updateCursor" @mouseleave="onCanvasLeave"></canvas>
<!-- 手机摇杆设置里可切换 -->
<div
@@ -2941,7 +3251,7 @@ function freshClass(f) {
<!-- 右上时钟 + 三围 + 体温 -->
<div v-show="playing || dead" class="hud-right">
<canvas ref="clockCv" width="92" height="92" class="clock"></canvas>
<canvas ref="clockCv" width="112" height="112" class="clock"></canvas>
<div class="badges">
<div class="badge" :class="{ low: hp < 40 }" title="生命">
<svg viewBox="0 0 24 24"><path d="M12 21C7 16.5 3 13 3 8.8 3 5.9 5.2 4 7.7 4c1.7 0 3.3.9 4.3 2.4C13 4.9 14.6 4 16.3 4 18.8 4 21 5.9 21 8.8c0 4.2-4 7.7-9 12.2z" fill="#d8524e" stroke="#1d1409" stroke-width="1.6"/></svg>
@@ -2964,9 +3274,10 @@ function freshClass(f) {
<!-- 左上存档 / 地图开关 / mod 标签 -->
<div v-show="playing" class="hud-left">
<button v-if="!isCoop" class="ds-btn" :disabled="saving" @click="doSave(true)">{{ saving ? '已保存' : '保存' }}</button>
<button class="ds-btn" @click="togglePause">{{ paused ? '继续' : '暂停 P' }}</button>
<button class="ds-btn" @click="showMap = !showMap; showMap && drawMinimap()">地图 M</button>
<button v-if="!isCoop" class="ds-btn icon-btn" :disabled="saving" title="保存" @click="doSave(true)">{{ saving ? '' : '💾' }}</button>
<button class="ds-btn icon-btn" :title="paused ? '继续' : '暂停'" @click="togglePause">{{ paused ? '' : '' }}</button>
<button class="ds-btn icon-btn" title="设置" @click="openSettingsFromGame"></button>
<button class="ds-btn icon-btn" title="地图 M" @click="showMap = !showMap; showMap && drawMinimap()">🗺</button>
<span v-if="uiMods.length" class="mod-tags" :title="uiMods.map((m) => m.name).join('、')">
Mod×{{ uiMods.length }}
</span>
@@ -2974,10 +3285,23 @@ function freshClass(f) {
<!-- 小地图 -->
<div v-show="playing && showMap" class="ds-map">
<canvas ref="mapCv" width="200" height="150"></canvas>
<canvas ref="mapCv" width="192" height="144"></canvas>
<button class="ds-btn" @click="showFullMap = true; drawFullMap()">全屏地图</button>
<span class="map-note">战争迷雾随探索揭开</span>
</div>
<!-- 全屏地图 -->
<div v-if="showFullMap" class="full-map-overlay">
<div class="full-map-card">
<div class="full-map-head">
<b>世界地图</b>
<button class="ds-btn" @click="showFullMap = false">关闭</button>
</div>
<canvas ref="fullMapCv" width="768" height="576"></canvas>
<span class="map-note">玩家为白色亮点 · Boss 为红色</span>
</div>
</div>
<!-- 幽灵横幅 -->
<div v-if="ghostMode && playing" class="ghost-banner">你变成了幽灵在队友全灭前一直旁观</div>
@@ -3231,8 +3555,17 @@ function freshClass(f) {
}
.ds-map canvas {
border-radius: 6px;
image-rendering: pixelated;
image-rendering: pixelated;
width: 192px;
height: 144px;
max-width: 80vw;
width: 360px;
height: 270px;
max-width: 80vw;
}
width: 360px;
height: 270px;
max-width: 80vw;
.map-note {
color: #b8a888;
font-size: 11px;
@@ -3668,4 +4001,145 @@ function freshClass(f) {
justify-content: center;
margin-top: 8px;
}
/* ---- 饥荒主题控件强化 ---- */
.ds-btn {
font-family: "Ma Shan Zheng", "KaiTi", serif;
letter-spacing: 1px;
background:
linear-gradient(180deg, rgba(255,255,255,0.09), transparent 42%),
linear-gradient(180deg, #5a4327, #2b1d10);
border: 2px solid #b89a5c;
box-shadow: inset 0 0 0 2px #1d1409, 0 3px 0 #0d0906;
color: #f4ead2;
text-shadow: 1px 1px 0 #0d0906;
}
.ds-btn:hover {
border-color: #f0c04a;
color: #fff2c0;
}
.pause-card,
.craft-panel,
.pot-panel,
.ds-map {
background:
radial-gradient(circle at 12% 10%, rgba(232, 188, 90, 0.14), transparent 34%),
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 36px rgba(0, 0, 0, 0.55);
color: #f4ead2;
}
.pause-card h3 {
font-family: "Ma Shan Zheng", "KaiTi", serif;
letter-spacing: 2px;
color: #f4d68a;
text-shadow: 2px 2px 0 #17120b;
}
.badge {
background: linear-gradient(180deg, #33261a, #17120b);
border-color: #6f5b38;
box-shadow: inset 0 0 0 2px #1d1409;
}
.hud-left .ds-btn {
padding: 5px 10px;
font-size: 12px;
}
.starve-wrap .ds-map canvas {
width: 192px !important;
height: 144px !important;
}
.full-map-overlay {
position: absolute;
inset: 0;
z-index: 60;
display: flex;
align-items: center;
justify-content: center;
background: rgba(8, 5, 4, 0.78);
backdrop-filter: blur(2px);
}
.full-map-card {
background: linear-gradient(180deg, #33261a, #17120b);
border: 2px solid #b89a5c;
box-shadow: inset 0 0 0 4px #241a10, 0 14px 44px rgba(0, 0, 0, 0.6);
border-radius: 8px;
padding: 14px;
display: flex;
flex-direction: column;
gap: 10px;
align-items: center;
}
.full-map-head {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
color: #f4ead2;
}
.full-map-card canvas {
image-rendering: pixelated;
max-width: min(768px, 92vw);
max-height: 72vh;
border-radius: 4px;
}
.icon-btn {
width: 34px;
height: 34px;
min-width: 34px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 0;
font-size: 16px;
image-rendering: pixelated;
}
.craft-tab {
border-radius: 0;
image-rendering: pixelated;
}
.craft-tab span {
display: none;
}
.craft-tab:hover span,
.craft-tab.on span {
display: inline;
}
.craft-item {
border-radius: 0;
image-rendering: pixelated;
}
/* 原版风格鼠标:米白色手套指针 */
.starve-wrap,
.starve-wrap .starve-canvas,
.starve-wrap .ds-btn,
.starve-wrap .slot,
.starve-wrap .pause-card button {
cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='26' height='26' viewBox='0 0 26 26'%3E%3Cpath d='M5 2v10l-3 2v6c0 4.2 3.3 7 7.6 7h2.6c5 0 8.2-3.3 8.2-7.4V9l-3.6-2-2.6-2.4-3.2-3z' fill='%23f4ead2' stroke='%231d1409' stroke-width='2'/%3E%3Cpath d='M11 8v9M15 9v9' stroke='%231d1409' stroke-width='1.6'/%3E%3C/svg%3E") 4 4, auto;
}
/* 攻击光标:短剑 */
.starve-wrap.cur-attack .starve-canvas {
cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='26' height='26' viewBox='0 0 26 26'%3E%3Cpath d='M4 3L22 21l-2 2L2 5z' fill='%23b0b4bc' stroke='%231d1409' stroke-width='1.8'/%3E%3Cpath d='M5 21l-3-3 6-6 3 3z' fill='%237a5b33' stroke='%231d1409' stroke-width='1.4'/%3E%3C/svg%3E") 4 4, crosshair;
}
/* 采集/交互光标:张开的手 */
.starve-wrap.cur-hand .starve-canvas {
cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='26' height='26' viewBox='0 0 26 26'%3E%3Cpath d='M4 10v4c0 7 4 11 10 11 5 0 8-3 8-7V9l-2 1-2-2-2-1-2-2-2-2v7z' fill='%23f4ead2' stroke='%231d1409' stroke-width='1.8'/%3E%3Cpath d='M8 12v7M12 10v9M16 11v9M20 13v6' stroke='%231d1409' stroke-width='1.4'/%3E%3C/svg%3E") 4 4, pointer;
}
/* 更精细的原版手套指针 */
.starve-wrap.cur-normal .starve-canvas {
cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='32' height='32' viewBox='0 0 32 32'%3E%3Cg%3E%3Cpath d='M11 2C8.8 2 7 3.8 7 6v9l-3.6 2v7.2c0 4.6 3.2 7.8 7.8 7.8h3.2c4.8 0 7.6-2.9 7.6-7.4V10.2l-3.8-2-3-2.2-2.6-2.4L11 2z' fill='%23f4ead2' stroke='%231d1409' stroke-width='2.2' stroke-linejoin='round'/%3E%3Cpath d='M8 8v8M12 7v9M15 8v9M18 10v8' stroke='%231d1409' stroke-width='1.6' stroke-linecap='round'/%3E%3Cpath d='M10 4c1.4.4 2.4 1.4 2.8 3l-3.2 1.6C8.6 7 9 5 10 4z' fill='%23ffffff' opacity='.45'/%3E%3Cpath d='M7.5 16.5c1.5.7 3 .8 4.4.3' stroke='%23ffffff' stroke-width='1.4' opacity='.4' fill='none' stroke-linecap='round'/%3E%3C/g%3E%3C/svg%3E") 4 4, auto;
}
/* 更精细的攻击短剑指针 */
.starve-wrap.cur-attack .starve-canvas {
cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='32' height='32' viewBox='0 0 32 32'%3E%3Cg%3E%3Cpath d='M4 3L23 22l-2 2L2 5z' fill='%23b8c0cc' stroke='%231d1409' stroke-width='2' stroke-linejoin='round'/%3E%3Cpath d='M4 3L23 22' stroke='%23ffffff' stroke-width='1.4' opacity='.6'/%3E%3Cpath d='M20 18l-3-3 4-4 4 4z' fill='%238a6238' stroke='%231d1409' stroke-width='1.8'/%3E%3Cpath d='M22 14l4 4-2 2-4-4z' fill='%236b4a28'/%3E%3Cpath d='M21 21l-2-2 2-2 3 3-2 2' fill='%23f0c04a' stroke='%231d1409' stroke-width='1.4'/%3E%3C/g%3E%3C/svg%3E") 4 4, crosshair;
}
/* 更精细的采集手掌指针 */
.starve-wrap.cur-hand .starve-canvas {
cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='32' height='32' viewBox='0 0 32 32'%3E%3Cg%3E%3Cpath d='M5 12v4c0 8 4 13 11 13 6 0 9-4 9-8V12l-2.4 1.2L19 11l-2.2-2-2-1.8-2-2.4-2-2.6v6l-2.6 1.4L5 12z' fill='%23f4ead2' stroke='%231d1409' stroke-width='2' stroke-linejoin='round'/%3E%3Cpath d='M9 14v8M13 12v10M17 13v10M21 16v7' stroke='%231d1409' stroke-width='1.6' stroke-linecap='round'/%3E%3Cpath d='M6 15c1.6.6 3 .5 4.4-.2' stroke='%23ffffff' stroke-width='1.4' opacity='.5' fill='none' stroke-linecap='round'/%3E%3C/g%3E%3C/svg%3E") 4 4, pointer;
}
</style>

View File

@@ -10,5 +10,6 @@ import magic from './magic.js'
import chill from './chill.js'
import traveler from './traveler.js'
import lucky from './lucky.js'
import loyaldog from './loyaldog.js'
export const MODS = [qol, farm, chest, chester, revive, peaceful, nightmare, magic, chill, traveler, lucky]
export const MODS = [qol, farm, chest, chester, revive, peaceful, nightmare, magic, chill, traveler, lucky, loyaldog]

View File

@@ -0,0 +1,150 @@
// 最忠诚的伙伴:地图随机散落骨肉/肉,找到小狗并喂食即可结为永久伙伴
const TAU = Math.PI * 2
function findFood(p) {
const areas = [['main', p.slots], ['pack', p.pack]].filter(([, area]) => area)
for (const [name, area] of areas) {
for (let i = 0; i < area.length; i++) {
const s = area[i]
if (s && (s.code === 'bonemeat' || s.code === 'meat')) return { area: name, i }
}
}
return null
}
export default {
id: 'loyaldog',
name: '最忠诚的伙伴',
desc: '地图散落骨肉与肉,找到小狗喂食后,它会攻击你攻击的目标和保护你',
factor: 1,
tag: 'content',
items: {
bonemeat: { name: '骨肉', stack: 10, food: { hunger: 12.5, hp: 2, san: 0 }, meatv: 0.5 },
},
mobs: {
dog: {
name: '小狗',
conf: { hp: 500, speed: 175, dmg: 26, score: 0, friendly: true },
update(m, dt, g) {
const conf = g.mon[m.kind] || this.conf
if (m.hp <= 0) return
const owner = m.owner && !m.owner.dead ? m.owner : null
if (!owner) {
// 流浪:原地短距游荡
m.wanderT = (m.wanderT || 0) - dt
if (m.wanderT <= 0) {
m.wanderT = 2 + Math.random() * 3
const a = Math.random() * TAU
m.tx = m.x + Math.cos(a) * 90
m.ty = m.y + Math.sin(a) * 90
}
if (m.tx !== undefined && Math.hypot(m.tx - m.x, m.ty - m.y) > 8) {
g.moveToward(m, m.tx, m.ty, conf.speed * 0.45, dt)
}
return
}
// 目标:主人当前攻击目标,或靠近主人的敌对生物
let target = null
if (owner.target?.kind === 'mon' && g.monsters.includes(owner.target.ref)) {
target = owner.target.ref
}
if (!target) {
let bd = 190
g.monsters.forEach((q) => {
if (!g.mon[q.kind]?.hostile) return
const d = Math.hypot(q.x - owner.x, q.y - owner.y)
if (d < bd) { bd = d; target = q }
})
}
if (target && !g.monsters.includes(target)) target = null
if (target) {
const d = Math.hypot(target.x - m.x, target.y - m.y)
if (d > 26) {
g.moveToward(m, target.x, target.y, conf.speed, dt)
} else if ((m.cd || 0) <= 0) {
m.cd = 1.1
target.hp -= conf.dmg
target.shake = 0.22
if (target.hp <= 0) g.killMob(target, owner)
}
} else {
const d = Math.hypot(owner.x - m.x, owner.y - m.y)
if (d > 66) g.moveToward(m, owner.x, owner.y, conf.speed, dt)
}
},
interact(p, m, g) {
if (m.owner) {
g.ptip(p, m.owner === p ? '小狗忠诚地跟在你身边' : '小狗已经有主人了')
return
}
const food = findFood(p)
if (!food) {
g.ptip(p, '小狗闻了闻你,似乎在找骨肉或肉')
return
}
g.consumeAt(p, food.area, food.i)
m.owner = p
m.loyalT = 999999
m.state = 'loyal'
m.hp = Math.max(m.hp, 500)
g.swing(p, 'eat')
g.ptip(p, '小狗吃下骨肉,成为了你最忠诚的伙伴!')
},
draw(ctx, m, time) {
ctx.save()
ctx.scale(m.dir || 1, 1)
const hop = Math.sin(time * 8 + (m.bob || 0)) * (m.moving ? 2 : 0.6)
// 身体
ctx.fillStyle = '#6b4a2a'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 2
ctx.beginPath()
ctx.ellipse(0, -13 + hop * 0.4, 11, 7, 0, 0, TAU)
ctx.fill(); ctx.stroke()
// 头
ctx.beginPath()
ctx.arc(11, -20 + hop * 0.6, 6, 0, TAU)
ctx.fill(); ctx.stroke()
// 耳
ctx.fillStyle = '#4a2e16'
ctx.beginPath(); ctx.moveTo(7, -24); ctx.lineTo(4, -32); ctx.lineTo(12, -25); ctx.closePath(); ctx.fill(); ctx.stroke()
// 眼鼻
ctx.fillStyle = '#f4ead2'
ctx.beginPath(); ctx.arc(13, -21, 1.6, 0, TAU); ctx.fill()
ctx.fillStyle = '#1d1409'
ctx.beginPath(); ctx.arc(15.6, -19.5, 1.2, 0, TAU); ctx.fill()
// 腿
ctx.strokeStyle = '#4a2e16'
ctx.lineWidth = 3
ctx.beginPath()
ctx.moveTo(-5, -7); ctx.lineTo(-6 + Math.sin(time * 10) * 2, 1)
ctx.moveTo(5, -7); ctx.lineTo(6 + Math.cos(time * 10) * 2, 1)
ctx.stroke()
// 忠诚标记
if (m.owner) {
ctx.fillStyle = '#e0485a'
ctx.beginPath()
ctx.moveTo(0, -32 + hop)
ctx.bezierCurveTo(-4, -36 + hop, -1, -39 + hop, 0, -36 + hop)
ctx.bezierCurveTo(1, -39 + hop, 4, -36 + hop, 0, -32 + hop)
ctx.fill()
}
ctx.restore()
},
},
},
hooks: {
onWorldGen(g) {
for (let i = 0; i < 6; i++) {
const pos = g.findLand(80 + Math.random() * (g.worldW - 160), 80 + Math.random() * (g.worldH - 160), 0, 20)
g.addEntity('loot', pos.x, pos.y, { code: 'bonemeat', n: 1 })
}
for (let i = 0; i < 3; i++) {
const pos = g.findLand(80 + Math.random() * (g.worldW - 160), 80 + Math.random() * (g.worldH - 160), 0, 20)
g.addEntity('loot', pos.x, pos.y, { code: 'meat', n: 1 })
}
const dogPos = g.findLand(g.worldW / 2, g.worldH / 2, 260, 520)
g.spawnMob('dog', dogPos.x, dogPos.y, {})
},
},
}

View File

@@ -6,8 +6,8 @@ function randInt(min, max) {
}
export const TILE = 80
export const GW = 40
export const GH = 30
export const GW = 64
export const GH = 48
export const WORLD = { w: GW * TILE, h: GH * TILE } // 3200 x 2400
export const BIOMES = {

View File

@@ -70,10 +70,30 @@ function saveTouchMode(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 isPortrait = ref(false)
let portraitMq = null
function updateOrientation() {
isPortrait.value = isMobile.value && !!portraitMq?.matches
}
async function enterLandscape() {
try {
if (document.fullscreenElement) await document.exitFullscreen()
await document.documentElement.requestFullscreen?.()
} catch (e) {}
try {
await screen.orientation?.lock?.('landscape')
} catch (e) {}
updateOrientation()
}
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)
@@ -581,11 +601,17 @@ onMounted(() => {
// 宽屏游戏(如饥荒):给 body 打标解除全局 1200px 限宽
if (meta?.wide) document.body.classList.add('wide-page')
load()
portraitMq = window.matchMedia('(orientation: portrait)')
window.addEventListener('nlg:open-settings', openSettings)
portraitMq.addEventListener('change', updateOrientation)
updateOrientation()
window.addEventListener('nlg:open-manual', openManual)
if (coopable) {
window.addEventListener('nlg:pending-join', tryPendingJoin)
tryPendingJoin()
}
portraitMq?.removeEventListener('change', updateOrientation)
window.removeEventListener('nlg:open-settings', openSettings)
})
onBeforeUnmount(() => {
document.body.classList.remove('wide-page')
@@ -600,8 +626,8 @@ onBeforeUnmount(() => {
</script>
<template>
<div v-if="detail" class="play-wrap" :class="{ 'play-fill': meta?.wide }">
<button v-if="floatHead" class="head-toggle" @click="headShow = !headShow">
<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>
<!-- 顶部信息条 -->
@@ -985,6 +1011,16 @@ onBeforeUnmount(() => {
</div>
</div>
</div>
<div v-if="floatHead && isPortrait" class="landscape-tip">
<div class="landscape-card">
<div class="landscape-icon">📱</div>
<h3>请横屏游玩</h3>
<p class="text-dim">年糕求生建议横屏操作点击按钮自动进入横屏</p>
<button class="btn" @click="enterLandscape">进入横屏</button>
<button class="btn btn-ghost" @click="isPortrait = false">暂不</button>
</div>
</div>
</div>
<!-- 未拥有提示 -->
@@ -1800,4 +1836,172 @@ onBeforeUnmount(() => {
color: var(--primary-2);
font-weight: 700;
}
/* ---- 移动端横屏提示 ---- */
.landscape-tip {
position: fixed;
inset: 0;
z-index: 400;
background: rgba(8, 5, 4, 0.82);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.landscape-card {
background: linear-gradient(180deg, #2b2115, #17120b);
border: 2px solid #b89a5c;
border-radius: 18px;
padding: 26px 30px;
text-align: center;
display: flex;
flex-direction: column;
gap: 12px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.6);
}
.landscape-icon {
font-size: 54px;
}
/* ---- 年糕求生 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 .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>