Files
nl-game/src/games/components/StarveGame.vue
2026-08-15 16:46:17 +08:00

4146 lines
133 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
// 饥荒网页版 V4官方对齐版岛屿五群系世界 + 四季天气体温 + 科技分级合成 +
// 格子背包装备 + 查理黑暗袭击 + 烹饪锅 + 猪牛蜂蛙触手巨鹿 + 组队联机 + 云存档
// 架构:数据/世界/生物/气候/美术拆分在 ../starve/ 模块,本文件负责
// 主循环、玩家交互、库存装备、联机快照、存档、渲染调度与 UI
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { createLoop, Keys, randInt } from '../engine'
import { skinByCode, drawPlayerFig } from '../starveSkins'
import { CAP, DAY_LEN, CRAFT_TABS, cookPot, DECOR } from '../starve/data'
import { seasonOf, daySplit, phaseOf, ambientTemp, rollWeather, tickWeather, updateBodyTemp } from '../starve/climate'
import {
TILE, GW, GH, WORLD, BIOMES, tileIdx, isLand, biomeAt, onRoad, findLandNear,
genIsland, planEntities, genDecor, buildTilePatterns, drawTiles,
} from '../starve/world'
import { buildIcons, drawEnt, drawMob, drawFirePainter, drawDecor } from '../starve/paint'
import { updateMob, moveMob } from '../starve/mobs'
import { activateMods, callHook, callHandled, callAllows, collectModSaves, applyModSaves } from '../starve/mods/registry'
// ---- mod 运行时注册表:以下 let 遮蔽基表,激活 mod 后指向合并表(核心其余代码无感) ----
let modRt = activateMods([])
let TUNE = modRt.tune
let ITEMS = modRt.items
let RECIPES = modRt.recipes
let MON = modRt.mon
let MOB_DROPS = modRt.mobDrops
let RES = modRt.res
let NAMES = modRt.names
let ITEM_CODES = modRt.itemCodes
let ITEM_IDX = modRt.itemIdx
function stackMax(code) {
return ITEMS[code]?.stack || TUNE.stackMax
}
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)
const dead = ref(false)
const ghostMode = ref(false)
const deathStats = ref({ day: 1, score: 0, kills: 0 })
const hp = ref(CAP.hp)
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('')
const craftOpen = ref(false)
const craftTab = ref('tools')
const showMap = ref(false)
const saving = ref(false)
const isCoop = ref(false)
const waitingWorld = ref(false)
const seasonBanner = ref('')
const uiSlots = ref([])
const uiPack = ref(null)
const uiEquip = ref({ hand: null, body: null, head: null })
const uiCounts = ref({})
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}
// 切换 mod 运行时:重定向遮蔽表 + 补齐 mod 物品图标
function applyModRuntime(rt) {
modRt = rt
TUNE = rt.tune
ITEMS = rt.items
RECIPES = rt.recipes
MON = rt.mon
MOB_DROPS = rt.mobDrops
RES = rt.res
NAMES = rt.names
ITEM_CODES = rt.itemCodes
ITEM_IDX = rt.itemIdx
rt.itemCodes.forEach((code) => {
if (ICONS.cv[code]) return
const cv = document.createElement('canvas')
cv.width = 40
cv.height = 40
const c = cv.getContext('2d')
c.translate(20, 20)
c.lineJoin = 'round'
c.lineCap = 'round'
const painter = rt.iconPainters[code]
if (painter) painter(c)
else {
// 无专属图标的 mod 物品:袋子 + 首字符
c.fillStyle = '#a8894c'
c.strokeStyle = '#1d1409'
c.lineWidth = 2
c.beginPath()
c.roundRect(-12, -10, 24, 22, 6)
c.fill()
c.stroke()
c.fillStyle = '#1d1409'
c.font = 'bold 13px sans-serif'
c.textAlign = 'center'
c.textBaseline = 'middle'
c.fillText((ITEMS[code]?.name || code)[0], 0, 2)
}
ICONS.cv[code] = cv
ICONS.url[code] = cv.toDataURL()
})
// 自定义建筑图标(配方列表用)
Object.entries(rt.iconPainters).forEach(([code, painter]) => {
if (ICONS.cv[code] && ITEMS[code]) return
if (ICONS.url[code]) return
const cv = document.createElement('canvas')
cv.width = 40
cv.height = 40
const c = cv.getContext('2d')
c.translate(20, 20)
c.lineJoin = 'round'
c.lineCap = 'round'
painter(c)
ICONS.cv[code] = cv
ICONS.url[code] = cv.toDataURL()
})
uiMods.value = rt.metas
}
const ACTS = ['', 'chop', 'mine', 'dig', 'attack', 'eat']
// ---- 非响应式游戏状态 ----
let ctx = null
let keys = null
let loop = null
let time = 0
let dayTime = 0
let seasonNow = seasonOf(1)
let phaseName = 'day'
let wx = { type: 'none', t: 0 }
let tiles = null
let road = null
let tilePats = null
let decoSpots = []
let explored = null
let entities = []
let monsters = []
let fires = []
let players = []
let player = null
let mySkinCode = 'wilson'
let camX = 0
let camY = 0
let eid = 1
let mid = 1
let fid = 1
let kills = { spider: 0, hound: 0, shadow: 0, treeguard: 0, other: 0 }
let treesChopped = 0
let houndPlan = { nextDay: 5, at: 40, warnT: 0, doneToday: false }
let deer = { spawned: false, warnT: 0 }
let shakeT = 0
let shakeAmp = 0
let flashRed = 0
let potOpenId = 0
let invDirty = true
let hudT = 0
let mapT = 0
let exploreT = 0
let fpsEma = 60
let lastDt = 0.016
let particles = []
let coop = null
let netT = 0
let moveSendT = 0
let pendingEv = [] // 主机 → 客机的私人提示 [[seat, text]]
let entsAdded = new Set()
let entsRemoved = new Set()
let entsDirty = new Set()
let fullSyncPending = false
let saveTimer = 0
// =====================================================================
// 基础工具
// =====================================================================
function tip(text) {
tips.value.push({ id: Math.random(), text })
if (tips.value.length > 4) tips.value.shift()
setTimeout(() => (tips.value = tips.value.filter((t) => t.text !== text || t.expired)), 3200)
}
// 面向具体玩家的提示:自己直接弹,客机玩家走快照 ev 通道
function ptip(p, text) {
if (p === player) tip(text)
else if (coop && p.seat !== undefined) pendingEv.push([p.seat, text])
}
function addScore(n, p = player) {
if (!p || p.dead) return
p.score += Math.round(n * modRt.scoreFactor)
if (p === player) emit('score', p.score)
}
function shake(amp) {
shakeT = 0.4
shakeAmp = Math.max(shakeAmp, amp)
}
function isHostRole() {
return !coop || coop.isHost
}
function totalKills() {
return kills.spider + kills.hound + kills.shadow + kills.treeguard + kills.other
}
// =====================================================================
// 玩家与库存
// =====================================================================
function makeSlots(n) {
return Array(n).fill(null)
}
function makePlayer(seat, name, skinCode, x, y) {
return {
seat, name: name || '荒野求生者', skin: skinCode || 'wilson',
x, y, dir: 1, walking: false, walkT: 0, speed: 172,
swing: 0, act: '', hitTimer: 0,
hp: CAP.hp, hunger: CAP.hunger, san: CAP.san, temp: 22,
dead: false, fallT: 1, freezeT: 0, darkT: 0, darkWarned: false,
slots: makeSlots(TUNE.invSlots), pack: null,
equip: { hand: null, body: null, head: null },
unlocked: new Set(),
target: null, input: { dx: 0, dy: 0 }, score: 0,
nx: 0, ny: 0, // 客机插值目标
}
}
function countItem(p, code) {
let n = 0
p.slots.forEach((s) => { if (s && s.code === code) n += s.n })
p.pack?.forEach((s) => { if (s && s.code === code) n += s.n })
return n
}
// 添加物品:先并入同类堆(保鲜度加权平均),再占空格,溢出落地成拾取物
function addItem(p, code, n = 1, fresh = 1, dur = null) {
const it = ITEMS[code]
if (!it) return
const max = stackMax(code)
const areas = [p.slots, p.pack].filter(Boolean)
if (max > 1) {
for (const area of areas) {
for (const s of area) {
if (n <= 0) break
if (s && s.code === code && s.n < max) {
const take = Math.min(n, max - s.n)
if (it.perish) s.fresh = ((s.fresh ?? 1) * s.n + fresh * take) / (s.n + take)
s.n += take
n -= take
}
}
}
}
for (const area of areas) {
for (let i = 0; i < area.length; i++) {
if (n <= 0) break
if (!area[i]) {
const take = Math.min(n, max)
area[i] = { code, n: take }
if (it.perish) area[i].fresh = fresh
if (dur != null) area[i].dur = dur
else if (it.uses) area[i].dur = it.uses
else if (it.burn) area[i].dur = it.burn
else if (it.armor) area[i].dur = it.armor
n -= take
}
}
}
invDirty = true
if (n > 0 && isHostRole()) dropLoot(p.x + randInt(-20, 20), p.y + randInt(10, 26), code, n, fresh)
}
function hasCost(p, cost) {
return Object.entries(cost).every(([code, n]) => countItem(p, code) >= n)
}
function payCost(p, cost) {
Object.entries(cost).forEach(([code, need]) => {
let n = need
;[p.slots, p.pack].filter(Boolean).forEach((area) => {
for (let i = 0; i < area.length && n > 0; i++) {
const s = area[i]
if (s && s.code === code) {
const take = Math.min(n, s.n)
s.n -= take
n -= take
if (s.n <= 0) area[i] = null
}
}
})
})
invDirty = true
}
function slotArea(p, area) {
return area === 'pack' ? p.pack : p.slots
}
// 保鲜阶段:>0.5 新鲜 / >0.2 陈旧(效果打 75 折)/ 其余腐坏(饥饿减半、回血归零)
function freshStage(s) {
const f = s.fresh ?? 1
if (f > 0.5) return { mult: 1, hpOk: true, label: '' }
if (f > 0.2) return { mult: 0.75, hpOk: true, label: '(陈旧)' }
return { mult: 0.5, hpOk: false, label: '(发馊)' }
}
// 库存计时:食物腐烂三段 → 腐烂物
function perishTick(p, dt) {
;[p.slots, p.pack].filter(Boolean).forEach((area) => {
for (let i = 0; i < area.length; i++) {
const s = area[i]
if (!s) continue
const it = ITEMS[s.code]
if (!it?.perish) continue
const before = s.fresh ?? 1
s.fresh = before - dt / (it.perish * DAY_LEN)
if (s.fresh <= 0) {
area[i] = { code: 'rot', n: s.n }
ptip(p, `${it.name}腐烂了…`)
invDirty = true
} else if ((before > 0.5) !== (s.fresh > 0.5) || (before > 0.2) !== (s.fresh > 0.2)) {
invDirty = true
}
}
})
}
// 装备:从格子穿上(原装备回格子),背包附带 8 格
function equipFromSlot(p, area, i) {
const arr = slotArea(p, area)
const s = arr[i]
if (!s) return
const it = ITEMS[s.code]
if (!it?.equip) return
const slotName = it.equip
const old = p.equip[slotName]
arr[i] = null
if (old) {
if (ITEMS[old.code]?.pack) spillPack(p)
arr[i] = { code: old.code, n: 1, dur: old.dur }
} else if (p.equip[slotName]?.code && ITEMS[p.equip[slotName].code]?.pack) {
spillPack(p)
}
p.equip[slotName] = { code: s.code, dur: s.dur ?? (it.uses || it.burn || it.armor || 0) }
if (it.pack && !p.pack) p.pack = makeSlots(TUNE.packSlots)
if (!it.pack && slotName === 'body' && old && ITEMS[old.code]?.pack) {
// 已在 spillPack 处理
}
invDirty = true
ptip(p, `装备了${it.name}`)
}
function unequipSlot(p, slotName) {
const eq = p.equip[slotName]
if (!eq) return
if (ITEMS[eq.code]?.pack) spillPack(p)
p.equip[slotName] = null
addItem(p, eq.code, 1, 1, eq.dur)
invDirty = true
}
// 卸背包:先把附加格里的东西倒回主格/地面
function spillPack(p) {
if (!p.pack) return
const items = p.pack.filter(Boolean)
p.pack = null
items.forEach((s) => addItem(p, s.code, s.n, s.fresh ?? 1, s.dur))
}
function breakEquip(p, slotName, text) {
const eq = p.equip[slotName]
if (!eq) return
if (ITEMS[eq.code]?.pack) spillPack(p)
p.equip[slotName] = null
invDirty = true
ptip(p, text)
}
function handItem(p) {
return p.equip.hand ? ITEMS[p.equip.hand.code] : null
}
function weaponDmg(p) {
return handItem(p)?.dmg || TUNE.fistDmg
}
// 武器耐久(矛类按次损耗;工具当武器不掉耐久,官方规则)
function useWeaponDur(p) {
const eq = p.equip.hand
if (!eq) return
const it = ITEMS[eq.code]
if (!it.uses || it.tool) return
eq.dur -= 1
invDirty = true
if (eq.dur <= 0) breakEquip(p, 'hand', `${it.name}用坏了!`)
}
function useToolDur(p) {
const eq = p.equip.hand
if (!eq) return
eq.dur -= 1
invDirty = true
if (eq.dur <= 0) breakEquip(p, 'hand', `${ITEMS[eq.code].name}用坏了!`)
}
// 承伤管线:护甲减免 → 扣血 → 死亡
function damagePlayer(p, dmg, label) {
if (p.dead) return
let best = null
let bestSlot = ''
;['body', 'head'].forEach((sl) => {
const eq = p.equip[sl]
const it = eq && ITEMS[eq.code]
if (it?.absorb && (!best || it.absorb > ITEMS[best.code].absorb)) {
best = eq
bestSlot = sl
}
})
let real = dmg
if (best) {
const it = ITEMS[best.code]
real = dmg * (1 - it.absorb)
best.dur -= dmg * it.absorb
invDirty = true
if (best.dur <= 0) breakEquip(p, bestSlot, `${it.name}碎裂了!`)
}
p.hp = Math.max(0, p.hp - real)
if (label && real >= 25) ptip(p, label)
if (p.hp <= 0) die(p)
}
function die(p) {
if (p.dead) return
if (callHandled(modRt, 'onPlayerDeath', gCtx, p)) return
p.dead = true
p.fallT = 0
p.target = null
ptip(p, '你死了……')
if (!coop) {
gameOver()
return
}
if (p === player) ghostMode.value = true
if (isHostRole() && players.every((q) => q.dead)) {
coop.send('starve_over', { score: player?.score || 0 })
gameOver()
}
}
function gameOver() {
dead.value = true
ghostMode.value = false
deathStats.value = { day: day.value, score: player?.score || 0, kills: totalKills() }
playing.value = false
loop.stop()
syncHud(true)
draw()
setTimeout(() => emit('end', { score: player?.score || 0 }), 2600)
}
// =====================================================================
// 世界构建
// =====================================================================
function makeEnt(type, x, y, extra = {}) {
const conf = RES[type] || DECOR[type] || { size: 16 }
const e = {
id: eid++, type, x, y,
size: conf.size || 16,
hits: conf.hits || 0, left: conf.hits || 0,
tool: conf.tool || '', drops: conf.drops || null,
regrow: conf.regrow || 0, regrowT: 0, stub: false,
vanish: !!conf.vanish, mobile: !!conf.mobile, nest: !!conf.nest,
struct: !!conf.struct, pot: !!conf.pot, lurk: !!conf.lurk,
solid: !!conf.solid, loot: !!conf.loot,
hp: conf.hp || 0, maxHp: conf.hp || 0,
deco: !RES[type],
shake: 0, state: '', uses: 0, ...extra,
}
if (type === 'trap') e.state = 'armed'
if (type === 'crockpot') { e.ing = []; e.cookT = 0; e.dish = '' ; e.state = 'idle' }
if (type === 'tentacle') { e.state = 'lurk'; e.atkT = 0; e.cd = 0; e.dir = 1 }
if (type === 'rabbit') { e.vx = 0; e.vy = 0; e.dir = 1; e.hop = Math.random() * 7; e.flee = false; e.wanderT = 0 }
if (type === 'rabbithole') { e.respawnT = 0; e.rabbitId = 0 }
if (type === 'beehive') e.beeCd = 0
if (type === 'pighouse') { e.pigId = 0; e.respawnT = 0 }
modRt.entKinds[type]?.init?.(e)
return e
}
function addEntity(type, x, y, extra) {
const e = makeEnt(type, x, y, extra)
entities.push(e)
markAdded(e)
return e
}
function removeEntity(e) {
entities = entities.filter((x) => x !== e)
markRemoved(e)
}
function dropLoot(x, y, code, n, fresh = 1) {
const spot = findLandNear(tiles, x, y, 0, 18)
return addEntity('loot', spot.x, spot.y, { code, n, fresh })
}
function spawnMob(kind, x, y, extra = {}) {
const conf = MON[kind]
const m = {
id: mid++, kind, x, y, hp: conf.hp, cd: 0, shake: 0,
bob: Math.random() * 7, dir: 1, moving: false,
state: kind === 'treeguard' || kind === 'deerclops' ? 'chase' : '',
t: 0, awayT: 0, wanderT: 0, loyalT: 0, angryT: 0, life: 15,
...extra,
}
monsters.push(m)
callHook(modRt, 'onMobSpawn', gCtx, m)
return m
}
function removeMob(m) {
monsters = monsters.filter((x) => x !== m)
}
function killMob(m, credit) {
removeMob(m)
const conf = MON[m.kind]
if (kills[m.kind] !== undefined) kills[m.kind]++
else kills.other++
if (credit) {
addScore(conf.score, credit)
if (m.kind === 'shadow') {
credit.san = Math.min(CAP.san, credit.san + TUNE.shadowKillSan)
ptip(credit, `驱散了暗影 · 理智 +${TUNE.shadowKillSan}`)
} else {
ptip(credit, `击杀了${conf.name}`)
}
}
;(MOB_DROPS[m.kind] || []).forEach(([prob, code, n]) => {
if (Math.random() < prob) dropLoot(m.x, m.y, code, n)
})
if (m.kind === 'deerclops') tip('独眼巨鹿倒下了!它掉落了眼球与大肉')
}
// 巨鹿拆家:建筑损毁并崩出部分材料
function smashStructure(e) {
tip(`${NAMES[e.type] || '建筑'}被独眼巨鹿砸毁了!`)
const refund = { sciencemachine: { rock: 2, log: 2 }, alchemyengine: { rock: 2, gold: 2 }, crockpot: { rock: 3 }, pighouse: { pigskin: 1, log: 2 } }[e.type]
if (refund) Object.entries(refund).forEach(([code, n]) => dropLoot(e.x, e.y, code, n))
removeEntity(e)
shake(6)
}
function spawnNestSpiders(nest, n) {
for (let i = 0; i < n; i++) {
const spot = findLandNear(tiles, nest.x, nest.y, 20, 60)
spawnMob('spider', spot.x, spot.y, { nest })
}
}
function spawnPig(house) {
const spot = findLandNear(tiles, house.x, house.y, 30, 60)
const m = spawnMob('pig', spot.x, spot.y, { home: { x: house.x, y: house.y + 20 }, houseId: house.id })
house.pigId = m.id
return m
}
function spawnRabbit(hole) {
const spot = findLandNear(tiles, hole.x, hole.y, 10, 40)
const r = addEntity('rabbit', spot.x, spot.y, { hole: hole.id })
hole.rabbitId = r.id
return r
}
// 生成整个世界(单人 / 主机)
function genWorldAll() {
const isle = genIsland()
tiles = isle.tiles
road = isle.road
const plan = planEntities(tiles, road)
entities = plan.placed.map(({ type, x, y }) => makeEnt(type, x, y))
decoSpots = genDecor(tiles)
explored = new Uint8Array(GW * GH)
if (modRt.flags.noFog) explored.fill(1)
monsters = []
fires = []
entities.filter((e) => e.type === 'spidernest').forEach((n) => spawnNestSpiders(n, 2))
entities.filter((e) => e.type === 'pighouse').forEach((h) => spawnPig(h))
entities.filter((e) => e.type === 'rabbithole').forEach((h) => spawnRabbit(h))
plan.herds.forEach((h) => {
const cnt = randInt(4, 6)
for (let i = 0; i < cnt; i++) {
const spot = findLandNear(tiles, h.x, h.y, 20, 140)
spawnMob('beefalo', spot.x, spot.y, { home: h })
}
})
plan.frogs.forEach((f) => spawnMob('frog', f.x, f.y))
callHook(modRt, 'onWorldGen', gCtx)
}
// =====================================================================
// 光照 / 温度辅助
// =====================================================================
function fireRadius(f) {
const r = 88 + 70 * Math.min(1, f.ttl / 45)
return f.pit ? r * 1.15 : r
}
function litAt(x, y) {
if (phaseName !== 'night') return true
for (const f of fires) {
if (f.ttl > 0 && Math.hypot(f.x - x, f.y - y) < fireRadius(f)) return true
}
for (const p of players) {
if (!p.dead && p.equip.hand?.code === 'torch' && Math.hypot(p.x - x, p.y - y) < 105) return true
}
return false
}
// 附近火源热度 0~1体温用手持火把算 0.35
function heatAt(p) {
let h = 0
for (const f of fires) {
if (f.ttl <= 0) continue
const d = Math.hypot(f.x - p.x, f.y - p.y)
if (d < 130) h = Math.max(h, 1 - d / 130)
}
if (p.equip.hand?.code === 'torch') h = Math.max(h, 0.35)
return h
}
// =====================================================================
// 玩家交互(宿主权威执行;客机通过输入消息转发)
// =====================================================================
function swingAct(p, act) {
p.swing = 1
p.act = act
}
function playerSpeed(p) {
let s = p.dead ? 150 : p.speed
if (!p.dead && onRoad(road, p.x, p.y)) s *= TUNE.roadSpeed
if (p.freezeT > 0) s *= 0.6
return s
}
function tryMovePlayer(p, dx, dy) {
if (p.dead) {
// 幽灵不受地形限制
p.x = Math.max(20, Math.min(WORLD.w - 20, p.x + dx))
p.y = Math.max(20, Math.min(WORLD.h - 20, p.y + dy))
return
}
moveMob(p, dx, dy, gCtx)
p.x = Math.max(20, Math.min(WORLD.w - 20, p.x))
p.y = Math.max(20, Math.min(WORLD.h - 20, p.y))
}
// 画布点击:优先怪 > 火 > 实体 > 走路
function onCanvasClick(ev) {
if (!playing.value || !player || player.dead || waitingWorld.value) 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) return setMyTarget({ kind: 'mon', id: mon.id })
const f = fires.find((f2) => Math.hypot(f2.x - wxp, f2.y - wyp) < 36)
if (f) return setMyTarget({ kind: 'fire', id: f.fid })
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) 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)
if (coop && !coop.isHost) coop.send('starve_input', { seat: coop.mySeat, act: { type: 'target', t } })
}
function resolveTarget(t) {
if (t.kind === 'mon') {
const m = monsters.find((m2) => m2.id === t.id)
return m ? { kind: 'mon', ref: m } : null
}
if (t.kind === 'fire') {
const f = fires.find((f2) => f2.fid === t.id)
return f ? { kind: 'fire', ref: f } : null
}
if (t.kind === 'ent') {
const e = entities.find((e2) => e2.id === t.id)
return e ? { kind: 'ent', ref: e } : null
}
return { kind: 'walk', x: t.x, y: t.y }
}
// 空格:就近采集(掉落物 / 陷阱 / 好锅 / 资源)
function doGather() {
if (!player || player.dead) return
let best = null
let bd = 130
entities.forEach((e) => {
if (e.stub) return
const ok = e.loot || (e.type === 'trap' && e.state === 'caught') || (e.pot && e.state === 'done') ||
(e.drops && !e.mobile) || (e.type === 'stump') || modRt.entKinds[e.type]?.interact
if (!ok) return
const d = Math.hypot(e.x - player.x, e.y - player.y)
if (d < bd) { bd = d; best = e }
})
if (best) setMyTarget({ kind: 'ent', id: best.id })
}
// F就近攻击敌对目标不含猪牛防误伤
function doAttack() {
if (!player || player.dead) return
let best = null
let bd = 170
monsters.forEach((m) => {
if (m.kind === 'pig' || m.kind === 'beefalo' || modRt.mobKinds[m.kind]?.friendly) return
const d = Math.hypot(m.x - player.x, m.y - player.y)
if (d < bd) { bd = d; best = { kind: 'mon', id: m.id } }
})
entities.forEach((e) => {
if (!(e.mobile || e.nest || e.lurk)) return
if (e.lurk && e.state !== 'up') return
const d = Math.hypot(e.x - player.x, e.y - player.y)
if (d < bd) { bd = d; best = { kind: 'ent', id: e.id } }
})
if (best) setMyTarget(best)
}
// 追目标 + 到位执行(主机对所有玩家生效)
function pursueTarget(p, dt) {
const t = p.target
if (!t) return
if (t.kind === 'walk') {
const d = Math.hypot(t.x - p.x, t.y - p.y)
if (d < 6) { p.target = null; return }
stepToward(p, t.x, t.y, dt)
return
}
const ref = t.ref
const alive = t.kind === 'mon' ? monsters.includes(ref) : t.kind === 'fire' ? fires.includes(ref) : entities.includes(ref)
if (!alive) { p.target = null; return }
const reach = t.kind === 'mon'
? (ref.kind === 'deerclops' ? 96 : ref.kind === 'treeguard' ? 68 : 34)
: t.kind === 'fire' ? 60 : ref.lurk ? 64 : Math.max(40, (ref.size || 20) + 18)
const d = Math.hypot(ref.x - p.x, ref.y - p.y)
if (d > reach) {
stepToward(p, ref.x, ref.y, dt)
return
}
p.dir = ref.x >= p.x ? 1 : -1
if (t.kind === 'fire') { feedFire(p, ref); p.target = null; return }
// 友方 mod 生物:交互而非攻击(如切斯特)
if (t.kind === 'mon') {
const mk = modRt.mobKinds[ref.kind]
if (mk?.interact) {
mk.interact(p, ref, gCtx)
p.target = null
return
}
}
if (p.hitTimer > 0) return
// 采集类动作吃 gatherMul 加速(战斗与巢穴不加速)
const gatherable = t.kind === 'ent' && ref.drops && !ref.mobile && !ref.nest
p.hitTimer = TUNE.attackGap * (gatherable ? TUNE.gatherMul : 1)
if (t.kind === 'mon') hitMonster(p, ref)
else hitEntity(p, ref)
}
function stepToward(p, tx, ty, dt) {
const d = Math.hypot(tx - p.x, ty - p.y) || 1
const s = playerSpeed(p)
tryMovePlayer(p, ((tx - p.x) / d) * s * dt, ((ty - p.y) / d) * s * dt)
p.dir = tx > p.x ? 1 : -1
p.walking = true
}
// 攻击生物
function hitMonster(p, m) {
swingAct(p, 'attack')
const dmg = weaponDmg(p)
useWeaponDur(p)
m.hp -= dmg
m.shake = 0.25
if (m.kind === 'pig') {
m.loyalT = 0
m.owner = null
m.angryT = 9
m.angryAt = p
}
if (m.kind === 'beefalo') {
monsters.forEach((q) => {
if (q.kind === 'beefalo' && Math.hypot(q.x - m.x, q.y - m.y) < 320) {
q.angryT = 9
q.angryAt = p
}
})
ptip(p, '牛群被激怒了!')
}
if (m.hp <= 0) killMob(m, p)
}
// 与实体交互(采集/攻击/拾取/建筑)
function hitEntity(p, e) {
const modEnt = modRt.entKinds[e.type]
if (modEnt?.interact) {
modEnt.interact(p, e, gCtx)
p.target = null
return
}
if (e.loot) {
addItem(p, e.code, e.n, e.fresh ?? 1)
ptip(p, `捡起 ${ITEMS[e.code]?.name || ''} x${e.n}`)
removeEntity(e)
p.target = null
return
}
if (e.type === 'trap') {
if (e.state === 'caught') {
addItem(p, 'morsel', 1)
e.uses = (e.uses || 0) + 1
addScore(8, p)
if (e.uses >= (ITEMS.trap.uses || 8)) {
removeEntity(e)
ptip(p, '捉到兔子 · 小肉 +1 · 陷阱用坏了')
} else {
e.state = 'armed'
markDirty(e)
ptip(p, '捉到兔子 · 小肉 +1')
}
} else {
addItem(p, 'trap', 1, 1)
removeEntity(e)
ptip(p, '收回了陷阱')
}
p.target = null
return
}
if (e.pot) {
if (e.state === 'done') {
addItem(p, e.dish, 1, 1)
ptip(p, `取出了${ITEMS[e.dish]?.name || '料理'}`)
e.state = 'idle'
e.dish = ''
e.ing = []
markDirty(e)
} else if (p === player) {
potOpenId = e.id
}
p.target = null
return
}
if (e.struct) {
if (p === player) {
const t = RES[e.type]?.techTier
tip(t ? `${NAMES[e.type]}:靠近可解锁 ${t} 级科技配方` : `${NAMES[e.type]}`)
}
p.target = null
return
}
if (e.type === 'stump') {
if (handItem(p)?.tool !== 'dig') {
ptip(p, '需要铲子才能挖树桩')
p.target = null
return
}
swingAct(p, 'dig')
useToolDur(p)
addItem(p, 'log', 1)
ptip(p, '挖出树桩 · 木头 +1')
removeEntity(e)
p.target = null
return
}
// 可攻击实体(兔子 / 蜘蛛巢 / 蜂巢 / 触手)
if (e.hp > 0 && (e.mobile || e.nest || e.lurk)) {
swingAct(p, 'attack')
const dmg = weaponDmg(p)
useWeaponDur(p)
e.hp -= dmg
e.shake = 0.25
markDirty(e)
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 = 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, hiveId: e.id })
}
ptip(p, '惹恼了蜂巢!')
}
}
if (e.hp <= 0) killEntity(p, e)
return
}
// 资源采集
if (!e.drops) { p.target = null; return }
if (e.tool) {
const need = e.tool === 'axe' ? 'axe' : 'pick'
if (handItem(p)?.tool !== need) {
ptip(p, `需要${need === 'axe' ? '斧头' : '镐子'}(装备到手上)`)
p.target = null
return
}
}
swingAct(p, e.tool === 'axe' ? 'chop' : e.tool === 'pick' ? 'mine' : 'chop')
if (e.tool) useToolDur(p)
e.left -= 1
e.shake = 0.2
if (e.left > 0) { markDirty(e); return }
// 采尽结算
Object.entries(e.drops).forEach(([code, n]) => {
const cnt = code === 'gold' ? randInt(1, n) : n
addItem(p, code, cnt)
addScore(cnt * 2, p)
})
ptip(p, Object.entries(e.drops).map(([code, n]) => `${ITEMS[code]?.name} +${n}`).join(' '))
callHook(modRt, 'onGather', gCtx, p, e)
if (e.type === 'flower') {
p.san = Math.min(CAP.san, p.san + TUNE.flowerSan)
ptip(p, `采下野花 · 理智 +${TUNE.flowerSan}`)
}
if (e.type === 'tree') {
treesChopped++
removeEntity(e)
addEntity('stump', e.x, e.y)
maybeTreeguard(p, e)
} else if (e.type === 'rock' || e.type === 'goldrock') {
removeEntity(e)
addEntity('rubble', e.x, e.y)
} else if (e.vanish) {
removeEntity(e)
} else if (e.regrow) {
e.stub = true
e.regrowT = e.regrow * (0.8 + Math.random() * 0.4)
markDirty(e)
} else {
removeEntity(e)
}
p.target = null
}
function killEntity(p, e) {
if (e.type === 'rabbit') {
dropLoot(e.x, e.y, 'morsel', 1)
addScore(8, p)
const hole = entities.find((h) => h.id === e.hole)
if (hole) { hole.rabbitId = 0; hole.respawnT = 40 }
} else if (e.type === 'spidernest') {
dropLoot(e.x, e.y, 'silk', 4)
dropLoot(e.x, e.y, 'spidergland', 1)
addScore(30, p)
ptip(p, '捣毁了蜘蛛巢!')
} else if (e.type === 'beehive') {
dropLoot(e.x, e.y, 'honey', 3)
addScore(20, p)
ptip(p, '捣毁了蜂巢,蜂蜜洒了一地')
} else if (e.type === 'tentacle') {
dropLoot(e.x, e.y, 'monstermeat', 1)
if (Math.random() < 0.5) dropLoot(e.x, e.y, 'tentaclespike', 1)
addScore(40, p)
ptip(p, '斩杀了触手!')
}
removeEntity(e)
p.target = null
}
function maybeTreeguard(p, e) {
if (treesChopped >= 5 && Math.random() < 0.06 + treesChopped * 0.004) {
treesChopped = 0
const spot = findLandNear(tiles, e.x, e.y, 100, 200)
spawnMob('treeguard', spot.x, spot.y)
tip('大地震颤——树精被你的滥伐惹怒了!')
shake(6)
}
}
// 添柴 / 重燃火堆
function feedFire(p, f) {
const fuels = [['log', 30], ['grass', 10], ['twig', 8]]
for (const [code, add] of fuels) {
if (countItem(p, code) > 0) {
payCost(p, { [code]: 1 })
const wasOut = f.ttl <= 0
f.ttl = Math.min(f.max, Math.max(0, f.ttl) + add)
ptip(p, wasOut && f.pit ? '火堆重新燃起' : `添了把${ITEMS[code].name} · 火势更旺`)
return
}
}
ptip(p, '没有燃料(木头/草/树枝)')
}
// 点击物品格:吃 / 用 / 装备 / 放置
function clickSlot(area, i) {
if (!player || player.dead || !playing.value) return
if (potOpenId) {
const pot = entities.find((e) => e.id === potOpenId)
const s = slotArea(player, area)?.[i]
if (pot && pot.state === 'idle' && s && ITEMS[s.code]?.food && s.code !== 'rot') {
if (coop && !coop.isHost) coop.send('starve_input', { seat: coop.mySeat, act: { type: 'potadd', id: potOpenId, area, i } })
else potAdd(player, pot, area, i)
return
}
}
if (coop && !coop.isHost) {
coop.send('starve_input', { seat: coop.mySeat, act: { type: 'use', area, i } })
return
}
useItemAt(player, area, i)
}
// 使用某格物品(主机侧)
function useItemAt(p, area, i) {
const arr = slotArea(p, area)
const s = arr?.[i]
if (!s) return
const it = ITEMS[s.code]
if (!it) return
// mod 物品优先接管(锄头/种子/魔杖/回旋镖等)
if (callHandled(modRt, 'onUseItem', gCtx, p, s, area, i)) {
invDirty = true
return
}
if (it.equip) return equipFromSlot(p, area, i)
if (it.heal) {
if (p.hp >= CAP.hp) return ptip(p, '生命值已满')
p.hp = Math.min(CAP.hp, p.hp + it.heal)
consumeSlot(arr, i)
invDirty = true
ptip(p, `敷上${it.name} · 生命 +${it.heal}`)
return
}
if (it.place) {
// 放置陷阱
const spot = findLandNear(tiles, p.x + p.dir * 44, p.y + 10, 0, 20)
addEntity('trap', spot.x, spot.y)
consumeSlot(arr, i)
invDirty = true
ptip(p, '放好了陷阱,等兔子上钩吧')
return
}
if (it.thermal) {
return ptip(p, `保暖石热量 ${Math.round(s.dur ?? 0)}%(靠近火堆充热)`)
}
if (it.food) {
if (s.code === 'rot') return ptip(p, '这已经腐烂了,不能吃')
// 喂猪结盟:附近有未结盟猪人且拿的是肉
if (it.meatv) {
const pig = monsters.find((m) => m.kind === 'pig' && m.loyalT <= 20 && m.angryT <= 0 && Math.hypot(m.x - p.x, m.y - p.y) < 80)
if (pig) {
consumeSlot(arr, i)
invDirty = true
pig.loyalT = TUNE.pigLoyal
pig.owner = p
ptip(p, '猪人吃下了肉:"你 朋友!"(将并肩作战一阵子)')
return
}
}
// 火边烤制
if (it.cook) {
const nearFire = fires.some((f) => f.ttl > 0 && Math.hypot(f.x - p.x, f.y - p.y) < 90)
if (nearFire) {
const fresh = s.fresh ?? 1
consumeSlot(arr, i)
addItem(p, it.cook, 1, Math.max(0.6, fresh))
swingAct(p, 'chop')
ptip(p, `烤好了${ITEMS[it.cook].name}`)
return
}
}
// 进食
const st = freshStage(s)
const f = it.food
p.hunger = Math.min(CAP.hunger, p.hunger + Math.max(0, f.hunger) * st.mult)
if (f.hp > 0 && st.hpOk) p.hp = Math.min(CAP.hp, p.hp + f.hp)
else if (f.hp < 0) p.hp = Math.max(1, p.hp + f.hp)
if (f.san) p.san = Math.max(0, Math.min(CAP.san, p.san + f.san))
consumeSlot(arr, i)
invDirty = true
swingAct(p, 'eat')
const parts = [`饥饿 +${Math.round(f.hunger * st.mult)}`]
if (f.hp) parts.push(`生命 ${f.hp > 0 ? '+' : ''}${st.hpOk || f.hp < 0 ? f.hp : 0}`)
if (f.san) parts.push(`理智 ${f.san > 0 ? '+' : ''}${f.san}`)
ptip(p, `吃下${it.name}${st.label} · ${parts.join(' ')}`)
return
}
ptip(p, `${it.name}:合成材料`)
}
function consumeSlot(arr, i) {
const s = arr[i]
s.n -= 1
if (s.n <= 0) arr[i] = null
invDirty = true
}
function unequipClick(slotName) {
if (!player || player.dead) return
if (coop && !coop.isHost) {
coop.send('starve_input', { seat: coop.mySeat, act: { type: 'unequip', slot: slotName } })
return
}
unequipSlot(player, slotName)
}
// ---- 科技与合成 ----
function techLevel(p) {
let lv = 0
entities.forEach((e) => {
const t = RES[e.type]?.techTier
if (t && Math.hypot(e.x - p.x, e.y - p.y) < 160) lv = Math.max(lv, t)
})
return lv
}
function recipeAvailable(r, p) {
return r.tech === 0 || p.unlocked.has(r.code) || techLevel(p) >= r.tech
}
function doCraft(code) {
if (!player || player.dead) return
if (coop && !coop.isHost) {
coop.send('starve_input', { seat: coop.mySeat, act: { type: 'craft', code } })
return
}
craftFor(player, code)
}
function craftFor(p, code) {
const r = RECIPES.find((x) => x.code === code)
if (!r) return
if (!recipeAvailable(r, p)) return ptip(p, r.tech === 1 ? '需要靠近科学机器' : '需要靠近炼金引擎')
if (!hasCost(p, r.cost)) return ptip(p, '材料不足')
payCost(p, r.cost)
if (r.tech > 0 && !p.unlocked.has(r.code)) {
p.unlocked.add(r.code)
ptip(p, `原型打造成功 · ${r.name}已永久解锁`)
}
if (code === 'campfire') {
fires.push({ fid: fid++, x: p.x + p.dir * 40, y: p.y + 8, ttl: 50, max: 90, pit: false })
ptip(p, '篝火燃起来了')
} else if (code === 'firepit') {
fires.push({ fid: fid++, x: p.x + p.dir * 44, y: p.y + 8, ttl: 60, max: 150, pit: true })
ptip(p, '石砌火堆造好了(永久建筑)')
} else if (code === 'sciencemachine' || code === 'alchemyengine' || code === 'crockpot' || r.build) {
const spot = findLandNear(tiles, p.x + p.dir * 52, p.y, 0, 26)
addEntity(code, spot.x, spot.y)
ptip(p, `${r.name}建好了`)
addScore(30, p)
} else {
const it = ITEMS[code]
if (it?.equip && !p.equip[it.equip]) {
p.equip[it.equip] = { code, dur: it.uses || it.burn || it.armor || 0 }
if (it.pack) p.pack = makeSlots(TUNE.packSlots)
ptip(p, `打造并装备了${r.name}`)
} else {
addItem(p, code, 1, 1)
ptip(p, `打造了${r.name}`)
}
}
invDirty = true
addScore(10, p)
}
// ---- 烹饪锅 ----
function potAdd(p, pot, area, i) {
if (pot.state !== 'idle' || (pot.ing?.length || 0) >= 4) return
const arr = slotArea(p, area)
const s = arr?.[i]
if (!s || !ITEMS[s.code]?.food || s.code === 'rot') return
pot.ing.push(s.code)
consumeSlot(arr, i)
markDirty(pot)
ptip(p, `投入${ITEMS[s.code].name}${pot.ing.length}/4`)
}
function potCookClick() {
if (!potOpenId) return
if (coop && !coop.isHost) {
coop.send('starve_input', { seat: coop.mySeat, act: { type: 'potcook', id: potOpenId } })
return
}
const pot = entities.find((e) => e.id === potOpenId)
if (pot) potCook(player, pot)
}
function potCook(p, pot) {
if (pot.state !== 'idle' || (pot.ing?.length || 0) < 4) return
pot.state = 'cook'
pot.cookT = TUNE.potCookTime
markDirty(pot)
ptip(p, '锅里咕嘟咕嘟炖上了……')
}
// =====================================================================
// 每帧模拟(主机 / 单人)
// =====================================================================
// 生物 AI 上下文门面
// 游戏受控上下文:生物 AI 与 mod 的统一 facadegetter 保证实时,无需手动刷新)
const gCtx = {
get players() { return players },
get monsters() { return monsters },
get entities() { return entities },
get fires() { return fires },
get phase() { return phaseName },
get season() { return seasonNow.code },
get time() { return time },
get day() { return day.value },
get mon() { return MON },
get items() { return ITEMS },
get tune() { return TUNE },
worldW: WORLD.w, worldH: WORLD.h,
isLand: (x, y) => !tiles || isLand(tiles, x, y),
litAt: (x, y) => litAt(x, y),
damagePlayer: (p, dmg, label) => damagePlayer(p, dmg, label),
addEntity: (type, x, y, extra) => addEntity(type, x, y, extra),
removeEntity: (e) => removeEntity(e),
addLoot: (x, y, code, n) => dropLoot(x, y, code, n),
removeMob: (m) => removeMob(m),
killMob: (m, credit) => killMob(m, credit),
smashStructure: (e) => smashStructure(e),
tip: (t) => tip(t),
ptip: (p, t) => ptip(p, t),
shake: (a) => shake(a),
// ---- 以下为 mod 专用能力 ----
addItem: (p, code, n = 1, fresh = 1, dur = null) => addItem(p, code, n, fresh, dur),
countItem: (p, code) => countItem(p, code),
consumeAt: (p, area, i) => { const arr = slotArea(p, area); if (arr?.[i]) consumeSlot(arr, i) },
hasCost: (p, cost) => hasCost(p, cost),
payCost: (p, cost) => payCost(p, cost),
spawnMob: (kind, x, y, extra) => spawnMob(kind, x, y, extra),
dropLoot: (x, y, code, n, fresh) => dropLoot(x, y, code, n, fresh),
addScore: (n, p) => addScore(n, p),
damageMob: (m, dmg, credit) => {
m.hp -= dmg
m.shake = 0.25
if (m.hp <= 0) killMob(m, credit)
},
markDirty: (e) => markDirty(e),
findLand: (x, y, min, max) => findLandNear(tiles, x, y, min, max),
rand: (a, b) => randInt(a, b),
moveToward: (m, x, y, speed, dt) => {
const d = Math.hypot(x - m.x, y - m.y) || 1
moveMob(m, ((x - m.x) / d) * speed * dt, ((y - m.y) / d) * speed * dt, gCtx)
m.dir = x > m.x ? 1 : x < m.x ? -1 : m.dir
m.moving = true
},
swing: (p, act) => swingAct(p, act),
heatAt: (p) => heatAt(p),
me: () => player,
isHost: () => isHostRole(),
invalidate: () => { invDirty = true },
}
function updateDayNight(dt) {
dayTime += dt
if (dayTime >= DAY_LEN) {
dayTime -= DAY_LEN
day.value += 1
addScore(100)
onNewDay()
}
phaseName = phaseOf(seasonNow.code, dayTime)
if (tickWeather(wx, dt)) tip('雨停了')
// 火焰燃烧(雨天更快)
const burn = dt * (wx.type === 'rain' ? 1.6 : 1)
fires.forEach((f) => { if (f.ttl > 0) f.ttl -= burn })
fires = fires.filter((f) => {
if (f.pit) { f.ttl = Math.max(0, f.ttl); return true }
if (f.ttl <= 0) {
dropLoot(f.x, f.y, 'ash', 1)
return false
}
return true
})
}
function onNewDay() {
const prev = seasonNow
seasonNow = seasonOf(day.value)
if (seasonNow.code !== prev.code) {
seasonBanner.value = `${seasonNow.name}季来临`
setTimeout(() => (seasonBanner.value = ''), 4200)
tip(seasonNow.code === 'winter' ? '冬季来临:注意保暖,植物停止生长' : `${seasonNow.name}季来临`)
if (seasonNow.code === 'winter') deer = { spawned: false, warnT: 0 }
}
wx = rollWeather(seasonNow.code)
if (wx.type === 'rain') tip('下雨了……篝火消耗更快,理智缓缓流失')
// 猎犬计划
if (day.value === houndPlan.nextDay) {
houndPlan.at = DAY_LEN * (0.2 + Math.random() * 0.5)
houndPlan.doneToday = false
}
// 生物每日补充
entities.filter((e) => e.type === 'spidernest').forEach((n) => {
const cur = monsters.filter((m) => m.kind === 'spider' && m.nest === n).length
if (cur < 3) spawnNestSpiders(n, Math.min(2, 3 - cur))
})
entities.filter((e) => e.type === 'pighouse').forEach((h) => {
if (!monsters.some((m) => m.kind === 'pig' && m.houseId === h.id)) spawnPig(h)
})
const frogCount = monsters.filter((m) => m.kind === 'frog').length
if (frogCount < 6) {
for (let i = 0; i < 2; i++) {
const marsh = []
for (let ti = 0; ti < tiles.length; ti++) if (tiles[ti] === 6) marsh.push(ti)
if (marsh.length) {
const ti = marsh[randInt(0, marsh.length - 1)]
spawnMob('frog', (ti % GW) * TILE + 40, Math.floor(ti / GW) * TILE + 40)
}
}
}
callHook(modRt, 'onNewDay', gCtx)
if (!coop) { doSave(false); saveTimer = 0 }
}
// 猎犬波 + 独眼巨鹿排程
function updateThreats(dt) {
// 猎犬
if (day.value === houndPlan.nextDay && !houndPlan.doneToday && dayTime >= houndPlan.at && houndPlan.warnT <= 0) {
if (callAllows(modRt, 'onSpawnWave', gCtx, 'hound')) {
houndPlan.warnT = 6
tip('你听见了远处的猎犬嚎叫……')
} else {
houndPlan.doneToday = true
houndPlan.nextDay = day.value + randInt(4, 7)
}
}
if (houndPlan.warnT > 0) {
houndPlan.warnT -= dt
if (houndPlan.warnT <= 0) {
houndPlan.doneToday = true
houndPlan.nextDay = day.value + Math.max(2, Math.round(randInt(4, 7) * TUNE.houndGapMul))
const n = Math.min(2 + Math.floor(day.value / 10), 6) + Math.max(0, players.filter((p) => !p.dead).length - 1) + TUNE.houndExtra
const targetP = players.find((p) => !p.dead) || player
for (let i = 0; i < n; i++) {
const spot = findLandNear(tiles, targetP.x, targetP.y, 420, 620)
const kind = seasonNow.code === 'winter' && i % 2 === 0 ? 'icehound' : 'hound'
spawnMob(kind, spot.x, spot.y)
}
tip('猎犬扑过来了!')
}
}
// 巨鹿:冬季第 8 天起
if (seasonNow.code === 'winter' && seasonNow.dayIn >= 8 && !deer.spawned && deer.warnT <= 0 && !monsters.some((m) => m.kind === 'deerclops')
&& callAllows(modRt, 'onSpawnWave', gCtx, 'deerclops')) {
deer.warnT = 15
tip('大地在颤抖,远处传来沉重的呼吸声……')
shake(3)
}
if (deer.warnT > 0) {
deer.warnT -= dt
if (deer.warnT <= 0 && !deer.spawned) {
deer.spawned = true
const targetP = players.find((p) => !p.dead) || player
const spot = findLandNear(tiles, targetP.x, targetP.y, 480, 680)
spawnMob('deerclops', spot.x, spot.y)
tip('独眼巨鹿降临了!保护你的营地!')
shake(8)
}
}
// 暗影怪:理智不足才浮现(官方机制)
players.forEach((p) => {
if (p.dead || p.san >= 80) return
const cap = p.san < 40 ? 2 : 1
const cur = monsters.filter((m) => m.kind === 'shadow').length
if (cur < cap * players.filter((q) => !q.dead).length && Math.random() < dt * 0.25 * (1 - p.san / 100)) {
const spot = findLandNear(tiles, p.x, p.y, 240, 380)
spawnMob('shadow', spot.x, spot.y)
}
})
if (monsters.some((m) => m.kind === 'shadow') && players.every((p) => p.dead || p.san >= 95)) {
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 openSettingsFromGame() {
window.dispatchEvent(new CustomEvent('nlg:open-settings'))
}
function togglePause() {
setPaused(!paused.value)
}
function onStarveTouchMode(e) {
touchMode.value = e.detail || 'both'
}
function updatePlayers(dt) {
players.forEach((p) => {
p.walking = false
if (p.swing > 0) p.swing = Math.max(0, p.swing - dt * 3.4)
if (p.hitTimer > 0) p.hitTimer -= dt
if (p.freezeT > 0) p.freezeT -= dt
if (p.dead) {
if (p.fallT < 1) p.fallT = Math.min(1, p.fallT + dt * 1.4)
if (!coop) return
}
// 键盘输入(本机玩家实时读取;客机玩家用其上报值)
let dx = p.input.dx
let dy = p.input.dy
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
}
if (dx || dy) {
p.target = null
const len = Math.hypot(dx, dy) || 1
const s = playerSpeed(p)
tryMovePlayer(p, (dx / len) * s * dt, (dy / len) * s * dt)
if (dx) p.dir = dx > 0 ? 1 : -1
p.walking = true
} else if (!p.dead) {
pursueTarget(p, dt)
}
if (p.walking) p.walkT += dt
})
}
function updateEntities(dt) {
const winter = seasonNow.code === 'winter'
entities.slice().forEach((e) => {
if (e.shake > 0) e.shake -= dt
// 再生(冬季暂停)
if (e.stub && e.regrowT > 0 && !winter) {
e.regrowT -= dt
if (e.regrowT <= 0) {
e.stub = false
e.left = e.hits
markDirty(e)
}
}
const modEnt = modRt.entKinds[e.type]
if (modEnt) { modEnt.update?.(e, dt, gCtx) }
else if (e.type === 'rabbit') updateRabbit(e, dt)
else if (e.type === 'rabbithole') {
if (e.respawnT > 0 && phaseName === 'day') {
e.respawnT -= dt
if (e.respawnT <= 0 && !e.rabbitId) spawnRabbit(e)
}
} else if (e.type === 'trap' && e.state === 'armed') {
const r = entities.find((r2) => r2.type === 'rabbit' && Math.hypot(r2.x - e.x, r2.y - e.y) < 22)
if (r) {
removeEntity(r)
const hole = entities.find((h) => h.id === r.hole)
if (hole) { hole.rabbitId = 0; hole.respawnT = 45 }
e.state = 'caught'
e.shake = 0.5
markDirty(e)
}
} else if (e.type === 'beehive') {
if (e.beeCd > 0) e.beeCd -= dt
} else if (e.type === 'tentacle') updateTentacle(e, dt)
else if (e.pot && e.state === 'cook') {
e.cookT -= dt
if (e.cookT <= 0) {
e.dish = cookPot(e.ing)
e.state = 'done'
markDirty(e)
}
}
})
}
function updateRabbit(e, dt) {
const hole = entities.find((h) => h.id === e.hole)
const scare = players.find((p) => !p.dead && Math.hypot(p.x - e.x, p.y - e.y) < 130)
const goHome = phaseName !== 'day'
e.flee = !!scare || goHome
if (e.flee && hole) {
const d = Math.hypot(hole.x - e.x, hole.y - e.y)
if (d < 16) {
// 钻回洞里
hole.rabbitId = 0
hole.respawnT = goHome ? 20 : 26 + Math.random() * 20
removeEntity(e)
return
}
const s = 150
moveMob(e, ((hole.x - e.x) / d) * s * dt, ((hole.y - e.y) / d) * s * dt, gCtx)
e.dir = hole.x > e.x ? 1 : -1
e.vx = 10
return
}
e.vx = 0
e.wanderT -= dt
if (e.wanderT <= 0) {
e.wanderT = 1.2 + Math.random() * 2.4
const ax = hole ? hole.x : e.x
const ay = hole ? hole.y : e.y
const a = Math.random() * 7
e.tx = ax + Math.cos(a) * Math.random() * 110
e.ty = ay + Math.sin(a) * Math.random() * 110
}
if (e.tx !== undefined) {
const d = Math.hypot(e.tx - e.x, e.ty - e.y)
if (d > 8) {
moveMob(e, ((e.tx - e.x) / d) * 60 * dt, ((e.ty - e.y) / d) * 60 * dt, gCtx)
e.dir = e.tx > e.x ? 1 : -1
e.vx = 6
} else e.vx = 0
}
}
function updateTentacle(e, dt) {
if (e.cd > 0) e.cd -= dt
if (e.atkT > 0) e.atkT -= dt * 2.4
const victim = players.find((p) => !p.dead && Math.hypot(p.x - e.x, p.y - e.y) < (e.state === 'up' ? 150 : 90))
|| monsters.find((m) => m.kind === 'pig' && Math.hypot(m.x - e.x, m.y - e.y) < 90)
if (e.state !== 'up') {
if (victim) {
e.state = 'up'
markDirty(e)
}
return
}
if (!victim) {
e.state = 'lurk'
markDirty(e)
return
}
e.dir = victim.x > e.x ? 1 : -1
const d = Math.hypot(victim.x - e.x, victim.y - e.y)
if (d < 82 && e.cd <= 0) {
e.cd = 1.25
e.atkT = 1
if (victim.seat !== undefined) {
damagePlayer(victim, 34, '被触手抽中了!')
ptip(victim, '被触手抽中了 · 生命 -34')
} else {
victim.hp -= 34
victim.targetMob = null
if (victim.hp <= 0) killMob(victim, null)
}
}
}
function updateMonstersAll(dt) {
monsters.slice().forEach((m) => {
const mk = modRt.mobKinds[m.kind]
if (mk?.update) {
m.moving = false
if (m.shake > 0) m.shake -= dt
if (m.cd > 0) m.cd -= dt
mk.update(m, dt, gCtx)
} else updateMob(m, dt, gCtx)
})
}
// 生存结算:饥饿 / 理智 / 体温 / 火把花环耐久 / 查理 / 保鲜
function updateSurvival(dt) {
const winter = seasonNow.code === 'winter'
players.forEach((p) => {
if (p.dead) return
// 饥饿
p.hunger = Math.max(0, p.hunger - TUNE.hungerRate * dt)
if (p.hunger <= 0) p.hp -= TUNE.starveHp * dt
// 理智:官方途径(黄昏/黑暗/雨/花环)
const lit = litAt(p.x, p.y)
if (phaseName === 'dusk') p.san -= TUNE.duskSan * dt
else if (phaseName === 'night') p.san -= (lit ? TUNE.nightLitSan : TUNE.nightDarkSan) * dt
if (wx.type === 'rain') p.san -= TUNE.rainSan * dt
const head = p.equip.head
if (head && ITEMS[head.code]?.sanAura) p.san += ITEMS[head.code].sanAura * dt
p.san = Math.max(0, Math.min(CAP.san, p.san))
// 体温
const insul = ['body', 'head'].reduce((n, sl) => n + (ITEMS[p.equip[sl]?.code]?.insul || 0), 0)
updateBodyTemp(p, ambientTemp(seasonNow.code, phaseName, wx.type), heatAt(p), insul, dt)
// 保暖石:有热量时托底体温
;[p.slots, p.pack].filter(Boolean).forEach((area) => {
area.forEach((s) => {
if (!s || s.code !== 'thermalstone') return
if (heatAt(p) > 0.3) s.dur = Math.min(100, (s.dur || 0) + dt * 14)
else if ((s.dur || 0) > 0 && p.temp < 8) {
s.dur = Math.max(0, s.dur - dt * 1.6)
p.temp = Math.max(p.temp, 8)
}
})
})
if (p.temp <= TUNE.freezeAt) {
p.hp -= TUNE.freezeHp * dt
if (Math.random() < dt * 0.25) ptip(p, '你快冻僵了!生火取暖!')
}
// 计时耐久:火把(手持燃烧)、花环(佩戴枯萎)
const hand = p.equip.hand
if (hand?.code === 'torch') {
hand.dur -= dt
if (hand.dur <= 0) breakEquip(p, 'hand', '火把燃尽了!')
}
if (head?.code === 'garland') {
head.dur -= dt
if (head.dur <= 0) breakEquip(p, 'head', '花环枯萎了')
}
// 查理:完全黑暗计时 → 预警 → 袭击
if (phaseName === 'night' && !lit) {
p.darkT += dt
if (p.darkT >= TUNE.charlieHit) {
damagePlayer(p, TUNE.charlieDmg, '查理在黑暗中袭击了你!')
p.san = Math.max(0, p.san - TUNE.charlieSan)
p.darkT = TUNE.charlieHit - TUNE.charlieRe
if (p === player) { flashRed = 0.55; shake(9) }
}
} else {
p.darkT = 0
}
perishTick(p, dt)
if (p.hp <= 0) die(p)
})
}
// 本机查理演出(预警提示;客机也本地跑,只做视觉不掉血)
function charlieFx(dt) {
const p = player
if (!p || p.dead) return
if (phaseName === 'night' && !litAt(p.x, p.y)) {
if (!isHostRole()) p.darkT += dt
if (!p.darkWarned && p.darkT >= TUNE.charlieWarn) {
p.darkWarned = true
tip('黑暗中似乎有什么东西在逼近……快找光源!')
}
if (!isHostRole() && p.darkT >= TUNE.charlieHit) {
p.darkT = TUNE.charlieHit - TUNE.charlieRe
flashRed = 0.55
shake(9)
}
} else {
if (!isHostRole()) p.darkT = 0
p.darkWarned = false
}
}
function updateCamera() {
if (!player) return
const W = canvas.value.width
const H = canvas.value.height
camX = Math.max(0, Math.min(WORLD.w - W, player.x - W / 2))
camY = Math.max(0, Math.min(WORLD.h - H, player.y - H / 2))
}
// 探索迷雾0.5s 一次揭开玩家周围瓦片
function updateExplore(dt) {
exploreT += dt
if (exploreT < 0.5 || !explored) return
exploreT = 0
if (modRt.flags.noFog) return
players.forEach((p) => {
if (p.dead && !coop) return
const tx = Math.floor(p.x / TILE)
const ty = Math.floor(p.y / TILE)
for (let oy = -4; oy <= 4; oy++) {
for (let ox = -4; ox <= 4; ox++) {
if (ox * ox + oy * oy > 18) continue
const x = tx + ox
const y = ty + oy
if (x >= 0 && x < GW && y >= 0 && y < GH) explored[y * GW + x] = 1
}
}
})
}
// =====================================================================
// HUD 同步
// =====================================================================
function packUiSlot(s) {
if (!s) return null
const it = ITEMS[s.code]
return {
code: s.code, n: s.n, name: it?.name || s.code,
fresh: it?.perish ? (s.fresh ?? 1) : -1,
dur: s.dur != null && (it?.uses || it?.burn || it?.armor) ? s.dur / (it.uses || it.burn || it.armor) : (s.code === 'thermalstone' ? (s.dur || 0) / 100 : -1),
}
}
function syncHud(force = false) {
if (!player) return
hp.value = Math.max(0, Math.round(player.hp))
hunger.value = Math.max(0, Math.round(player.hunger))
san.value = Math.max(0, Math.round(player.san))
temp.value = Math.round(player.temp)
seasonTag.value = { name: seasonNow.name, dayIn: seasonNow.dayIn, len: seasonNow.len, tint: seasonNow.tint, code: seasonNow.code }
if (invDirty || force) {
invDirty = false
uiSlots.value = player.slots.map(packUiSlot)
uiPack.value = player.pack ? player.pack.map(packUiSlot) : null
uiEquip.value = {
hand: player.equip.hand ? packUiSlot({ ...player.equip.hand, n: 1 }) : null,
body: player.equip.body ? packUiSlot({ ...player.equip.body, n: 1 }) : null,
head: player.equip.head ? packUiSlot({ ...player.equip.head, n: 1 }) : null,
}
const counts = {}
RECIPES.forEach((r) => Object.keys(r.cost).forEach((c) => { counts[c] = countItem(player, c) }))
uiCounts.value = counts
uiUnlocked.value = Array.from(player.unlocked)
}
uiTech.value = techLevel(player)
// 烹饪锅面板视图
if (potOpenId) {
const pot = entities.find((e) => e.id === potOpenId)
if (!pot || Math.hypot(pot.x - player.x, pot.y - player.y) > 130) {
potOpenId = 0
potView.value = null
} else {
potView.value = { id: pot.id, ing: [...(pot.ing || [])], state: pot.state, t: Math.ceil(pot.cookT || 0), dish: pot.dish }
}
} else if (potView.value) potView.value = null
updateHint()
}
function updateHint() {
if (!player || player.dead) { hint.value = '' ; return }
const parts = []
let ne = null
let nd = 120
entities.forEach((e) => {
if (e.stub || (e.deco && e.type !== 'stump') || e.solid) return
const d = Math.hypot(e.x - player.x, e.y - player.y)
if (d < nd) { nd = d; ne = e }
})
if (ne) parts.push(modRt.entKinds[ne.type] ? `空格/点击:${NAMES[ne.type] || ''}` : `空格:${ne.loot ? '拾取' : '采集'}${NAMES[ne.type] || ''}`)
let nm = null
let nmd = 170
monsters.forEach((m) => {
if (m.kind === 'pig' || m.kind === 'beefalo' || modRt.mobKinds[m.kind]?.friendly) return
const d = Math.hypot(m.x - player.x, m.y - player.y)
if (d < nmd) { nmd = d; nm = m }
})
if (nm) parts.push(`F攻击${MON[nm.kind].name}`)
const nf = fires.find((f) => Math.hypot(f.x - player.x, f.y - player.y) < 90)
if (nf) parts.push(nf.ttl <= 0 ? '点击火堆重燃' : '点击火焰添柴 · 生食可点击烤制')
const pig = monsters.find((m) => m.kind === 'pig' && m.loyalT <= 0 && Math.hypot(m.x - player.x, m.y - player.y) < 90)
if (pig) parts.push('点肉类可喂食猪人结盟')
hint.value = parts.slice(0, 3).join(' ')
}
function hudTick(dt) {
hudT += dt
if (hudT >= 0.25) {
hudT = 0
syncHud()
}
mapT += dt
if (showMap.value && mapT >= 0.5) {
mapT = 0
drawMinimap()
}
}
// =====================================================================
// 主循环
// =====================================================================
function update(dt) {
if (!playing.value) return
time += dt
lastDt = dt
fpsEma = fpsEma * 0.95 + (1 / Math.max(dt, 0.001)) * 0.05
if (coop && !coop.isHost) {
clientTick(dt)
return
}
updateDayNight(dt)
updatePlayers(dt)
updateEntities(dt)
updateMonstersAll(dt)
updateThreats(dt)
updateSurvival(dt)
callHook(modRt, 'onTick', gCtx, dt)
charlieFx(dt)
updateExplore(dt)
updateCamera()
hudTick(dt)
draw()
if (coop && coop.isHost) {
netT += dt
if (netT >= 0.15) {
netT = 0
sendSnapshot()
}
}
}
// 客机帧:本地预测自己移动 + 插值他人 + 渲染
function clientTick(dt) {
if (waitingWorld.value) return
dayTime += dt
if (dayTime >= DAY_LEN) dayTime -= DAY_LEN
phaseName = phaseOf(seasonNow.code, dayTime)
const p = player
if (p) {
p.walking = false
if (p.swing > 0) p.swing = Math.max(0, p.swing - dt * 3.4)
const dx = (keys.has('ArrowRight') || keys.has('d') ? 1 : 0) - (keys.has('ArrowLeft') || keys.has('a') ? 1 : 0)
const dy = (keys.has('ArrowDown') || keys.has('s') ? 1 : 0) - (keys.has('ArrowUp') || keys.has('w') ? 1 : 0)
if (dx || dy) {
p.target = null
const len = Math.hypot(dx, dy) || 1
const s = playerSpeed(p)
tryMovePlayer(p, (dx / len) * s * dt, (dy / len) * s * dt)
if (dx) p.dir = dx > 0 ? 1 : -1
p.walking = true
} else if (p.target && !p.dead) {
// 本地朝目标走(动作在主机侧结算)
const t = p.target
const tx = t.kind === 'walk' ? t.x : t.ref?.x
const ty = t.kind === 'walk' ? t.y : t.ref?.y
if (tx !== undefined) {
const d = Math.hypot(tx - p.x, ty - p.y)
if (d > (t.kind === 'walk' ? 6 : 44)) {
stepToward(p, tx, ty, dt)
} else if (t.kind === 'walk') p.target = null
}
}
if (p.walking) p.walkT += dt
moveSendT += dt
if (moveSendT >= 0.1) {
moveSendT = 0
coop.send('starve_input', { seat: coop.mySeat, mv: { dx, dy, x: Math.round(p.x), y: Math.round(p.y) } })
}
}
// 插值其他玩家与生物
players.forEach((q) => {
if (q === player) return
if (q.nx || q.ny) {
q.walking = Math.hypot(q.nx - q.x, q.ny - q.y) > 3
q.x += (q.nx - q.x) * Math.min(1, dt * 10)
q.y += (q.ny - q.y) * Math.min(1, dt * 10)
if (q.walking) { q.walkT += dt; q.dir = q.nx > q.x ? 1 : q.nx < q.x ? -1 : q.dir }
if (q.swing > 0) q.swing = Math.max(0, q.swing - dt * 3.4)
}
})
monsters.forEach((m) => {
if (m.shake > 0) m.shake -= dt
if (m.nx !== undefined) {
m.moving = Math.hypot(m.nx - m.x, m.ny - m.y) > 3
m.x += (m.nx - m.x) * Math.min(1, dt * 10)
m.y += (m.ny - m.y) * Math.min(1, dt * 10)
}
})
entities.forEach((e) => { if (e.shake > 0) e.shake -= dt })
fires.forEach((f) => { if (f.ttl > 0) f.ttl -= dt })
charlieFx(dt)
updateExplore(dt)
updateCamera()
hudTick(dt)
draw()
}
// =====================================================================
// 联机快照
// =====================================================================
function packSlotNet(s) {
return s ? [ITEM_IDX[s.code], s.n, Math.round((s.fresh ?? 1) * 100), Math.round(s.dur ?? 0)] : 0
}
function unpackSlotNet(v) {
if (!v) return null
const code = ITEM_CODES[v[0]]
const it = ITEMS[code] || {}
const s = { code, n: v[1] }
if (it.perish) s.fresh = v[2] / 100
if (v[3]) s.dur = v[3]
return s
}
function packEquipNet(eq) {
return eq ? [ITEM_IDX[eq.code], Math.round(eq.dur ?? 0)] : 0
}
function unpackEquipNet(v) {
return v ? { code: ITEM_CODES[v[0]], dur: v[1] } : null
}
function serializePlayerRow(p) {
return [
p.seat, Math.round(p.x), Math.round(p.y), p.dir, p.walking ? 1 : 0,
Math.round(p.hp), Math.round(p.hunger), Math.round(p.san), p.dead ? 1 : 0,
p.score, p.swing > 0.6 ? 1 : 0, ACTS.indexOf(p.act) < 0 ? 0 : ACTS.indexOf(p.act),
Math.round(p.temp), p.freezeT > 0 ? 1 : 0,
p.slots.map(packSlotNet), p.pack ? p.pack.map(packSlotNet) : 0,
[packEquipNet(p.equip.hand), packEquipNet(p.equip.body), packEquipNet(p.equip.head)],
Array.from(p.unlocked).map((c) => RECIPES.findIndex((r) => r.code === c)),
]
}
function extraOf(e) {
if (e.loot) return [ITEM_IDX[e.code], e.n, Math.round((e.fresh ?? 1) * 100)]
if (e.pot) return [(e.ing || []).map((c) => ITEM_IDX[c]), Math.round(e.cookT || 0), e.dish ? ITEM_IDX[e.dish] : -1]
if (e.mx) return { mx: e.mx } // mod 实体自定义状态(需可 JSON 序列化的小对象)
return 0
}
function applyExtra(e, x) {
if (!x) return
if (e.loot) {
e.code = ITEM_CODES[x[0]]
e.n = x[1]
e.fresh = x[2] / 100
} else if (e.pot) {
e.ing = (x[0] || []).map((i) => ITEM_CODES[i])
e.cookT = x[1]
e.dish = x[2] >= 0 ? ITEM_CODES[x[2]] : ''
} else if (x.mx) {
e.mx = x.mx
}
}
function serializeEntFull(e) {
return [e.id, e.type, Math.round(e.x), Math.round(e.y), e.left, e.stub ? 1 : 0, e.state || '', e.uses || 0, Math.round(e.hp || 0), extraOf(e)]
}
function restoreEntFull(row) {
const [id, type, x, y, left, stub, state, uses, hpv, extra] = row
const e = makeEnt(type, x, y)
e.id = id
e.left = left
e.stub = !!stub
e.state = state || (e.pot ? 'idle' : e.type === 'trap' ? 'armed' : e.lurk ? 'lurk' : '')
e.uses = uses
e.hp = hpv
applyExtra(e, extra)
eid = Math.max(eid, id + 1)
return e
}
function markAdded(e) {
if (coop && coop.isHost) entsAdded.add(e.id)
}
function markRemoved(e) {
if (coop && coop.isHost) {
entsAdded.delete(e.id)
entsDirty.delete(e.id)
entsRemoved.add(e.id)
}
}
function markDirty(e) {
if (coop && coop.isHost && !entsAdded.has(e.id)) entsDirty.add(e.id)
}
function sendSnapshot() {
const snap = {
day: day.value, dayTime: Math.round(dayTime * 10) / 10,
wx: [wx.type, Math.round(wx.t)],
players: players.map(serializePlayerRow),
mons: monsters.map((m) => [m.id, m.kind, Math.round(m.x), Math.round(m.y), m.dir, m.state || '', m.shake > 0 ? 1 : 0, Math.round((m.hp / MON[m.kind].hp) * 100), m.loyalT > 0 ? 1 : 0, m.moving ? 1 : 0]),
fires: fires.map((f) => [f.fid, Math.round(f.x), Math.round(f.y), Math.round(f.ttl), f.max, f.pit ? 1 : 0]),
}
if (entsAdded.size) {
snap.ea = entities.filter((e) => entsAdded.has(e.id)).map(serializeEntFull)
entsAdded.clear()
}
if (entsRemoved.size) {
snap.er = Array.from(entsRemoved)
entsRemoved.clear()
}
if (entsDirty.size) {
snap.eu = entities.filter((e) => entsDirty.has(e.id)).map((e) => [e.id, e.left, e.stub ? 1 : 0, e.state || '', Math.round(e.hp || 0), extraOf(e)])
entsDirty.clear()
}
if (pendingEv.length) {
snap.ev = pendingEv
pendingEv = []
}
coop.send('starve_state', snap)
}
function sendFullWorld() {
coop.send('starve_state', {
full: 1,
mods: modRt.ids,
tiles: Array.from(tiles).join(''),
road: Array.from(road).join(''),
ents: entities.map(serializeEntFull),
day: day.value, dayTime,
wx: [wx.type, Math.round(wx.t)],
players: players.map(serializePlayerRow),
mons: monsters.map((m) => [m.id, m.kind, Math.round(m.x), Math.round(m.y), m.dir, m.state || '', 0, 100, 0, 0]),
fires: fires.map((f) => [f.fid, Math.round(f.x), Math.round(f.y), Math.round(f.ttl), f.max, f.pit ? 1 : 0]),
})
}
function applySnapshot(snap) {
if (snap.full) {
// 客机对齐主机的 mod 集合(物品索引/实体表必须一致才能解包快照)
applyModRuntime(activateMods(snap.mods || []))
tiles = Uint8Array.from(snap.tiles.split('').map(Number))
road = Uint8Array.from(snap.road.split('').map(Number))
decoSpots = genDecor(tiles)
explored = new Uint8Array(GW * GH)
if (modRt.flags.noFog) explored.fill(1)
entities = snap.ents.map(restoreEntFull)
waitingWorld.value = false
if (modRt.ids.length) tip(`本局启用 Mod${modRt.metas.map((m) => m.name).join('、')}`)
} else if (waitingWorld.value) return
day.value = snap.day
const wasSeason = seasonNow.code
seasonNow = seasonOf(snap.day)
if (seasonNow.code !== wasSeason) {
seasonBanner.value = `${seasonNow.name}季来临`
setTimeout(() => (seasonBanner.value = ''), 4200)
}
dayTime = snap.dayTime
wx = { type: snap.wx?.[0] || 'none', t: snap.wx?.[1] || 0 }
// 玩家
snap.players.forEach((row) => {
const [seat, x, y, dir, walking, hpv, hgv, snv, deadv, score, swing, actIdx, tempv, freeze, slots, pack, equip, unlocked] = row
const p = players.find((q) => q.seat === seat)
if (!p) return
if (p !== player) {
p.nx = x
p.ny = y
p.dir = dir
if (walking && !p.walking) p.walking = true
} else if (Math.hypot(p.x - x, p.y - y) > 140) {
p.x = x
p.y = y
}
const wasDead = p.dead
p.hp = hpv; p.hunger = hgv; p.san = snv; p.temp = tempv
p.dead = !!deadv
if (p.dead && !wasDead) { p.fallT = 0; if (p === player) ghostMode.value = true }
if (!p.dead) p.fallT = 1
else if (p.fallT < 1) p.fallT = Math.min(1, p.fallT + 0.15)
if (p === player && score !== p.score) { p.score = score; emit('score', score) } else p.score = score
if (swing && p.swing <= 0) p.swing = 1
p.act = ACTS[actIdx] || ''
p.freezeT = freeze ? 1 : 0
p.slots = (slots || []).map(unpackSlotNet)
while (p.slots.length < TUNE.invSlots) p.slots.push(null)
p.pack = pack ? pack.map(unpackSlotNet) : null
p.equip = {
hand: unpackEquipNet(equip?.[0]),
body: unpackEquipNet(equip?.[1]),
head: unpackEquipNet(equip?.[2]),
}
p.unlocked = new Set((unlocked || []).map((i) => RECIPES[i]?.code).filter(Boolean))
})
invDirty = true
// 生物
const seen = new Set()
snap.mons.forEach((row) => {
const [id2, kind, x, y, dir, state, shook, hpPct, loyal, moving] = row
seen.add(id2)
let m = monsters.find((q) => q.id === id2)
if (!m) {
m = spawnMob(kind, x, y)
m.id = id2
}
m.nx = x
m.ny = y
if (m.nx === undefined) { m.x = x; m.y = y }
m.dir = dir
m.state = state
if (shook) m.shake = 0.2
m.hpPct = hpPct
m.loyalT = loyal ? 10 : 0
m.moving = !!moving
})
monsters = monsters.filter((m) => seen.has(m.id))
// 火焰
fires = snap.fires.map(([fid2, x, y, ttl, max, pit]) => ({ fid: fid2, x, y, ttl, max, pit: !!pit }))
// 实体增量
snap.ea?.forEach((row) => {
if (!entities.some((e) => e.id === row[0])) entities.push(restoreEntFull(row))
})
if (snap.er?.length) {
const gone = new Set(snap.er)
entities = entities.filter((e) => !gone.has(e.id))
}
snap.eu?.forEach(([id2, left, stub, state, hpv, extra]) => {
const e = entities.find((q) => q.id === id2)
if (!e) return
e.left = left
e.stub = !!stub
e.state = state || (e.pot ? 'idle' : e.type === 'trap' ? 'armed' : e.lurk ? 'lurk' : '')
e.hp = hpv
applyExtra(e, extra)
})
// 私发提示
snap.ev?.forEach(([seat, text]) => {
if (seat === coop.mySeat) tip(text)
})
}
// 主机收客机输入
function applyClientInput(data) {
const p = players.find((q) => q.seat === data.seat)
if (!p || p.dead) return
if (data.mv) {
p.input.dx = data.mv.dx
p.input.dy = data.mv.dy
// 采信客机预测位置(限幅防作弊/穿海)
if (data.mv.x !== undefined && Math.hypot(data.mv.x - p.x, data.mv.y - p.y) < 160 && isLand(tiles, data.mv.x, data.mv.y)) {
p.x = data.mv.x
p.y = data.mv.y
}
return
}
const act = data.act
if (!act) return
if (act.type === 'target') p.target = resolveTarget(act.t)
else if (act.type === 'use') useItemAt(p, act.area, act.i)
else if (act.type === 'unequip') unequipSlot(p, act.slot)
else if (act.type === 'craft') craftFor(p, act.code)
else if (act.type === 'potadd') {
const pot = entities.find((e) => e.id === act.id)
if (pot?.pot) potAdd(p, pot, act.area, act.i)
} else if (act.type === 'potcook') {
const pot = entities.find((e) => e.id === act.id)
if (pot?.pot) potCook(p, pot)
} else if (act.type === 'need_world') {
fullSyncPending = true
}
}
function handleNet(type, data) {
try {
if (type === 'starve_input' && coop?.isHost) {
// 兼容旧服务端双重包装 { seat, data: {...} }
const payload = data?.data && typeof data.data === 'object' && (data.act === undefined && data.mv === undefined)
? { ...data.data, seat: data.seat ?? data.data.seat }
: data
applyClientInput(payload)
if (fullSyncPending) {
fullSyncPending = false
sendFullWorld()
}
} else if (type === 'starve_state' && coop && !coop.isHost) {
if (!data || typeof data !== 'object') return
applySnapshot(data)
} else if (type === 'starve_end') {
if (!dead.value) gameOver()
} else if (type === 'room_closed') {
if (playing.value) {
tip('房间已关闭')
gameOver()
}
}
} catch (err) {
console.error('[starve] net handle failed', type, err)
}
}
// =====================================================================
// 存档v4
// =====================================================================
function packSaveSlot(s) {
return s ? [s.code, s.n, Math.round((s.fresh ?? 1) * 1000) / 1000, s.dur != null ? Math.round(s.dur * 10) / 10 : null] : 0
}
function unpackSaveSlot(v) {
if (!v) return null
const s = { code: v[0], n: v[1] }
if (ITEMS[v[0]]?.perish) s.fresh = v[2]
if (v[3] != null) s.dur = v[3]
return s
}
function serialize() {
return {
v: 4,
mods: modRt.ids,
modData: collectModSaves(modRt, gCtx),
day: day.value,
dayTime: Math.round(dayTime * 10) / 10,
score: player?.score || 0,
px: Math.round(player.x), py: Math.round(player.y),
hp: Math.round(player.hp), hunger: Math.round(player.hunger),
san: Math.round(player.san), temp: Math.round(player.temp),
slots: player.slots.map(packSaveSlot),
pack: player.pack ? player.pack.map(packSaveSlot) : 0,
equip: [packSaveSlot(player.equip.hand && { ...player.equip.hand, n: 1 }), packSaveSlot(player.equip.body && { ...player.equip.body, n: 1 }), packSaveSlot(player.equip.head && { ...player.equip.head, n: 1 })],
unlocked: Array.from(player.unlocked),
kills, chopped: treesChopped,
hound: { nextDay: houndPlan.nextDay },
deer: { spawned: deer.spawned },
wx: { type: wx.type, t: Math.round(wx.t) },
tiles: Array.from(tiles).join(''),
road: Array.from(road).join(''),
explored: Array.from(explored).join(''),
ents: entities.map(serializeEntFull),
fires: fires.map((f) => [f.fid, Math.round(f.x), Math.round(f.y), Math.round(f.ttl), f.max, f.pit ? 1 : 0]),
}
}
function restore(v) {
day.value = v.day || 1
dayTime = v.dayTime || 0
seasonNow = seasonOf(day.value)
player.score = v.score || 0
if (v.v >= 4) {
tiles = Uint8Array.from(v.tiles.split('').map(Number))
road = Uint8Array.from(v.road.split('').map(Number))
explored = v.explored ? Uint8Array.from(v.explored.split('').map(Number)) : new Uint8Array(GW * GH)
decoSpots = genDecor(tiles)
entities = (v.ents || []).map(restoreEntFull)
fires = (v.fires || []).map(([fid2, x, y, ttl, max, pit]) => ({ fid: fid2, x, y, ttl, max: max || 90, pit: !!pit }))
fid = fires.reduce((n, f) => Math.max(n, f.fid + 1), 1)
player.x = v.px
player.y = v.py
player.hp = v.hp
player.hunger = v.hunger
player.san = v.san
player.temp = v.temp ?? 20
player.slots = (v.slots || []).map(unpackSaveSlot)
while (player.slots.length < TUNE.invSlots) player.slots.push(null)
player.pack = v.pack ? v.pack.map(unpackSaveSlot) : null
const [h, b, hd] = v.equip || []
player.equip = {
hand: h ? { code: h[0], dur: h[3] ?? 0 } : null,
body: b ? { code: b[0], dur: b[3] ?? 0 } : null,
head: hd ? { code: hd[0], dur: hd[3] ?? 0 } : null,
}
if (player.equip.body && ITEMS[player.equip.body.code]?.pack && !player.pack) player.pack = makeSlots(TUNE.packSlots)
player.unlocked = new Set(v.unlocked || [])
kills = { spider: 0, hound: 0, shadow: 0, treeguard: 0, other: 0, ...(v.kills || {}) }
treesChopped = v.chopped || 0
houndPlan = { nextDay: v.hound?.nextDay || day.value + randInt(4, 7), at: DAY_LEN * 0.4, warnT: 0, doneToday: true }
deer = { spawned: !!v.deer?.spawned, warnT: 0 }
wx = { type: v.wx?.type || 'none', t: v.wx?.t || 0 }
// 生物重生(不入档:从巢/屋/群系恢复)
monsters = []
entities.filter((e) => e.type === 'spidernest').forEach((n) => spawnNestSpiders(n, 2))
entities.filter((e) => e.type === 'pighouse').forEach((h2) => spawnPig(h2))
entities.filter((e) => e.type === 'rabbithole' && !e.rabbitId).forEach((h2) => { h2.respawnT = 5 })
respawnBiomeMobs()
if (deer.spawned && seasonNow.code === 'winter') {
const spot = findLandNear(tiles, player.x, player.y, 500, 700)
spawnMob('deerclops', spot.x, spot.y)
}
applyModSaves(modRt, gCtx, v.modData)
if (modRt.flags.noFog) explored.fill(1)
tip(`回到第 ${day.value} 天(${seasonNow.name}季)的荒野`)
return
}
// v3 旧档尽力迁移:保留进度与背包,世界按新版重新生成
genWorldAll()
player.hp = Math.min(CAP.hp, Math.round((v.hp ?? 100) * 1.5))
player.hunger = Math.min(CAP.hunger, Math.round((v.hunger ?? 100) * 1.5))
player.san = Math.min(CAP.san, Math.round((v.san ?? 100) * 2))
const mapCode = { bandage: 'healingsalve', pick: 'pickaxe' }
Object.entries(v.inv || {}).forEach(([code, n]) => {
const real = mapCode[code] || code
if (ITEMS[real] && n > 0) addItem(player, real, n, 1)
})
Object.entries(v.tools || {}).forEach(([code, uses]) => {
const real = mapCode[code] || code
if (ITEMS[real] && uses > 0) addItem(player, real, 1, 1, Math.min(ITEMS[real].uses || 100, uses * 10))
})
kills = { spider: 0, hound: 0, shadow: 0, treeguard: 0, other: 0, ...(v.kills || {}) }
houndPlan = { nextDay: day.value + randInt(3, 6), at: DAY_LEN * 0.4, warnT: 0, doneToday: true }
tip('旧存档已迁移:世界按新版重新生成,进度与物资保留')
}
function respawnBiomeMobs() {
const savanna = []
const marsh = []
for (let i = 0; i < tiles.length; i++) {
if (tiles[i] === 2) savanna.push(i)
else if (tiles[i] === 6) marsh.push(i)
}
if (savanna.length) {
const anchor = savanna[Math.floor(savanna.length / 2)]
const hx = (anchor % GW) * TILE + 40
const hy = Math.floor(anchor / GW) * TILE + 40
for (let i = 0; i < 5; i++) {
const spot = findLandNear(tiles, hx, hy, 20, 160)
spawnMob('beefalo', spot.x, spot.y, { home: { x: hx, y: hy } })
}
}
for (let i = 0; i < 8 && marsh.length; i++) {
const ti = marsh[randInt(0, marsh.length - 1)]
spawnMob('frog', (ti % GW) * TILE + 40, Math.floor(ti / GW) * TILE + 40)
}
}
function doSave(manual = true) {
if (coop || !player || player.dead) return
emit('save', serialize())
if (manual) {
saving.value = true
tip('已保存冒险进度')
setTimeout(() => (saving.value = false), 900)
}
}
// =====================================================================
// 渲染
// =====================================================================
function draw() {
if (!ctx) return
const W = canvas.value.width
const H = canvas.value.height
ctx.clearRect(0, 0, W, H)
ctx.save()
// 画面震动
if (shakeT > 0) {
shakeT -= lastDt
ctx.translate((Math.random() - 0.5) * shakeAmp * shakeT * 2.4, (Math.random() - 0.5) * shakeAmp * shakeT * 2.4)
if (shakeT <= 0) shakeAmp = 0
}
const winter = seasonNow.code === 'winter'
if (tiles && tilePats) drawTiles(ctx, tiles, road, tilePats, camX, camY, W, H, time, winter)
// 装饰点缀
decoSpots.forEach((d) => {
const sx = d.x - camX
const sy = d.y - camY
if (sx < -20 || sx > W + 20 || sy < -20 || sy > H + 20) return
drawDecor(ctx, d, sx, sy)
})
// 目标标记
if (player?.target?.kind === 'walk') {
const t = player.target
ctx.strokeStyle = 'rgba(255,255,255,0.5)'
ctx.lineWidth = 2
ctx.beginPath()
ctx.ellipse(t.x - camX, t.y - camY, 10 + Math.sin(time * 6) * 2, 5, 0, 0, 7)
ctx.stroke()
}
// y 排序绘制世界对象
const env = { winter, time, icons: ICONS.cv }
const drawables = []
entities.forEach((e) => {
const sx = e.x - camX
const sy = e.y - camY
if (sx < -90 || sx > W + 90 || sy < -110 || sy > H + 110) return
drawables.push({ y: e.loot ? e.y - 900 : e.y, fn: () => drawEntWrap(e, env) })
})
fires.forEach((f) => {
const sx = f.x - camX
const sy = f.y - camY
if (sx < -80 || sx > W + 80 || sy < -80 || sy > H + 80) return
drawables.push({ y: f.y, fn: () => {
ctx.save()
ctx.translate(sx, sy)
drawFirePainter(ctx, f, time)
ctx.restore()
} })
})
monsters.forEach((m) => {
const sx = m.x - camX
const sy = m.y - camY
if (sx < -120 || sx > W + 120 || sy < -160 || sy > H + 160) return
drawables.push({ y: m.y, fn: () => drawMobWrap(m, env) })
})
players.forEach((p) => drawables.push({ y: p.y, fn: () => drawPlayerChar(p) }))
drawables.sort((a, b) => a.y - b.y)
drawables.forEach((d) => d.fn())
callHook(modRt, 'onDraw', gCtx, ctx, { camX, camY, W, H, time })
drawWeather(W, H)
drawNight(W, H)
// 查理袭击红闪
if (flashRed > 0) {
flashRed -= lastDt * 1.4
ctx.fillStyle = `rgba(160,20,24,${Math.max(0, flashRed) * 0.55})`
ctx.fillRect(0, 0, W, H)
}
// 低理智暗角 + 冰冻寒气
if (player && !player.dead) {
if (player.san < 70) {
const k = 1 - player.san / 70
const g = ctx.createRadialGradient(W / 2, H / 2, H * 0.32, W / 2, H / 2, H * 0.75)
g.addColorStop(0, 'rgba(30,8,40,0)')
g.addColorStop(1, `rgba(30,8,40,${0.5 * k + Math.sin(time * 2.4) * 0.06 * k})`)
ctx.fillStyle = g
ctx.fillRect(0, 0, W, H)
}
if (player.temp < 6) {
const k = Math.min(1, (6 - player.temp) / 14)
const g = ctx.createRadialGradient(W / 2, H / 2, H * 0.3, W / 2, H / 2, H * 0.8)
g.addColorStop(0, 'rgba(140,190,230,0)')
g.addColorStop(1, `rgba(150,200,240,${0.4 * k})`)
ctx.fillStyle = g
ctx.fillRect(0, 0, W, H)
}
}
ctx.restore()
drawClockV2()
if (showFullMap.value) drawFullMap()
}
function drawEntWrap(e, env) {
const sx = e.x - camX
const sy = e.y - camY
ctx.save()
ctx.translate(sx + (e.shake > 0 ? Math.sin(time * 44) * 2.4 : 0), sy)
const modEnt = modRt.entKinds[e.type]
if (modEnt?.draw) modEnt.draw(ctx, e, env)
else drawEnt(ctx, e, env)
// 受击白闪(不用 ctx.filter低端机友好
if (e.shake > 0.12) {
ctx.globalAlpha = (e.shake - 0.12) * 2
ctx.fillStyle = '#fff'
ctx.beginPath()
ctx.ellipse(0, -e.size * 0.5, e.size * 0.7, e.size * 0.7, 0, 0, 7)
ctx.fill()
ctx.globalAlpha = 1
}
// 多段采集进度点
if (e.hits > 1 && e.left < e.hits && !e.stub && e.drops) {
for (let i = 0; i < e.hits; i++) {
ctx.fillStyle = i < e.left ? 'rgba(255,255,255,0.85)' : 'rgba(255,255,255,0.25)'
ctx.beginPath()
ctx.arc((i - (e.hits - 1) / 2) * 10, 12, 2.6, 0, 7)
ctx.fill()
}
}
// 可攻击实体血条
if (e.maxHp > 0 && e.hp < e.maxHp && !e.loot) {
ctx.fillStyle = 'rgba(0,0,0,0.5)'
ctx.fillRect(-16, -e.size - 14, 32, 5)
ctx.fillStyle = '#e04840'
ctx.fillRect(-15, -e.size - 13, 30 * Math.max(0, e.hp / e.maxHp), 3)
}
ctx.restore()
}
function drawMobWrap(m, env) {
const sx = m.x - camX
const sy = m.y - camY
ctx.save()
ctx.translate(sx + (m.shake > 0 ? Math.sin(time * 44) * 2.6 : 0), sy)
const modMob = modRt.mobKinds[m.kind]
if (modMob?.draw) modMob.draw(ctx, m, time)
else drawMob(ctx, m, time)
if (m.shake > 0.12) {
ctx.globalAlpha = (m.shake - 0.12) * 2
ctx.fillStyle = '#fff'
const r = m.kind === 'deerclops' ? 46 : m.kind === 'treeguard' ? 34 : 16
ctx.beginPath()
ctx.ellipse(0, -r, r, r, 0, 0, 7)
ctx.fill()
ctx.globalAlpha = 1
}
// Boss 血条
if (MON[m.kind]?.boss) {
const pct = coop && !coop.isHost ? (m.hpPct ?? 100) / 100 : m.hp / MON[m.kind].hp
const w = m.kind === 'deerclops' ? 76 : 56
const yOff = m.kind === 'deerclops' ? -150 : -120
ctx.fillStyle = 'rgba(0,0,0,0.55)'
ctx.fillRect(-w / 2, yOff, w, 7)
ctx.fillStyle = '#e04840'
ctx.fillRect(-w / 2 + 1, yOff + 1, (w - 2) * Math.max(0, pct), 5)
}
ctx.restore()
}
function drawPlayerChar(p) {
const sx = p.x - camX
const sy = p.y - camY
const W = canvas.value.width
const H = canvas.value.height
if (sx < -80 || sx > W + 80 || sy < -100 || sy > H + 100) return
ctx.save()
ctx.translate(sx, sy)
const ghost = p.dead && p.fallT >= 1 && !!coop
if (p.dead && p.fallT < 1) {
// 倒地演出
drawPlayerFig(ctx, skinByCode(p.skin), {
dir: p.dir, walking: false, walkT: 0, swing: 0,
breathe: 0, toolCode: '', ghost: false, time, fall: p.fallT,
})
} else if (!p.dead || ghost) {
drawPlayerFig(ctx, skinByCode(p.skin), {
dir: p.dir, walking: p.walking, walkT: p.walkT,
swing: p.swing, act: p.act,
breathe: p.walking ? 0 : Math.sin(time * 2.2 + p.seat) * 1.2,
toolCode: p.equip.hand?.code || '',
ghost, time,
})
// 冰冻寒雾
if (p.freezeT > 0 && !p.dead) {
ctx.fillStyle = 'rgba(150,210,240,0.28)'
ctx.beginPath()
ctx.ellipse(0, -18, 18, 24, 0, 0, 7)
ctx.fill()
}
}
// 名字(联机)
if (coop && !p.dead) {
ctx.font = 'bold 11px "Ma Shan Zheng", sans-serif'
ctx.textAlign = 'center'
ctx.fillStyle = p === player ? 'rgba(255,235,160,0.95)' : 'rgba(255,255,255,0.85)'
ctx.fillText(p.name, 0, -46)
}
ctx.restore()
}
// 天气粒子(对象池,雨 110 / 雪 70 上限)
function drawWeather(W, H) {
const want = wx.type === 'rain' ? 110 : wx.type === 'snow' || seasonNow.code === 'winter' ? 70 : 0
while (particles.length < want) {
particles.push({
x: Math.random() * W, y: Math.random() * H,
vx: wx.type === 'rain' ? -60 : -12 + Math.random() * 24,
vy: wx.type === 'rain' ? 620 : 46 + Math.random() * 30,
seed: Math.random() * 7,
})
}
if (particles.length > want) particles.length = want
if (!particles.length) return
const rain = wx.type === 'rain'
ctx.strokeStyle = 'rgba(170,200,230,0.5)'
ctx.fillStyle = 'rgba(240,246,252,0.8)'
ctx.lineWidth = 1.4
particles.forEach((pt) => {
pt.x += pt.vx * lastDt
pt.y += pt.vy * lastDt
if (pt.y > H + 8) { pt.y = -8; pt.x = Math.random() * W }
if (pt.x < -8) pt.x = W + 8
if (rain) {
ctx.beginPath()
ctx.moveTo(pt.x, pt.y)
ctx.lineTo(pt.x + pt.vx * 0.014, pt.y + pt.vy * 0.014)
ctx.stroke()
} else {
ctx.beginPath()
ctx.arc(pt.x + Math.sin(time * 1.4 + pt.seed) * 6, pt.y, 1.8, 0, 7)
ctx.fill()
}
})
}
// 夜幕:黑底挖光圈(火/火把)
function drawNight(W, H) {
const [duskAt, nightAt] = daySplit(seasonNow.code)
let alpha = 0
if (dayTime >= nightAt || dayTime < 0) alpha = 0.9
else if (dayTime >= duskAt) alpha = 0.32 * ((dayTime - duskAt) / (nightAt - duskAt))
// 黎明淡出
if (dayTime < 6) alpha = Math.max(alpha, 0.9 * (1 - dayTime / 6))
if (alpha <= 0.01) return
const layer = document.createElement('canvas')
layer.width = W
layer.height = H
const lc = layer.getContext('2d')
lc.fillStyle = seasonNow.code === 'winter' ? `rgba(6,10,20,${alpha})` : `rgba(8,6,18,${alpha})`
lc.fillRect(0, 0, W, H)
lc.globalCompositeOperation = 'destination-out'
const carve = (x, y, r) => {
const g = lc.createRadialGradient(x, y, r * 0.25, x, y, r)
g.addColorStop(0, 'rgba(0,0,0,1)')
g.addColorStop(1, 'rgba(0,0,0,0)')
lc.fillStyle = g
lc.beginPath()
lc.arc(x, y, r, 0, 7)
lc.fill()
}
fires.forEach((f) => {
if (f.ttl <= 0) return
carve(f.x - camX, f.y - camY, fireRadius(f) * (1 + Math.sin(time * 8) * 0.03))
})
players.forEach((p) => {
if (!p.dead && p.equip.hand?.code === 'torch') carve(p.x - camX, p.y - camY - 14, 108)
})
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 drawClockLegacy() {
const c = clockCv.value?.getContext('2d')
if (!c) return
const S = 92
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)
const segs = [
[0, duskAt / DAY_LEN, '#e8bc5a'],
[duskAt / DAY_LEN, nightAt / DAY_LEN, '#b06a3c'],
[nightAt / DAY_LEN, 1, '#2a2545'],
]
segs.forEach(([a, b, col]) => {
c.beginPath()
c.moveTo(cx2, cy2)
c.arc(cx2, cy2, R, -Math.PI / 2 + a * 7 - (7 - Math.PI * 2) * a, -Math.PI / 2 + b * Math.PI * 2)
c.closePath()
c.fillStyle = col
c.fill()
})
// 16 段刻度
c.strokeStyle = 'rgba(20,14,8,0.4)'
c.lineWidth = 1
for (let i = 0; i < 16; i++) {
const a = -Math.PI / 2 + (i / 16) * Math.PI * 2
c.beginPath()
c.moveTo(cx2 + Math.cos(a) * (R - 6), cy2 + Math.sin(a) * (R - 6))
c.lineTo(cx2 + Math.cos(a) * R, cy2 + Math.sin(a) * R)
c.stroke()
}
// 指针
const ang = -Math.PI / 2 + (dayTime / DAY_LEN) * Math.PI * 2
c.strokeStyle = '#1d1409'
c.lineWidth = 3
c.beginPath()
c.moveTo(cx2, cy2)
c.lineTo(cx2 + Math.cos(ang) * (R - 9), cy2 + Math.sin(ang) * (R - 9))
c.stroke()
// 中盘:天数 + 季节
c.beginPath()
c.arc(cx2, cy2, R * 0.52, 0, 7)
c.fillStyle = '#f4ead2'
c.fill()
c.strokeStyle = seasonTag.value.tint
c.lineWidth = 3
c.stroke()
c.fillStyle = '#3a2d18'
c.font = 'bold 15px "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 + 9)
// 外圈
c.beginPath()
c.arc(cx2, cy2, R, 0, 7)
c.strokeStyle = '#1d1409'
c.lineWidth = 2.4
c.stroke()
}
// 小地图2Hz 更新):群系底色 + 战争迷雾 + 标记
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++) {
for (let tx = 0; tx < GW; tx++) {
const i = ty * GW + tx
if (!explored[i]) continue
const b = tiles[i]
c.fillStyle = b ? (road[i] ? '#a8895c' : BIOMES[b].map) : '#20404c'
c.fillRect(tx * s, ty * s, s, s)
}
}
// 标记
const dot = (x, y, col, r = 2) => {
c.fillStyle = col
c.beginPath()
c.arc((x / TILE) * s, (y / TILE) * s, r, 0, 7)
c.fill()
}
fires.forEach((f) => { if (f.ttl > 0 || f.pit) dot(f.x, f.y, '#f0923c', 2) })
entities.forEach((e) => {
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) {
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)
}
// =====================================================================
// 生命周期与外部接口
// =====================================================================
function fitCanvas() {
if (!canvas.value) return
const el = canvas.value.parentElement
// 画布分辨率跟随容器真实尺寸CSS 拉伸 100%),统一缩放系数封顶控性能,保持宽高比不失真
const w = Math.max(320, el.clientWidth)
const h = Math.max(240, el.clientHeight)
const s = Math.min(1, 1920 / w, 1200 / h)
canvas.value.width = Math.round(w * s)
canvas.value.height = Math.round(h * s)
}
function resetState() {
time = 0
dayTime = 0
day.value = 1
seasonNow = seasonOf(1)
phaseName = 'day'
wx = { type: 'none', t: 0 }
kills = { spider: 0, hound: 0, shadow: 0, treeguard: 0, other: 0 }
treesChopped = 0
houndPlan = { nextDay: randInt(5, 8), at: DAY_LEN * 0.4, warnT: 0, doneToday: false }
deer = { spawned: false, warnT: 0 }
entities = []
monsters = []
fires = []
players = []
decoSpots = []
particles = []
eid = 1
mid = 1
fid = 1
potOpenId = 0
shakeT = 0
flashRed = 0
pendingEv = []
entsAdded = new Set()
entsRemoved = new Set()
entsDirty = new Set()
dead.value = false
ghostMode.value = false
craftOpen.value = false
showMap.value = false
tips.value = []
seasonBanner.value = ''
waitingWorld.value = false
invDirty = true
}
function start(opts = {}) {
const o = typeof opts === 'object' && opts ? opts : {}
loop?.stop()
resetState()
mySkinCode = o.skin || 'wilson'
coop = o.coop || null
isCoop.value = !!coop
// mod 激活:续档以存档记录为准;联机客机等主机全量快照对齐;其余用开局勾选
const modIds = o.save && Array.isArray(o.save.mods) ? o.save.mods
: coop && !coop.isHost ? []
: (o.mods || [])
applyModRuntime(activateMods(modIds))
if (!coop && modRt.ids.length) {
tip(`已装载 Mod${modRt.metas.map((m) => m.name).join('、')}${modRt.scoreFactor !== 1 ? `(得分 ×${modRt.scoreFactor}` : ''}`)
}
if (!tilePats) tilePats = buildTilePatterns()
fitCanvas()
if (coop) {
coop.onMessage(handleNet)
const cx2 = WORLD.w / 2
const cy2 = WORLD.h / 2
players = coop.players.map((pl, i) => makePlayer(pl.seat, pl.name, pl.skin, cx2 + (i - (coop.players.length - 1) / 2) * 60, cy2 + 40))
player = players.find((p) => p.seat === coop.mySeat) || players[0]
if (coop.isHost) {
genWorldAll()
// 延迟 + 再发一次全量:客机可能比房主晚挂上 gameHandler首包会丢
tip('联机模式:你是房主(权威模拟端)')
setTimeout(() => { if (coop && coop.isHost && playing.value) sendFullWorld() }, 200)
setTimeout(() => { if (coop && coop.isHost && playing.value) sendFullWorld() }, 800)
} else {
tiles = new Uint8Array(GW * GH)
road = new Uint8Array(GW * GH)
explored = new Uint8Array(GW * GH)
waitingWorld.value = true
tip('联机模式:等待房主同步世界……')
// 多次请求全量世界,直到收到为止(房主可能尚未就绪)
const askWorld = () => {
if (!coop || coop.isHost || !waitingWorld.value || !playing.value) return
coop.send('starve_input', { seat: coop.mySeat, act: { type: 'need_world' } })
}
setTimeout(askWorld, 100)
setTimeout(askWorld, 500)
setTimeout(askWorld, 1200)
}
playing.value = true
syncHud(true)
emit('score', 0)
loop.start()
return
}
players = [makePlayer(0, '我', mySkinCode, WORLD.w / 2, WORLD.h / 2 + 40)]
player = players[0]
if (o.save) {
try {
restore(o.save)
} catch (err) {
console.error('存档恢复失败', err)
resetState()
players = [makePlayer(0, '我', mySkinCode, WORLD.w / 2, WORLD.h / 2 + 40)]
player = players[0]
genWorldAll()
tip('存档损坏,已开始新的冒险')
}
} else {
genWorldAll()
tip('欢迎来到饥荒世界:先撸草和树枝,天黑前必须有火!')
}
playing.value = true
syncHud(true)
emit('score', player.score)
loop.start()
}
function stop() {
playing.value = false
loop?.stop()
coop?.offMessage?.()
coop = null
}
function useProp() {
return false
}
defineExpose({ start, stop, useProp, setPaused, togglePause })
onMounted(() => {
ctx = canvas.value.getContext('2d')
loop = createLoop(update)
keys = new Keys()
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
else if (k === 'm' || k === 'M') {
showMap.value = !showMap.value
if (showMap.value) drawMinimap()
} else if (k === 'Escape') {
if (potOpenId) { potOpenId = 0; potView.value = null }
else if (showMap.value) showMap.value = false
else craftOpen.value = false
} else if (k >= '1' && k <= '9') {
clickSlot('main', +k - 1)
}
})
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) => {
let d = 1
while (seasonOf(d).code !== code && d < 400) d++
day.value = d
onNewDay()
tip(`跳到${seasonNow.name}季(第 ${d} 天)`)
return d
},
give: (code, n = 1) => player && addItem(player, code, n),
craft: (code) => player && craftFor(player, code),
use: (i, area = 'main') => player && useItemAt(player, area, i),
useCode: (code) => {
if (!player) return false
const i = player.slots.findIndex((s) => s?.code === code)
if (i >= 0) { useItemAt(player, 'main', i); return true }
const j = player.pack ? player.pack.findIndex((s) => s?.code === code) : -1
if (j >= 0) { useItemAt(player, 'pack', j); return true }
return false
},
drop: (code, n = 1) => player && dropLoot(player.x + 30, player.y + 16, code, n),
clearInv: () => {
if (!player) return false
player.slots.fill(null)
player.pack?.fill(null)
return true
},
hound: () => { houndPlan.nextDay = day.value; houndPlan.doneToday = false; houndPlan.at = 0; houndPlan.warnT = 0 },
boss: () => spawnMob('treeguard', player.x + 200, player.y),
deerclops: () => { deer.warnT = 1; deer.spawned = false },
spider: () => spawnMob('spider', player.x + 150, player.y),
pig: () => spawnMob('pig', player.x + 100, player.y, { home: { x: player.x, y: player.y } }),
temp: (v) => { if (player) player.temp = v },
pos: (x, y) => { if (player) { player.x = x; player.y = y } },
weather: (t) => { wx = { type: t, t: 60 } },
san: (v) => { if (player) player.san = v },
wipe: () => { entities = []; monsters = [] },
// ---- mod 调试 ----
mods: () => modRt.ids,
hurt: (n) => player && damagePlayer(player, n, '调试伤害'),
heal: () => { if (player) { player.hp = CAP.hp; player.hunger = CAP.hunger; player.san = CAP.san } },
wipeHostiles: () => {
const doomed = monsters.filter((m) => MON[m.kind]?.hostile)
doomed.forEach((m) => removeMob(m))
return doomed.length
},
mobHp: (kind) => monsters.find((m) => m.kind === kind)?.hp,
nearMobHp: (kind) => {
if (!player) return undefined
let best = null
let bd = Infinity
monsters.forEach((m) => {
if (m.kind !== kind) return
const d = Math.hypot(m.x - player.x, m.y - player.y)
if (d < bd) { bd = d; best = m }
})
return best ? Math.round(best.hp * 100) / 100 : undefined
},
entCount: (type) => entities.filter((e) => e.type === type).length,
find: (type) => { const e = entities.find((e2) => e2.type === type); return e ? [Math.round(e.x), Math.round(e.y)] : null },
poke: (type) => {
const e = entities
.filter((e2) => e2.type === type)
.sort((a, b) => Math.hypot(a.x - player.x, a.y - player.y) - Math.hypot(b.x - player.x, b.y - player.y))[0]
if (e) hitEntity(player, e)
return !!e
},
pokeMob: (kind) => {
const m = monsters.find((m2) => m2.kind === kind)
if (m) modRt.mobKinds[kind]?.interact?.(player, m, gCtx)
return !!m
},
tickEnts: (sec) => updateEntities(sec),
mobStore: (kind) => monsters.find((m) => m.kind === kind)?.store?.filter(Boolean).map((s) => `${s.code}x${s.n}`),
state: () => ({
day: day.value, dayTime, phase: phaseName, season: seasonNow.code,
hp: player?.hp, hunger: player?.hunger, san: player?.san, temp: player?.temp,
pos: player && [Math.round(player.x), Math.round(player.y)],
biome: player && BIOMES[biomeAt(tiles, player.x, player.y)]?.code,
ents: entities.length, mons: monsters.length, fires: fires.length,
monKinds: monsters.map((m) => m.kind),
mods: modRt.ids, scoreFactor: modRt.scoreFactor,
fps: Math.round(fpsEma),
slots: player?.slots.filter(Boolean).map((s) => `${s.code}x${s.n}`),
equip: player && Object.fromEntries(Object.entries(player.equip).map(([k, v]) => [k, v?.code || ''])),
overlaps: entities.filter((e, i) => entities.some((e2, j) => j > i && !e.deco && !e2.deco && Math.hypot(e.x - e2.x, e.y - e2.y) < 12)).length,
}),
}
})
onBeforeUnmount(() => {
loop?.stop()
keys?.detach()
window.removeEventListener('resize', fitCanvas)
window.removeEventListener('starve-touch-mode', onStarveTouchMode)
coop?.offMessage?.()
delete window.__starve
})
// ---- 模板辅助 ----
function tabRecipes(tab) {
return RECIPES.filter((r) => r.tab === tab)
}
function recipeState(r) {
const okTech = r.tech === 0 || uiUnlocked.value.includes(r.code) || uiTech.value >= r.tech
const okCost = Object.entries(r.cost).every(([c, n]) => (uiCounts.value[c] || 0) >= n)
return { okTech, okCost, can: okTech && okCost }
}
function costText(r) {
return Object.entries(r.cost).map(([c, n]) => `${ITEMS[c]?.name}x${n}`).join(' ')
}
function freshClass(f) {
if (f < 0) return ''
return f > 0.5 ? 'fr-good' : f > 0.2 ? 'fr-mid' : 'fr-bad'
}
</script>
<template>
<div class="starve-wrap" :class="'cur-' + cursorMode">
<canvas ref="canvas" class="starve-canvas" @click="onCanvasClick" @mousemove="updateCursor" @mouseleave="onCanvasLeave"></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="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>
<b>{{ hp }}</b>
</div>
<div class="badge" :class="{ low: hunger < 40 }" title="饥饿">
<svg viewBox="0 0 24 24"><ellipse cx="12" cy="12" rx="9" ry="8" fill="#d8a04a" stroke="#1d1409" stroke-width="1.6"/><path d="M7 12c1.6-2 3.2-2 5-.4 1.8 1.6 3.4 1.6 5 .1" fill="none" stroke="#1d1409" stroke-width="1.4"/></svg>
<b>{{ hunger }}</b>
</div>
<div class="badge" :class="{ low: san < 60 }" title="理智">
<svg viewBox="0 0 24 24"><path d="M12 3c4.4 0 8 3.2 8 7.4 0 2.7-1.4 4.6-3.4 5.8l.6 4-4-1.6c-.4 0-.8.1-1.2.1-4.4 0-8-3.2-8-7.4S7.6 3 12 3z" fill="#b48ad8" stroke="#1d1409" stroke-width="1.6"/><path d="M9 9.5c.8-1 2-1 2.8 0M13.5 9.5c.8-1 2-1 2.8 0" fill="none" stroke="#1d1409" stroke-width="1.3"/></svg>
<b>{{ san }}</b>
</div>
<div class="badge temp-badge" :class="{ cold: temp <= 5, hot: temp >= 45 }" title="体温">
<svg viewBox="0 0 24 24"><rect x="10" y="3" width="4" height="12" rx="2" fill="#ece7db" stroke="#1d1409" stroke-width="1.4"/><circle cx="12" cy="18" r="4" :fill="temp <= 5 ? '#7fa8d8' : '#d8524e'" stroke="#1d1409" stroke-width="1.4"/></svg>
<b>{{ temp }}°</b>
</div>
</div>
</div>
<!-- 左上存档 / 地图开关 / mod 标签 -->
<div v-show="playing" class="hud-left">
<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>
</div>
<!-- 小地图 -->
<div v-show="playing && showMap" class="ds-map">
<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>
<!-- 等待世界 -->
<div v-if="waitingWorld" class="waiting">正在从房主同步世界</div>
<!-- 合成面板 -->
<div v-show="playing && craftOpen" class="craft-panel">
<div class="craft-tabs">
<button
v-for="t in CRAFT_TABS"
:key="t.code"
class="craft-tab"
:class="{ on: craftTab === t.code }"
@click="craftTab = t.code"
>
<img :src="ICONS.url[t.icon]" alt="" />
<span>{{ t.name }}</span>
</button>
</div>
<div class="craft-list">
<button
v-for="r in tabRecipes(craftTab)"
:key="r.code"
class="craft-item"
:class="{ ok: recipeState(r).can, lock: !recipeState(r).okTech }"
@click="doCraft(r.code)"
>
<img :src="ICONS.url[r.code]" alt="" />
<span class="ci-name">{{ r.name }}</span>
<span class="ci-cost">{{ costText(r) }}</span>
<span class="ci-desc">{{ r.desc }}</span>
<span v-if="!recipeState(r).okTech" class="ci-lock">{{ r.tech === 1 ? '需科学机器' : '需炼金引擎' }}</span>
</button>
</div>
<div class="craft-note">科技配方在机器旁首次打造后永久解锁原型机制</div>
</div>
<!-- 烹饪锅面板 -->
<div v-if="playing && potView" class="pot-panel">
<b class="pot-title">烹饪锅</b>
<div class="pot-ings">
<span v-for="i in 4" :key="i" class="pot-cell">
<img v-if="potView.ing[i - 1]" :src="ICONS.url[potView.ing[i - 1]]" alt="" />
</span>
</div>
<div class="pot-state">
<template v-if="potView.state === 'idle'">
{{ potView.ing.length < 4 ? `点击下方食材投料${potView.ing.length}/4` : '食材齐了' }}
</template>
<template v-else-if="potView.state === 'cook'">炖煮中 {{ potView.t }}s</template>
<template v-else>出锅了点击锅取出料理</template>
</div>
<button v-if="potView.state === 'idle' && potView.ing.length === 4" class="ds-btn" @click="potCookClick">开始烹饪</button>
<button class="pot-close" @click="potOpenId = 0; potView = null">×</button>
</div>
<!-- 提示流 -->
<div v-show="playing || dead" class="tips">
<div v-for="t in tips" :key="t.id" class="tip-line">{{ t.text }}</div>
</div>
<!-- 动作提示条 -->
<div v-show="playing && hint" class="hint-bar">{{ hint }}</div>
<!-- 季节横幅 -->
<div v-if="seasonBanner" class="season-banner" :style="{ borderColor: seasonTag.tint, color: seasonTag.tint }">{{ seasonBanner }}</div>
<!-- 底部装备位 + 背包格 + 物品栏 -->
<div v-show="playing" class="inv-dock">
<div v-if="uiPack" class="pack-row">
<button
v-for="(s, i) in uiPack"
:key="'p' + i"
class="slot"
:class="{ filled: s }"
@click="clickSlot('pack', i)"
>
<template v-if="s">
<img :src="ICONS.url[s.code]" :alt="s.name" :title="s.name" />
<b v-if="s.n > 1" class="cnt">{{ s.n }}</b>
<i v-if="s.fresh >= 0" class="fresh" :class="freshClass(s.fresh)"></i>
<i v-if="s.dur >= 0" class="dur" :style="{ width: Math.max(4, s.dur * 100) + '%' }"></i>
</template>
</button>
</div>
<div class="inv-row">
<div class="equips">
<button
v-for="sl in ['hand', 'body', 'head']"
:key="sl"
class="slot equip-slot"
:class="{ filled: uiEquip[sl] }"
:title="sl === 'hand' ? '手部(点击卸下)' : sl === 'body' ? '身体(点击卸下)' : '头部(点击卸下)'"
@click="unequipClick(sl)"
>
<span v-if="!uiEquip[sl]" class="eq-hint">{{ sl === 'hand' ? '' : sl === 'body' ? '' : '' }}</span>
<template v-else>
<img :src="ICONS.url[uiEquip[sl].code]" :alt="uiEquip[sl].name" :title="uiEquip[sl].name" />
<i v-if="uiEquip[sl].dur >= 0" class="dur" :style="{ width: Math.max(4, uiEquip[sl].dur * 100) + '%' }"></i>
</template>
</button>
</div>
<div class="slots">
<button
v-for="(s, i) in uiSlots"
:key="i"
class="slot"
:class="{ filled: s }"
@click="clickSlot('main', i)"
>
<span v-if="i < 9" class="key-tag">{{ i + 1 }}</span>
<template v-if="s">
<img :src="ICONS.url[s.code]" :alt="s.name" :title="s.name" />
<b v-if="s.n > 1" class="cnt">{{ s.n }}</b>
<i v-if="s.fresh >= 0" class="fresh" :class="freshClass(s.fresh)"></i>
<i v-if="s.dur >= 0" class="dur" :style="{ width: Math.max(4, s.dur * 100) + '%' }"></i>
</template>
</button>
</div>
<button class="ds-btn craft-toggle" @click="craftOpen = !craftOpen">合成 C</button>
</div>
</div>
<!-- 死亡结算 -->
<div v-if="dead" class="death-overlay">
<div class="death-card">
<b class="death-title">你死了</b>
<p class="death-sub">黑暗吞噬了一切</p>
<div class="death-stats">
<span>存活 <b>{{ deathStats.day }}</b> </span>
<span>得分 <b>{{ deathStats.score }}</b></span>
<span>击杀 <b>{{ deathStats.kills }}</b></span>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.starve-wrap {
position: relative;
width: 100%;
height: 100%;
min-height: 0;
overflow: hidden;
background: #101418;
}
/* 双类选择器抬高优先级,压过 GamePlay 的 .stage canvas { height:auto } 通用规则 */
.starve-wrap .starve-canvas {
width: 100%;
height: 100%;
max-width: none;
display: block;
cursor: crosshair;
}
/* ---- HUD ---- */
.hud-right {
position: absolute;
top: 10px;
right: 12px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
pointer-events: none;
}
.clock {
filter: drop-shadow(0 3px 6px rgba(0, 0, 0, 0.5));
}
.badges {
display: flex;
flex-direction: column;
gap: 6px;
}
.badge {
display: flex;
align-items: center;
gap: 5px;
background: rgba(20, 16, 10, 0.72);
border: 1.6px solid rgba(240, 226, 190, 0.25);
border-radius: 20px;
padding: 3px 10px 3px 5px;
min-width: 66px;
}
.badge svg {
width: 22px;
height: 22px;
}
.badge b {
color: #f4ead2;
font-size: 14px;
}
.badge.low {
animation: pulse-low 1s infinite;
border-color: rgba(224, 72, 64, 0.8);
}
.temp-badge.cold {
border-color: rgba(127, 168, 216, 0.9);
}
.temp-badge.hot {
border-color: rgba(240, 146, 60, 0.9);
}
@keyframes pulse-low {
50% { background: rgba(120, 24, 20, 0.75); }
}
.hud-left {
position: absolute;
top: 10px;
left: 12px;
display: flex;
gap: 8px;
align-items: center;
}
.mod-tags {
background: rgba(88, 52, 130, 0.6);
border: 1.6px solid rgba(200, 160, 255, 0.4);
color: #e6d4ff;
font-size: 12px;
font-weight: 700;
padding: 4px 8px;
border-radius: 8px;
cursor: help;
}
.ds-btn {
background: rgba(20, 16, 10, 0.78);
border: 1.6px solid rgba(240, 226, 190, 0.35);
color: #f4ead2;
border-radius: 8px;
padding: 6px 12px;
font-size: 13px;
cursor: pointer;
font-family: inherit;
}
.ds-btn:hover {
border-color: #e8bc5a;
}
/* ---- 小地图 ---- */
.ds-map {
position: absolute;
top: 52px;
left: 12px;
background: rgba(14, 11, 7, 0.9);
border: 2px solid rgba(240, 226, 190, 0.35);
border-radius: 10px;
padding: 6px;
display: flex;
flex-direction: column;
gap: 4px;
align-items: center;
}
.ds-map canvas {
border-radius: 6px;
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;
}
/* ---- 合成 ---- */
.craft-panel {
position: absolute;
left: 12px;
bottom: 88px;
width: 340px;
max-height: 62%;
background: rgba(18, 14, 9, 0.92);
border: 2px solid rgba(240, 226, 190, 0.3);
border-radius: 12px;
padding: 8px;
display: flex;
flex-direction: column;
gap: 6px;
}
.craft-tabs {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.craft-tab {
display: flex;
align-items: center;
gap: 3px;
background: rgba(60, 48, 30, 0.5);
border: 1.4px solid transparent;
color: #d8c8a8;
border-radius: 7px;
padding: 3px 7px;
font-size: 12px;
cursor: pointer;
font-family: inherit;
}
.craft-tab img {
width: 18px;
height: 18px;
}
.craft-tab.on {
border-color: #e8bc5a;
color: #f4ead2;
background: rgba(100, 78, 40, 0.6);
}
.craft-list {
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 4px;
}
.craft-item {
position: relative;
display: grid;
grid-template-columns: 34px 1fr;
grid-template-rows: auto auto;
column-gap: 8px;
align-items: center;
text-align: left;
background: rgba(46, 36, 22, 0.55);
border: 1.4px solid rgba(240, 226, 190, 0.14);
border-radius: 9px;
padding: 5px 8px;
cursor: pointer;
opacity: 0.55;
font-family: inherit;
}
.craft-item img {
grid-row: span 2;
width: 30px;
height: 30px;
}
.craft-item.ok {
opacity: 1;
border-color: rgba(232, 188, 90, 0.55);
}
.craft-item.ok:hover {
background: rgba(100, 78, 40, 0.6);
}
.craft-item.lock {
opacity: 0.42;
}
.ci-name {
color: #f4ead2;
font-size: 13px;
}
.ci-cost {
color: #d8b478;
font-size: 11px;
justify-self: end;
grid-column: 2;
grid-row: 1;
text-align: right;
}
.ci-desc {
grid-column: 2;
color: #a89878;
font-size: 11px;
}
.ci-lock {
position: absolute;
right: 8px;
bottom: 5px;
color: #7fd8f0;
font-size: 11px;
}
.craft-note {
color: #8a7c62;
font-size: 11px;
text-align: center;
}
/* ---- 烹饪锅 ---- */
.pot-panel {
position: absolute;
right: 12px;
bottom: 96px;
width: 210px;
background: rgba(18, 14, 9, 0.94);
border: 2px solid rgba(240, 226, 190, 0.35);
border-radius: 12px;
padding: 10px;
display: flex;
flex-direction: column;
gap: 8px;
align-items: center;
}
.pot-title {
color: #f4ead2;
font-size: 15px;
}
.pot-ings {
display: flex;
gap: 6px;
}
.pot-cell {
width: 38px;
height: 38px;
border: 1.6px dashed rgba(240, 226, 190, 0.4);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(46, 36, 22, 0.5);
}
.pot-cell img {
width: 30px;
height: 30px;
}
.pot-state {
color: #d8c8a8;
font-size: 12px;
text-align: center;
}
.pot-close {
position: absolute;
top: 4px;
right: 8px;
background: none;
border: none;
color: #b8a888;
font-size: 16px;
cursor: pointer;
}
/* ---- 提示 ---- */
.tips {
position: absolute;
left: 50%;
top: 14%;
transform: translateX(-50%);
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
pointer-events: none;
}
.tip-line {
background: rgba(16, 12, 8, 0.78);
color: #f4ead2;
border: 1.4px solid rgba(240, 226, 190, 0.25);
border-radius: 16px;
padding: 4px 14px;
font-size: 13px;
animation: tip-in 0.25s ease-out;
}
@keyframes tip-in {
from { transform: translateY(-8px); opacity: 0; }
}
.hint-bar {
position: absolute;
bottom: 78px;
left: 50%;
transform: translateX(-50%);
background: rgba(16, 12, 8, 0.66);
color: #d8c8a8;
border-radius: 14px;
padding: 3px 14px;
font-size: 12px;
pointer-events: none;
white-space: nowrap;
}
.season-banner {
position: absolute;
top: 26%;
left: 50%;
transform: translateX(-50%);
font-size: 34px;
letter-spacing: 12px;
padding: 10px 34px;
border-top: 2px solid;
border-bottom: 2px solid;
background: rgba(12, 9, 6, 0.55);
pointer-events: none;
animation: banner-in 0.6s ease-out;
}
@keyframes banner-in {
from { opacity: 0; letter-spacing: 30px; }
}
.ghost-banner {
position: absolute;
top: 8%;
left: 50%;
transform: translateX(-50%);
color: #bfe8ff;
background: rgba(30, 50, 70, 0.6);
border: 1.4px solid rgba(150, 220, 255, 0.4);
border-radius: 16px;
padding: 5px 18px;
font-size: 13px;
pointer-events: none;
}
.waiting {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: #f4ead2;
font-size: 18px;
background: rgba(10, 8, 5, 0.55);
pointer-events: none;
}
/* ---- 底部物品栏 ---- */
.inv-dock {
position: absolute;
bottom: 8px;
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.inv-row {
display: flex;
align-items: center;
gap: 10px;
}
.slots,
.pack-row,
.equips {
display: flex;
gap: 4px;
}
.pack-row {
background: rgba(70, 54, 32, 0.55);
border-radius: 10px;
padding: 3px;
}
.slot {
position: relative;
width: 42px;
height: 42px;
background: rgba(26, 20, 12, 0.82);
border: 1.6px solid rgba(240, 226, 190, 0.22);
border-radius: 9px;
cursor: pointer;
padding: 0;
}
.slot:hover {
border-color: #e8bc5a;
}
.slot img {
width: 32px;
height: 32px;
margin-top: 2px;
pointer-events: none;
}
.slot .cnt {
position: absolute;
right: 3px;
bottom: 2px;
color: #f4ead2;
font-size: 11px;
text-shadow: 0 1px 2px #000;
}
.key-tag {
position: absolute;
left: 3px;
top: 1px;
color: rgba(240, 226, 190, 0.5);
font-size: 10px;
}
.fresh {
position: absolute;
left: 4px;
bottom: 4px;
width: 7px;
height: 7px;
border-radius: 50%;
border: 1px solid rgba(0, 0, 0, 0.5);
}
.fr-good { background: #7fc86a; }
.fr-mid { background: #e8bc5a; }
.fr-bad { background: #d8524e; }
.dur {
position: absolute;
left: 3px;
bottom: 1px;
height: 3px;
border-radius: 2px;
background: linear-gradient(90deg, #e8bc5a, #7fc86a);
max-width: calc(100% - 6px);
}
.equip-slot {
border-color: rgba(127, 216, 240, 0.35);
background: rgba(20, 26, 30, 0.85);
}
.eq-hint {
color: rgba(240, 226, 190, 0.45);
font-size: 13px;
}
.craft-toggle {
height: 42px;
}
/* ---- 死亡 ---- */
.death-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(8, 5, 4, 0.82);
animation: death-in 1.2s ease-out;
}
@keyframes death-in {
from { opacity: 0; }
}
.death-card {
text-align: center;
display: flex;
flex-direction: column;
gap: 12px;
align-items: center;
}
.death-title {
font-size: 44px;
color: #d8524e;
letter-spacing: 14px;
}
.death-sub {
color: #b8a888;
margin: 0;
}
.death-stats {
display: flex;
gap: 22px;
color: #d8c8a8;
font-size: 15px;
}
.death-stats b {
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;
}
/* ---- 饥荒主题控件强化 ---- */
.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>