饥荒改名年糕生存,优化动画

This commit is contained in:
李琦
2026-08-15 09:52:01 +08:00
parent 7c56d1e658
commit e70b7aa3d7
10 changed files with 385 additions and 19 deletions

View File

@@ -1,11 +1,13 @@
<script setup>
// 游戏卡片:大厅与商城通用(图标、名称、分类、价格/拥有标识、热度)
import { computed } from 'vue'
import GameIcon from './GameIcon.vue'
defineProps({
const props = defineProps({
game: { type: Object, required: true }, // 游戏对象(含 owned 字段)
showBuy: { type: Boolean, default: false }, // 商城模式显示购买按钮
})
const displayName = computed(() => (props.game.code === 'starve' ? '年糕求生' : props.game.name))
defineEmits(['play', 'buy', 'addCart'])
</script>
@@ -20,7 +22,7 @@ defineEmits(['play', 'buy', 'addCart'])
</div>
<div class="info">
<div class="name-row">
<span class="name">{{ game.name }}</span>
<span class="name">{{ displayName }}</span>
<span class="tag">{{ game.category }}</span>
</div>
<p class="desc text-dim">{{ game.description }}</p>

View File

@@ -14,6 +14,9 @@ const props = defineProps({
interval: { type: Number, default: 5 }, // 自动播放间隔(秒)
})
const emit = defineEmits(['play'])
function gameName(g) {
return g.code === 'starve' ? '年糕求生' : g.name
}
const idx = ref(0)
const n = computed(() => props.games.length)
@@ -112,7 +115,7 @@ onBeforeUnmount(pause)
<span class="tag">{{ g.engine === 'online' ? '⚔️ 联机对战' : '🕹️ 单机' }}</span>
<span v-if="g.weekly_free" class="tag tag-accent"> 本周周免</span>
</div>
<h3 class="s-name">{{ g.name }}</h3>
<h3 class="s-name">{{ gameName(g) }}</h3>
<p class="s-desc text-dim">{{ g.description }}</p>
<div class="s-foot">
<button class="btn" @click.stop="emit('play', g)">{{ actionText(g) }}</button>

View File

@@ -64,6 +64,16 @@ const uiTech = ref(0)
const uiUnlocked = ref([])
const potView = ref(null) // {id, ing:[code], state, t, dish}
const uiMods = ref([]) // 本局启用的 mod 元数据HUD 展示)
const paused = ref(false)
const touchMode = ref(localStorage.getItem('starve_touch_mode') || 'both')
const isTouchDevice = typeof window !== 'undefined' && ('ontouchstart' in window || navigator.maxTouchPoints > 0)
const joyActive = ref(false)
const joyKnobX = ref(0)
const joyKnobY = ref(0)
let joyDX = 0
let joyDY = 0
let joyBaseX = 0
let joyBaseY = 0
const ICONS = buildIcons() // {url, cv}
@@ -854,15 +864,18 @@ function hitEntity(p, e) {
e.hp -= dmg
e.shake = 0.25
markDirty(e)
if (e.type === 'beehive' && e.beeCd <= 0) {
if (e.type === 'beehive') {
const bees = monsters.filter((m) => m.kind === 'bee' && m.hiveId === e.id).length
if (bees < 6 && e.beeCd <= 0) {
e.beeCd = 3
const n = randInt(2, 3)
const n = Math.min(2, 6 - bees)
for (let i = 0; i < n; i++) {
const spot = findLandNear(tiles, e.x, e.y, 10, 40)
spawnMob('bee', spot.x, spot.y, { angryAt: p, life: 16 })
spawnMob('bee', spot.x, spot.y, { angryAt: p, life: 16, hiveId: e.id })
}
ptip(p, '惹恼了蜂巢!')
}
}
if (e.hp <= 0) killEntity(p, e)
return
}
@@ -1332,6 +1345,55 @@ function updateThreats(dt) {
monsters = monsters.filter((m) => m.kind !== 'shadow')
}
}
function joyStart(e) {
if (touchMode.value === 'tap') return
const t = e.touches?.[0] || e
joyActive.value = true
joyBaseX = t.clientX
joyBaseY = t.clientY
joyDX = 0
joyDY = 0
}
function joyMove(e) {
if (!joyActive.value) return
const t = e.touches?.[0] || e
let dx = t.clientX - joyBaseX
let dy = t.clientY - joyBaseY
const max = 42
const d = Math.hypot(dx, dy)
if (d > max) {
dx *= max / d
dy *= max / d
}
joyKnobX.value = dx
joyKnobY.value = dy
joyDX = dx / max
joyDY = dy / max
}
function joyEnd() {
joyActive.value = false
joyKnobX.value = 0
joyKnobY.value = 0
joyDX = 0
joyDY = 0
}
function setPaused(v) {
if (coop) return // 联机模式不暂停
if (paused.value === v) return
paused.value = v
if (v) loop?.stop()
else if (playing.value) loop?.start()
}
function openManualFromGame() {
window.dispatchEvent(new CustomEvent('nlg:open-manual'))
}
function togglePause() {
setPaused(!paused.value)
}
function onStarveTouchMode(e) {
touchMode.value = e.detail || 'both'
}
function updatePlayers(dt) {
players.forEach((p) => {
p.walking = false
@@ -1348,6 +1410,15 @@ function updatePlayers(dt) {
if (p === player) {
dx = (keys.has('ArrowRight') || keys.has('d') ? 1 : 0) - (keys.has('ArrowLeft') || keys.has('a') ? 1 : 0)
dy = (keys.has('ArrowDown') || keys.has('s') ? 1 : 0) - (keys.has('ArrowUp') || keys.has('w') ? 1 : 0)
if (touchMode.value !== 'tap' && joyActive.value) {
dx = joyDX
dy = joyDY
}
const inputLen = Math.hypot(dx, dy)
if (inputLen > 1) {
dx /= inputLen
dy /= inputLen
}
p.input.dx = dx
p.input.dy = dy
}
@@ -2689,7 +2760,7 @@ function stop() {
function useProp() {
return false
}
defineExpose({ start, stop, useProp })
defineExpose({ start, stop, useProp, setPaused, togglePause })
onMounted(() => {
ctx = canvas.value.getContext('2d')
@@ -2698,6 +2769,10 @@ onMounted(() => {
keys.attach()
keys.onPress((k) => {
if (!playing.value) return
if ((k === 'p' || k === 'P') || (k === 'Escape' && !potOpenId && !showMap.value && !craftOpen.value)) {
togglePause()
return
}
if (k === ' ') doGather()
else if (k === 'f' || k === 'F') doAttack()
else if (k === 'c' || k === 'C') craftOpen.value = !craftOpen.value
@@ -2714,8 +2789,10 @@ onMounted(() => {
})
window.addEventListener('resize', fitCanvas)
fitCanvas()
window.addEventListener('starve-touch-mode', onStarveTouchMode)
// 调试后门e2e / 人工验证)
window.__starve = {
touchModeDebug: true,
time: (t) => { dayTime = Math.max(0, Math.min(DAY_LEN - 1, t)) },
day: (d) => { day.value = d; onNewDay() },
season: (code) => {
@@ -2810,6 +2887,7 @@ onBeforeUnmount(() => {
loop?.stop()
keys?.detach()
window.removeEventListener('resize', fitCanvas)
window.removeEventListener('starve-touch-mode', onStarveTouchMode)
coop?.offMessage?.()
delete window.__starve
})
@@ -2836,6 +2914,31 @@ function freshClass(f) {
<div class="starve-wrap">
<canvas ref="canvas" class="starve-canvas" @click="onCanvasClick"></canvas>
<!-- 手机摇杆设置里可切换 -->
<div
v-if="isTouchDevice && playing && touchMode !== 'tap'"
class="joy-stick"
@touchstart.prevent="joyStart"
@touchmove.prevent="joyMove"
@touchend.prevent="joyEnd"
@touchcancel.prevent="joyEnd"
>
<span class="joy-base"></span>
<span class="joy-knob" :style="{ transform: `translate(${joyKnobX}px, ${joyKnobY}px)` }"></span>
</div>
<!-- 暂停遮罩 -->
<div v-if="paused" class="pause-overlay">
<div class="pause-card">
<h3>已暂停</h3>
<p class="text-dim">快捷键 P / Esc 继续</p>
<div class="pause-btns">
<button class="ds-btn" @click="setPaused(false)">继续游戏</button>
<button class="ds-btn ghost" @click="openManualFromGame">操作手册</button>
</div>
</div>
</div>
<!-- 右上时钟 + 三围 + 体温 -->
<div v-show="playing || dead" class="hud-right">
<canvas ref="clockCv" width="92" height="92" class="clock"></canvas>
@@ -2862,6 +2965,7 @@ 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>
<span v-if="uiMods.length" class="mod-tags" :title="uiMods.map((m) => m.name).join('、')">
Mod×{{ uiMods.length }}
@@ -3503,4 +3607,65 @@ function freshClass(f) {
color: #f4ead2;
font-size: 20px;
}
/* ---- 手机摇杆 ---- */
.joy-stick {
position: absolute;
left: 18px;
bottom: 92px;
width: 112px;
height: 112px;
touch-action: none;
user-select: none;
-webkit-user-select: none;
z-index: 20;
}
.joy-base {
position: absolute;
inset: 10px;
border-radius: 50%;
border: 2px solid rgba(244, 234, 210, 0.55);
background: rgba(0, 0, 0, 0.28);
box-shadow: 0 0 0 6px rgba(0, 0, 0, 0.12);
}
.joy-knob {
position: absolute;
left: 50%;
top: 50%;
width: 46px;
height: 46px;
margin: -23px 0 0 -23px;
border-radius: 50%;
background: rgba(244, 234, 210, 0.9);
border: 2px solid rgba(0, 0, 0, 0.5);
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.35);
}
/* ---- 暂停 ---- */
.pause-overlay {
position: absolute;
inset: 0;
z-index: 40;
display: flex;
align-items: center;
justify-content: center;
background: rgba(8, 5, 4, 0.6);
backdrop-filter: blur(2px);
}
.pause-card {
background: var(--bg-panel, #1d1f3a);
border: 1px solid var(--border, #34386b);
border-radius: 16px;
padding: 26px 30px;
text-align: center;
display: flex;
flex-direction: column;
gap: 10px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5);
}
.pause-btns {
display: flex;
gap: 10px;
justify-content: center;
margin-top: 8px;
}
</style>

View File

@@ -47,13 +47,17 @@ function nearestVictim(m, g, playersOnly = false) {
// 对受害者造成伤害(玩家走减伤管线;猪人直接扣血并反击)
function biteVictim(m, victim, dmg, label, g) {
if (victim.kind === 'p') {
g.damagePlayer(victim.ref, dmg, label)
if (typeof g.damagePlayer === 'function') {
g.damagePlayer(victim.ref, dmg, label)
} else if (victim.ref && victim.ref.hp !== undefined) {
victim.ref.hp = Math.max(0, victim.ref.hp - dmg)
}
} else {
const pig = victim.ref
pig.hp -= dmg
pig.shake = 0.22
pig.targetMob = m // 猪人被咬会还手
if (pig.hp <= 0) g.killMob(pig, null)
if (pig.hp <= 0 && typeof g.killMob === 'function') g.killMob(pig, null)
}
}
// 通用追咬:追近 → 咬一口 → 弹开

View File

@@ -0,0 +1,13 @@
// 悠闲时光:降低生存压力,适合轻松体验
export default {
id: 'chill',
name: '悠闲时光',
desc: '饥饿减缓 40% · 采集更快 · 猎犬袭击间隔更长',
factor: 1.1,
tag: 'easy',
tune: {
hungerRate: 0.6, // 原 75/DAY_LEN
gatherMul: 0.7,
houndGapMul: 1.5,
},
}

View File

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

View File

@@ -0,0 +1,18 @@
// 幸运矿工:矿脉收益更高
export default {
id: 'lucky',
name: '幸运矿工',
desc: '金矿额外掉金块 · 普通岩石也有概率出金',
factor: 1.15,
tag: 'easy',
hooks: {
onGather(g, p, e) {
if (!p) return
if (e.type === 'goldrock') {
g.addItem(p, 'gold', g.rand(1, 2))
} else if (e.type === 'rock' && g.rand(1, 4) === 1) {
g.addItem(p, 'gold', 1)
}
},
},
}

View File

@@ -0,0 +1,13 @@
// 旅者行囊:更大的随身空间
export default {
id: 'traveler',
name: '旅者行囊',
desc: '主物品栏 +5 · 背包 +4 · 物品堆叠上限 60',
factor: 1.1,
tag: 'qol',
tune: {
invSlots: 20,
packSlots: 12,
stackMax: 60,
},
}

View File

@@ -184,11 +184,11 @@ function drawToolLegacy(c, toolCode) {
}
// 增强手持物:更大、轮廓更清楚、材质区分更明显(斧/镐/铲/矛/触手/火把)
function drawToolEnhanced(c, toolCode) {
function drawToolEnhanced(c, toolCode, toolAng = 0) {
if (!toolCode) return
c.save()
c.translate(0, 10)
c.rotate(-0.45)
c.translate(0, 9)
c.rotate(toolAng)
c.lineJoin = 'round'
c.lineCap = 'round'
const wood = '#8a5a2b'
@@ -511,6 +511,8 @@ export function drawPlayerFig(c, skin, pose = {}) {
else if (act === 'eat') swingAng = -0.4 - Math.sin(t * Math.PI) * 1.95 // 抬手到嘴边
else swingAng = -2.1 + t * 2.7 // 砍树/通用横劈
}
// 工具在手中的基础角度:挥击时跟手,平时按动作/待机姿态微调
const toolAng = swing > 0 ? 0 : act === 'eat' ? 0.9 : act === 'attack' ? 0 : act === 'chop' ? -0.25 : (act === 'mine' || act === 'dig') ? -0.5 : -0.15
c.save()
c.translate(5 + thrust, -11 + breathe)
c.rotate(swingAng)
@@ -520,7 +522,7 @@ export function drawPlayerFig(c, skin, pose = {}) {
c.moveTo(0, 0)
c.lineTo(0, 11)
c.stroke()
drawToolEnhanced(c, toolCode)
drawToolEnhanced(c, toolCode, toolAng)
c.restore()
c.restore()
}

View File

@@ -28,6 +28,9 @@ 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([]) // 我的道具背包
@@ -61,6 +64,31 @@ 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 }))
}
function openManual() {
showManual.value = true
if (phase.value === 'playing') gameRef.value?.setPaused?.(true)
}
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,
@@ -549,10 +577,11 @@ watch(() => coopStore.roomState?.code, (code) => {
})
onMounted(() => {
window.addEventListener('mousemove', onHeadMouseMove)
// play-head 通过吸顶按钮手动展开/收起,不再跟随鼠标自动弹出
// 宽屏游戏(如饥荒):给 body 打标解除全局 1200px 限宽
if (meta?.wide) document.body.classList.add('wide-page')
load()
window.addEventListener('nlg:open-manual', openManual)
if (coopable) {
window.addEventListener('nlg:pending-join', tryPendingJoin)
tryPendingJoin()
@@ -560,8 +589,9 @@ onMounted(() => {
})
onBeforeUnmount(() => {
document.body.classList.remove('wide-page')
window.removeEventListener('nlg:open-manual', openManual)
window.removeEventListener('nlg:pending-join', tryPendingJoin)
window.removeEventListener('mousemove', onHeadMouseMove)
// play-head 展开状态由组件自行管理
clearInterval(inviteTickTimer)
gameRef.value?.stop()
// 离开游玩页断开联机连接(房间自动退出)
@@ -571,18 +601,24 @@ onBeforeUnmount(() => {
<template>
<div v-if="detail" class="play-wrap" :class="{ 'play-fill': meta?.wide }">
<button v-if="floatHead" class="head-toggle" @click="headShow = !headShow">
{{ 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>{{ detail.game.name }}</b>
<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>
<!-- 游戏区域 -->
@@ -595,7 +631,7 @@ onBeforeUnmount(() => {
<!-- 1. 模式选择 -->
<template v-if="coopStage === 'mode'">
<div class="ov-icon"><GameIcon :code="code" :icon="detail.game.icon" :size="56" /></div>
<h3>{{ detail.game.name }}</h3>
<h3>{{ displayName }}</h3>
<p class="text-dim ov-desc">{{ detail.game.description }}</p>
<div class="mode-cards">
<button class="mode-card" @click="coopStage = 'solo'">
@@ -617,6 +653,10 @@ onBeforeUnmount(() => {
<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'">
更衣室 · 30 款人物皮肤
</button>
@@ -783,9 +823,13 @@ onBeforeUnmount(() => {
<!-- ============ 普通游戏原有开始面板 ============ -->
<template v-else>
<div class="ov-icon"><GameIcon :code="code" :icon="detail.game.icon" :size="levelsMeta ? 40 : 56" /></div>
<h3>{{ detail.game.name }}</h3>
<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') }} 本局积分翻倍
@@ -893,6 +937,54 @@ onBeforeUnmount(() => {
</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 攻击 · C 合成 · 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>
<!-- 未拥有提示 -->
@@ -1657,4 +1749,55 @@ onBeforeUnmount(() => {
.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;
}
</style>