升级饥荒
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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 款人物皮肤',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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: '碎石',
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
188
src/games/starve/mods/zoneUtil.js
Normal file
188
src/games/starve/mods/zoneUtil.js
Normal 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 }
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user