4146 lines
133 KiB
Vue
4146 lines
133 KiB
Vue
<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 的统一 facade(getter 保证实时,无需手动刷新)
|
||
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>
|