升级饥荒

This commit is contained in:
李琦
2026-09-24 17:04:27 +08:00
parent d7ef97d46e
commit 7fe810a320
12 changed files with 2464 additions and 339 deletions

View File

@@ -56,6 +56,9 @@ const hint = ref('')
const nearActions = ref([])
const craftOpen = ref(false)
const craftTab = ref('tools')
const packOpen = ref(false)
const boxView = ref(null) // { id, slots: uiSlot[] }
const slotTip = ref({ show: false, text: '', x: 0, y: 0 })
const showMap = ref(false)
const saving = ref(false)
const isCoop = ref(false)
@@ -182,6 +185,8 @@ let shakeT = 0
let shakeAmp = 0
let flashRed = 0
let potOpenId = 0
let boxOpenId = 0
const BOX_SLOTS = 12
let invDirty = true
let hudT = 0
let mapT = 0
@@ -411,6 +416,43 @@ function useToolDur(p) {
invDirty = true
if (eq.dur <= 0) breakEquip(p, 'hand', `${ITEMS[eq.code].name}用坏了!`)
}
// 默认工具配方:斧/镐/铲(mod 高级工具走库存匹配,不自动合成)
const TOOL_CRAFT = { axe: 'axe', pick: 'pickaxe', dig: 'shovel' }
// 在背包/物品栏找对应工具类型的格子
function findToolSlot(p, toolType) {
for (const area of ['main', 'pack']) {
const arr = slotArea(p, area)
if (!arr) continue
for (let i = 0; i < arr.length; i++) {
const s = arr[i]
if (s && ITEMS[s.code]?.tool === toolType) return { area, i }
}
}
return null
}
// 确保手上是指定工具:已装备→跳过;背包有→自动装备;没有→尝试合成默认配方再装备
function ensureHandTool(p, toolType) {
if (!toolType) return true
if (handItem(p)?.tool === toolType) return true
const found = findToolSlot(p, toolType)
if (found) {
equipFromSlot(p, found.area, found.i)
return handItem(p)?.tool === toolType
}
const code = TOOL_CRAFT[toolType]
if (!code) {
ptip(p, `需要${toolType === 'axe' ? '斧头' : toolType === 'pick' ? '镐子' : '铲子'}`)
return false
}
craftFor(p, code)
if (handItem(p)?.tool === toolType) return true
const again = findToolSlot(p, toolType)
if (again) {
equipFromSlot(p, again.area, again.i)
return handItem(p)?.tool === toolType
}
return false
}
// 承伤管线:护甲减免 → 扣血 → 死亡
function damagePlayer(p, dmg, label) {
if (p.dead) return
@@ -476,7 +518,7 @@ function makeEnt(type, x, y, extra = {}) {
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,
struct: !!conf.struct, pot: !!conf.pot, chest: !!conf.chest, lurk: !!conf.lurk,
solid: !!conf.solid, loot: !!conf.loot,
hp: conf.hp || 0, maxHp: conf.hp || 0,
deco: !RES[type],
@@ -484,6 +526,12 @@ function makeEnt(type, x, y, extra = {}) {
}
if (type === 'trap') e.state = 'armed'
if (type === 'crockpot') { e.ing = []; e.cookT = 0; e.dish = '' ; e.state = 'idle' }
if (type === 'woodchest') {
if (!e.mx) e.mx = {}
if (!Array.isArray(e.mx.slots)) e.mx.slots = makeSlots(BOX_SLOTS)
while (e.mx.slots.length < BOX_SLOTS) e.mx.slots.push(null)
e.mx.open = false
}
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 }
@@ -540,11 +588,12 @@ function killMob(m, credit) {
if (Math.random() < prob) dropLoot(m.x, m.y, code, n)
})
if (m.kind === 'deerclops') tip('独眼巨鹿倒下了!它掉落了眼球与大肉')
callHook(modRt, 'onKillMob', gCtx, m, credit)
}
// 巨鹿拆家:建筑损毁并崩出部分材料
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]
const refund = { sciencemachine: { rock: 2, log: 2 }, alchemyengine: { rock: 2, gold: 2 }, crockpot: { rock: 3 }, woodchest: { log: 2 }, 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)
@@ -662,10 +711,46 @@ function eventToWorld(ev) {
function screenDeltaToLocal(dx, dy) {
return pageLandscapeOn() ? { x: -dy, y: dx } : { x: dx, y: dy }
}
// 框选拖拽:若 mod 处理了 pointer 序列,则抑制随后的 click 选中/走路
let suppressClick = false
function onCanvasPointerDown(ev) {
if (!playing.value || !player || player.dead || waitingWorld.value) return
if (ev.button != null && ev.button !== 0) return
const { x: wxp, y: wyp } = eventToWorld(ev)
if (callHandled(modRt, 'onPointerDown', gCtx, wxp, wyp, ev)) {
suppressClick = true
ev.preventDefault?.()
}
}
function onCanvasPointerMove(ev) {
if (!playing.value || !player || player.dead || waitingWorld.value) {
updateCursor(ev)
return
}
const { x: wxp, y: wyp } = eventToWorld(ev)
if (callHandled(modRt, 'onPointerMove', gCtx, wxp, wyp, ev)) {
suppressClick = true
ev.preventDefault?.()
return
}
updateCursor(ev)
}
function onCanvasPointerUp(ev) {
if (!playing.value || !player || player.dead || waitingWorld.value) return
const { x: wxp, y: wyp } = eventToWorld(ev)
if (callHandled(modRt, 'onPointerUp', gCtx, wxp, wyp, ev)) {
suppressClick = true
ev.preventDefault?.()
}
}
// 画布点击:优先怪 > 火 > 实体 > 走路
function onCanvasClick(ev) {
ev.preventDefault?.()
canvas.value?.blur?.()
if (suppressClick) {
suppressClick = false
return
}
if (!playing.value || !player || player.dead || waitingWorld.value) return
const { x: wxp, y: wyp } = eventToWorld(ev)
// mod 点击钩子:任一 mod 处理并返回 true 则吃掉本次点击(不选中怪物/火/实体,也不点地移动)
@@ -684,7 +769,9 @@ function onCanvasClick(ev) {
let ed = Infinity
entities.forEach((e) => {
if (e.deco && e.type !== 'stump') return
if (e.stub || e.solid) return
if (e.stub) return
// 栅栏等 solid 不可点选;门始终可点以开关
if (e.solid && e.type !== 'gate' && e.type !== 'campgate') 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 }
})
@@ -717,7 +804,8 @@ function updateCursor(ev) {
let ed = Infinity
entities.forEach((e) => {
if (e.deco && e.type !== 'stump') return
if (e.stub || e.solid) return
if (e.stub) return
if (e.solid && e.type !== 'gate' && e.type !== 'campgate') 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 }
})
@@ -753,16 +841,20 @@ function resolveTarget(t) {
return { kind: 'walk', x: t.x, y: t.y }
}
function canInteractEnt(e) {
if (!e || e.stub || e.solid) return false
if (!e || e.stub) return false
// 关着的门仍可点开;其它 solid 不可交互
if (e.solid && e.type !== 'gate' && e.type !== 'campgate') return false
if (e.deco && e.type !== 'stump') return false
return !!(e.loot || e.type === 'trap' || (e.pot && (e.state === 'done' || e.state === 'idle')) ||
(e.drops && !e.mobile) || e.type === 'stump' || e.struct || modRt.entKinds[e.type]?.interact)
}
function entActionLabel(e) {
if (e.type === 'gate' || e.type === 'campgate') return e.mx?.open ? '关门' : '开门'
if (modRt.entKinds[e.type]) return NAMES[e.type] || '交互'
if (e.loot) return `拾取${ITEMS[e.code]?.name || NAMES[e.type] || ''}`
if (e.type === 'trap') return e.state === 'caught' ? '收取猎物' : '收回陷阱'
if (e.pot) return e.state === 'done' ? '取出料理' : '打开锅'
if (e.chest || e.type === 'woodchest') return '打开木箱'
if (e.type === 'stump') return '挖掘树桩'
if (e.struct) return NAMES[e.type] || '查看'
return `采集${NAMES[e.type] || ''}`
@@ -984,6 +1076,24 @@ function hitEntity(p, e) {
markDirty(e)
} else if (p === player) {
potOpenId = e.id
boxOpenId = 0
packOpen.value = false
}
p.target = null
return
}
if (e.chest || e.type === 'woodchest') {
if (p === player) {
ensureBoxSlots(e)
e.mx.open = true
boxOpenId = e.id
potOpenId = 0
potView.value = null
packOpen.value = false
craftOpen.value = false
markDirty(e)
invDirty = true
ptip(p, '打开了木箱')
}
p.target = null
return
@@ -997,8 +1107,7 @@ function hitEntity(p, e) {
return
}
if (e.type === 'stump') {
if (handItem(p)?.tool !== 'dig') {
ptip(p, '需要铲子才能挖树桩')
if (!ensureHandTool(p, 'dig')) {
p.target = null
return
}
@@ -1036,9 +1145,7 @@ function hitEntity(p, e) {
// 资源采集
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' ? '斧头' : '镐子'}(装备到手上)`)
if (!ensureHandTool(p, e.tool)) {
p.target = null
return
}
@@ -1126,9 +1233,124 @@ function feedFire(p, f) {
}
ptip(p, '没有燃料(木头/草/树枝)')
}
// 点击物品格:吃 / 用 / 装备 / 放置
function ensureBoxSlots(e) {
if (!e.mx) e.mx = {}
if (!Array.isArray(e.mx.slots)) e.mx.slots = makeSlots(BOX_SLOTS)
while (e.mx.slots.length < BOX_SLOTS) e.mx.slots.push(null)
if (e.mx.slots.length > BOX_SLOTS) e.mx.slots = e.mx.slots.slice(0, BOX_SLOTS)
return e.mx.slots
}
function closeBox() {
if (boxOpenId) {
const e = entities.find((x) => x.id === boxOpenId)
if (e?.mx) {
e.mx.open = false
markDirty(e)
}
}
boxOpenId = 0
boxView.value = null
}
function depositToBox(p, area, i) {
const e = entities.find((x) => x.id === boxOpenId)
if (!e) return false
const arr = slotArea(p, area)
const s = arr?.[i]
if (!s) return false
const slots = ensureBoxSlots(e)
const max = stackMax(s.code)
// 先合并堆叠
for (const bs of slots) {
if (bs && bs.code === s.code && bs.n < max) {
const take = Math.min(s.n, max - bs.n)
if (ITEMS[s.code]?.perish) {
bs.fresh = ((bs.fresh ?? 1) * bs.n + (s.fresh ?? 1) * take) / (bs.n + take)
}
bs.n += take
s.n -= take
if (s.n <= 0) arr[i] = null
markDirty(e)
invDirty = true
ptip(p, `存入${ITEMS[s.code]?.name || s.code}`)
return true
}
}
const empty = slots.findIndex((x) => !x)
if (empty < 0) {
ptip(p, '箱子已满')
return true
}
slots[empty] = { ...s }
arr[i] = null
markDirty(e)
invDirty = true
ptip(p, `存入${ITEMS[s.code]?.name || s.code}`)
return true
}
function withdrawFromBox(p, bi) {
const e = entities.find((x) => x.id === boxOpenId)
if (!e) return
const slots = ensureBoxSlots(e)
const s = slots[bi]
if (!s) return
addItem(p, s.code, s.n, s.fresh ?? 1, s.dur ?? null)
slots[bi] = null
markDirty(e)
invDirty = true
ptip(p, `取出${ITEMS[s.code]?.name || s.code}`)
}
function showSlotTip(ev, text) {
if (!text) {
slotTip.value = { show: false, text: '', x: 0, y: 0 }
return
}
const wrap = ev.currentTarget?.closest?.('.starve-wrap') || canvas.value?.parentElement
const rect = wrap?.getBoundingClientRect?.() || { left: 0, top: 0 }
slotTip.value = {
show: true,
text,
x: ev.clientX - rect.left,
y: ev.clientY - rect.top - 12,
}
}
function hideSlotTip() {
slotTip.value.show = false
}
function tipTextForSlot(s) {
if (!s) return ''
let t = s.name || s.code
if (s.n > 1) t += ` ×${s.n}`
return t
}
function tipTextForEquip(sl) {
const eq = uiEquip.value[sl]
if (!eq) return sl === 'hand' ? '手部' : sl === 'body' ? '身体' : '头部'
return eq.name || eq.code
}
function togglePack() {
if (!uiPack.value) {
tip('需要装备背包才能打开')
return
}
packOpen.value = !packOpen.value
if (packOpen.value) {
craftOpen.value = false
closeBox()
potOpenId = 0
potView.value = null
}
}
// 点击物品格:吃 / 用 / 装备 / 放置;箱子打开时改为存入
function clickSlot(area, i) {
if (!player || player.dead || !playing.value) return
if (boxOpenId) {
if (coop && !coop.isHost) {
coop.send('starve_input', { seat: coop.mySeat, act: { type: 'boxdep', id: boxOpenId, area, i } })
return
}
depositToBox(player, area, i)
return
}
if (potOpenId) {
const pot = entities.find((e) => e.id === potOpenId)
const s = slotArea(player, area)?.[i]
@@ -1144,6 +1366,14 @@ function clickSlot(area, i) {
}
useItemAt(player, area, i)
}
function clickBoxSlot(bi) {
if (!player || player.dead || !boxOpenId) return
if (coop && !coop.isHost) {
coop.send('starve_input', { seat: coop.mySeat, act: { type: 'boxwit', id: boxOpenId, i: bi } })
return
}
withdrawFromBox(player, bi)
}
// 使用某格物品(主机侧)
function useItemAt(p, area, i) {
const arr = slotArea(p, area)
@@ -1334,6 +1564,7 @@ const gCtx = {
get season() { return seasonNow.code },
get time() { return time },
get day() { return day.value },
get dayTime() { return dayTime },
get mon() { return MON },
get items() { return ITEMS },
get tune() { return TUNE },
@@ -1341,6 +1572,20 @@ const gCtx = {
get worldH() { return WORLD.h },
isLand: (x, y) => !tiles || isLand(tiles, x, y),
litAt: (x, y) => litAt(x, y),
// 推进游戏内时刻;跨日时与 updateDayNight 一致触发 day++ / onNewDay
setDayTime: (t) => {
let nt = Number(t)
if (!Number.isFinite(nt)) return
while (nt >= DAY_LEN) {
nt -= DAY_LEN
day.value += 1
addScore(100)
onNewDay()
}
if (nt < 0) nt = 0
dayTime = nt
phaseName = phaseOf(seasonNow.code, dayTime)
},
damagePlayer: (p, dmg, label) => damagePlayer(p, dmg, label),
addEntity: (type, x, y, extra) => addEntity(type, x, y, extra),
removeEntity: (e) => removeEntity(e),
@@ -1376,6 +1621,24 @@ const gCtx = {
},
swing: (p, act) => swingAct(p, act),
heatAt: (p) => heatAt(p),
cookPot: (codes) => cookPot(codes),
// 按物品 code 对玩家施加 food 效果(食堂点餐等,不扣背包)
applyFood: (p, code, fresh = 1) => {
const it = ITEMS[code]
if (!it?.food || code === 'rot') return false
const st = freshStage({ fresh })
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))
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 true
},
// 新增火焰(供 mod 使用):eternal 火焰永久燃烧、免疫消耗与雨天加速
addFire: (x, y, o = {}) => {
const f = { fid: fid++, x, y, ttl: o.ttl ?? 50, max: o.max ?? 90, pit: !!o.pit, eternal: !!o.eternal }
@@ -1888,6 +2151,18 @@ function syncHud(force = false) {
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
// 木箱面板
if (boxOpenId) {
const box = entities.find((e) => e.id === boxOpenId)
if (!box || Math.hypot(box.x - player.x, box.y - player.y) > 130) {
closeBox()
} else {
const slots = ensureBoxSlots(box)
boxView.value = { id: box.id, slots: slots.map(packUiSlot) }
}
} else if (boxView.value) boxView.value = null
// 卸下背包时关闭面板
if (!player.pack) packOpen.value = false
updateHint()
}
function updateHint() {
@@ -2267,6 +2542,12 @@ function applyClientInput(data) {
} else if (act.type === 'potcook') {
const pot = entities.find((e) => e.id === act.id)
if (pot?.pot) potCook(p, pot)
} else if (act.type === 'boxdep') {
boxOpenId = act.id
depositToBox(p, act.area, act.i)
} else if (act.type === 'boxwit') {
boxOpenId = act.id
withdrawFromBox(p, act.i)
} else if (act.type === 'need_world') {
fullSyncPending = true
}
@@ -3106,6 +3387,10 @@ function resetState() {
mid = 1
fid = 1
potOpenId = 0
boxOpenId = 0
boxView.value = null
packOpen.value = false
slotTip.value = { show: false, text: '', x: 0, y: 0 }
shakeT = 0
flashRed = 0
pendingEv = []
@@ -3211,18 +3496,24 @@ onMounted(() => {
keys.attach()
keys.onPress((k) => {
if (!playing.value) return
if ((k === 'p' || k === 'P') || (k === 'Escape' && !potOpenId && !showMap.value && !craftOpen.value)) {
if ((k === 'p' || k === 'P') || (k === 'Escape' && !potOpenId && !boxOpenId && !packOpen.value && !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 === 'e' || k === 'E') doQuickInteract()
else if (k === 'c' || k === 'C') {
craftOpen.value = !craftOpen.value
if (craftOpen.value) { packOpen.value = false; closeBox() }
} else if (k === 'b' || k === 'B') togglePack()
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 }
if (boxOpenId) closeBox()
else if (packOpen.value) packOpen.value = false
else if (potOpenId) { potOpenId = 0; potView.value = null }
else if (showMap.value) showMap.value = false
else craftOpen.value = false
} else if (k >= '1' && k <= '9') {
@@ -3356,7 +3647,16 @@ function freshClass(f) {
<template>
<div class="starve-wrap" :class="'cur-' + cursorMode">
<canvas ref="canvas" class="starve-canvas" tabindex="-1" @click="onCanvasClick" @mousemove="updateCursor" @mouseleave="onCanvasLeave"></canvas>
<canvas
ref="canvas"
class="starve-canvas"
tabindex="-1"
@click="onCanvasClick"
@mousedown="onCanvasPointerDown"
@mousemove="onCanvasPointerMove"
@mouseup="onCanvasPointerUp"
@mouseleave="onCanvasLeave"
></canvas>
<!-- 手机摇杆(设置里可切换) -->
<div
@@ -3493,6 +3793,77 @@ function freshClass(f) {
<button class="pot-close" @click="potOpenId = 0; potView = null">×</button>
</div>
<!-- 背包模态框 -->
<div v-if="playing && packOpen && uiPack" class="modal-mask" @click.self="packOpen = false">
<div class="modal-card pack-modal">
<div class="modal-head">
<b>背包</b>
<button type="button" class="modal-x" @click="packOpen = false">×</button>
</div>
<p class="modal-hint">点击格子使用物品 · 快捷键 B</p>
<div class="modal-slots">
<button
v-for="(s, i) in uiPack"
:key="'mp' + i"
type="button"
class="slot"
:class="{ filled: s }"
:data-tip="tipTextForSlot(s)"
@click="clickSlot('pack', i)"
@mouseenter="showSlotTip($event, tipTextForSlot(s))"
@mousemove="showSlotTip($event, tipTextForSlot(s))"
@mouseleave="hideSlotTip"
>
<template v-if="s">
<img :src="ICONS.url[s.code]" :alt="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>
</div>
<!-- 木箱模态框 -->
<div v-if="playing && boxView" class="modal-mask" @click.self="closeBox">
<div class="modal-card box-modal">
<div class="modal-head">
<b>木箱</b>
<button type="button" class="modal-x" @click="closeBox">×</button>
</div>
<p class="modal-hint">点击物品栏格存入 · 点击箱内格取出</p>
<div class="modal-slots box-grid">
<button
v-for="(s, i) in boxView.slots"
:key="'bx' + i"
type="button"
class="slot"
:class="{ filled: s }"
:data-tip="tipTextForSlot(s)"
@click="clickBoxSlot(i)"
@mouseenter="showSlotTip($event, tipTextForSlot(s))"
@mousemove="showSlotTip($event, tipTextForSlot(s))"
@mouseleave="hideSlotTip"
>
<template v-if="s">
<img :src="ICONS.url[s.code]" :alt="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>
</div>
<!-- 物品名悬浮提示(跟随指针) -->
<div
v-show="playing && slotTip.show && slotTip.text"
class="slot-float-tip"
:style="{ left: slotTip.x + 'px', top: slotTip.y + 'px' }"
>{{ slotTip.text }}</div>
<!-- 提示流 -->
<div v-show="playing || dead" class="tips">
<div v-for="t in tips" :key="t.id" class="tip-line">{{ t.text }}</div>
@@ -3514,42 +3885,31 @@ function freshClass(f) {
type="button"
class="quick-act"
@click.stop="doQuickInteract"
>一键交互</button>
title="快捷键 E · 缺少工具时自动装备/合成"
>一键交互 · E</button>
<!-- 季节横幅 -->
<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"
type="button"
class="slot equip-slot"
:class="{ filled: uiEquip[sl] }"
:title="sl === 'hand' ? '手部(点击卸下)' : sl === 'body' ? '身体(点击卸下)' : '头部(点击卸下)'"
:data-tip="tipTextForEquip(sl)"
@click="unequipClick(sl)"
@mouseenter="showSlotTip($event, tipTextForEquip(sl))"
@mousemove="showSlotTip($event, tipTextForEquip(sl))"
@mouseleave="hideSlotTip"
>
<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" />
<img :src="ICONS.url[uiEquip[sl].code]" :alt="uiEquip[sl].name" />
<i v-if="uiEquip[sl].dur >= 0" class="dur" :style="{ width: Math.max(4, uiEquip[sl].dur * 100) + '%' }"></i>
</template>
</button>
@@ -3558,20 +3918,32 @@ function freshClass(f) {
<button
v-for="(s, i) in uiSlots"
:key="i"
type="button"
class="slot"
:class="{ filled: s }"
:data-tip="tipTextForSlot(s)"
@click="clickSlot('main', i)"
@mouseenter="showSlotTip($event, tipTextForSlot(s))"
@mousemove="showSlotTip($event, tipTextForSlot(s))"
@mouseleave="hideSlotTip"
>
<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" />
<img :src="ICONS.url[s.code]" :alt="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>
<button
v-if="uiPack"
type="button"
class="ds-btn craft-toggle"
:class="{ on: packOpen }"
@click="togglePack"
>背包 B</button>
<button type="button" class="ds-btn craft-toggle" :class="{ on: craftOpen }" @click="craftOpen = !craftOpen">合成 C</button>
</div>
</div>
@@ -4024,16 +4396,10 @@ function freshClass(f) {
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;
@@ -4061,6 +4427,80 @@ function freshClass(f) {
font-size: 11px;
text-shadow: 0 1px 2px #000;
}
.slot-float-tip {
position: absolute;
z-index: 80;
transform: translate(-50%, -100%);
padding: 4px 10px;
border-radius: 6px;
background: rgba(20, 14, 8, 0.92);
border: 1px solid rgba(240, 226, 190, 0.35);
color: #f4ead2;
font-size: 12px;
white-space: nowrap;
pointer-events: none;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35);
}
.modal-mask {
position: absolute;
inset: 0;
z-index: 55;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.45);
pointer-events: auto;
}
.modal-card {
width: min(420px, calc(100% - 32px));
background: #3a2a18;
border: 3px solid #1d1409;
border-radius: 12px;
padding: 12px 14px 16px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45);
}
.modal-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.modal-head b {
color: #f4ead2;
font-size: 16px;
}
.modal-x {
width: 28px;
height: 28px;
border: none;
border-radius: 6px;
background: #7a2f24;
color: #f4ead2;
font-size: 18px;
line-height: 1;
cursor: pointer;
}
.modal-hint {
margin: 0 0 10px;
color: #c9b89a;
font-size: 12px;
}
.modal-slots {
display: flex;
flex-wrap: wrap;
gap: 6px;
justify-content: center;
}
.box-grid {
display: grid;
grid-template-columns: repeat(4, 42px);
gap: 6px;
justify-content: center;
}
.craft-toggle.on {
border-color: #e8bc5a;
background: rgba(90, 64, 30, 0.95);
}
.key-tag {
position: absolute;
left: 3px;

View File

@@ -205,6 +205,6 @@ export const gameRegistry = {
coop: true, // 组队联机:进入时可选单人冒险 / 组队联机(2~4 人共享世界)
mods: true, // Mod 装载:开局可勾选内置 mod(难度类 mod 影响得分系数)
controls:
'WASD 移动 · 空格采集 · F 攻击 · 1~9 使用物品栏 · C 合成 · M 地图 · Esc 关面板(鼠标点击同样可用);夜晚完全黑暗会遭查理袭击,务必备火;科学机器/炼金引擎解锁高级配方,烹饪锅可炖官方料理;历经秋冬春夏四季(冬季注意保暖、提防独眼巨鹿),猪人可喂肉结盟;每活一天 +100 分并自动云存档;支持 2~4 人组队联机与 45 款人物皮肤',
'WASD 移动 · 空格采集 · F 攻击 · E 一键交互(缺工具自动装备/合成)· 1~9 使用物品栏 · C 合成 · B 背包 · M 地图 · Esc 关面板(鼠标点击同样可用);夜晚完全黑暗会遭查理袭击,务必备火;科学机器/炼金引擎解锁高级配方,烹饪锅可炖官方料理;历经秋冬春夏四季(冬季注意保暖、提防独眼巨鹿),猪人可喂肉结盟;每活一天 +100 分并自动云存档(可多存档命名);支持 2~4 人组队联机与 45 款人物皮肤',
},
}

View File

@@ -147,6 +147,7 @@ export const RECIPES = [
{ code: 'rope', name: '绳子', tab: 'surv', tech: 0, cost: { grass: 3 }, desc: '基础合成材料' },
{ code: 'trap', name: '陷阱', tab: 'surv', tech: 0, cost: { grass: 6, twig: 2 }, desc: '放置捕兔 · 耐久 8 次' },
{ code: 'backpack', name: '背包', tab: 'surv', tech: 1, cost: { grass: 4, twig: 4 }, desc: '身体装备 · 物品栏 +8 格' },
{ code: 'woodchest', name: '木箱', tab: 'surv', tech: 0, cost: { log: 3, twig: 2 }, build: true, desc: '可存放物资的箱子(点击打开)' },
{ code: 'healingsalve', name: '治疗药膏', tab: 'surv', tech: 1, cost: { ash: 1, rock: 1, spidergland: 2 }, desc: '使用回复 20 生命' },
{ code: 'honeypoultice', name: '蜂蜜药膏', tab: 'surv', tech: 2, cost: { reeds: 2, honey: 1 }, desc: '使用回复 30 生命' },
{ code: 'thermalstone', name: '保暖石', tab: 'surv', tech: 2, cost: { rock: 10, flint: 3, rope: 1 }, desc: '篝火旁充热,随身抵御冰冻' },
@@ -237,6 +238,7 @@ export const RES = {
sciencemachine: { size: 34, struct: true, techTier: 1 },
alchemyengine: { size: 38, struct: true, techTier: 2 },
crockpot: { size: 26, struct: true, pot: true },
woodchest: { size: 28, struct: true, chest: true },
trap: { size: 22, placeable: true },
loot: { size: 12, loot: true },
}
@@ -247,6 +249,6 @@ export const NAMES = {
tree: '树', rock: '岩石', goldrock: '金矿石', grass: '草丛', sapling: '树枝苗', berry: '浆果丛',
reeds: '芦苇', flower: '野花', flint: '燧石', rabbit: '兔子', rabbithole: '兔子洞',
spidernest: '蜘蛛巢', beehive: '蜂巢', pighouse: '猪屋', tentacle: '触手', trap: '陷阱',
sciencemachine: '科学机器', alchemyengine: '炼金引擎', crockpot: '烹饪锅', loot: '掉落物',
sciencemachine: '科学机器', alchemyengine: '炼金引擎', crockpot: '烹饪锅', woodchest: '木箱', loot: '掉落物',
stump: '树桩', rubble: '碎石',
}

View File

@@ -6,16 +6,33 @@
// removeMob(m) · killMob(m,credit) · smashStructure(ent) · tip(t) · ptip(p,t) · shake(amp)
import { MON } from './data.js'
// 带陆地碰撞的位移:先整体,再退化为单轴滑动
// 固体栅栏/关着的门挡路(开着的 gate/campgate 不挡)
function blockedBySolid(g, x, y) {
if (!g.entities) return false
for (const e of g.entities) {
if (!e.solid) continue
if ((e.type === 'gate' || e.type === 'campgate') && e.mx?.open) continue
const fenceLike = e.type === 'fence' || e.type === 'gate' || e.type === 'campfence' || e.type === 'campgate'
const r = fenceLike
? Math.max(22, (e.size || 24) * 0.65)
: Math.max(14, (e.size || 24) * 0.55)
if (Math.hypot(e.x - x, e.y - y) < r) return true
}
return false
}
function canStand(g, x, y) {
return g.isLand(x, y) && !blockedBySolid(g, x, y)
}
// 带陆地 + 栅栏碰撞的位移:先整体,再退化为单轴滑动
export function moveMob(m, dx, dy, g) {
const nx = m.x + dx
const ny = m.y + dy
if (g.isLand(nx, ny)) {
if (canStand(g, nx, ny)) {
m.x = nx
m.y = ny
} else if (g.isLand(nx, m.y)) {
} else if (canStand(g, nx, m.y)) {
m.x = nx
} else if (g.isLand(m.x, ny)) {
} else if (canStand(g, m.x, ny)) {
m.y = ny
}
}

View File

@@ -4,15 +4,29 @@
// - 水井:打水回理智(60 秒冷却),同时是良田的灌溉水源
// - 粮仓:存入/取出 wheat/pumpkin/berry/carrot 类作物
// - 磨坊:wheat×3 → flour×2
// - 框选农业区:可命名多块农田,区内自动铺正方形良田
// - 采草 12% 掉小麦种子、采浆果丛 12% 掉南瓜种子
import { normalizeRect, clearZoneClutter, pointInAabb } from './zoneUtil.js'
const TAU = Math.PI * 2
const GROW_TOTAL = 200 // 生长总时长(秒):比 farm.js 的 320 秒更快
const FARM_SPACING = 64 // 正方形田块中心距
// 种子 → 成熟作物 code
const SEED_CROP = { wheatseeds: 'wheat', pumpkinseeds: 'pumpkin', berryseeds: 'berry' }
// 粮仓可收纳的作物
const CROPS = ['wheat', 'pumpkin', 'berry', 'carrot']
// ---- 农业区框选状态(多块可命名;UI 模式不入档) ----
let agriZones = [] // [{ id, name, x0, y0, x1, y1 }]
let nextAgriId = 1
let zoneMode = false
let drag = null // { x0, y0, x1, y1 }
let uiCamX = 0
let uiCamY = 0
let uiBtn = null
let uiCancel = null
function stageOf(t) {
if (t >= GROW_TOTAL) return 'ready'
if (t >= 133) return 'grow2'
@@ -28,10 +42,61 @@ function plant(p, plot, seedCode, g) {
g.ptip(p, `种下了${g.items[SEED_CROP[seedCode]]?.name || '作物'}种子`)
}
function promptZoneName(kind, def) {
try {
const v = typeof window !== 'undefined' ? window.prompt(`给这块${kind}起个名字`, def) : null
const name = (v == null ? def : String(v)).trim()
return name.slice(0, 12) || def
} catch {
return def
}
}
function syncAgrizoneMarkers(g) {
g.entities.filter((e) => e.type === 'agrizone').forEach((e) => g.removeEntity(e))
agriZones.forEach((z) => {
const cx = (z.x0 + z.x1) / 2
const cy = (z.y0 + z.y1) / 2
g.addEntity('agrizone', cx, cy, { mx: { ...z } })
})
}
function fillFarmsInZone(g, z, opts = {}) {
if (!z) return 0
if (opts.clear !== false) clearZoneClutter(g, z)
const half = FARM_SPACING / 2
let added = 0
for (let y = z.y0 + half; y <= z.y1 - half; y += FARM_SPACING) {
for (let x = z.x0 + half; x <= z.x1 - half; x += FARM_SPACING) {
if (!g.isLand(x, y)) continue
const crowded = g.entities.some((e) => e.type === 'agrifarm' && Math.hypot(e.x - x, e.y - y) < FARM_SPACING * 0.48)
if (crowded) continue
g.addEntity('agrifarm', x, y)
added++
}
}
syncAgrizoneMarkers(g)
return added
}
function inside(x, y, r) {
return !!r && x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h
}
function renameZoneAt(g, wx, wy) {
const hit = agriZones.find((z) => pointInAabb(wx, wy, z))
if (!hit) return false
const name = promptZoneName('农田', hit.name)
hit.name = name
syncAgrizoneMarkers(g)
g.tip(`农田已命名为「${name}」`)
return true
}
export default {
id: 'agri',
name: '农业文明',
desc: '良田、水井与粮仓:把荒野变成粮仓',
desc: '良田、水井与粮仓:框选农业区自动铺田',
factor: 1.05,
tag: 'content',
tabs: [{ code: 'agri', name: '农业', icon: 'agrihoe' }],
@@ -207,9 +272,17 @@ export default {
},
},
ents: {
agrizone: {
name: '农业区',
conf: { size: 8, deco: true },
init(e) {
if (!e.mx) e.mx = { x0: e.x - 40, y0: e.y - 40, x1: e.x + 40, y1: e.y + 40 }
},
draw() { /* 范围由 onDraw 虚线框绘制 */ },
},
agrifarm: {
name: '良田',
conf: { size: 26 },
conf: { size: 52 },
init(e) {
if (!e.state) e.state = 'empty'
},
@@ -254,21 +327,22 @@ export default {
g.ptip(p, `作物生长中…… ${pct}%(靠近水井更快)`)
},
draw(ctx, e) {
// 垄起的土地
// 正方形田块
const s = 26
ctx.fillStyle = '#6b4a2e'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 2
ctx.beginPath()
ctx.ellipse(0, 0, 24, 14, 0, 0, TAU)
ctx.roundRect(-s, -s, s * 2, s * 2, 3)
ctx.fill()
ctx.stroke()
// 两条田垄线(良田更规整)
// 田垄线
ctx.strokeStyle = '#553a24'
ctx.lineWidth = 2
;[-5, 5].forEach((y) => {
ctx.lineWidth = 1.8
;[-10, 0, 10].forEach((y) => {
ctx.beginPath()
ctx.moveTo(-18, y)
ctx.lineTo(18, y)
ctx.moveTo(-s + 4, y)
ctx.lineTo(s - 4, y)
ctx.stroke()
})
const st = e.state
@@ -536,6 +610,31 @@ export default {
},
},
hooks: {
save() {
return { zones: agriZones, nextId: nextAgriId }
},
load(g, data) {
zoneMode = false
drag = null
// 兼容旧单区存档
if (data?.zones?.length) {
agriZones = data.zones.map((z, i) => ({
id: z.id || i + 1,
name: z.name || `农田${i + 1}`,
x0: z.x0, y0: z.y0, x1: z.x1, y1: z.y1,
}))
nextAgriId = data.nextId || (Math.max(0, ...agriZones.map((z) => z.id)) + 1)
} else if (data?.zone) {
const z = data.zone
agriZones = [{ id: 1, name: z.name || '农田1', x0: z.x0, y0: z.y0, x1: z.x1, y1: z.y1 }]
nextAgriId = 2
} else {
agriZones = []
nextAgriId = 1
}
syncAgrizoneMarkers(g)
agriZones.forEach((z) => fillFarmsInZone(g, z, { clear: false }))
},
onUseItem(g, p, s, area, i) {
if (s.code === 'agrihoe') {
const spot = g.findLand(p.x + p.dir * 46, p.y + 8, 0, 24)
@@ -589,5 +688,127 @@ export default {
g.ptip(p, '摘到了一把南瓜种子')
}
},
onPointerDown(g, wx, wy) {
const sx = wx - uiCamX
const sy = wy - uiCamY
if (uiBtn && inside(sx, sy, uiBtn)) return false
if (uiCancel && inside(sx, sy, uiCancel)) return false
if (!zoneMode) return false
drag = { x0: wx, y0: wy, x1: wx, y1: wy }
return true
},
onPointerMove(g, wx, wy) {
if (!zoneMode || !drag) return false
drag.x1 = wx
drag.y1 = wy
return true
},
onPointerUp(g, wx, wy) {
if (!zoneMode) return false
if (!drag) {
zoneMode = false
return true
}
drag.x1 = wx
drag.y1 = wy
const rect = normalizeRect(drag.x0, drag.y0, drag.x1, drag.y1)
drag = null
zoneMode = false
if (rect.x1 - rect.x0 < 60 || rect.y1 - rect.y0 < 60) {
g.tip('农业区太小,再划大一点')
return true
}
const def = `农田${agriZones.length + 1}`
const name = promptZoneName('农田', def)
const z = { id: nextAgriId++, name, ...rect }
agriZones.push(z)
const n = fillFarmsInZone(g, z)
g.tip(`「${name}」已划定:清出杂项,铺设良田 ${n} 块`)
return true
},
onClick(g, wx, wy) {
const sx = wx - uiCamX
const sy = wy - uiCamY
if (uiBtn && inside(sx, sy, uiBtn)) {
zoneMode = !zoneMode
drag = null
g.tip(zoneMode ? '拖拽框选农业区(松手命名)' : '取消划定农田')
return true
}
if (uiCancel && inside(sx, sy, uiCancel)) {
zoneMode = false
drag = null
return true
}
// 非框选时点击已有农田虚线框可改名
if (!zoneMode && renameZoneAt(g, wx, wy)) return true
return false
},
onDraw(g, ctx, env) {
agriZones.forEach((z) => {
ctx.save()
ctx.strokeStyle = 'rgba(120, 200, 80, 0.55)'
ctx.fillStyle = 'rgba(100, 180, 60, 0.08)'
ctx.lineWidth = 2
ctx.setLineDash([8, 6])
const x = z.x0 - env.camX
const y = z.y0 - env.camY
const w = z.x1 - z.x0
const h = z.y1 - z.y0
ctx.fillRect(x, y, w, h)
ctx.strokeRect(x, y, w, h)
ctx.setLineDash([])
ctx.fillStyle = 'rgba(20, 40, 12, 0.7)'
ctx.beginPath(); ctx.roundRect(x + 4, y + 4, Math.min(120, w - 8), 18, 4); ctx.fill()
ctx.fillStyle = '#e8f5c8'
ctx.font = 'bold 12px sans-serif'
ctx.textAlign = 'left'
ctx.textBaseline = 'middle'
ctx.fillText(z.name || '农田', x + 10, y + 13)
ctx.restore()
})
if (drag) {
const r = normalizeRect(drag.x0, drag.y0, drag.x1, drag.y1)
ctx.save()
ctx.strokeStyle = 'rgba(180, 255, 120, 0.9)'
ctx.fillStyle = 'rgba(140, 220, 80, 0.15)'
ctx.lineWidth = 2
ctx.setLineDash([6, 4])
ctx.fillRect(r.x0 - env.camX, r.y0 - env.camY, r.x1 - r.x0, r.y1 - r.y0)
ctx.strokeRect(r.x0 - env.camX, r.y0 - env.camY, r.x1 - r.x0, r.y1 - r.y0)
ctx.restore()
}
},
onDrawUI(g, ctx, env) {
uiCamX = env.camX
uiCamY = env.camY
const W = env.W
const bw = 92
const bh = 28
// 避开右上 HTML 时钟(约 112px)与右边距
const bx = W - bw - 130
const by = 12
uiBtn = { x: bx, y: by, w: bw, h: bh }
ctx.save()
ctx.fillStyle = zoneMode ? '#4a7a2a' : '#3a4a28'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 2
ctx.beginPath(); ctx.roundRect(bx, by, bw, bh, 6); ctx.fill(); ctx.stroke()
ctx.fillStyle = '#f4ead2'
ctx.font = '12px sans-serif'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText(zoneMode ? '框选中…' : '划定农田', bx + bw / 2, by + bh / 2)
if (zoneMode) {
uiCancel = { x: bx, y: by + bh + 6, w: bw, h: 22 }
ctx.fillStyle = '#5a3a28'
ctx.beginPath(); ctx.roundRect(uiCancel.x, uiCancel.y, uiCancel.w, uiCancel.h, 5); ctx.fill(); ctx.stroke()
ctx.fillStyle = '#f4ead2'
ctx.fillText('取消', uiCancel.x + bw / 2, uiCancel.y + 11)
} else {
uiCancel = null
}
ctx.restore()
},
},
}

View File

@@ -1,22 +1,97 @@
// 营地:出生点自带房屋、长明篝火与无敌守卫,可建造营地仓储
// - 房屋 camphouse:点击休息,回复理智/生命(每天一次)
// 营地:出生点自带房屋、长明篝火与无敌守卫,可建造营地仓储与食堂
// - 房屋 camphouse:按游戏日休息——夜里跳到次日清晨,白天回复体力
// - 守卫 guard:无限血,营地半径内巡逻并拦截、击杀敌对生物
// - 营地仓储 campstore:8 个箱子,每箱 6 种、每种最多 999(共 48 种容量)
// - 领地:以房屋为圆心 CAMP_R 范围,通过核心 onMobVictim 否决钩子让敌对生物不再锁定营地内的玩家;守卫自动拦截入侵者
// - 食堂 canteen:厨师做菜存入,点击点餐
// - 营地升级:采集/击杀/产业发展涨 XP,扩半径、暗处补永久篝火;8 级围栏+门
// - 领地:以房屋为圆心动态半径,通过核心 onMobVictim 否决钩子让敌对生物不再锁定营地内的玩家
// 时间/数值基准:三围 150/150/200,一天 160 秒
import { rebuildFencePerimeter, findDarkSpots } from './zoneUtil.js'
const TAU = Math.PI * 2
const CAMP_R = 420 // 营地安全半径(以 camphouse 为圆心)
const CAMP_R0 = 420 // 1 级营地安全半径
const BOXES = 8 // 箱子数
const BOX_KINDS = 6 // 每箱最多物品种类
const BOX_CAP = 999 // 每种最多数量
const DAY_LEN = 160
// ---- 营地升级状态(入存档) ----
let campState = { level: 1, xp: 0, fenced: false }
// ---- 模块级面板状态(不入存档;面板只是一次交互会话) ----
let storeOpen = null // 当前打开的 campstore 实体 id
let uiCamX = 0 // onDrawUI 记录的相机位置,供 onClick 做世界→屏幕换算
let canteenOpen = null
let uiCamX = 0
let uiCamY = 0
let uiPanel = null // 面板矩形(屏幕坐标)
let uiClose = null // 关闭按钮矩形
let uiRows = [] // 每行箱子的按钮矩形 [{ i, dep, wit }]
let uiPanel = null
let uiClose = null
let uiRows = []
let uiCanteenPanel = null
let uiCanteenClose = null
let uiCanteenRows = []
function campR() {
const lv = Math.max(1, campState.level || 1)
return CAMP_R0 + (lv - 1) * 80
}
function xpNeed(level) {
return 80 * Math.max(1, level)
}
function hiredCount(g) {
let n = 0
g.entities.forEach((e) => {
if (e.type !== 'laboroffice' || !e.mx) return
n += (e.mx.farmer || 0) + (e.mx.herder || 0) + (e.mx.chef || 0)
})
return n
}
function eternalFireCount(g) {
return (g.fires || []).filter((f) => f.eternal).length
}
function addCampXp(g, base) {
if (!base || base <= 0) return
const mult = 1 + 0.08 * hiredCount(g)
campState.xp += Math.round(base * mult)
let guard = 0
while (campState.level < 20 && campState.xp >= xpNeed(campState.level) && guard++ < 10) {
campState.xp -= xpNeed(campState.level)
campState.level += 1
onCampLevelUp(g)
}
}
function onCampLevelUp(g) {
const r = campR()
const c = campCenter(g)
const softCap = 3 + campState.level
const need = Math.min(2, Math.max(0, softCap - eternalFireCount(g)))
const spots = findDarkSpots(g, c.x, c.y, r, need, 100)
spots.forEach((s) => g.addFire(s.x, s.y, { pit: true, eternal: true, ttl: 150, max: 150 }))
g.tip(`营地升至 ${campState.level} 级!领地扩大${spots.length ? `,新增 ${spots.length} 座长明篝火` : ''}`)
if (campState.level >= 8 && !campState.fenced) {
buildCampFence(g)
}
}
function buildCampFence(g) {
const c = campCenter(g)
const r = campR() * 0.92
const z = { x0: c.x - r, y0: c.y - r * 0.62, x1: c.x + r, y1: c.y + r * 0.62 }
rebuildFencePerimeter(g, z, { fenceType: 'campfence', gateType: 'campgate', step: 56 })
campState.fenced = true
g.tip('营地周围筑起了围栏与大门')
}
function placeStarterFires(g, cx, cy) {
const offsets = [
[90, 70],
[-100, 50],
[40, -110],
[-70, -80],
[120, -30],
]
offsets.forEach(([dx, dy]) => {
const spot = g.findLand(cx + dx, cy + dy, 0, 50)
if (spot) g.addFire(spot.x, spot.y, { pit: true, eternal: true, ttl: 150, max: 150 })
})
}
// 相机无关的“点是否在矩形内”
function inside(x, y, r) {
@@ -98,21 +173,24 @@ function campCenter(g) {
function campHouseList(g) {
return g.entities.filter((e) => e.type === 'camphouse')
}
// 玩家在「任意一座」营地房屋的 CAMP_R 内都算进入营地 —— 自建的分基地同样生效。
// ⚠️ 这里不能用 find 只取第一座,否则玩家再造房屋不会扩展安全区。
// 玩家在「任意一座」营地房屋的动态半径内都算进入营地
function inCamp(g, x, y) {
return g.entities.some((e) => e.type === 'camphouse' && Math.hypot(x - e.x, y - e.y) < CAMP_R)
const r = campR()
return g.entities.some((e) => e.type === 'camphouse' && Math.hypot(x - e.x, y - e.y) < r)
}
export default {
id: 'camp',
name: '营地',
desc: '出生点自带房屋、长明篝火与无敌守卫,可建造营地仓储',
desc: '房屋休息跳夜、营地升级扩半径、食堂点餐与长明篝火',
factor: 0.75, // 大幅降低难度
tag: 'easy',
tabs: [{ code: 'camp', name: '营地', icon: 'camphouse' }],
recipes: [
{ code: 'campstore', name: '营地仓储', tab: 'camp', tech: 1, cost: { log: 10, rock: 4 }, build: true, desc: '8 个箱子,可建造多个' },
{ code: 'camphouse', name: '营地房屋', tab: 'camp', tech: 1, cost: { log: 12, rock: 8 }, build: true, desc: '休息回复理智与生命' },
{ code: 'camphouse', name: '营地房屋', tab: 'camp', tech: 1, cost: { log: 12, rock: 8 }, build: true, desc: '按游戏日休息:夜跳昼 / 昼回体力' },
{ code: 'canteen', name: '食堂', tab: 'camp', tech: 1, cost: { log: 10, rock: 6, rope: 2 }, build: true, desc: '厨师做菜,点击点餐' },
{ code: 'campfence', name: '营地栅栏', tab: 'camp', tech: 2, cost: { log: 4, rock: 2 }, build: true, desc: '营地围栏(8 级自动筑墙)' },
{ code: 'campgate', name: '营地大门', tab: 'camp', tech: 2, cost: { log: 6, rock: 4, rope: 2 }, build: true, desc: '可开关的营地大门' },
],
icons: {
camphouse: (c) => {
@@ -164,6 +242,37 @@ export default {
c.lineWidth = 1
c.beginPath(); c.moveTo(-14, 11); c.lineTo(-2, 11); c.moveTo(1, 12); c.lineTo(13, 12); c.stroke()
},
canteen: (c) => {
c.fillStyle = '#b07f47'
c.strokeStyle = '#1d1409'
c.lineWidth = 1.8
c.beginPath(); c.roundRect(-14, -8, 28, 18, 2); c.fill(); c.stroke()
c.fillStyle = '#7a5230'
c.beginPath(); c.moveTo(-16, -8); c.lineTo(0, -18); c.lineTo(16, -8); c.closePath(); c.fill(); c.stroke()
c.fillStyle = '#3a2a16'
c.beginPath(); c.roundRect(-5, 0, 10, 10, 1); c.fill()
c.fillStyle = '#e8c070'
c.beginPath(); c.ellipse(0, -2, 6, 3, 0, 0, TAU); c.fill()
},
campfence: (c) => {
c.fillStyle = '#8d9097'
c.strokeStyle = '#1d1409'
c.lineWidth = 1.6
;[-10, 10].forEach((x) => {
c.beginPath(); c.roundRect(x - 3, -12, 6, 24, 1); c.fill(); c.stroke()
})
c.beginPath(); c.roundRect(-14, -5, 28, 4, 1); c.fill(); c.stroke()
c.beginPath(); c.roundRect(-14, 4, 28, 4, 1); c.fill(); c.stroke()
},
campgate: (c) => {
c.fillStyle = '#8d9097'
c.strokeStyle = '#1d1409'
c.lineWidth = 1.6
c.beginPath(); c.roundRect(-3, -14, 6, 28, 1); c.fill(); c.stroke()
c.beginPath(); c.roundRect(-14, -10, 16, 5, 1); c.fill(); c.stroke()
c.fillStyle = '#c9a34a'
c.beginPath(); c.arc(5, -2, 2, 0, TAU); c.fill()
},
},
ents: {
camphouse: {
@@ -179,11 +288,22 @@ export default {
g.ptip(p, '今天已经休息过了')
return
}
p.san = Math.min(200, p.san + 30)
p.hp = Math.min(150, p.hp + 20)
if (g.phase === 'night') {
// 推进到次日清晨(setDayTime(DAY_LEN) → day++ 且 dayTime=0)
if (typeof g.setDayTime === 'function') g.setDayTime(DAY_LEN)
e.mx.restDay = g.day
g.markDirty(e)
g.ptip(p, '睡到了天亮……新的一天开始了')
g.swing(p, 'eat')
return
}
// 白天:恢复体力(饥饿/生命/理智)
p.hunger = Math.min(150, p.hunger + 40)
p.hp = Math.min(150, p.hp + 25)
p.san = Math.min(200, p.san + 15)
e.mx.restDay = g.day
g.markDirty(e)
g.ptip(p, '在屋里歇了一会儿:理智 +30、生命 +20')
g.ptip(p, '白天歇息:饥饿 +40、生命 +25、理智 +15')
g.swing(p, 'eat')
},
draw(ctx, e, env) {
@@ -315,6 +435,102 @@ export default {
crate(14, -10, 18, 18) // 右侧一只
},
},
canteen: {
name: '食堂',
conf: { size: 44, struct: true },
init(e) {
if (!e.mx) e.mx = { menu: {} }
if (!e.mx.menu || typeof e.mx.menu !== 'object') e.mx.menu = {}
},
interact(p, e, g) {
if (!e.mx) e.mx = { menu: {} }
if (!e.mx.menu) e.mx.menu = {}
canteenOpen = e.id
storeOpen = null
g.ptip(p, '打开食堂菜单')
},
draw(ctx, e, env) {
ctx.fillStyle = 'rgba(0,0,0,0.25)'
ctx.beginPath(); ctx.ellipse(0, 8, 28, 8, 0, 0, TAU); ctx.fill()
ctx.fillStyle = '#b07f47'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 2
ctx.beginPath(); ctx.roundRect(-26, -36, 52, 40, 3); ctx.fill(); ctx.stroke()
ctx.fillStyle = '#7a5230'
ctx.beginPath(); ctx.moveTo(-30, -36); ctx.lineTo(0, -58); ctx.lineTo(30, -36); ctx.closePath(); ctx.fill(); ctx.stroke()
ctx.fillStyle = '#3a2a16'
ctx.beginPath(); ctx.roundRect(-10, -18, 20, 22, 2); ctx.fill(); ctx.stroke()
ctx.fillStyle = '#e8c070'
ctx.beginPath(); ctx.ellipse(0, -22, 10, 5, 0, 0, TAU); ctx.fill(); ctx.stroke()
const n = Object.values(e.mx?.menu || {}).reduce((a, b) => a + (b || 0), 0)
if (n > 0) {
ctx.fillStyle = '#f4ead2'
ctx.font = 'bold 11px system-ui, sans-serif'
ctx.textAlign = 'center'
ctx.fillText(`菜×${n}`, 0, 14)
}
},
},
campfence: {
name: '营地栅栏',
conf: { size: 24, struct: true, solid: true },
draw(ctx) {
ctx.fillStyle = 'rgba(0,0,0,0.25)'
ctx.beginPath(); ctx.ellipse(0, 4, 16, 5, 0, 0, TAU); ctx.fill()
ctx.fillStyle = '#8d9097'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 2
;[-11, 11].forEach((x) => {
ctx.beginPath(); ctx.roundRect(x - 4, -28, 8, 32, 2); ctx.fill(); ctx.stroke()
})
ctx.beginPath(); ctx.roundRect(-16, -20, 32, 5, 2); ctx.fill(); ctx.stroke()
ctx.beginPath(); ctx.roundRect(-16, -8, 32, 5, 2); ctx.fill(); ctx.stroke()
},
},
campgate: {
name: '营地大门',
conf: { size: 44, struct: true, solid: true },
init(e) {
if (!e.mx) e.mx = { open: false, openT: 0 }
if (e.mx.open == null) e.mx.open = false
e.solid = !e.mx.open
},
interact(p, e, g) {
if (!e.mx) e.mx = { open: false, openT: 0 }
if (e.mx.open) {
e.mx.open = false
e.mx.openT = 0
e.solid = true
g.ptip(p, '关上了营地大门')
} else {
e.mx.open = true
e.mx.openT = 4
e.solid = false
g.ptip(p, '打开了营地大门(4 秒后自动关闭)')
}
g.markDirty(e)
},
draw(ctx, e) {
const open = !!e.mx?.open
ctx.fillStyle = 'rgba(0,0,0,0.25)'
ctx.beginPath(); ctx.ellipse(0, 4, 20, 5, 0, 0, TAU); ctx.fill()
ctx.save()
if (open) ctx.rotate(-0.7)
ctx.fillStyle = '#8d9097'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 2
;[-14, 14].forEach((x) => {
ctx.beginPath(); ctx.roundRect(x - 4, -30, 8, 34, 2); ctx.fill(); ctx.stroke()
})
ctx.beginPath(); ctx.roundRect(-20, -22, 40, 6, 2); ctx.fill(); ctx.stroke()
ctx.beginPath(); ctx.roundRect(-20, -10, 40, 6, 2); ctx.fill(); ctx.stroke()
ctx.fillStyle = '#e0c060'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 1.4
ctx.beginPath(); ctx.roundRect(open ? 8 : 10, -18, 10, 8, 2); ctx.fill(); ctx.stroke()
ctx.restore()
},
},
},
mobs: {
guard: {
@@ -329,7 +545,7 @@ export default {
const c = campCenter(g)
// 找营地半径内的敌对生物
let target = null
let bd = CAMP_R
let bd = campR()
g.monsters.forEach((q) => {
if (q === m) return
if (!g.mon[q.kind]?.hostile) return
@@ -427,6 +643,18 @@ export default {
},
},
hooks: {
save() {
return { ...campState }
},
load(g, data) {
campState = {
level: Math.max(1, data?.level || 1),
xp: Math.max(0, data?.xp || 0),
fenced: !!data?.fenced,
}
storeOpen = null
canteenOpen = null
},
onWorldGen(g) {
// 出生点即世界中心,营地在中心附近错开摆位(避免卡住玩家)
const cx = g.worldW / 2
@@ -435,19 +663,55 @@ export default {
g.addEntity('camphouse', house.x, house.y)
const gs = g.findLand(cx + 60, cy + 40, 0, 80)
g.spawnMob('guard', gs.x, gs.y, {})
// 长明篝火:永不熄灭,营地夜间照明
g.addFire(cx + 90, cy + 70, { pit: true, eternal: true, ttl: 150, max: 150 })
g.tip('营地已就绪:房屋、长明篝火与守卫')
placeStarterFires(g, house.x, house.y)
const can = g.findLand(cx - 80, cy + 50, 0, 60)
g.addEntity('canteen', can.x, can.y)
g.tip('营地已就绪:房屋、五座长明篝火、食堂与守卫')
},
onMobVictim(g, m, victim) {
// 核心 nearestVictim() 会调此钩子:返回 false = 这只怪不把该玩家算作可选目标。
// 只对敌对生物生效(保留「怪可以去打守卫/猪人」的能力)。
if (!g.mon[m.kind]?.hostile) return
// 判断依据是「受害者在不在营地内」,怪本身不做任何位移(无跳变)。
if (inCamp(g, victim.x, victim.y)) return false
},
onGather(g) {
addCampXp(g, 2)
},
onKillMob(g, m, credit) {
if (!credit) return
const score = g.mon[m.kind]?.score || 10
addCampXp(g, Math.max(3, Math.round(score / 5)))
},
onNewDay(g) {
let xp = 0
const farms = g.entities.filter((e) => e.type === 'agrifarm')
xp += Math.min(20, farms.filter((e) => e.state === 'ready').length * 2 + Math.floor(farms.length / 4))
const pens = g.monsters.filter((m) => m.pen).length
const coops = g.entities.filter((e) => e.type === 'coop').length
xp += Math.min(15, pens + coops)
const industry = g.entities.filter((e) => e.type === 'smelter' || e.type === 'miner' || e.type === 'factory').length
xp += Math.min(15, industry * 2)
if (xp > 0) addCampXp(g, xp)
},
onTick(g, dt) {
g.entities.forEach((e) => {
if (e.type !== 'campgate') return
if (!e.mx) e.mx = { open: false, openT: 0 }
if (e.mx.open) {
e.mx.openT = (e.mx.openT ?? 4) - dt
if (e.mx.openT <= 0) {
e.mx.open = false
e.mx.openT = 0
e.solid = true
g.markDirty(e)
} else {
e.solid = false
}
} else {
e.solid = true
}
})
},
onDraw(g, ctx, env) {
// 虚线画出**每一座**营地房屋的领地边界(世界坐标 → 画布屏幕坐标,故减去相机)
const r = campR()
const houses = campHouseList(g)
const list = houses.length ? houses : [campCenter(g)]
ctx.save()
@@ -456,7 +720,7 @@ export default {
ctx.setLineDash([10, 8])
for (const h of list) {
ctx.beginPath()
ctx.ellipse(h.x - env.camX, h.y - env.camY, CAMP_R, CAMP_R * 0.62, 0, 0, TAU)
ctx.ellipse(h.x - env.camX, h.y - env.camY, r, r * 0.62, 0, 0, TAU)
ctx.stroke()
}
ctx.restore()
@@ -465,6 +729,101 @@ export default {
// 记录相机位置,供 onClick 做世界→屏幕换算
uiCamX = env.camX
uiCamY = env.camY
const W = env.W
const H = env.H
// 营地等级 HUD
ctx.save()
ctx.fillStyle = 'rgba(40,28,14,0.75)'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 2
ctx.beginPath(); ctx.roundRect(10, 48, 168, 36, 6); ctx.fill(); ctx.stroke()
ctx.fillStyle = '#f4ead2'
ctx.font = 'bold 12px system-ui, sans-serif'
ctx.textAlign = 'left'
ctx.textBaseline = 'middle'
const need = xpNeed(campState.level)
ctx.fillText(`营地 Lv.${campState.level} XP ${campState.xp}/${need}`, 18, 66)
ctx.restore()
// 食堂面板
if (canteenOpen != null) {
const e = g.entities.find((x) => x.id === canteenOpen)
const p = g.me()
if (!e || !p) {
canteenOpen = null
} else {
if (!e.mx) e.mx = { menu: {} }
const menu = e.mx.menu || {}
const entries = Object.entries(menu).filter(([, n]) => n > 0)
const rowH = 30
const headerH = 40
const footerH = 42
const pad = 10
const panelW = Math.min(360, W - 24)
const panelH = headerH + Math.max(1, entries.length) * rowH + footerH + pad
const px = Math.round((W - panelW) / 2)
const py = Math.round((H - panelH) / 2)
uiCanteenPanel = { x: px, y: py, w: panelW, h: panelH }
uiCanteenRows = []
ctx.save()
ctx.fillStyle = 'rgba(0,0,0,0.45)'
ctx.fillRect(0, 0, W, H)
ctx.fillStyle = '#3a2a18'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 3
ctx.beginPath(); ctx.roundRect(px, py, panelW, panelH, 10); ctx.fill(); ctx.stroke()
ctx.fillStyle = '#5a4022'
ctx.beginPath(); ctx.roundRect(px + 3, py + 3, panelW - 6, headerH - 6, [8, 8, 0, 0]); ctx.fill()
ctx.fillStyle = '#f4ead2'
ctx.font = 'bold 15px system-ui, sans-serif'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText('食堂菜单', px + panelW / 2, py + headerH / 2)
if (!entries.length) {
ctx.fillStyle = '#c9b89a'
ctx.font = '13px system-ui, sans-serif'
ctx.fillText('暂无菜品(招聘厨师自动做饭)', px + panelW / 2, py + headerH + 20)
} else {
entries.forEach(([code, n], i) => {
const y = py + headerH + i * rowH
const name = g.items[code]?.name || code
ctx.fillStyle = i % 2 ? 'rgba(255,255,255,0.04)' : 'transparent'
ctx.fillRect(px + 6, y, panelW - 12, rowH)
ctx.fillStyle = '#f4ead2'
ctx.font = '13px system-ui, sans-serif'
ctx.textAlign = 'left'
ctx.fillText(`${name} ×${n}`, px + 14, y + rowH / 2)
const btn = { x: px + panelW - 78, y: y + 4, w: 64, h: 22, code }
uiCanteenRows.push(btn)
ctx.fillStyle = '#5d7a3a'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 1.5
ctx.beginPath(); ctx.roundRect(btn.x, btn.y, btn.w, btn.h, 5); ctx.fill(); ctx.stroke()
ctx.fillStyle = '#f4ead2'
ctx.textAlign = 'center'
ctx.fillText('食用', btn.x + btn.w / 2, btn.y + btn.h / 2)
})
}
const cw = 90
const ch = 26
uiCanteenClose = { x: px + panelW / 2 - cw / 2, y: py + panelH - footerH + 8, w: cw, h: ch }
ctx.fillStyle = '#7a2f24'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 2
ctx.beginPath(); ctx.roundRect(uiCanteenClose.x, uiCanteenClose.y, cw, ch, 6); ctx.fill(); ctx.stroke()
ctx.fillStyle = '#f4ead2'
ctx.textAlign = 'center'
ctx.font = 'bold 14px system-ui, sans-serif'
ctx.fillText('关闭', uiCanteenClose.x + cw / 2, uiCanteenClose.y + ch / 2)
ctx.restore()
}
} else {
uiCanteenPanel = null
uiCanteenClose = null
uiCanteenRows = []
}
if (storeOpen == null) return
const e = g.entities.find((x) => x.id === storeOpen)
const p = g.me()
@@ -473,8 +832,6 @@ export default {
return
}
const boxes = boxesOf(e)
const W = env.W
const H = env.H
const rowH = 30
const headerH = 40
const footerH = 42
@@ -573,9 +930,45 @@ export default {
ctx.restore()
},
onClick(g, wx, wy) {
if (storeOpen == null) return undefined // 没有面板:不干扰游戏点击
const sx = wx - uiCamX
const sy = wy - uiCamY
// 食堂面板优先
if (canteenOpen != null) {
const e = g.entities.find((x) => x.id === canteenOpen)
const p = g.me()
if (!e || !p) {
canteenOpen = null
return true
}
if (inside(sx, sy, uiCanteenClose)) {
canteenOpen = null
return true
}
for (const btn of uiCanteenRows) {
if (inside(sx, sy, btn)) {
if (!e.mx.menu) e.mx.menu = {}
const n = e.mx.menu[btn.code] || 0
if (n <= 0) {
g.ptip(p, '这道菜已经没了')
return true
}
if (g.applyFood && g.applyFood(p, btn.code, 1)) {
e.mx.menu[btn.code] = n - 1
if (e.mx.menu[btn.code] <= 0) delete e.mx.menu[btn.code]
g.markDirty(e)
} else {
g.ptip(p, '无法食用')
}
return true
}
}
if (inside(sx, sy, uiCanteenPanel)) return true
canteenOpen = null
return true
}
if (storeOpen == null) return undefined // 没有面板:不干扰游戏点击
const e = g.entities.find((x) => x.id === storeOpen)
const p = g.me()
if (!e || !p) {

View File

@@ -1,9 +1,152 @@
import { normalizeRect, pointInAabb, aabbArea, rebuildFencePerimeter, clearZoneClutter } from './zoneUtil.js'
// 牧业文明:栅栏圈养、饲料槽投喂 —— 羊毛与鸡蛋的牧场经济
// - 剪刀(科学机器合成,耐久 30):点击绵羊剪毛,每只羊每天一次(给羊毛 ×2)
// - 饲料槽:投喂浆果/作物,让附近鸡舍每日囤更多蛋;点击鸡舍收取鸡蛋
// - 框选牧场:可命名多块;清杂项 → 外轮廓围栏+门;门手动开、数秒后自动关
// - 牛靠近有饲料的饲料槽每日产奶
// - 羊毛可做便宜的保暖帽(保暖 60),鸡蛋/牛奶/奶酪补充食物
// 数值基准:三围 150/150/200,一天 160 秒,官方食物折算
const TAU = Math.PI * 2
const GATE_AUTO_CLOSE = 4 // 牧场门打开后自动关闭秒数
// ---- 牧场框选(多块可命名) ----
let pastureZones = [] // [{ id, name, x0, y0, x1, y1 }]
let nextPastureId = 1
let zoneMode = false
let drag = null
let uiCamX = 0
let uiCamY = 0
let uiBtn = null
let uiCancel = null
function inside(x, y, r) {
return !!r && x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h
}
function promptPastureName(def) {
try {
const v = typeof window !== 'undefined' ? window.prompt('给这块牧场起个名字', def) : null
const name = (v == null ? def : String(v)).trim()
return name.slice(0, 12) || def
} catch {
return def
}
}
function syncPastureMarkers(g) {
g.entities.filter((e) => e.type === 'pasturezone').forEach((e) => g.removeEntity(e))
pastureZones.forEach((z) => {
g.addEntity('pasturezone', (z.x0 + z.x1) / 2, (z.y0 + z.y1) / 2, { mx: { ...z } })
})
}
function stockPasture(g, z) {
if (!z) return
const area = aabbArea(z)
const maxPig = Math.min(4, 1 + Math.floor(area / 80000))
const maxCow = Math.min(3, 1 + Math.floor(area / 100000))
const maxChick = Math.min(6, 2 + Math.floor(area / 60000))
const maxRabbit = Math.min(4, 1 + Math.floor(area / 70000))
const inZ = (m) => m.pen && m.zoneId === z.id
const countKind = (kind) => g.monsters.filter((m) => m.kind === kind && inZ(m)).length
const countRabbit = () => g.entities.filter((e) => e.type === 'rabbit' && e.mx?.pen && e.mx.zoneId === z.id).length
const spawnIn = (kind, n) => {
for (let i = 0; i < n; i++) {
const x = z.x0 + 50 + Math.random() * Math.max(20, z.x1 - z.x0 - 100)
const y = z.y0 + 50 + Math.random() * Math.max(20, z.y1 - z.y0 - 100)
const spot = g.findLand(x, y, 0, 30)
if (!spot) continue
const m = g.spawnMob(kind, spot.x, spot.y, {
pen: true,
zoneId: z.id,
homeX: (z.x0 + z.x1) / 2,
homeY: (z.y0 + z.y1) / 2,
zone: { ...z },
})
if (m) { m.pen = true; m.zoneId = z.id }
}
}
spawnIn('pig', Math.max(0, maxPig - countKind('pig')))
spawnIn('beefalo', Math.max(0, maxCow - countKind('beefalo')))
spawnIn('chicken', Math.max(0, maxChick - countKind('chicken')))
const needRab = Math.max(0, maxRabbit - countRabbit())
for (let i = 0; i < needRab; i++) {
const x = z.x0 + 50 + Math.random() * Math.max(20, z.x1 - z.x0 - 100)
const y = z.y0 + 50 + Math.random() * Math.max(20, z.y1 - z.y0 - 100)
const spot = g.findLand(x, y, 0, 30)
if (!spot) continue
g.addEntity('rabbit', spot.x, spot.y, {
mx: { pen: true, zoneId: z.id, zone: { ...z } },
homeX: (z.x0 + z.x1) / 2,
homeY: (z.y0 + z.y1) / 2,
})
}
syncPastureMarkers(g)
}
function applyPasture(g, rect, name) {
const z = { id: nextPastureId++, name, ...rect }
pastureZones.push(z)
clearZoneClutter(g, z)
rebuildFencePerimeter(g, z, { fenceType: 'fence', gateType: 'gate' })
stockPasture(g, z)
return z
}
function keepPenMobs(g, dt) {
if (!pastureZones.length) return
const zoneById = Object.fromEntries(pastureZones.map((z) => [z.id, z]))
g.monsters.forEach((m) => {
if (!m.pen) return
const z = zoneById[m.zoneId] || m.zone || pastureZones[0]
if (!z) return
const cx = (z.x0 + z.x1) / 2
const cy = (z.y0 + z.y1) / 2
if (!pointInAabb(m.x, m.y, z)) {
g.moveToward(m, cx, cy, (g.mon[m.kind]?.speed || 80) * 0.8, dt)
}
})
g.entities.forEach((e) => {
if (e.type !== 'rabbit' || !e.mx?.pen) return
const z = zoneById[e.mx.zoneId] || e.mx.zone || pastureZones[0]
if (!z) return
const cx = (z.x0 + z.x1) / 2
const cy = (z.y0 + z.y1) / 2
if (!pointInAabb(e.x, e.y, z)) {
g.moveToward(e, cx, cy, 50, dt)
}
})
}
function tickGates(g, dt) {
g.entities.forEach((e) => {
if (e.type !== 'gate') return
if (!e.mx) e.mx = { open: false, openT: 0 }
if (e.mx.open) {
e.mx.openT = (e.mx.openT ?? GATE_AUTO_CLOSE) - dt
if (e.mx.openT <= 0) {
e.mx.open = false
e.mx.openT = 0
e.solid = true
g.markDirty(e)
} else {
e.solid = false
}
} else {
e.solid = true
}
})
}
function renamePastureAt(g, wx, wy) {
const hit = pastureZones.find((z) => pointInAabb(wx, wy, z))
if (!hit) return false
hit.name = promptPastureName(hit.name)
syncPastureMarkers(g)
g.tip(`牧场已命名为「${hit.name}」`)
return true
}
// 绵羊觅食:无害短距游荡(照抄 loyaldog 流浪分支,速度乘 0.5)
function wander(m, dt, g, speed) {
@@ -22,7 +165,7 @@ function wander(m, dt, g, speed) {
export default {
id: 'husbandry',
name: '牧业文明',
desc: '栅栏圈养、饲料槽投喂:羊毛与鸡蛋的牧场经济',
desc: '框选牧场自动围栏圈养:羊毛、鸡蛋与牛奶',
factor: 1.05,
tag: 'content',
tabs: [{ code: 'husbandry', name: '牧业', icon: 'husbshears' }],
@@ -38,9 +181,11 @@ export default {
recipes: [
{ code: 'husbshears', name: '剪刀', tab: 'husbandry', tech: 1, cost: { gold: 2, rope: 1 }, desc: '给绵羊剪毛 · 耐久 30 次' },
{ code: 'fence', name: '栅栏', tab: 'husbandry', tech: 1, cost: { twig: 4, grass: 2 }, build: true, desc: '圈养动物的围栏' },
{ code: 'gate', name: '牧场门', tab: 'husbandry', tech: 1, cost: { twig: 6, log: 2, grass: 2 }, build: true, desc: '可开关的牧场大门' },
{ code: 'trough', name: '饲料槽', tab: 'husbandry', tech: 1, cost: { log: 4, rock: 2 }, build: true, desc: '投喂作物,提升牧场产出' },
{ code: 'coop', name: '鸡舍', tab: 'husbandry', tech: 2, cost: { log: 6, silk: 4, rope: 2 }, build: true, desc: '每日自动囤蛋' },
{ code: 'woolhat', name: '羊毛帽', tab: 'husbandry', tech: 2, cost: { wool: 6, rope: 1 }, desc: '保暖 60(弱于冬帽但便宜)' },
{ code: 'cheese', name: '奶酪', tab: 'husbandry', tech: 1, cost: { milk: 2 }, desc: '牛奶发酵而成' },
],
icons: {
husbshears: (c) => {
@@ -156,6 +301,16 @@ export default {
c.lineWidth = 1
c.beginPath(); c.moveTo(-10, -13); c.lineTo(-10, 13); c.moveTo(10, -13); c.lineTo(10, 13); c.stroke()
},
gate: (c) => {
c.fillStyle = '#8a6238'
c.strokeStyle = '#1d1409'
c.lineWidth = 1.8
c.beginPath(); c.roundRect(-3, -14, 6, 28, 1.5); c.fill(); c.stroke()
c.beginPath(); c.roundRect(-14, -10, 16, 5, 1.5); c.fill(); c.stroke()
c.beginPath(); c.roundRect(-14, 0, 16, 5, 1.5); c.fill(); c.stroke()
c.fillStyle = '#c9a34a'
c.beginPath(); c.arc(5, -2, 2, 0, TAU); c.fill()
},
trough: (c) => {
c.fillStyle = '#8a5f38'
c.strokeStyle = '#1d1409'
@@ -189,6 +344,14 @@ export default {
},
},
ents: {
pasturezone: {
name: '牧业区',
conf: { size: 8 },
init(e) {
if (!e.mx) e.mx = { x0: e.x - 40, y0: e.y - 40, x1: e.x + 40, y1: e.y + 40 }
},
draw() {},
},
fence: {
name: '栅栏',
conf: { size: 24, struct: true, solid: true },
@@ -212,6 +375,53 @@ export default {
ctx.beginPath(); ctx.moveTo(-11, -28); ctx.lineTo(-11, 4); ctx.moveTo(11, -28); ctx.lineTo(11, 4); ctx.stroke()
},
},
gate: {
name: '牧场门',
conf: { size: 44, struct: true, solid: true },
init(e) {
if (!e.mx) e.mx = { open: false, openT: 0 }
if (e.mx.open == null) e.mx.open = false
if (e.mx.openT == null) e.mx.openT = 0
e.solid = !e.mx.open
},
interact(p, e, g) {
if (!e.mx) e.mx = { open: false, openT: 0 }
if (e.mx.open) {
e.mx.open = false
e.mx.openT = 0
e.solid = true
g.ptip(p, '关上了牧场门')
} else {
e.mx.open = true
e.mx.openT = GATE_AUTO_CLOSE
e.solid = false
g.ptip(p, `打开了牧场门(${GATE_AUTO_CLOSE} 秒后自动关闭)`)
}
g.markDirty(e)
},
draw(ctx, e) {
const open = !!e.mx?.open
ctx.fillStyle = 'rgba(0,0,0,0.25)'
ctx.beginPath(); ctx.ellipse(0, 4, 20, 5, 0, 0, TAU); ctx.fill()
ctx.save()
if (open) ctx.rotate(-0.7)
// 门扇:比普通栅栏更宽,关门时堵住缺口
ctx.fillStyle = '#8a6238'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 2
;[-14, 14].forEach((x) => {
ctx.beginPath(); ctx.roundRect(x - 4, -30, 8, 34, 2); ctx.fill(); ctx.stroke()
})
ctx.beginPath(); ctx.roundRect(-20, -22, 40, 6, 2); ctx.fill(); ctx.stroke()
ctx.beginPath(); ctx.roundRect(-20, -10, 40, 6, 2); ctx.fill(); ctx.stroke()
// 金色门闩提示可交互
ctx.fillStyle = '#e0c060'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 1.4
ctx.beginPath(); ctx.roundRect(open ? 8 : 10, -18, 10, 8, 2); ctx.fill(); ctx.stroke()
ctx.restore()
},
},
trough: {
name: '饲料槽',
conf: { size: 26, struct: true },
@@ -453,6 +663,29 @@ export default {
},
},
hooks: {
save() {
return { zones: pastureZones, nextId: nextPastureId }
},
load(g, data) {
zoneMode = false
drag = null
if (data?.zones?.length) {
pastureZones = data.zones.map((z, i) => ({
id: z.id || i + 1,
name: z.name || `牧场${i + 1}`,
x0: z.x0, y0: z.y0, x1: z.x1, y1: z.y1,
}))
nextPastureId = data.nextId || (Math.max(0, ...pastureZones.map((z) => z.id)) + 1)
} else if (data?.zone) {
const z = data.zone
pastureZones = [{ id: 1, name: z.name || '牧场1', x0: z.x0, y0: z.y0, x1: z.x1, y1: z.y1 }]
nextPastureId = 2
} else {
pastureZones = []
nextPastureId = 1
}
syncPastureMarkers(g)
},
onWorldGen(g) {
// 草原上散落绵羊与鸡(寻找邻近陆地投放)
const sheepN = g.rand(4, 6)
@@ -467,6 +700,10 @@ export default {
}
g.tip('草原上传来羊叫与鸡鸣……')
},
onTick(g, dt) {
keepPenMobs(g, dt)
tickGates(g, dt)
},
onNewDay(g) {
// 结算每座鸡舍的囤蛋:附近有鸡或喂了饲料槽则翻倍,每日上限 4
g.entities.filter((e) => e.type === 'coop').forEach((coop) => {
@@ -483,6 +720,130 @@ export default {
coop.mx.stored = Math.min(4, (coop.mx.stored || 0) + inc)
g.markDirty(coop)
})
// 牧场牛产奶:靠近有饲料的饲料槽
g.monsters.filter((m) => m.kind === 'beefalo' && m.pen).forEach((cow) => {
const trough = g.entities.find((t) => t.type === 'trough' && (t.mx?.feed || 0) > 0 && Math.hypot(t.x - cow.x, t.y - cow.y) < 140)
if (!trough) return
trough.mx.feed -= 1
g.markDirty(trough)
g.dropLoot(cow.x + g.rand(-16, 16), cow.y + 20, 'milk', 1)
})
},
onPointerDown(g, wx, wy) {
const sx = wx - uiCamX
const sy = wy - uiCamY
if (uiBtn && inside(sx, sy, uiBtn)) return false
if (uiCancel && inside(sx, sy, uiCancel)) return false
if (!zoneMode) return false
drag = { x0: wx, y0: wy, x1: wx, y1: wy }
return true
},
onPointerMove(g, wx, wy) {
if (!zoneMode || !drag) return false
drag.x1 = wx
drag.y1 = wy
return true
},
onPointerUp(g, wx, wy) {
if (!zoneMode) return false
if (!drag) {
zoneMode = false
return true
}
drag.x1 = wx
drag.y1 = wy
const rect = normalizeRect(drag.x0, drag.y0, drag.x1, drag.y1)
drag = null
zoneMode = false
if (rect.x1 - rect.x0 < 80 || rect.y1 - rect.y0 < 80) {
g.tip('牧场太小,再划大一点')
return true
}
const name = promptPastureName(`牧场${pastureZones.length + 1}`)
const z = applyPasture(g, rect, name)
g.tip(`「${z.name}」已划定:杂项已清出,底边有可开关的栅栏门`)
return true
},
onClick(g, wx, wy) {
const sx = wx - uiCamX
const sy = wy - uiCamY
if (uiBtn && inside(sx, sy, uiBtn)) {
zoneMode = !zoneMode
drag = null
g.tip(zoneMode ? '拖拽框选牧场(松手命名)' : '取消划定牧场')
return true
}
if (uiCancel && inside(sx, sy, uiCancel)) {
zoneMode = false
drag = null
return true
}
if (!zoneMode && renamePastureAt(g, wx, wy)) return true
return false
},
onDraw(g, ctx, env) {
pastureZones.forEach((z) => {
ctx.save()
ctx.strokeStyle = 'rgba(200, 160, 80, 0.55)'
ctx.fillStyle = 'rgba(180, 140, 60, 0.08)'
ctx.lineWidth = 2
ctx.setLineDash([8, 6])
ctx.fillRect(z.x0 - env.camX, z.y0 - env.camY, z.x1 - z.x0, z.y1 - z.y0)
ctx.strokeRect(z.x0 - env.camX, z.y0 - env.camY, z.x1 - z.x0, z.y1 - z.y0)
ctx.setLineDash([])
const x = z.x0 - env.camX
const y = z.y0 - env.camY
ctx.fillStyle = 'rgba(40, 28, 10, 0.72)'
ctx.beginPath(); ctx.roundRect(x + 4, y + 4, Math.min(120, z.x1 - z.x0 - 8), 18, 4); ctx.fill()
ctx.fillStyle = '#f4ead2'
ctx.font = 'bold 12px sans-serif'
ctx.textAlign = 'left'
ctx.textBaseline = 'middle'
ctx.fillText(z.name || '牧场', x + 10, y + 13)
ctx.restore()
})
if (drag) {
const r = normalizeRect(drag.x0, drag.y0, drag.x1, drag.y1)
ctx.save()
ctx.strokeStyle = 'rgba(255, 200, 100, 0.9)'
ctx.fillStyle = 'rgba(220, 170, 70, 0.15)'
ctx.lineWidth = 2
ctx.setLineDash([6, 4])
ctx.fillRect(r.x0 - env.camX, r.y0 - env.camY, r.x1 - r.x0, r.y1 - r.y0)
ctx.strokeRect(r.x0 - env.camX, r.y0 - env.camY, r.x1 - r.x0, r.y1 - r.y0)
ctx.restore()
}
},
onDrawUI(g, ctx, env) {
uiCamX = env.camX
uiCamY = env.camY
const W = env.W
const bw = 92
const bh = 28
// 与农田按钮同列,避开右上时钟
const bx = W - bw - 130
const by = 46
uiBtn = { x: bx, y: by, w: bw, h: bh }
ctx.save()
ctx.fillStyle = zoneMode ? '#7a5a2a' : '#4a3a22'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 2
ctx.beginPath(); ctx.roundRect(bx, by, bw, bh, 6); ctx.fill(); ctx.stroke()
ctx.fillStyle = '#f4ead2'
ctx.font = '12px sans-serif'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText(zoneMode ? '框选中…' : '划定牧场', bx + bw / 2, by + bh / 2)
if (zoneMode) {
uiCancel = { x: bx, y: by + bh + 6, w: bw, h: 22 }
ctx.fillStyle = '#5a3a28'
ctx.beginPath(); ctx.roundRect(uiCancel.x, uiCancel.y, uiCancel.w, uiCancel.h, 5); ctx.fill(); ctx.stroke()
ctx.fillStyle = '#f4ead2'
ctx.fillText('取消', uiCancel.x + bw / 2, uiCancel.y + 11)
} else {
uiCancel = null
}
ctx.restore()
},
},
}

View File

@@ -1,8 +1,9 @@
// 劳务市场:用粮食招聘农民与牧工,替你自动开垦、播种、收割、入库与投喂
// - 招聘所 laboroffice:打开面板招/解雇岗位(农民上限 4、牧工上限 4)
// 劳务市场:用粮食招聘农民、牧工与厨师,替你自动开垦、播种、收割、入库、投喂与做饭
// - 招聘所 laboroffice:打开面板招/解雇岗位(农民上限 4、牧工上限 4、厨师上限 2)
// - 大仓库 laborwarehouse:12 个箱子 × 6 种 × 999,共 72 格容量,农民收获自动入库
// - 农民 laborfarmer:收割 ready 良田 → 播种空良田 → 开垦新田(需农业 mod)→ 游荡
// - 牧工 laborherder:投喂饲料槽 → 收鸡蛋入仓(需牧业 mod)→ 游荡
// - 厨师 laborchef:从仓库取食材按烹饪锅规则做菜,存入食堂 canteen
// - 每日工资:每亩人手 2 份粮食,优先扣仓库,付不出则全体罢工;补上即复工
// - 货币「粮食」= 任何带 food 属性的物品(背包优先,不够再扣大仓库)
// 数值基准:三围 150/150/200,一天 160 秒
@@ -15,14 +16,74 @@ const SEEDS = Object.keys(SEED_CROP)
const FEEDS = ['wheat', 'pumpkin', 'carrot', 'berry']
// 岗位与经济
const MAX_WORKERS = 4
const MAX_CHEF = 2
const FARMER_FEE = 8
const HERDER_FEE = 6
const CHEF_FEE = 10
const WAGE = 2
// 大仓库结构:12 箱 × 每箱 6 种 × 每种 999 = 72 格容量
const BOX_COUNT = 12
const BOX_KINDS = 6
const BOX_STACK = 999
// 劳工姓名池(每人唯一)
const WORKER_NAMES = [
'阿牛', '铁柱', '二狗', '翠花', '小满', '春生', '秋实', '老黄', '石头', '青禾',
'麦穗', '稻香', '福贵', '旺财', '腊月', '清明', '谷雨', '立夏', '大壮', '细妹',
]
// 衣服配色变体(同岗位也尽量不同)
const OUTFIT_PALETTES = [
{ shirt: '#7d9a6a', hat: '#d8b06a', skin: '#f0c89a', accent: '#5a4630', pants: '#3f2f1e' },
{ shirt: '#6a8a9a', hat: '#c9a06a', skin: '#e8b98a', accent: '#4a5a3a', pants: '#2f3a4a' },
{ shirt: '#9a7a5a', hat: '#e0c080', skin: '#f2c8a0', accent: '#6a4030', pants: '#4a3020' },
{ shirt: '#8a6a8a', hat: '#b89060', skin: '#dcb080', accent: '#5a3a5a', pants: '#3a2a3a' },
{ shirt: '#5a8a6a', hat: '#d0a858', skin: '#f4d0a8', accent: '#3a5a40', pants: '#2a3a28' },
{ shirt: '#a07050', hat: '#c8b070', skin: '#e8c090', accent: '#704030', pants: '#403028' },
{ shirt: '#7080a0', hat: '#a88858', skin: '#deb898', accent: '#405070', pants: '#283040' },
{ shirt: '#8a9a50', hat: '#e8d090', skin: '#f0c8a0', accent: '#5a6030', pants: '#3a4020' },
]
function pickUniqueName(g) {
const used = new Set(
g.monsters
.filter((m) => m.kind === 'laborfarmer' || m.kind === 'laborherder' || m.kind === 'laborchef')
.map((m) => m.workerName)
.filter(Boolean),
)
const avail = WORKER_NAMES.filter((n) => !used.has(n))
if (avail.length) return avail[Math.floor(Math.random() * avail.length)]
return `伙计${1 + Math.floor(Math.random() * 90)}`
}
function pickOutfit(g) {
const used = g.monsters
.filter((m) => m.kind === 'laborfarmer' || m.kind === 'laborherder' || m.kind === 'laborchef')
.map((m) => m.outfit | 0)
for (let i = 0; i < OUTFIT_PALETTES.length; i++) {
if (!used.includes(i)) return i
}
return Math.floor(Math.random() * OUTFIT_PALETTES.length)
}
function agriZoneList(g) {
return g.entities
.filter((e) => e.type === 'agrizone' && e.mx)
.map((e) => ({ id: e.mx.id, name: e.mx.name || '农田', mx: e.mx }))
.filter((z) => z.id != null)
}
function cycleFarmerBind(p, m, g) {
const zones = agriZoneList(g)
const opts = [null, ...zones.map((z) => z.id)]
let i = opts.indexOf(m.boundZoneId ?? null)
if (i < 0) i = 0
m.boundZoneId = opts[(i + 1) % opts.length]
const label = m.boundZoneId == null
? '全部农田'
: (zones.find((z) => z.id === m.boundZoneId)?.name || '未知农田')
g.ptip(p, `${m.workerName || '农民'} 负责:${label}`)
}
// 当前打开的面板(存实体 id):招聘所 / 大仓库
let officeOpen = null
let storeOpen = null
@@ -35,6 +96,7 @@ let uiH = 600
let toldNoAgri = false
let toldNoHusbandry = false
let toldNoSeed = false
let toldNoCanteen = false
// =====================================================================
// 通用工具
@@ -44,15 +106,20 @@ function entById(g, id) {
}
function mxOf(office) {
if (!office.mx) office.mx = { hired: 0, farmer: 0, herder: 0, strike: 0 }
if (!office.mx) office.mx = { hired: 0, farmer: 0, herder: 0, chef: 0, strike: 0 }
const mx = office.mx
if (mx.farmer == null) mx.farmer = 0
if (mx.herder == null) mx.herder = 0
if (mx.chef == null) mx.chef = 0
if (mx.strike == null) mx.strike = 0
mx.hired = (mx.farmer || 0) + (mx.herder || 0)
mx.hired = (mx.farmer || 0) + (mx.herder || 0) + (mx.chef || 0)
return mx
}
function syncHired(mx) {
mx.hired = (mx.farmer || 0) + (mx.herder || 0) + (mx.chef || 0)
}
// 取(并补齐)大仓库存放结构
function boxesOf(wh) {
if (!wh.mx) wh.mx = {}
@@ -297,58 +364,69 @@ function boxWithdraw(g, p, wh, bi) {
// =====================================================================
// 招聘 / 解雇
// =====================================================================
const ROLE_META = {
farmer: { kind: 'laborfarmer', key: 'farmer', fee: FARMER_FEE, max: MAX_WORKERS, label: '农民' },
herder: { kind: 'laborherder', key: 'herder', fee: HERDER_FEE, max: MAX_WORKERS, label: '牧工' },
chef: { kind: 'laborchef', key: 'chef', fee: CHEF_FEE, max: MAX_CHEF, label: '厨师' },
}
function hire(g, p, office, role) {
const meta = ROLE_META[role]
if (!meta) return
const mx = mxOf(office)
const isFarmer = role === 'farmer'
const roleKey = isFarmer ? 'farmer' : 'herder'
const kind = isFarmer ? 'laborfarmer' : 'laborherder'
const fee = isFarmer ? FARMER_FEE : HERDER_FEE
if ((mx[roleKey] || 0) >= MAX_WORKERS) {
g.ptip(p, `该岗位最多雇 ${MAX_WORKERS} 人`)
if ((mx[meta.key] || 0) >= meta.max) {
g.ptip(p, `该岗位最多雇 ${meta.max} 人`)
return
}
const wh = nearestWarehouse(g, office)
if (availableFood(g, p, wh) < fee) {
g.ptip(p, `粮食不足:招聘需要 ${fee} 份粮食`)
if (availableFood(g, p, wh) < meta.fee) {
g.ptip(p, `粮食不足:招聘需要 ${meta.fee} 份粮食`)
return
}
takeFood(g, p, wh, fee, false)
takeFood(g, p, wh, meta.fee, false)
const spot = g.findLand(office.x, office.y + 50, 22, 70)
g.spawnMob(kind, spot.x, spot.y, { owner: p, office: office.id, role: roleKey, hiredAt: g.day })
mx[roleKey] = (mx[roleKey] || 0) + 1
mx.hired = (mx.farmer || 0) + (mx.herder || 0)
const workerName = pickUniqueName(g)
const outfit = pickOutfit(g)
g.spawnMob(meta.kind, spot.x, spot.y, {
owner: p,
office: office.id,
role: meta.key,
hiredAt: g.day,
workerName,
outfit,
boundZoneId: null,
})
mx[meta.key] = (mx[meta.key] || 0) + 1
syncHired(mx)
g.markDirty(office)
g.ptip(p, isFarmer ? '雇到了一位农民' : '雇到了一位牧工')
g.ptip(p, `雇到了${workerName}(${meta.label}${meta.key === 'farmer' ? ' · 点他可绑定农田' : ''})`)
}
function fire(g, p, office, role) {
const meta = ROLE_META[role]
if (!meta) return
const mx = mxOf(office)
const isFarmer = role === 'farmer'
const roleKey = isFarmer ? 'farmer' : 'herder'
const kind = isFarmer ? 'laborfarmer' : 'laborherder'
const mine = g.monsters.filter((m) => m.kind === kind && m.office === office.id)
const mine = g.monsters.filter((m) => m.kind === meta.kind && m.office === office.id)
if (!mine.length) {
mx[roleKey] = 0
mx.hired = (mx.farmer || 0) + (mx.herder || 0)
mx[meta.key] = 0
syncHired(mx)
g.markDirty(office)
g.ptip(p, '没有可以解雇的人手')
return
}
g.removeMob(mine[mine.length - 1])
mx[roleKey] = Math.max(0, (mx[roleKey] || 0) - 1)
mx.hired = (mx.farmer || 0) + (mx.herder || 0)
mx[meta.key] = Math.max(0, (mx[meta.key] || 0) - 1)
syncHired(mx)
g.markDirty(office)
g.ptip(p, isFarmer ? '解雇了一位农民' : '解雇了一位牧工')
g.ptip(p, `解雇了一位${meta.label}`)
}
function roleInfo(g, office, role) {
const meta = ROLE_META[role]
const mx = mxOf(office)
const isFarmer = role === 'farmer'
const kind = isFarmer ? 'laborfarmer' : 'laborherder'
const key = isFarmer ? 'farmer' : 'herder'
const n = mx[key] || 0
const striking = g.monsters.some((m) => m.kind === kind && m.office === office.id && m.strike)
return { n, striking }
const n = mx[meta.key] || 0
const striking = g.monsters.some((m) => m.kind === meta.kind && m.office === office.id && m.strike)
return { n, striking, max: meta.max }
}
// =====================================================================
@@ -356,20 +434,42 @@ function roleInfo(g, office, role) {
// =====================================================================
function pickFarmerJob(m, g, anchor, agriOn) {
m.jobT = 8
// 1) 收割:最近的 ready 良田
const ready = nearestEnt(g, m, 'agrifarm', (e) => e.state === 'ready')
if (ready) { m.job = 'harvest'; m.jobTarget = ready; return }
// 2) 播种:空的良田
const empty = nearestEnt(g, m, 'agrifarm', (e) => e.state === 'empty')
if (empty) { m.job = 'sow'; m.jobTarget = empty; return }
// 3) 开垦:空闲良田不足 3 块,则在锚点 320px 内找空地
const emptyPlots = g.entities.filter((e) => e.type === 'agrifarm' && e.state === 'empty').length
if (agriOn && emptyPlots < 3) {
const spot = g.findLand(anchor.x, anchor.y, 30, 320)
const crowded = g.entities.some((e) => !e.deco && !e.loot && Math.hypot(e.x - spot.x, e.y - spot.y) < 34)
if (!crowded) { m.job = 'till'; m.jobTarget = { x: spot.x, y: spot.y }; return }
const zones = agriZoneList(g)
const bound = m.boundZoneId != null ? zones.find((z) => z.id === m.boundZoneId) : null
// 未绑定:负责全部;已绑定:只在该区内干活(不再回落到区外)
const inScope = (e) => {
if (!bound?.mx) return true
const z = bound.mx
return e.x >= z.x0 && e.x <= z.x1 && e.y >= z.y0 && e.y <= z.y1
}
let ready = nearestEnt(g, m, 'agrifarm', (e) => e.state === 'ready' && inScope(e))
if (!bound && !ready) ready = nearestEnt(g, m, 'agrifarm', (e) => e.state === 'ready')
if (ready) { m.job = 'harvest'; m.jobTarget = ready; return }
let empty = nearestEnt(g, m, 'agrifarm', (e) => e.state === 'empty' && inScope(e))
if (!bound && !empty) empty = nearestEnt(g, m, 'agrifarm', (e) => e.state === 'empty')
if (empty) { m.job = 'sow'; m.jobTarget = empty; return }
const emptyPlots = g.entities.filter((e) => e.type === 'agrifarm' && e.state === 'empty' && inScope(e)).length
if (agriOn && emptyPlots < 3) {
let spot = null
const searchZones = bound ? [bound.mx] : zones.map((z) => z.mx).filter(Boolean)
for (const z of searchZones) {
for (let i = 0; i < 8 && !spot; i++) {
const tx = z.x0 + 40 + Math.random() * Math.max(20, z.x1 - z.x0 - 80)
const ty = z.y0 + 40 + Math.random() * Math.max(20, z.y1 - z.y0 - 80)
const s = g.findLand(tx, ty, 0, 40)
if (!s) continue
const crowded = g.entities.some((e) => !e.deco && !e.loot && Math.hypot(e.x - s.x, e.y - s.y) < 34)
if (!crowded) spot = s
}
if (spot) break
}
if (!bound && !spot) {
const s = g.findLand(anchor.x, anchor.y, 30, 320)
const crowded = g.entities.some((e) => !e.deco && !e.loot && Math.hypot(e.x - s.x, e.y - s.y) < 34)
if (!crowded) spot = s
}
if (spot) { m.job = 'till'; m.jobTarget = { x: spot.x, y: spot.y }; return }
}
// 4) 无事可做:游荡
m.job = 'idle'
m.jobTarget = null
}
@@ -386,7 +486,7 @@ function doHarvest(m, g, plot, wh) {
plot.state = 'empty'
plot.mx = null
g.markDirty(plot)
if (m.owner) g.ptip(m.owner, `农民收获${g.items[crop]?.name || '作物'} ×${n}`)
if (m.owner) g.ptip(m.owner, `${m.workerName || '农民'}收获${g.items[crop]?.name || '作物'} ×${n}`)
}
function doSow(m, g, plot, wh) {
@@ -527,22 +627,149 @@ function updateHerder(m, dt, g) {
wanderAround(m, dt, g, anchor.x, anchor.y, 110, speed * 0.5)
}
// =====================================================================
// 厨师:从仓库抽 4 份食材 → cookPot → 写入食堂
// =====================================================================
function nearestCanteen(g, ref) {
let best = null
let bd = Infinity
g.entities.forEach((e) => {
if (e.type !== 'canteen') return
const d = Math.hypot(e.x - ref.x, e.y - ref.y)
if (d < bd) { bd = d; best = e }
})
return best
}
function listCookIngredients(g, wh) {
if (!wh) return []
const out = []
boxesOf(wh).forEach((box) => {
Object.entries(box.items || {}).forEach(([code, n]) => {
if (n <= 0) return
const it = g.items[code]
if (!it?.food || code === 'rot') return
// 成品菜不再二次入锅
if (['meatballs', 'meatystew', 'honeyham', 'honeynuggets', 'taffy', 'monsterlasagna', 'wetgoop', 'bread', 'cheese'].includes(code)) return
for (let i = 0; i < n; i++) out.push(code)
})
})
return out
}
function takeIngredients(g, wh, codes) {
const taken = []
for (const code of codes) {
if (warehouseCount(wh, code) <= 0) break
withdraw(g, wh, code, 1)
taken.push(code)
}
return taken
}
function doCook(m, g, wh, canteen) {
const pool = listCookIngredients(g, wh)
if (pool.length < 4) {
if (m.owner && !toldNoCanteen) {
// 缺料时安静游荡,不刷屏
}
return false
}
// 尽量凑肉/甜以出正经菜:优先取肉度/甜度高的
const scored = [...new Set(pool)].map((code) => {
const it = g.items[code] || {}
return { code, s: (it.meatv || 0) * 2 + (it.sweet || 0) * 2 + (it.veg || 0) }
}).sort((a, b) => b.s - a.s)
const pick = []
for (const { code } of scored) {
while (pick.length < 4 && warehouseCount(wh, code) > 0) {
pick.push(code)
if (pick.length >= 4) break
}
if (pick.length >= 4) break
}
while (pick.length < 4 && pool.length) {
const c = pool[Math.floor(Math.random() * pool.length)]
if (warehouseCount(wh, c) > 0) pick.push(c)
else break
}
if (pick.length < 4) return false
const used = takeIngredients(g, wh, pick.slice(0, 4))
if (used.length < 4) {
used.forEach((c) => deposit(g, wh, c, 1))
return false
}
const dish = typeof g.cookPot === 'function' ? g.cookPot(used) : 'wetgoop'
if (!canteen.mx) canteen.mx = { menu: {} }
if (!canteen.mx.menu) canteen.mx.menu = {}
canteen.mx.menu[dish] = (canteen.mx.menu[dish] || 0) + 1
g.markDirty(canteen)
if (m.owner) g.ptip(m.owner, `厨师做好了${g.items[dish]?.name || dish}`)
return true
}
function updateChef(m, dt, g) {
if (m.hp < 300) m.hp = 300
if (m.strike) { m.moving = false; return }
const anchor = anchorOf(g, m)
const speed = g.mon[m.kind]?.speed || 118
const canteen = nearestCanteen(g, m)
if (!canteen) {
if (!toldNoCanteen) {
toldNoCanteen = true
if (m.owner) g.ptip(m.owner, '厨师:需要建造食堂才能上菜')
}
wanderAround(m, dt, g, anchor.x, anchor.y, 110, speed * 0.5)
return
}
const wh = nearestWarehouse(g, m)
m.jobT = (m.jobT || 0) - dt
if (!m.job || m.jobT <= 0) {
m.job = 'cook'
m.jobT = 10
m.jobTarget = canteen
}
if (m.job === 'cook') {
// 先去仓库取料,再到食堂
if (wh && Math.hypot(wh.x - m.x, wh.y - m.y) > 50 && !m.gotIng) {
g.moveToward(m, wh.x, wh.y, speed, dt)
return
}
if (wh && !m.gotIng) {
m.gotIng = true
m.cookAt = canteen
}
const t = m.cookAt || canteen
const d = Math.hypot(t.x - m.x, t.y - m.y)
if (d > 42) {
g.moveToward(m, t.x, t.y, speed, dt)
return
}
doCook(m, g, wh, t)
m.gotIng = false
m.job = null
m.jobT = 2
return
}
wanderAround(m, dt, g, anchor.x, anchor.y, 90, speed * 0.45)
}
// =====================================================================
// 面板布局(onDrawUI 与 onClick 共用同一套矩形,保证命中一致)
// =====================================================================
function storeLayout(W, H) {
const pw = Math.min(560, W * 0.86)
const headH = 54
const rowH = 78
const rowH = 72
const footH = 78
const nRows = 2
const nRows = 3
const ph = headH + nRows * rowH + footH
const px = (W - pw) / 2
const py = Math.max(14, (H - ph) / 2)
const bw = Math.min(88, pw * 0.22)
const bh = 42
const bh = 38
const btns = []
const roles = ['farmer', 'herder']
const roles = ['farmer', 'herder', 'chef']
for (let i = 0; i < nRows; i++) {
const rowY = py + headH + i * rowH
const by = rowY + (rowH - bh) / 2
@@ -624,6 +851,7 @@ function drawStore(g, ctx, office) {
const roles = [
{ role: 'farmer', label: '农民', fee: FARMER_FEE },
{ role: 'herder', label: '牧工', fee: HERDER_FEE },
{ role: 'chef', label: '厨师', fee: CHEF_FEE },
]
ctx.textAlign = 'left'
roles.forEach((r, idx) => {
@@ -631,12 +859,12 @@ function drawStore(g, ctx, office) {
const info = roleInfo(g, office, r.role)
ctx.fillStyle = '#e8d9b8'
ctx.font = 'bold 16px "PingFang SC", "Microsoft YaHei", sans-serif'
ctx.fillText(r.label, L.px + 18, rowY + 24)
ctx.fillText(r.label, L.px + 18, rowY + 22)
ctx.fillStyle = '#c9b48c'
ctx.font = '12px "PingFang SC", "Microsoft YaHei", sans-serif'
ctx.fillText(`招聘费 ${r.fee} 份粮食 · 每日工资 ${WAGE} 份`, L.px + 18, rowY + 46)
ctx.fillText(`招聘费 ${r.fee} 份粮食 · 每日工资 ${WAGE} 份`, L.px + 18, rowY + 42)
ctx.fillStyle = info.striking ? '#e08a6a' : '#a9c98a'
ctx.fillText(`已有 ${info.n}/${MAX_WORKERS} · ${info.striking ? '罢工中' : '在岗'}`, L.px + 18, rowY + 64)
ctx.fillText(`已有 ${info.n}/${info.max} · ${info.striking ? '罢工中' : '在岗'}`, L.px + 18, rowY + 60)
})
// 岗位按钮
L.btns.forEach((b) => {
@@ -714,15 +942,119 @@ function inRect(r, sx, sy) {
return sx >= r.px && sx <= r.px + r.pw && sy >= r.py && sy <= r.py + r.ph
}
function drawLaborer(ctx, m, time, role) {
const pal = OUTFIT_PALETTES[m.outfit | 0] || OUTFIT_PALETTES[0]
const hop = Math.sin(time * 8 + (m.bob || 0)) * (m.moving ? 1.8 : 0.45)
const leg = m.moving ? Math.sin(time * 10) * 3.6 : 0
const dir = m.dir || 1
const label = m.workerName || (role === 'farmer' ? '农民' : role === 'herder' ? '牧工' : '厨师')
// 名牌(不随朝向翻转)
ctx.save()
ctx.fillStyle = 'rgba(20,16,10,0.72)'
const tw = Math.max(28, label.length * 8 + 10)
ctx.beginPath(); ctx.roundRect(-tw / 2, -62 + hop * 0.3, tw, 14, 4); ctx.fill()
ctx.fillStyle = '#f4ead2'
ctx.font = 'bold 10px "PingFang SC","Microsoft YaHei",sans-serif'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText(label, 0, -55 + hop * 0.3)
if (role === 'farmer' && m.boundZoneId != null) {
ctx.fillStyle = '#a8d878'
ctx.font = '9px sans-serif'
ctx.fillText('专管', 0, -68 + hop * 0.3)
}
if (m.strike) {
ctx.fillStyle = '#8d9097'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 1.4
ctx.beginPath(); ctx.roundRect(-11, -80, 9, 14, 3); ctx.fill(); ctx.stroke()
ctx.beginPath(); ctx.arc(-6.5, -63, 1.8, 0, TAU); ctx.fill()
}
ctx.restore()
// 身体随朝向翻转
ctx.save()
ctx.scale(dir, 1)
ctx.fillStyle = 'rgba(0,0,0,0.28)'
ctx.beginPath(); ctx.ellipse(0, 4, 13, 4.8, 0, 0, TAU); ctx.fill()
ctx.strokeStyle = pal.pants
ctx.lineWidth = 4.2
ctx.lineCap = 'round'
ctx.beginPath()
ctx.moveTo(-4.5, -8); ctx.lineTo(-5.5 + leg, 3)
ctx.moveTo(4.5, -8); ctx.lineTo(5.5 - leg, 3)
ctx.stroke()
ctx.fillStyle = '#1d1409'
ctx.beginPath()
ctx.ellipse(-5.5 + leg, 4, 3.2, 1.8, 0, 0, TAU)
ctx.ellipse(5.5 - leg, 4, 3.2, 1.8, 0, 0, TAU)
ctx.fill()
ctx.fillStyle = role === 'chef' ? '#e8e4dc' : pal.shirt
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 2
ctx.beginPath(); ctx.roundRect(-9, -28 + hop * 0.3, 18, 20, 5); ctx.fill(); ctx.stroke()
if (role === 'chef') {
ctx.fillStyle = '#cf3b34'
ctx.beginPath(); ctx.roundRect(-9, -18 + hop * 0.3, 18, 5, 1); ctx.fill()
} else {
ctx.strokeStyle = pal.accent
ctx.lineWidth = 2.6
ctx.beginPath(); ctx.moveTo(-9, -12 + hop * 0.3); ctx.lineTo(9, -12 + hop * 0.3); ctx.stroke()
}
ctx.fillStyle = pal.skin
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 2
ctx.beginPath(); ctx.arc(0, -35 + hop * 0.4, 7.5, 0, TAU); ctx.fill(); ctx.stroke()
if (role === 'chef') {
ctx.fillStyle = '#f4ead2'
ctx.beginPath(); ctx.roundRect(-7.5, -54 + hop * 0.4, 15, 14, 4); ctx.fill(); ctx.stroke()
ctx.beginPath(); ctx.ellipse(0, -40 + hop * 0.4, 10.5, 3.2, 0, 0, TAU); ctx.fill(); ctx.stroke()
} else if (role === 'herder') {
ctx.fillStyle = pal.hat
ctx.beginPath(); ctx.ellipse(0, -39 + hop * 0.4, 15.5, 4.8, 0, 0, TAU); ctx.fill(); ctx.stroke()
ctx.beginPath(); ctx.arc(0, -41 + hop * 0.4, 6.5, Math.PI, TAU); ctx.closePath(); ctx.fill(); ctx.stroke()
} else {
ctx.fillStyle = pal.hat
ctx.beginPath(); ctx.ellipse(0, -40 + hop * 0.4, 12.5, 4.2, 0, 0, TAU); ctx.fill(); ctx.stroke()
ctx.beginPath(); ctx.arc(0, -42 + hop * 0.4, 6.5, Math.PI, TAU); ctx.closePath(); ctx.fill(); ctx.stroke()
}
ctx.fillStyle = '#1d1409'
ctx.beginPath(); ctx.arc(3.4, -35 + hop * 0.4, 1.2, 0, TAU); ctx.fill()
ctx.fillStyle = 'rgba(220,100,90,0.35)'
ctx.beginPath(); ctx.ellipse(5.5, -32 + hop * 0.4, 2.2, 1.4, 0, 0, TAU); ctx.fill()
if (role === 'farmer') {
ctx.save()
ctx.translate(-2, -30 + hop * 0.3)
ctx.rotate(-0.55)
ctx.strokeStyle = '#7a5b33'
ctx.lineWidth = 2.8
ctx.beginPath(); ctx.moveTo(-16, 6); ctx.lineTo(14, -6); ctx.stroke()
ctx.fillStyle = '#8d9097'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 1.4
ctx.beginPath(); ctx.moveTo(10, -8); ctx.lineTo(18, -4); ctx.lineTo(18, -10); ctx.lineTo(11, -12); ctx.closePath(); ctx.fill(); ctx.stroke()
ctx.restore()
} else if (role === 'herder') {
ctx.strokeStyle = '#8a6a3a'
ctx.lineWidth = 2.5
ctx.beginPath(); ctx.moveTo(9, 6); ctx.lineTo(13, -32 + hop * 0.3); ctx.stroke()
ctx.strokeStyle = '#b7bec7'
ctx.lineWidth = 1.6
;[-3, 0, 3].forEach((o) => {
ctx.beginPath(); ctx.moveTo(13, -32 + hop * 0.3); ctx.lineTo(13 + o, -42 + hop * 0.3); ctx.stroke()
})
}
ctx.restore()
}
export default {
id: 'labor',
name: '劳务市场',
desc: '用粮食招聘农民与牧工:自动开垦、播种、收割、入库',
desc: '用粮食招聘农民、牧工与厨师:耕作、放牧、食堂做饭',
factor: 1.05,
tag: 'content',
tabs: [{ code: 'labor', name: '劳务', icon: 'laboroffice' }],
recipes: [
{ code: 'laboroffice', name: '招聘所', tab: 'labor', tech: 1, cost: { log: 8, rock: 4, rope: 2 }, build: true, desc: '用粮食招聘农民与牧工' },
{ code: 'laboroffice', name: '招聘所', tab: 'labor', tech: 1, cost: { log: 8, rock: 4, rope: 2 }, build: true, desc: '招聘农民、牧工与厨师' },
{ code: 'laborwarehouse', name: '大仓库', tab: 'labor', tech: 1, cost: { log: 12, rock: 6 }, build: true, desc: '12 个箱子 · 农民自动入库' },
],
icons: {
@@ -791,7 +1123,7 @@ export default {
name: '招聘所',
conf: { size: 44, struct: true },
init(e) {
if (!e.mx) e.mx = { hired: 0, farmer: 0, herder: 0, strike: 0 }
if (!e.mx) e.mx = { hired: 0, farmer: 0, herder: 0, chef: 0, strike: 0 }
},
interact(p, e, g) {
mxOf(e)
@@ -929,65 +1261,14 @@ export default {
updateFarmer(m, dt, g)
},
interact(p, m, g) {
g.ptip(p, m.owner ? '这位农民正在替你干活(可在招聘所解雇)' : '一位流落的农民,正等着被雇佣')
if (!m.owner) {
g.ptip(p, '一位流落的农民,正等着被雇佣')
return
}
cycleFarmerBind(p, m, g)
},
draw(ctx, m, time) {
ctx.save()
ctx.scale(m.dir || 1, 1)
const hop = Math.sin(time * 8 + (m.bob || 0)) * (m.moving ? 1.6 : 0.4)
// 投影
ctx.fillStyle = 'rgba(0,0,0,0.25)'
ctx.beginPath(); ctx.ellipse(0, 3, 12, 4.5, 0, 0, TAU); ctx.fill()
// 腿(走路摆动)
ctx.strokeStyle = '#3f2f1e'
ctx.lineWidth = 4
const leg = m.moving ? Math.sin(time * 10) * 3.2 : 0
ctx.beginPath()
ctx.moveTo(-4, -10); ctx.lineTo(-5 + leg, 2)
ctx.moveTo(4, -10); ctx.lineTo(5 - leg, 2)
ctx.stroke()
// 布衣身体
ctx.fillStyle = '#7d9a6a'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 2
ctx.beginPath(); ctx.roundRect(-8, -26 + hop * 0.3, 16, 18, 4); ctx.fill(); ctx.stroke()
// 腰带
ctx.strokeStyle = '#5a4630'
ctx.lineWidth = 2.4
ctx.beginPath(); ctx.moveTo(-8, -13 + hop * 0.3); ctx.lineTo(8, -13 + hop * 0.3); ctx.stroke()
// 头
ctx.fillStyle = '#f0c89a'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 2
ctx.beginPath(); ctx.arc(0, -33 + hop * 0.4, 7, 0, TAU); ctx.fill(); ctx.stroke()
// 草帽
ctx.fillStyle = '#d8b06a'
ctx.beginPath(); ctx.ellipse(0, -38 + hop * 0.4, 12, 4, 0, 0, TAU); ctx.fill(); ctx.stroke()
ctx.beginPath(); ctx.arc(0, -40 + hop * 0.4, 6, Math.PI, TAU); ctx.closePath(); ctx.fill(); ctx.stroke()
// 眼
ctx.fillStyle = '#1d1409'
ctx.beginPath(); ctx.arc(3.2, -33 + hop * 0.4, 1.1, 0, TAU); ctx.fill()
// 肩上扛着的锄头
ctx.save()
ctx.translate(-2, -30 + hop * 0.3)
ctx.rotate(-0.6)
ctx.strokeStyle = '#7a5b33'
ctx.lineWidth = 2.6
ctx.beginPath(); ctx.moveTo(-16, 6); ctx.lineTo(14, -6); ctx.stroke()
ctx.fillStyle = '#8d9097'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 1.4
ctx.beginPath(); ctx.moveTo(10, -8); ctx.lineTo(18, -4); ctx.lineTo(18, -10); ctx.lineTo(11, -12); ctx.closePath(); ctx.fill(); ctx.stroke()
ctx.restore()
// 罢工标记(灰暗感叹号)
if (m.strike) {
ctx.fillStyle = '#8d9097'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 1.4
ctx.beginPath(); ctx.roundRect(-11, -60 + hop * 0.4, 9, 16, 3); ctx.fill(); ctx.stroke()
ctx.beginPath(); ctx.arc(-6.5, -41 + hop * 0.4, 1.8, 0, TAU); ctx.fill()
}
ctx.restore()
drawLaborer(ctx, m, time, 'farmer')
},
},
laborherder: {
@@ -999,62 +1280,29 @@ export default {
updateHerder(m, dt, g)
},
interact(p, m, g) {
g.ptip(p, m.owner ? '这位牧工正在替你干活(可在招聘所解雇)' : '一位流落的牧工,正等着被雇佣')
g.ptip(p, m.owner
? `${m.workerName || '牧工'}正在放牧(可在招聘所解雇)`
: '一位流落的牧工,正等着被雇佣')
},
draw(ctx, m, time) {
ctx.save()
ctx.scale(m.dir || 1, 1)
const hop = Math.sin(time * 8 + (m.bob || 0)) * (m.moving ? 1.6 : 0.4)
// 投影
ctx.fillStyle = 'rgba(0,0,0,0.25)'
ctx.beginPath(); ctx.ellipse(0, 3, 12, 4.5, 0, 0, TAU); ctx.fill()
// 腿
ctx.strokeStyle = '#3f2f1e'
ctx.lineWidth = 4
const leg = m.moving ? Math.sin(time * 10) * 3.2 : 0
ctx.beginPath()
ctx.moveTo(-4, -10); ctx.lineTo(-5 + leg, 2)
ctx.moveTo(4, -10); ctx.lineTo(5 - leg, 2)
ctx.stroke()
// 布衣身体
ctx.fillStyle = '#8a7d5a'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 2
ctx.beginPath(); ctx.roundRect(-8, -26 + hop * 0.3, 16, 18, 4); ctx.fill(); ctx.stroke()
// 腰带
ctx.strokeStyle = '#5a4630'
ctx.lineWidth = 2.4
ctx.beginPath(); ctx.moveTo(-8, -13 + hop * 0.3); ctx.lineTo(8, -13 + hop * 0.3); ctx.stroke()
// 头
ctx.fillStyle = '#e8b98a'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 2
ctx.beginPath(); ctx.arc(0, -33 + hop * 0.4, 7, 0, TAU); ctx.fill(); ctx.stroke()
// 宽檐帽
ctx.fillStyle = '#b98f52'
ctx.beginPath(); ctx.ellipse(0, -37 + hop * 0.4, 15, 4.5, 0, 0, TAU); ctx.fill(); ctx.stroke()
ctx.beginPath(); ctx.arc(0, -39 + hop * 0.4, 6, Math.PI, TAU); ctx.closePath(); ctx.fill(); ctx.stroke()
// 眼
ctx.fillStyle = '#1d1409'
ctx.beginPath(); ctx.arc(3.2, -33 + hop * 0.4, 1.1, 0, TAU); ctx.fill()
// 手里拿的干草叉
ctx.strokeStyle = '#8a6a3a'
ctx.lineWidth = 2.4
ctx.beginPath(); ctx.moveTo(9, 6); ctx.lineTo(13, -30 + hop * 0.3); ctx.stroke()
ctx.strokeStyle = '#b7bec7'
ctx.lineWidth = 1.6
;[-3, 0, 3].forEach((o) => {
ctx.beginPath(); ctx.moveTo(13, -30 + hop * 0.3); ctx.lineTo(13 + o, -40 + hop * 0.3); ctx.stroke()
})
// 罢工标记(灰暗感叹号)
if (m.strike) {
ctx.fillStyle = '#8d9097'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 1.4
ctx.beginPath(); ctx.roundRect(-11, -60 + hop * 0.4, 9, 16, 3); ctx.fill(); ctx.stroke()
ctx.beginPath(); ctx.arc(-6.5, -41 + hop * 0.4, 1.8, 0, TAU); ctx.fill()
}
ctx.restore()
drawLaborer(ctx, m, time, 'herder')
},
},
laborchef: {
name: '厨师',
friendly: true,
conf: { hp: 300, speed: 118, dmg: 5, score: 0, friendly: true, name: '厨师' },
drops: [],
update(m, dt, g) {
updateChef(m, dt, g)
},
interact(p, m, g) {
g.ptip(p, m.owner
? `${m.workerName || '厨师'}正在食堂做饭(可在招聘所解雇)`
: '一位流落的厨师')
},
draw(ctx, m, time) {
drawLaborer(ctx, m, time, 'chef')
},
},
},
@@ -1091,6 +1339,8 @@ export default {
else if (hit.id === 'farmer:fire') fire(g, p, office, 'farmer')
else if (hit.id === 'herder:hire') hire(g, p, office, 'herder')
else if (hit.id === 'herder:fire') fire(g, p, office, 'herder')
else if (hit.id === 'chef:hire') hire(g, p, office, 'chef')
else if (hit.id === 'chef:fire') fire(g, p, office, 'chef')
return true
}
if (!inRect(L, sx, sy)) officeOpen = null
@@ -1113,30 +1363,33 @@ export default {
let anyStrike = false
g.entities.filter((e) => e.type === 'laboroffice').forEach((office) => {
const mx = mxOf(office)
const mine = g.monsters.filter((m) => (m.kind === 'laborfarmer' || m.kind === 'laborherder') && m.office === office.id)
if (!mine.length) { mx.strike = 0; return }
const total = mine.length * WAGE
const mine = g.monsters.filter((m) => (m.kind === 'laborfarmer' || m.kind === 'laborherder' || m.kind === 'laborchef') && m.office === office.id)
const wh = nearestWarehouse(g, office)
const p = payerOf(g)
if (availableFood(g, p, wh) >= total) {
takeFood(g, p, wh, total, true) // 工资优先扣仓库,再退回背包
const total = mine.length * WAGE
const p = g.me()
if (total > 0 && availableFood(g, p, wh) < total) {
mine.forEach((m) => { m.strike = true })
mx.strike = 1
anyStrike = true
} else if (total > 0) {
takeFood(g, p, wh, total, true)
mine.forEach((m) => { m.strike = false })
mx.strike = 0
g.markDirty(office)
} else {
mine.forEach((m) => { m.strike = true })
mx.strike = mine.length
g.markDirty(office)
anyStrike = true
}
// 同步计数
mx.farmer = g.monsters.filter((m) => m.kind === 'laborfarmer' && m.office === office.id).length
mx.herder = g.monsters.filter((m) => m.kind === 'laborherder' && m.office === office.id).length
mx.chef = g.monsters.filter((m) => m.kind === 'laborchef' && m.office === office.id).length
syncHired(mx)
g.markDirty(office)
})
if (anyStrike) g.tip('劳务市场:粮食不足,工人们罢工了!')
if (anyStrike) g.tip('劳工罢工了:仓库/背包粮食不够发工资')
},
// 农民/牧工不进世界快照,这里单独持久化
// 劳工不进世界快照,这里单独持久化
save(g) {
const offices = g.entities.filter((e) => e.type === 'laboroffice').map((e) => ({ id: e.id, mx: e.mx || null }))
const workers = g.monsters
.filter((m) => m.kind === 'laborfarmer' || m.kind === 'laborherder')
.filter((m) => m.kind === 'laborfarmer' || m.kind === 'laborherder' || m.kind === 'laborchef')
.map((m) => ({
kind: m.kind,
x: Math.round(m.x),
@@ -1144,6 +1397,9 @@ export default {
strike: m.strike ? 1 : 0,
office: m.office != null ? m.office : null,
seat: m.owner && m.owner.seat !== undefined ? m.owner.seat : null,
workerName: m.workerName || '',
outfit: m.outfit | 0,
boundZoneId: m.boundZoneId != null ? m.boundZoneId : null,
}))
if (!offices.length && !workers.length) return null
return { offices, workers }
@@ -1151,21 +1407,22 @@ export default {
load(g, data) {
officeOpen = null
storeOpen = null
// 恢复各所状态(核心已随实体恢复 mx,这里做补充与兼容)
;(data.offices || []).forEach((rec) => {
const office = g.entities.find((e) => e.type === 'laboroffice' && e.id === rec.id)
if (office && rec.mx) office.mx = rec.mx
})
// 复原工人;找不到对应招聘所的记录直接丢弃
;(data.workers || []).forEach((w) => {
const office = w.office != null ? g.entities.find((e) => e.type === 'laboroffice' && e.id === w.office) : null
if (w.office != null && !office) return
const kind = w.kind === 'laborherder' ? 'laborherder' : 'laborfarmer'
const kind = w.kind === 'laborherder' ? 'laborherder' : w.kind === 'laborchef' ? 'laborchef' : 'laborfarmer'
const spot = g.findLand(w.x, w.y, 0, 24)
const m = g.spawnMob(kind, spot.x, spot.y, {})
m.office = office ? office.id : null
m.role = kind === 'laborfarmer' ? 'farmer' : 'herder'
m.role = kind === 'laborfarmer' ? 'farmer' : kind === 'laborherder' ? 'herder' : 'chef'
m.strike = !!w.strike
m.workerName = w.workerName || pickUniqueName(g)
m.outfit = w.outfit != null ? w.outfit : pickOutfit(g)
m.boundZoneId = w.boundZoneId != null ? w.boundZoneId : null
m.hp = g.mon[kind]?.hp || 300
if (w.seat != null) {
const owner = g.players.find((q) => q.seat === w.seat)

View File

@@ -0,0 +1,188 @@
// 分区框选 / 围栏贴边共用工具
const FENCE_STEP = 48
export function normalizeRect(x0, y0, x1, y1) {
return {
x0: Math.min(x0, x1),
y0: Math.min(y0, y1),
x1: Math.max(x0, x1),
y1: Math.max(y0, y1),
}
}
export function mergeAabb(a, b) {
if (!a) return { ...b }
if (!b) return { ...a }
return {
x0: Math.min(a.x0, b.x0),
y0: Math.min(a.y0, b.y0),
x1: Math.max(a.x1, b.x1),
y1: Math.max(a.y1, b.y1),
}
}
export function pointInAabb(x, y, z) {
return !!z && x >= z.x0 && x <= z.x1 && y >= z.y0 && y <= z.y1
}
export function aabbArea(z) {
if (!z) return 0
return Math.max(0, z.x1 - z.x0) * Math.max(0, z.y1 - z.y0)
}
// 划定农田/牧场时保留的建筑与区标记(其余自然物移出)
const ZONE_KEEP = new Set([
'agrifarm', 'agrizone', 'pasturezone', 'farm',
'fence', 'gate', 'campfence', 'campgate',
'well', 'granary', 'mill', 'trough', 'coop',
'sciencemachine', 'alchemyengine', 'crockpot', 'woodchest',
'pighouse', 'trap', 'canteen', 'resttent', 'camphq',
])
function shouldKeepInZone(e) {
if (!e) return true
if (ZONE_KEEP.has(e.type)) return true
if (e.struct || e.chest || e.pot) return true
if (e.mx?.pen) return true // 圈养兔
if (e.loot) return true
return false
}
/** 把实体挪到 AABB 外侧附近陆地;找不到则删除 */
function relocateOutside(g, e, z) {
const pad = 56
const w = Math.max(40, z.x1 - z.x0)
const h = Math.max(40, z.y1 - z.y0)
for (let i = 0; i < 48; i++) {
const side = i % 4
const t = Math.random()
const spread = pad + Math.random() * (40 + (i % 6) * 18)
let x
let y
if (side === 0) { x = z.x0 + t * w; y = z.y0 - spread }
else if (side === 1) { x = z.x1 + spread; y = z.y0 + t * h }
else if (side === 2) { x = z.x0 + t * w; y = z.y1 + spread }
else { x = z.x0 - spread; y = z.y0 + t * h }
const spot = g.findLand?.(x, y, 0, 48)
if (!spot || pointInAabb(spot.x, spot.y, z)) continue
const crowded = g.entities.some((o) => o !== e && !o.deco && !o.loot && Math.hypot(o.x - spot.x, o.y - spot.y) < 28)
if (crowded) continue
e.x = spot.x
e.y = spot.y
g.markDirty?.(e)
return true
}
g.removeEntity?.(e)
return false
}
/**
* 清出分区内杂项(树/草/洞/野兔等),挪到区外;建筑与圈养物保留
* @returns {number} 处理数量
*/
export function clearZoneClutter(g, z) {
if (!z || !g?.entities) return 0
const list = g.entities.filter((e) => pointInAabb(e.x, e.y, z) && !shouldKeepInZone(e))
let n = 0
// 先洞后兔,避免野兔失去关联后乱跑
list.sort((a, b) => (a.type === 'rabbithole' ? -1 : 0) - (b.type === 'rabbithole' ? -1 : 0))
list.forEach((e) => {
const ox = e.x
const oy = e.y
if (!relocateOutside(g, e, z)) {
n++
return
}
n++
// 洞挪走时,把还在洞附近的野兔一起带走
if (e.type === 'rabbithole') {
g.entities.forEach((r) => {
if (r.type !== 'rabbit' || r.mx?.pen) return
if (r.hole === e.id || Math.hypot(r.x - ox, r.y - oy) < 140) {
r.x = e.x + (Math.random() - 0.5) * 40
r.y = e.y + (Math.random() - 0.5) * 40
r.hole = e.id
g.markDirty?.(r)
}
})
}
})
return n
}
/** 沿 AABB 外轮廓采样点;底边仅一个点作为门位(避免大门缺口过大) */
export function perimeterPoints(z, step = FENCE_STEP, gateGap = true) {
const pts = []
const { x0, y0, x1, y1 } = z
const w = x1 - x0
const h = y1 - y0
if (w < step * 2 || h < step * 2) return pts
const midX = (x0 + x1) / 2
// 顶边
for (let x = x0; x <= x1 + 0.1; x += step) pts.push({ x, y: y0, gate: false })
// 右边
for (let y = y0 + step; y <= y1 - step + 0.1; y += step) pts.push({ x: x1, y, gate: false })
// 底边
const bottom = []
for (let x = x1; x >= x0 - 0.1; x -= step) bottom.push({ x, y: y1, gate: false })
if (gateGap && bottom.length) {
let best = 0
let bd = Infinity
bottom.forEach((p, i) => {
const d = Math.abs(p.x - midX)
if (d < bd) { bd = d; best = i }
})
bottom[best].gate = true
}
pts.push(...bottom)
// 左边
for (let y = y1 - step; y >= y0 + step - 0.1; y -= step) pts.push({ x: x0, y, gate: false })
return pts
}
/**
* 清除矩形内旧围栏/门,按外轮廓重建
* @param {object} g 游戏上下文
* @param {object} z AABB
* @param {{ fenceType?: string, gateType?: string, step?: number }} opt
*/
export function rebuildFencePerimeter(g, z, opt = {}) {
const fenceType = opt.fenceType || 'fence'
const gateType = opt.gateType || 'gate'
const step = opt.step || FENCE_STEP
const pad = 8
// 清除内部及边缘旧栏
const doomed = g.entities.filter((e) => {
if (e.type !== fenceType && e.type !== gateType) return false
return e.x >= z.x0 - pad && e.x <= z.x1 + pad && e.y >= z.y0 - pad && e.y <= z.y1 + pad
})
doomed.forEach((e) => g.removeEntity(e))
const pts = perimeterPoints(z, step, true)
pts.forEach((p) => {
if (!g.isLand(p.x, p.y)) return
if (p.gate) {
g.addEntity(gateType, p.x, p.y, { mx: { open: false, openT: 0 } })
return
}
const near = g.entities.some((e) => (e.type === fenceType || e.type === gateType) && Math.hypot(e.x - p.x, e.y - p.y) < step * 0.45)
if (near) return
g.addEntity(fenceType, p.x, p.y)
})
}
/** 在圆内采样找远离已有篝火的陆地,用于升级补篝火 */
export function findDarkSpots(g, cx, cy, radius, count, minDist = 90) {
const out = []
for (let i = 0; i < count * 12 && out.length < count; i++) {
const a = Math.random() * Math.PI * 2
const r = 40 + Math.random() * Math.max(20, radius - 60)
const spot = g.findLand(cx + Math.cos(a) * r, cy + Math.sin(a) * r, 0, 40)
if (!spot) continue
if (out.some((p) => Math.hypot(p.x - spot.x, p.y - spot.y) < minDist)) continue
if (g.fires?.some((f) => Math.hypot(f.x - spot.x, f.y - spot.y) < minDist)) continue
out.push(spot)
}
return out
}
export { FENCE_STEP }

View File

@@ -301,6 +301,11 @@ export function drawItemIcon(c, code) {
c.beginPath(); c.ellipse(0, -4, 8, 3, 0, 0, TAU); fs(c, '#33333a')
c.strokeStyle = '#241812'; c.lineWidth = 2
c.beginPath(); c.moveTo(-11, 0); c.lineTo(-9, 3); c.moveTo(11, 0); c.lineTo(9, 3); c.stroke()
} else if (code === 'woodchest') {
c.beginPath(); c.roundRect(-12, -8, 24, 16, 2); fs(c, '#8a6238', '#241812', 1.6)
c.beginPath(); c.roundRect(-13, -14, 26, 8, 2); fs(c, '#a9773c', '#241812', 1.6)
c.fillStyle = '#d8b04a'
c.beginPath(); c.roundRect(-2, -6, 4, 5, 1); c.fill()
}
}
@@ -308,7 +313,7 @@ export function drawItemIcon(c, code) {
export function buildIcons() {
const url = {}
const cv = {}
const extra = ['campfire', 'firepit', 'sciencemachine', 'alchemyengine', 'crockpot']
const extra = ['campfire', 'firepit', 'sciencemachine', 'alchemyengine', 'crockpot', 'woodchest']
;[...ITEM_CODES, ...extra.filter((e) => !ITEMS[e])].forEach((code) => {
const canvas = document.createElement('canvas')
canvas.width = 40
@@ -661,6 +666,8 @@ export function drawEnt(ctx, ent, env) {
blit(ctx, sprite(winter ? 'alchemy_w' : 'alchemy', 60, 76, 30, 62, (c) => paintAlchemy(c, winter)))
} else if (ent.type === 'crockpot') {
drawCrockpot(ctx, ent, time)
} else if (ent.type === 'woodchest') {
drawWoodchest(ctx, ent)
} else if (ent.type === 'trap') {
drawTrap(ctx, ent, time)
} else if (ent.type === 'rabbit') {
@@ -674,6 +681,32 @@ export function drawEnt(ctx, ent, env) {
}
}
function drawWoodchest(ctx, ent) {
shadow(ctx, 0, 6, 18, 5)
const open = !!ent.mx?.open
ctx.fillStyle = '#7a5230'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 2
ctx.beginPath(); ctx.roundRect(-18, -10, 36, 20, 3); ctx.fill(); ctx.stroke()
ctx.fillStyle = open ? '#5d3f24' : '#8a5f38'
ctx.beginPath()
if (open) {
ctx.roundRect(-18, -28, 36, 10, 3)
} else {
ctx.roundRect(-19, -18, 38, 10, 3)
}
ctx.fill(); ctx.stroke()
if (open) {
ctx.fillStyle = '#2a1c10'
ctx.fillRect(-15, -10, 30, 6)
} else {
ctx.fillStyle = '#d8b04a'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 1.4
ctx.beginPath(); ctx.roundRect(-3, -8, 6, 7, 1.5); ctx.fill(); ctx.stroke()
}
}
// 陷阱:armed 张开支起 / caught 扣地晃动
function drawTrap(ctx, ent, time) {
const wob = ent.state === 'caught' && ent.shake > 0 ? Math.sin(time * 40) * 2 : 0

View File

@@ -378,8 +378,19 @@ function drawToolLegacy(c, toolCode) {
}
// 增强手持物:更大、轮廓更清楚、材质区分更明显(斧/镐/铲/矛/触手/火把)
function resolveToolKind(toolCode) {
if (!toolCode) return ''
if (toolCode === 'pickaxe' || toolCode === 'indpickaxe' || toolCode === 'pick') return 'pick'
if (toolCode === 'axe' || toolCode === 'indaxe') return 'axe'
if (toolCode === 'shovel') return 'shovel'
if (toolCode === 'agrihoe' || toolCode === 'hoe') return 'hoe'
if (toolCode === 'husbshears') return 'shears'
return toolCode
}
function drawToolEnhanced(c, toolCode, toolAng = 0) {
if (!toolCode) return
const kind = resolveToolKind(toolCode)
c.save()
c.translate(0, 9)
c.rotate(toolAng)
@@ -388,7 +399,7 @@ function drawToolEnhanced(c, toolCode, toolAng = 0) {
const wood = '#8a5a2b'
const dark = '#1d1409'
const iron = '#9aa0a8'
if (toolCode === 'shovel') {
if (kind === 'shovel') {
// 木柄 + 宽铲头
c.strokeStyle = wood
c.lineWidth = 3.2
@@ -402,7 +413,22 @@ function drawToolEnhanced(c, toolCode, toolAng = 0) {
fs(c, iron, dark, 1.4)
c.fillStyle = 'rgba(255,255,255,0.35)'
c.beginPath(); c.moveTo(-2, -16); c.quadraticCurveTo(0, -22, 2, -16); c.closePath(); c.fill()
} else if (toolCode === 'tentaclespike') {
} else if (kind === 'hoe') {
c.strokeStyle = wood
c.lineWidth = 3.2
c.beginPath(); c.moveTo(0, 6); c.lineTo(0, -18); c.stroke()
c.beginPath()
c.moveTo(0, -16); c.lineTo(12, -12); c.lineTo(12, -6); c.lineTo(2, -10)
c.closePath()
fs(c, iron, dark, 1.4)
} else if (kind === 'shears') {
c.strokeStyle = '#6f767d'
c.lineWidth = 2.4
c.beginPath(); c.moveTo(-3, 4); c.lineTo(4, -18); c.stroke()
c.beginPath(); c.moveTo(3, 4); c.lineTo(-4, -18); c.stroke()
c.fillStyle = '#4a4f55'
c.beginPath(); c.arc(0, -6, 2.2, 0, 7); c.fill()
} else if (kind === 'tentaclespike') {
c.strokeStyle = '#6b4a7e'
c.lineWidth = 3.6
c.beginPath(); c.moveTo(0, 8); c.quadraticCurveTo(3, -10, 0, -24); c.stroke()
@@ -410,7 +436,7 @@ function drawToolEnhanced(c, toolCode, toolAng = 0) {
;[[0, -10], [1, -16], [-1, -21]].forEach(([x, y]) => {
c.beginPath(); c.moveTo(x, y); c.lineTo(x + 5, y - 2); c.lineTo(x + 1, y - 6); c.closePath(); c.fill()
})
} else if (toolCode === 'torch') {
} else if (kind === 'torch') {
c.strokeStyle = wood
c.lineWidth = 3.2
c.beginPath(); c.moveTo(0, 6); c.lineTo(0, -14); c.stroke()
@@ -427,7 +453,7 @@ function drawToolEnhanced(c, toolCode, toolAng = 0) {
c.quadraticCurveTo(4, -15, 0, -12)
c.quadraticCurveTo(-4, -15, 0, -19)
fs(c, '#ffd76a', '#e8a03c', 1)
} else if (toolCode === 'axe') {
} else if (kind === 'axe') {
c.strokeStyle = wood
c.lineWidth = 3.2
c.beginPath(); c.moveTo(0, 6); c.lineTo(0, -15); c.stroke()
@@ -440,7 +466,7 @@ function drawToolEnhanced(c, toolCode, toolAng = 0) {
fs(c, '#b8bec6', dark, 1.4)
c.fillStyle = 'rgba(255,255,255,0.4)'
c.beginPath(); c.moveTo(1, -14); c.lineTo(9, -10.4); c.lineTo(8, -5.5); c.lineTo(3, -8); c.closePath(); c.fill()
} else if (toolCode === 'pick') {
} else if (kind === 'pick') {
c.strokeStyle = wood
c.lineWidth = 3.2
c.beginPath(); c.moveTo(0, 6); c.lineTo(0, -14); c.stroke()
@@ -453,7 +479,7 @@ function drawToolEnhanced(c, toolCode, toolAng = 0) {
fs(c, '#5c6470', dark, 1.4)
c.fillStyle = 'rgba(255,255,255,0.28)'
c.beginPath(); c.moveTo(-6, -14); c.quadraticCurveTo(0, -20, 6, -14); c.quadraticCurveTo(0, -17, -6, -14); c.closePath(); c.fill()
} else if (toolCode === 'spear') {
} else if (kind === 'spear') {
c.strokeStyle = wood
c.lineWidth = 3
c.beginPath(); c.moveTo(0, 8); c.lineTo(0, -24); c.stroke()
@@ -466,6 +492,15 @@ function drawToolEnhanced(c, toolCode, toolAng = 0) {
fs(c, '#c3c8d0', dark, 1.4)
c.fillStyle = 'rgba(255,255,255,0.45)'
c.beginPath(); c.moveTo(0, -30); c.lineTo(1.6, -24); c.lineTo(-1.6, -24); c.closePath(); c.fill()
} else {
// 未知手持:通用木柄,避免挥空
c.strokeStyle = wood
c.lineWidth = 3
c.beginPath(); c.moveTo(0, 6); c.lineTo(0, -14); c.stroke()
c.fillStyle = iron
c.strokeStyle = dark
c.lineWidth = 1.4
c.beginPath(); c.roundRect(-4, -20, 8, 8, 2); c.fill(); c.stroke()
}
c.restore()
}

View File

@@ -43,9 +43,34 @@ const buyingProp = ref('') // 正在购买的道具码(防连点)
const levelsMeta = meta?.levels || null // {total, names?}
const progress = ref({ level: 1, friends: [] }) // 我的进度 + 好友进度
// ---- 云存档(注册表标记 saveable 的游戏才启用,如饥荒) ----
// ---- 云存档(注册表标记 saveable 的游戏才启用,如饥荒;支持多存档) ----
const saveable = !!meta?.saveable
const saveInfo = ref({ exists: false }) // {exists, day, score, data}
const savesList = ref([]) // [{id, name, day, score, updated_at}]
const savesMax = ref(12)
const activeSaveId = ref(null) // 本局正在写入的存档 id
const pendingSaveName = ref('') // 新开局待写入的存档名
const showSaveNameModal = ref(false)
const saveNameInput = ref('')
const saveBusy = ref(false)
function formatSaveTime(ts) {
if (!ts) return ''
const d = new Date((ts < 1e12 ? ts * 1000 : ts))
if (Number.isNaN(d.getTime())) return ''
const p = (n) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
}
async function refreshSavesList() {
if (!saveable) return
try {
const res = await http.get(`/games/${code}/saves`)
savesList.value = res?.list || []
savesMax.value = res?.max || 12
} catch {
savesList.value = []
}
}
// ---- 组队联机 + 皮肤更衣室(注册表标记 coop 的游戏才启用,如饥荒) ----
const coopable = !!meta?.coop
@@ -152,14 +177,7 @@ const modFactor = computed(() => {
modsAvail.forEach((m) => { if (selMods.value.includes(m.id)) f *= m.factor })
return Math.round(Math.max(0.5, Math.min(1.5, f)) * 100) / 100
})
// 存档里记录的 mod 名单(继续冒险时只读展示,以存档为准)
const saveModNames = computed(() => {
if (!saveInfo.value?.exists || !saveInfo.value.data) return []
try {
const ids = JSON.parse(saveInfo.value.data).mods || []
return modsAvail.filter((m) => ids.includes(m.id)).map((m) => m.name)
} catch { return [] }
})
const hasSaves = computed(() => savesList.value.length > 0)
// 背包中指定道具的数量
function propCount(propCode) {
@@ -204,9 +222,9 @@ async function load() {
if (levelsMeta) {
progress.value = await http.get(`/games/${code}/progress`)
}
// 支持存档的游戏:读取云存档(有档则开始面板显示「继续冒险」)
// 支持存档的游戏:拉取多存档列表
if (saveable) {
saveInfo.value = await http.get(`/games/${code}/save`).catch(() => ({ exists: false }))
await refreshSavesList()
}
// 组队游戏:拉取皮肤商城列表(选中的皮肤若未拥有则回退默认)
if (coopable) {
@@ -220,15 +238,21 @@ async function load() {
phase.value = 'ready'
}
// 开始本局:数字=起始关卡(关卡选择面板传入);{save}=从云存档继续
// 开始本局:数字=起始关卡;{save}=从云存档继续;{saveName}=新开局命名
function startGame(arg = 1) {
const level = typeof arg === 'number' ? arg : 1
const save = typeof arg === 'object' && arg?.save ? arg.save : null
const saveName = typeof arg === 'object' && arg?.saveName ? String(arg.saveName).trim() : ''
liveScore.value = save?.score || 0
result.value = null
coopPlaying.value = false
phase.value = 'playing'
startAt.value = Date.now()
if (save && arg?.saveId) activeSaveId.value = arg.saveId
else if (!save) {
activeSaveId.value = null
pendingSaveName.value = saveName || '冒险'
}
// 等游戏组件挂载后启动(组件为异步 chunk,逐帧重试直到实例就绪,避免只等一帧的竞态)
const deadline = performance.now() + 3000
const tryStart = () => {
@@ -239,29 +263,83 @@ function startGame(arg = 1) {
requestAnimationFrame(tryStart)
}
// 从云存档继续(解析失败则按新开一局处理)
function continueSave() {
let snap = null
try {
snap = JSON.parse(saveInfo.value.data)
} catch (e) {
snap = null
// 打开「新存档」命名弹窗
function openNewSaveModal() {
if (savesList.value.length >= savesMax.value) {
toast(`最多 ${savesMax.value} 个存档,请先删除旧档`)
return
}
startGame(snap ? { save: snap } : 1)
saveNameInput.value = `冒险 ${savesList.value.length + 1}`
showSaveNameModal.value = true
}
// 放弃存档重新开始:先删服务端存档再开新局
async function restartFresh() {
await http.delete(`/games/${code}/save`).catch(() => {})
saveInfo.value = { exists: false }
startGame()
function confirmNewSave() {
const name = saveNameInput.value.trim() || '冒险'
showSaveNameModal.value = false
startGame({ saveName: name })
}
// 游戏内自动/手动存档:转存服务端(快照由游戏组件序列化)
function onSave(snap) {
if (!saveable || !snap) return
saveInfo.value = { exists: true, day: snap.day, score: snap.score, data: JSON.stringify(snap) }
http.post(`/games/${code}/save`, { day: snap.day || 1, score: snap.score || 0, data: JSON.stringify(snap) }).catch(() => {})
// 从指定云存档继续
async function continueSaveById(id) {
if (saveBusy.value) return
saveBusy.value = true
try {
const row = await http.get(`/games/${code}/saves/${id}`)
let snap = null
try {
snap = JSON.parse(row.data)
} catch {
snap = null
}
if (!snap) {
toast('存档损坏')
return
}
startGame({ save: snap, saveId: row.id })
} catch {
// 拦截器已提示
} finally {
saveBusy.value = false
}
}
// 删除指定存档
async function deleteSaveById(id) {
if (saveBusy.value) return
if (!confirm('确定删除这份存档?此操作不可恢复。')) return
saveBusy.value = true
try {
await http.delete(`/games/${code}/saves/${id}`)
if (activeSaveId.value === id) activeSaveId.value = null
await refreshSavesList()
toast('已删除存档')
} catch {
//
} finally {
saveBusy.value = false
}
}
// 游戏内自动/手动存档:新建或覆盖当前 activeSaveId
async function onSave(snap) {
if (!saveable || !snap || coopPlaying.value) return
const body = {
id: activeSaveId.value || 0,
name: pendingSaveName.value || '冒险',
day: snap.day || 1,
score: snap.score || 0,
data: JSON.stringify(snap),
}
try {
const res = await http.post(`/games/${code}/saves`, body)
if (res?.id) {
activeSaveId.value = res.id
pendingSaveName.value = res.name || pendingSaveName.value
}
await refreshSavesList()
} catch {
//
}
}
// 游戏内过关:上报解锁的新关卡(只增不减,后端同样兜底)
@@ -276,13 +354,16 @@ function onScore(n) {
liveScore.value = n
}
// 游戏结束回调:单人存档游戏死亡即删档(复刻原版永久死亡);联机局不动单人存档
function onEnd(payload) {
// 游戏结束回调:单人只删本局这份存档(永久死亡);联机局不动单人存档
async function onEnd(payload) {
finalScore.value = payload?.score ?? liveScore.value
phase.value = 'over'
if (saveable && !coopPlaying.value) {
saveInfo.value = { exists: false }
http.delete(`/games/${code}/save`).catch(() => {})
if (saveable && !coopPlaying.value && activeSaveId.value) {
const id = activeSaveId.value
activeSaveId.value = null
pendingSaveName.value = ''
await http.delete(`/games/${code}/saves/${id}`).catch(() => {})
await refreshSavesList()
}
coopPlaying.value = false
}
@@ -719,7 +800,7 @@ onBeforeUnmount(() => {
<!-- Mod 装载:新开局生效;继续冒险以存档内记录为准 -->
<div v-if="modsAvail.length" class="mod-pick">
<div class="mp-head">
<b>Mod 装载{{ saveable && saveInfo.exists ? '(重新开始时生效)' : '(可选)' }}</b>
<b>Mod 装载{{ saveable && hasSaves ? '(新建冒险时生效)' : '(可选)' }}</b>
<span v-if="modFactor !== 1" class="mp-total" :class="{ up: modFactor > 1 }">得分 ×{{ modFactor }}</span>
</div>
<label v-for="m in modsAvail" :key="m.id" class="mp-item" :class="{ on: selMods.includes(m.id) }">
@@ -734,18 +815,30 @@ onBeforeUnmount(() => {
<span v-if="selMods.length" class="mod-trigger-count">{{ selMods.length }} 个已选</span>
<span v-if="modFactor !== 1" class="mod-trigger-factor" :class="{ up: modFactor > 1 }">得分 ×{{ modFactor }}</span>
</button>
<template v-if="saveable && saveInfo.exists">
<div class="save-card">
<b>发现存档</b>
<span v-if="saveModNames.length" class="save-mods">Mod:{{ saveModNames.join('、') }}</span>
<span>第 {{ saveInfo.day }} 天 · 得分 {{ saveInfo.score }}</span>
<div v-if="saveable && hasSaves" class="save-list">
<div class="save-list-head">
<b>我的存档</b>
<span class="text-dim">{{ savesList.length }}/{{ savesMax }}</span>
</div>
<div class="ov-btns">
<button class="btn btn-lg" @click="continueSave">继续冒险</button>
<button class="btn btn-ghost" @click="restartFresh">放弃存档重新开始</button>
<div
v-for="s in savesList"
:key="s.id"
class="save-card save-row"
>
<div class="save-row-main">
<b>{{ s.name || '冒险' }}</b>
<span>第 {{ s.day }} 天 · 得分 {{ s.score }}</span>
<span v-if="s.updated_at" class="save-time">{{ formatSaveTime(s.updated_at) }}</span>
</div>
<div class="save-row-btns">
<button class="btn btn-sm" :disabled="saveBusy" @click="continueSaveById(s.id)">继续</button>
<button class="btn btn-ghost btn-sm" :disabled="saveBusy" @click="deleteSaveById(s.id)">删除</button>
</div>
</div>
</template>
<button v-else class="btn btn-lg" @click="startGame()">新的冒险</button>
</div>
<div class="ov-btns">
<button class="btn btn-lg" :disabled="saveBusy" @click="openNewSaveModal">新的冒险</button>
</div>
<button class="btn btn-ghost btn-sm" @click="coopStage = 'mode'">← 返回</button>
</template>
<!-- 3. 更衣室:分类 Tab + 立绘网格(已解锁可穿、未解锁积分购买) -->
@@ -895,18 +988,28 @@ onBeforeUnmount(() => {
:friends="progress.friends"
@pick="startGame"
/>
<!-- 存档游戏:有档显示「继续 / 重新开始」,无档显示「新的冒险」 -->
<template v-else-if="saveable && saveInfo.exists">
<div class="save-card">
<b>发现存档</b>
<span>第 {{ saveInfo.day }} 天 · 得分 {{ saveInfo.score }}</span>
</div>
<div class="ov-btns">
<button class="btn btn-lg" @click="continueSave">继续冒险</button>
<button class="btn btn-ghost" @click="restartFresh">放弃存档重新开始</button>
<!-- 存档游戏:多存档列表 + 新建 -->
<template v-else-if="saveable">
<div v-if="hasSaves" class="save-list">
<div class="save-list-head">
<b>我的存档</b>
<span class="text-dim">{{ savesList.length }}/{{ savesMax }}</span>
</div>
<div v-for="s in savesList" :key="s.id" class="save-card save-row">
<div class="save-row-main">
<b>{{ s.name || '冒险' }}</b>
<span>第 {{ s.day }} 天 · 得分 {{ s.score }}</span>
<span v-if="s.updated_at" class="save-time">{{ formatSaveTime(s.updated_at) }}</span>
</div>
<div class="save-row-btns">
<button class="btn btn-sm" :disabled="saveBusy" @click="continueSaveById(s.id)">继续</button>
<button class="btn btn-ghost btn-sm" :disabled="saveBusy" @click="deleteSaveById(s.id)">删除</button>
</div>
</div>
</div>
<button class="btn btn-lg" :disabled="saveBusy" @click="openNewSaveModal">新的冒险</button>
</template>
<button v-else class="btn btn-lg" @click="startGame()">{{ saveable ? '新的冒险' : '开始游戏' }}</button>
<button v-else class="btn btn-lg" @click="startGame()">开始游戏</button>
</template>
</div>
<!-- 结算遮罩 -->
@@ -990,6 +1093,26 @@ onBeforeUnmount(() => {
</div>
</div>
<!-- 新建存档命名 -->
<div v-if="showSaveNameModal" class="modal-mask" @click.self="showSaveNameModal = false">
<div class="save-name-modal panel">
<button class="bm-close" @click="showSaveNameModal = false">✕</button>
<h3>新建存档</h3>
<p class="text-dim" style="font-size: 12px">给这次冒险起个名字(最多 32 字)</p>
<input
v-model="saveNameInput"
class="save-name-input"
maxlength="32"
placeholder="存档名称"
@keyup.enter="confirmNewSave"
/>
<div class="ov-btns" style="margin-top: 14px">
<button class="btn btn-lg" @click="confirmNewSave">开始冒险</button>
<button class="btn btn-ghost" @click="showSaveNameModal = false">取消</button>
</div>
</div>
</div>
<!-- 操作手册 -->
<div v-if="showManual" class="modal-mask" @click.self="closeManual">
<div class="mod-modal panel manual-modal">
@@ -998,7 +1121,7 @@ onBeforeUnmount(() => {
<p class="text-dim" style="font-size: 12px; line-height: 1.6; text-align: left">
{{ meta.controls }}<br />
移动端:点击地面移动{{ touchMode === 'both' || touchMode === 'joystick' ? ',也可使用左下角摇杆' : '' }};点击目标会自动攻击/采集。<br />
快捷键:WASD 移动 · 空格采集 · F 攻击 · C 合成 · M 地图 · P/Esc 暂停。
快捷键:WASD 移动 · 空格采集 · F 攻击 · E 一键交互 · C 合成 · B 背包 · M 地图 · P/Esc 暂停。
</p>
<div class="ov-btns">
<button class="btn" @click="closeManual">知道了</button>
@@ -1135,6 +1258,23 @@ onBeforeUnmount(() => {
font-size: 13px;
}
/* 存档信息卡(存档游戏的开始面板) */
.save-list {
width: min(460px, 92%);
display: flex;
flex-direction: column;
gap: 8px;
max-height: 240px;
overflow: auto;
text-align: left;
}
.save-list-head {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 4px;
color: var(--text);
font-size: 13px;
}
.save-card {
display: flex;
flex-direction: column;
@@ -1142,12 +1282,50 @@ onBeforeUnmount(() => {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 12px 26px;
padding: 12px 16px;
}
.save-row {
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.save-row-main {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.save-row-btns {
display: flex;
gap: 6px;
flex-shrink: 0;
}
.save-card b {
color: var(--primary-2);
font-size: 14px;
}
.save-time {
font-size: 11px;
color: var(--text-dim, #888);
}
.save-name-modal {
width: min(360px, 92%);
position: relative;
padding: 20px 22px 18px;
text-align: center;
}
.save-name-input {
width: 100%;
margin-top: 10px;
padding: 10px 12px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--bg, #1a1520);
color: var(--text);
font-size: 14px;
box-sizing: border-box;
}
.save-mods {
color: #b48ad8;
}