diff --git a/src/games/components/StarveGame.vue b/src/games/components/StarveGame.vue
index 1182e57..32251e9 100644
--- a/src/games/components/StarveGame.vue
+++ b/src/games/components/StarveGame.vue
@@ -9,7 +9,7 @@ import { skinByCode, drawPlayerFig } from '../starveSkins'
import { CAP, DAY_LEN, cookPot, DECOR } from '../starve/data'
import { seasonOf, daySplit, phaseOf, ambientTemp, rollWeather, tickWeather, updateBodyTemp } from '../starve/climate'
import {
- TILE, GW, GH, WORLD, BIOMES, tileIdx, isLand, biomeAt, onRoad, findLandNear,
+ TILE, GW, GH, WORLD, BIOMES, tileIdx, isLand, biomeAt, onRoad, findLandNear, snapToLand,
genIsland, planEntities, genDecor, buildTilePatterns, drawTiles, configureWorld,
} from '../starve/world'
import { buildIcons, drawEnt, drawMob, drawFirePainter, drawDecor } from '../starve/paint'
@@ -46,6 +46,8 @@ const ghostMode = ref(false)
const deathStats = ref({ day: 1, score: 0, kills: 0 })
const hp = ref(CAP.hp)
const hunger = ref(CAP.hunger)
+const foodBag = ref(0)
+const foodStore = ref(0)
const san = ref(CAP.san)
const temp = ref(20)
const day = ref(1)
@@ -54,6 +56,7 @@ const seasonTag = ref({ name: '秋', dayIn: 1, len: 20, tint: '#c98548', code: '
const tips = ref([])
const hint = ref('')
const nearActions = ref([])
+const autoInteractOn = ref(false)
const craftOpen = ref(false)
const craftTab = ref('tools')
const packOpen = ref(false)
@@ -187,6 +190,13 @@ let flashRed = 0
let potOpenId = 0
let boxOpenId = 0
const BOX_SLOTS = 12
+// 建造/迁移:选位置放置(不落在脚下)
+let placeMode = null // { code, name, cost, kind:'ent'|'fire', pit?, relocateId?, extra? }
+let placeCursor = { x: 0, y: 0 }
+const NO_RELOCATE = new Set([
+ 'fence', 'gate', 'campfence', 'campgate', 'houseplan', 'agrizone', 'pasturezone',
+ 'housezone', 'agrifarm', 'stump', 'rubble', 'chest', 'startchest',
+])
let invDirty = true
let hudT = 0
let mapT = 0
@@ -296,10 +306,46 @@ function addItem(p, code, n = 1, fresh = 1, dur = null) {
invDirty = true
if (n > 0 && isHostRole()) dropLoot(p.x + randInt(-20, 20), p.y + randInt(10, 26), code, n, fresh)
}
+// 大仓库 / 营地仓储合计(合成、建造可共用)
+const STOCK_STORE_TYPES = new Set(['laborwarehouse', 'campstore'])
+function warehouseCount(code) {
+ let n = 0
+ for (const e of entities) {
+ if (!STOCK_STORE_TYPES.has(e.type)) continue
+ const boxes = e.mx?.boxes
+ if (!Array.isArray(boxes)) continue
+ for (const b of boxes) n += b?.items?.[code] || 0
+ }
+ return n
+}
+function withdrawWarehouse(code, amount) {
+ let need = Math.max(0, amount | 0)
+ if (need <= 0) return 0
+ let got = 0
+ for (const e of entities) {
+ if (need <= 0 || !STOCK_STORE_TYPES.has(e.type)) continue
+ const boxes = e.mx?.boxes
+ if (!Array.isArray(boxes)) continue
+ for (const b of boxes) {
+ if (need <= 0) break
+ if (!b.items) b.items = {}
+ const have = b.items[code] || 0
+ if (have <= 0) continue
+ const take = Math.min(have, need)
+ b.items[code] = have - take
+ if (b.items[code] <= 0) delete b.items[code]
+ need -= take
+ got += take
+ markDirty(e)
+ }
+ }
+ return got
+}
function hasCost(p, cost) {
- return Object.entries(cost).every(([code, n]) => countItem(p, code) >= n)
+ return Object.entries(cost).every(([code, n]) => countItem(p, code) + warehouseCount(code) >= n)
}
function payCost(p, cost) {
+ // 先扣背包,不够再扣仓库(避免「存入仓库」时误从仓里扣)
Object.entries(cost).forEach(([code, need]) => {
let n = need
;[p.slots, p.pack].filter(Boolean).forEach((area) => {
@@ -313,8 +359,10 @@ function payCost(p, cost) {
}
}
})
+ if (n > 0) withdrawWarehouse(code, n)
})
invDirty = true
+ return true
}
function slotArea(p, area) {
return area === 'pack' ? p.pack : p.slots
@@ -534,7 +582,7 @@ function makeEnt(type, x, y, extra = {}) {
}
if (type === 'tentacle') { e.state = 'lurk'; e.atkT = 0; e.cd = 0; e.dir = 1 }
if (type === 'rabbit') { e.vx = 0; e.vy = 0; e.dir = 1; e.hop = Math.random() * 7; e.flee = false; e.wanderT = 0 }
- if (type === 'rabbithole') { e.respawnT = 0; e.rabbitId = 0 }
+ if (type === 'rabbithole') { e.respawnT = 0; e.rabbitId = 0; e.solid = false }
if (type === 'beehive') e.beeCd = 0
if (type === 'pighouse') { e.pigId = 0; e.respawnT = 0 }
modRt.entKinds[type]?.init?.(e)
@@ -715,19 +763,76 @@ function screenDeltaToLocal(dx, dy) {
let suppressClick = false
function onCanvasPointerDown(ev) {
if (!playing.value || !player || player.dead || waitingWorld.value) return
+ // 右键:取消建造 / 开始迁移 / mod(升级农田牧场)
+ if (ev.button === 2) {
+ const { x: wxp, y: wyp } = eventToWorld(ev)
+ if (placeMode) {
+ cancelPlaceMode()
+ suppressClick = true
+ ev.preventDefault?.()
+ return
+ }
+ const mov = findRelocateAt(wxp, wyp)
+ if (mov) {
+ startPlaceMode(mov.type, RECIPES.find((x) => x.code === mov.type) || { name: NAMES[mov.type] || mov.type, cost: {} }, {
+ kind: 'ent',
+ relocateId: mov.id,
+ })
+ suppressClick = true
+ ev.preventDefault?.()
+ return
+ }
+ if (callHandled(modRt, 'onRightClick', gCtx, wxp, wyp, ev)) {
+ suppressClick = true
+ ev.preventDefault?.()
+ }
+ return
+ }
if (ev.button != null && ev.button !== 0) return
const { x: wxp, y: wyp } = eventToWorld(ev)
+ if (placeMode) {
+ confirmPlaceAt(wxp, wyp)
+ suppressClick = true
+ ev.preventDefault?.()
+ return
+ }
if (callHandled(modRt, 'onPointerDown', gCtx, wxp, wyp, ev)) {
suppressClick = true
ev.preventDefault?.()
}
}
+function onCanvasContextMenu(ev) {
+ ev.preventDefault?.()
+ if (!playing.value || !player || player.dead || waitingWorld.value) return
+ const { x: wxp, y: wyp } = eventToWorld(ev)
+ if (placeMode) {
+ cancelPlaceMode()
+ suppressClick = true
+ return
+ }
+ const mov = findRelocateAt(wxp, wyp)
+ if (mov) {
+ startPlaceMode(mov.type, RECIPES.find((x) => x.code === mov.type) || { name: NAMES[mov.type] || mov.type, cost: {} }, {
+ kind: 'ent',
+ relocateId: mov.id,
+ })
+ suppressClick = true
+ return
+ }
+ if (callHandled(modRt, 'onRightClick', gCtx, wxp, wyp, ev)) suppressClick = true
+}
function onCanvasPointerMove(ev) {
if (!playing.value || !player || player.dead || waitingWorld.value) {
updateCursor(ev)
return
}
const { x: wxp, y: wyp } = eventToWorld(ev)
+ placeCursor.x = wxp
+ placeCursor.y = wyp
+ if (placeMode) {
+ updateCursor(ev)
+ return
+ }
if (callHandled(modRt, 'onPointerMove', gCtx, wxp, wyp, ev)) {
suppressClick = true
ev.preventDefault?.()
@@ -737,6 +842,7 @@ function onCanvasPointerMove(ev) {
}
function onCanvasPointerUp(ev) {
if (!playing.value || !player || player.dead || waitingWorld.value) return
+ if (placeMode) return
const { x: wxp, y: wyp } = eventToWorld(ev)
if (callHandled(modRt, 'onPointerUp', gCtx, wxp, wyp, ev)) {
suppressClick = true
@@ -753,6 +859,10 @@ function onCanvasClick(ev) {
}
if (!playing.value || !player || player.dead || waitingWorld.value) return
const { x: wxp, y: wyp } = eventToWorld(ev)
+ if (placeMode) {
+ confirmPlaceAt(wxp, wyp)
+ return
+ }
// mod 点击钩子:任一 mod 处理并返回 true 则吃掉本次点击(不选中怪物/火/实体,也不点地移动)
if (callHandled(modRt, 'onClick', gCtx, wxp, wyp)) return
let mon = null
@@ -768,7 +878,7 @@ function onCanvasClick(ev) {
let ent = null
let ed = Infinity
entities.forEach((e) => {
- if (e.deco && e.type !== 'stump') return
+ if (e.deco && e.type !== 'stump' && !modRt.entKinds[e.type]?.interact) return
if (e.stub) return
// 栅栏等 solid 不可点选;门始终可点以开关
if (e.solid && e.type !== 'gate' && e.type !== 'campgate') return
@@ -844,7 +954,8 @@ function canInteractEnt(e) {
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
+ // deco 默认不可点;有 mod interact 的(如房屋地基)可点
+ if (e.deco && e.type !== 'stump' && !modRt.entKinds[e.type]?.interact) 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)
}
@@ -884,15 +995,50 @@ function advanceQuickQueue() {
quickAdvancing = false
}
}
+function isAutoInteractSkipEnt(e) {
+ if (!e) return true
+ // 篝火另有添柴;房屋/地基休息改名不进自动交互
+ if (e.type === 'camphouse' || e.type === 'houseplan' || e.type === 'resttent') return true
+ return false
+}
+function pickAutoInteractTarget() {
+ if (!player || player.dead) return null
+ let best = null
+ let bd = 150
+ entities.forEach((e) => {
+ if (!canInteractEnt(e) || isAutoInteractSkipEnt(e)) return
+ const d = Math.hypot(e.x - player.x, e.y - player.y)
+ if (d < bd) { bd = d; best = { kind: 'ent', id: e.id } }
+ })
+ // 可交互友好生物(劳工绑定等);不含篝火、不含纯攻击
+ monsters.forEach((m) => {
+ if (!modRt.mobKinds[m.kind]?.interact) return
+ const d = Math.hypot(m.x - player.x, m.y - player.y)
+ if (d < Math.min(bd, 90)) { bd = d; best = { kind: 'mon', id: m.id } }
+ })
+ return best
+}
+function tickAutoInteract() {
+ if (!autoInteractOn.value || !playing.value || !player || player.dead || waitingWorld.value) return
+ if (player.target) return
+ const t = pickAutoInteractTarget()
+ if (t) setMyTarget(t)
+}
function doQuickInteract() {
if (!playing.value || !player || player.dead || waitingWorld.value) return
- const list = nearActions.value
- if (!list.length) {
- doGather()
- return
+ // E:自动交互开关(开=持续寻找可交互物,关=停止;不含篝火/房屋)
+ autoInteractOn.value = !autoInteractOn.value
+ if (autoInteractOn.value) {
+ tip('自动交互:开(再按 E 关闭)')
+ clearQuickQueue()
+ tickAutoInteract()
+ } else {
+ clearQuickQueue()
+ if (player.target && (player.target.kind === 'ent' || player.target.kind === 'mon' || player.target.kind === 'fire')) {
+ player.target = null
+ }
+ tip('自动交互:关')
}
- quickQueue = list.map((a) => ({ kind: a.kind, id: a.id }))
- advanceQuickQueue()
}
function refreshNearActions() {
if (!playing.value || !player || player.dead) {
@@ -1114,7 +1260,9 @@ function hitEntity(p, e) {
swingAct(p, 'dig')
useToolDur(p)
addItem(p, 'log', 1)
- ptip(p, '挖出树桩 · 木头 +1')
+ ptip(p, '挖出树桩 · 木头 +1(此处不再长树)')
+ e.respawnAs = null
+ e.respawnT = 0
removeEntity(e)
p.target = null
return
@@ -1170,11 +1318,16 @@ function hitEntity(p, e) {
if (e.type === 'tree') {
treesChopped++
removeEntity(e)
- addEntity('stump', e.x, e.y)
+ const stump = addEntity('stump', e.x, e.y)
+ stump.respawnAs = 'tree'
+ stump.respawnT = 420 * (0.85 + Math.random() * 0.35)
maybeTreeguard(p, e)
} else if (e.type === 'rock' || e.type === 'goldrock') {
+ const was = e.type
removeEntity(e)
- addEntity('rubble', e.x, e.y)
+ const rubble = addEntity('rubble', e.x, e.y)
+ rubble.respawnAs = was
+ rubble.respawnT = 520 * (0.85 + Math.random() * 0.4)
} else if (e.vanish) {
removeEntity(e)
} else if (e.regrow) {
@@ -1486,11 +1639,118 @@ function doCraft(code) {
}
craftFor(player, code)
}
+function canRelocateEnt(e) {
+ if (!e || e.deco || e.loot || NO_RELOCATE.has(e.type)) return false
+ if (e.type === 'sciencemachine' || e.type === 'alchemyengine' || e.type === 'crockpot' || e.type === 'woodchest') return true
+ const r = RECIPES.find((x) => x.code === e.type)
+ return !!(r?.build)
+}
+function findRelocateAt(wx, wy) {
+ let best = null
+ let bd = Infinity
+ entities.forEach((e) => {
+ if (!canRelocateEnt(e)) return
+ const d = Math.hypot(e.x - wx, e.y - wy)
+ const r = Math.max(28, (e.size || 28) * 0.95)
+ if (d < r && d < bd) { bd = d; best = e }
+ })
+ return best
+}
+function cancelPlaceMode(silent = false) {
+ if (!placeMode) return
+ placeMode = null
+ if (!silent) tip('已取消')
+}
+function startPlaceMode(code, r, opts = {}) {
+ placeMode = {
+ code,
+ name: r?.name || NAMES[code] || code,
+ cost: opts.relocateId ? {} : { ...(r?.cost || {}) },
+ kind: opts.kind || 'ent',
+ pit: !!opts.pit,
+ relocateId: opts.relocateId || 0,
+ tech: r?.tech || 0,
+ extra: opts.extra || null,
+ }
+ craftOpen.value = false
+ packOpen.value = false
+ tip(opts.relocateId
+ ? `迁移「${placeMode.name}」:左键选新位置,右键取消`
+ : `放置「${placeMode.name}」:左键选位置,右键取消`)
+}
+function confirmPlaceAt(wx, wy) {
+ if (!placeMode || !player || player.dead) return false
+ const spot = snapToLand(tiles, wx, wy, 56)
+ if (!spot) {
+ tip('这里不能放,换块陆地')
+ return true
+ }
+ // 迁移:只改坐标
+ if (placeMode.relocateId) {
+ const e = entities.find((x) => x.id === placeMode.relocateId)
+ if (!e) {
+ cancelPlaceMode(true)
+ tip('建筑已不存在')
+ return true
+ }
+ e.x = spot.x
+ e.y = spot.y
+ markDirty(e)
+ tip(`「${placeMode.name}」已迁到新位置`)
+ placeMode = null
+ return true
+ }
+ const cost = placeMode.cost || {}
+ if (Object.keys(cost).length && !hasCost(player, cost)) {
+ tip('材料不足(背包+仓库)')
+ placeMode = null
+ return true
+ }
+ if (Object.keys(cost).length) payCost(player, cost)
+ if (placeMode.tech > 0 && !player.unlocked.has(placeMode.code)) {
+ player.unlocked.add(placeMode.code)
+ tip(`原型打造成功 · ${placeMode.name}已永久解锁`)
+ }
+ if (placeMode.kind === 'fire') {
+ if (placeMode.pit) {
+ fires.push({ fid: fid++, x: spot.x, y: spot.y, ttl: 60, max: 150, pit: true })
+ tip('石砌火堆造好了(永久建筑)')
+ } else {
+ fires.push({ fid: fid++, x: spot.x, y: spot.y, ttl: 50, max: 90, pit: false })
+ tip('篝火燃起来了')
+ }
+ } else {
+ let extra = placeMode.extra || {}
+ if (placeMode.code === 'camphouse' && !extra.mx) {
+ let name = '房屋'
+ try {
+ const v = typeof window !== 'undefined' ? window.prompt('给这座房屋起个名字', `房屋${entities.filter((e) => e.type === 'camphouse').length + 1}`) : null
+ if (v != null) name = String(v).trim().slice(0, 12) || name
+ } catch { /* */ }
+ extra = { mx: { restDay: 0, name, floors: 1 } }
+ }
+ addEntity(placeMode.code, spot.x, spot.y, extra)
+ tip(placeMode.code === 'camphouse' && extra.mx?.name ? `「${extra.mx.name}」建好了` : `${placeMode.name}建好了`)
+ addScore(30, player)
+ }
+ invDirty = true
+ addScore(10, player)
+ placeMode = null
+ return true
+}
function craftFor(p, code) {
const r = RECIPES.find((x) => x.code === code)
if (!r) return
if (!recipeAvailable(r, p)) return ptip(p, r.tech === 1 ? '需要靠近科学机器' : '需要靠近炼金引擎')
- if (!hasCost(p, r.cost)) return ptip(p, '材料不足')
+ if (!hasCost(p, r.cost)) return ptip(p, '材料不足(背包+仓库)')
+ const isBuild = code === 'campfire' || code === 'firepit' || code === 'sciencemachine' || code === 'alchemyengine' || code === 'crockpot' || r.build
+ // 本地玩家:进入选点放置;联机他人/主机代打仍脚下建造
+ if (isBuild && p === player) {
+ if (code === 'campfire') startPlaceMode(code, r, { kind: 'fire', pit: false })
+ else if (code === 'firepit') startPlaceMode(code, r, { kind: 'fire', pit: true })
+ else startPlaceMode(code, r, { kind: 'ent' })
+ return
+ }
payCost(p, r.cost)
if (r.tech > 0 && !p.unlocked.has(r.code)) {
p.unlocked.add(r.code)
@@ -1504,8 +1764,17 @@ function craftFor(p, code) {
ptip(p, '石砌火堆造好了(永久建筑)')
} else if (code === 'sciencemachine' || code === 'alchemyengine' || code === 'crockpot' || r.build) {
const spot = findLandNear(tiles, p.x + p.dir * 52, p.y, 0, 26)
- addEntity(code, spot.x, spot.y)
- ptip(p, `${r.name}建好了`)
+ let extra = {}
+ if (code === 'camphouse') {
+ let name = '房屋'
+ try {
+ const v = typeof window !== 'undefined' ? window.prompt('给这座房屋起个名字', `房屋${entities.filter((e) => e.type === 'camphouse').length + 1}`) : null
+ if (v != null) name = String(v).trim().slice(0, 12) || name
+ } catch { /* */ }
+ extra = { mx: { restDay: 0, name, floors: 1 } }
+ }
+ addEntity(code, spot.x, spot.y, extra)
+ ptip(p, code === 'camphouse' ? `「${extra.mx.name}」建好了` : `${r.name}建好了`)
addScore(30, p)
} else {
const it = ITEMS[code]
@@ -1621,7 +1890,7 @@ const gCtx = {
},
swing: (p, act) => swingAct(p, act),
heatAt: (p) => heatAt(p),
- cookPot: (codes) => cookPot(codes),
+ cookPot: (codes) => cookPot(codes, ITEMS),
// 按物品 code 对玩家施加 food 效果(食堂点餐等,不扣背包)
applyFood: (p, code, fresh = 1) => {
const it = ITEMS[code]
@@ -1649,9 +1918,12 @@ const gCtx = {
// 否决型钩子调用器(供 mod 使用):任一 mod 返回 false 即返回 false
// 与核心其它钩子一致,gCtx 作为钩子首参自动前置,mod 侧签名统一为 (g, ...)
allows: (name, ...args) => callAllows(modRt, name, gCtx, ...args),
+ keyDown: (k) => !!(keys && keys.has(k)),
me: () => player,
isHost: () => isHostRole(),
invalidate: () => { invDirty = true },
+ // 物品图标画布表(供 mod 面板预览)
+ get icons() { return ICONS.cv },
}
function updateDayNight(dt) {
@@ -1858,6 +2130,11 @@ function updatePlayers(dt) {
p.input.dx = dx
p.input.dy = dy
}
+ // mod 可接管移动(如房屋室内子地图)
+ if (p === player && !callAllows(modRt, 'allowsPlayerMove', gCtx, p)) {
+ p.walking = false
+ return
+ }
if (dx || dy) {
p.target = null
if (p === player) clearQuickQueue()
@@ -1868,7 +2145,10 @@ function updatePlayers(dt) {
p.walking = true
} else if (!p.dead) {
pursueTarget(p, dt)
- if (p === player && !p.target) advanceQuickQueue()
+ if (p === player && !p.target) {
+ if (autoInteractOn.value) tickAutoInteract()
+ else advanceQuickQueue()
+ }
}
if (p.walking) p.walkT += dt
})
@@ -1886,10 +2166,23 @@ function updateEntities(dt) {
markDirty(e)
}
}
+ // 树桩 / 碎石刷新回树 / 石头
+ if ((e.type === 'stump' || e.type === 'rubble') && e.respawnAs && e.respawnT != null) {
+ if (!winter && e.respawnT > 0) e.respawnT -= dt
+ if (e.respawnT <= 0) {
+ const typ = e.respawnAs
+ const x = e.x
+ const y = e.y
+ removeEntity(e)
+ addEntity(typ, x, y)
+ return
+ }
+ }
const modEnt = modRt.entKinds[e.type]
if (modEnt) { modEnt.update?.(e, dt, gCtx) }
else if (e.type === 'rabbit') updateRabbit(e, dt)
else if (e.type === 'rabbithole') {
+ if (e.solid) e.solid = false // 旧档洞不再挡路
if (e.respawnT > 0 && phaseName === 'day') {
e.respawnT -= dt
if (e.respawnT <= 0 && !e.rabbitId) spawnRabbit(e)
@@ -2126,19 +2419,44 @@ function syncHud(force = false) {
san.value = Math.max(0, Math.round(player.san))
temp.value = Math.round(player.temp)
seasonTag.value = { name: seasonNow.name, dayIn: seasonNow.dayIn, len: seasonNow.len, tint: seasonNow.tint, code: seasonNow.code }
- if (invDirty || force) {
- invDirty = false
- uiSlots.value = player.slots.map(packUiSlot)
- uiPack.value = player.pack ? player.pack.map(packUiSlot) : null
- uiEquip.value = {
- hand: player.equip.hand ? packUiSlot({ ...player.equip.hand, n: 1 }) : null,
- body: player.equip.body ? packUiSlot({ ...player.equip.body, n: 1 }) : null,
- head: player.equip.head ? packUiSlot({ ...player.equip.head, n: 1 }) : null,
+ {
+ let bag = 0
+ ;[player.slots, player.pack].filter(Boolean).forEach((area) => {
+ area.forEach((s) => { if (s && ITEMS[s.code]?.food) bag += s.n })
+ })
+ let store = 0
+ entities.forEach((e) => {
+ if (e.type === 'laborwarehouse' && Array.isArray(e.mx?.boxes)) {
+ e.mx.boxes.forEach((b) => {
+ Object.entries(b.items || {}).forEach(([code, n]) => {
+ if (ITEMS[code]?.food) store += n || 0
+ })
+ })
+ } else if (e.type === 'granary' && e.mx?.store) {
+ Object.values(e.mx.store).forEach((n) => { store += n || 0 })
+ }
+ })
+ foodBag.value = bag
+ foodStore.value = store
+ }
+ if (invDirty || force || craftOpen.value) {
+ if (invDirty || force) {
+ invDirty = false
+ uiSlots.value = player.slots.map(packUiSlot)
+ uiPack.value = player.pack ? player.pack.map(packUiSlot) : null
+ uiEquip.value = {
+ hand: player.equip.hand ? packUiSlot({ ...player.equip.hand, n: 1 }) : null,
+ body: player.equip.body ? packUiSlot({ ...player.equip.body, n: 1 }) : null,
+ head: player.equip.head ? packUiSlot({ ...player.equip.head, n: 1 }) : null,
+ }
+ uiUnlocked.value = Array.from(player.unlocked)
}
+ // 合成面板打开时每帧刷新材料数(含仓库),避免仓里有货仍灰显
const counts = {}
- RECIPES.forEach((r) => Object.keys(r.cost).forEach((c) => { counts[c] = countItem(player, c) }))
+ RECIPES.forEach((r) => Object.keys(r.cost).forEach((c) => {
+ counts[c] = countItem(player, c) + warehouseCount(c)
+ }))
uiCounts.value = counts
- uiUnlocked.value = Array.from(player.unlocked)
}
uiTech.value = techLevel(player)
// 烹饪锅面板视图
@@ -2252,7 +2570,8 @@ function clientTick(dt) {
} else if (t.kind === 'walk') p.target = null
}
} else if (!p.dead) {
- advanceQuickQueue()
+ if (autoInteractOn.value) tickAutoInteract()
+ else advanceQuickQueue()
}
if (p.walking) p.walkT += dt
moveSendT += dt
@@ -2791,6 +3110,37 @@ function draw() {
drawables.sort((a, b) => a.y - b.y)
drawables.forEach((d) => d.fn())
callHook(modRt, 'onDraw', gCtx, ctx, { camX, camY, W, H, time })
+ // 建造/迁移幽灵预览
+ if (placeMode) {
+ const spot = snapToLand(tiles, placeCursor.x, placeCursor.y, 56)
+ const px = (spot?.x ?? placeCursor.x) - camX
+ const py = (spot?.y ?? placeCursor.y) - camY
+ const ok = !!spot
+ ctx.save()
+ ctx.globalAlpha = 0.55
+ ctx.translate(px, py)
+ ctx.strokeStyle = ok ? '#8fd46a' : '#d46a6a'
+ ctx.fillStyle = ok ? 'rgba(120,200,80,0.18)' : 'rgba(200,80,80,0.18)'
+ ctx.lineWidth = 2
+ ctx.setLineDash([5, 4])
+ ctx.beginPath()
+ ctx.arc(0, 0, 22, 0, Math.PI * 2)
+ ctx.fill()
+ ctx.stroke()
+ ctx.setLineDash([])
+ // 落脚点十字,标明建筑脚底对准点击处
+ ctx.globalAlpha = 0.9
+ ctx.beginPath()
+ ctx.moveTo(-10, 0); ctx.lineTo(10, 0)
+ ctx.moveTo(0, -10); ctx.lineTo(0, 10)
+ ctx.stroke()
+ ctx.fillStyle = '#f4ead2'
+ ctx.font = 'bold 12px "PingFang SC","Microsoft YaHei",sans-serif'
+ ctx.textAlign = 'center'
+ ctx.textBaseline = 'bottom'
+ ctx.fillText(placeMode.relocateId ? `迁移 ${placeMode.name}` : `建造 ${placeMode.name}`, 0, -28)
+ ctx.restore()
+ }
drawWeather(W, H)
drawNight(W, H)
// 查理袭击红闪
@@ -3399,6 +3749,9 @@ function resetState() {
entsDirty = new Set()
dead.value = false
ghostMode.value = false
+ placeMode = null
+ autoInteractOn.value = false
+ clearQuickQueue()
craftOpen.value = false
showMap.value = false
tips.value = []
@@ -3633,11 +3986,16 @@ function tabRecipes(tab) {
}
function recipeState(r) {
const okTech = r.tech === 0 || uiUnlocked.value.includes(r.code) || uiTech.value >= r.tech
- const okCost = Object.entries(r.cost).every(([c, n]) => (uiCounts.value[c] || 0) >= n)
+ // 实时合计背包+仓库,不依赖可能过期的 uiCounts
+ const okCost = !!player && Object.entries(r.cost).every(([c, n]) => countItem(player, c) + warehouseCount(c) >= n)
return { okTech, okCost, can: okTech && okCost }
}
function costText(r) {
- return Object.entries(r.cost).map(([c, n]) => `${ITEMS[c]?.name}x${n}`).join(' ')
+ return Object.entries(r.cost).map(([c, n]) => {
+ const have = player ? countItem(player, c) + warehouseCount(c) : 0
+ const name = ITEMS[c]?.name || c
+ return have >= n ? `${name}x${n}` : `${name}x${n}(有${have})`
+ }).join(' ')
}
function freshClass(f) {
if (f < 0) return ''
@@ -3652,6 +4010,7 @@ function freshClass(f) {
class="starve-canvas"
tabindex="-1"
@click="onCanvasClick"
+ @contextmenu="onCanvasContextMenu"
@mousedown="onCanvasPointerDown"
@mousemove="onCanvasPointerMove"
@mouseup="onCanvasPointerUp"
@@ -3695,6 +4054,10 @@ function freshClass(f) {
{{ hunger }}
+
+
+
{{ foodBag }}/{{ foodStore }}
+
{{ san }}
@@ -3825,34 +4188,85 @@ function freshClass(f) {
-
+
木箱
-
点击物品栏格存入 · 点击箱内格取出
-
-
+
左侧取出 · 右侧点物品栏快速放入
+
+
+
箱内
+
+
+
+
+
+
物品栏 · 点击放入
+
+
+
+
+
+
+
@@ -3881,12 +4295,13 @@ function freshClass(f) {
+ title="E:开关自动交互(不含篝火/房屋)"
+ >{{ autoInteractOn ? '自动交互 · 开' : '自动交互 · E' }}
{{ seasonBanner }}
@@ -4335,6 +4750,12 @@ function freshClass(f) {
touch-action: manipulation;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
+.quick-act.on {
+ border-color: #8fd46a;
+ background: rgba(50, 90, 40, 0.92);
+ color: #dff5c8;
+ box-shadow: 0 0 0 1px rgba(143, 212, 106, 0.35);
+}
.quick-act:hover {
border-color: #e8bc5a;
}
@@ -4459,6 +4880,51 @@ function freshClass(f) {
padding: 12px 14px 16px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45);
}
+.modal-card.box-modal {
+ width: min(640px, calc(100% - 24px));
+}
+.box-split {
+ display: flex;
+ gap: 14px;
+ align-items: flex-start;
+ justify-content: center;
+}
+.box-side {
+ flex: 1;
+ min-width: 0;
+}
+.box-side-label {
+ display: block;
+ margin-bottom: 6px;
+ color: #c9b89a;
+ font-size: 12px;
+ text-align: center;
+}
+.inv-side {
+ border-left: 1px solid rgba(240, 226, 190, 0.18);
+ padding-left: 14px;
+}
+.inv-quick {
+ display: grid;
+ grid-template-columns: repeat(5, 42px);
+ gap: 6px;
+ justify-content: center;
+}
+.pack-quick {
+ margin-top: 8px;
+ padding-top: 8px;
+ border-top: 1px dashed rgba(240, 226, 190, 0.18);
+}
+.badge.food-badge b small {
+ font-size: 11px;
+ font-weight: 600;
+ opacity: 0.75;
+ margin-left: 1px;
+}
+@media (max-width: 560px) {
+ .box-split { flex-direction: column; }
+ .inv-side { border-left: none; padding-left: 0; border-top: 1px solid rgba(240, 226, 190, 0.18); padding-top: 10px; width: 100%; }
+}
.modal-head {
display: flex;
align-items: center;
diff --git a/src/games/starve/data.js b/src/games/starve/data.js
index bc9005d..297dbf3 100644
--- a/src/games/starve/data.js
+++ b/src/games/starve/data.js
@@ -161,7 +161,7 @@ export const RECIPES = [
{ code: 'winterhat', name: '冬帽', tab: 'dress', tech: 2, cost: { silk: 4, beefwool: 2 }, desc: '保暖 · 冬季生存必备' },
]
-// 烹饪锅配方:按优先级匹配(meatv 肉度 / sweet 甜度 / mm 怪物肉数)
+// 烹饪锅配方:按优先级匹配(meatv/sweet/mm/veg/grain;itemTable 可传入 mod 合并后的物品表)
const POT_RULES = [
{ dish: 'taffy', ok: (s) => s.sweet >= 3 },
{ dish: 'honeyham', ok: (s) => s.sweet >= 1 && s.meatv >= 1.5 },
@@ -169,16 +169,19 @@ const POT_RULES = [
{ dish: 'meatystew', ok: (s) => s.meatv >= 3 },
{ dish: 'monsterlasagna', ok: (s) => s.mm >= 2 },
{ dish: 'meatballs', ok: (s) => s.meatv >= 0.5 },
+ // 面食:面粉/小麦等(纯小麦×3+ 或 面粉×2+)
+ { dish: 'bread', ok: (s) => s.grain >= 2 || s.veg >= 3 },
]
// 输入 4 个食材 code,返回料理 code(不满足任何配方 = 湿哒哒糊糊)
-export function cookPot(codes) {
- const s = { meatv: 0, sweet: 0, mm: 0, veg: 0 }
+export function cookPot(codes, itemTable = ITEMS) {
+ const s = { meatv: 0, sweet: 0, mm: 0, veg: 0, grain: 0 }
codes.forEach((c) => {
- const it = ITEMS[c] || {}
+ const it = itemTable[c] || ITEMS[c] || {}
s.meatv += it.meatv || 0
s.sweet += it.sweet || 0
s.mm += it.mm || 0
s.veg += it.veg || 0
+ s.grain += it.grain || 0
})
const hit = POT_RULES.find((r) => r.ok(s))
return hit ? hit.dish : 'wetgoop'
@@ -230,7 +233,7 @@ export const RES = {
flower: { hits: 1, drops: { petals: 1 }, size: 14, vanish: true },
flint: { hits: 1, drops: { flint: 1 }, size: 14, vanish: true },
rabbit: { hp: 25, drops: { morsel: 1 }, size: 16, mobile: true },
- rabbithole: { size: 18, solid: true },
+ rabbithole: { size: 18 }, // 兔子洞不挡路(否则劳工会卡在洞边)
spidernest: { hp: 200, size: 36, nest: true },
beehive: { hp: 100, size: 30, nest: true },
pighouse: { size: 40, struct: true },
diff --git a/src/games/starve/mobs.js b/src/games/starve/mobs.js
index 6eda1a9..3f579e5 100644
--- a/src/games/starve/mobs.js
+++ b/src/games/starve/mobs.js
@@ -6,13 +6,27 @@
// 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) {
+function isLaborMob(m) {
+ const k = m?.kind
+ return !!k && (k.startsWith('labor') || k === 'campvillager')
+}
+function isFenceLike(e) {
+ return e.type === 'fence' || e.type === 'gate' || e.type === 'campfence' || e.type === 'campgate'
+}
+// 固体栅栏/关着的门挡路(劳工穿栅栏;开着的门、兔子洞不挡)
+function blockedBySolid(g, x, y, m = null) {
if (!g.entities) return false
+ const labor = isLaborMob(m)
for (const e of g.entities) {
if (!e.solid) continue
+ // 旧档兔子洞可能仍带 solid,一律不挡路
+ if (e.type === 'rabbithole') 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'
+ // 雇佣劳工可穿牧场/营地栅栏,避免卡在门外
+ if (labor && isFenceLike(e)) continue
+ // 敌对野怪翻墙成功期间可穿营地围栏(见 tryCampVault)
+ if ((m?.campVaultT || 0) > 0 && (e.type === 'campfence' || e.type === 'campgate')) continue
+ const fenceLike = isFenceLike(e)
const r = fenceLike
? Math.max(22, (e.size || 24) * 0.65)
: Math.max(14, (e.size || 24) * 0.55)
@@ -20,21 +34,52 @@ function blockedBySolid(g, x, y) {
}
return false
}
-function canStand(g, x, y) {
- return g.isLand(x, y) && !blockedBySolid(g, x, y)
+function canStand(g, x, y, m = null) {
+ return g.isLand(x, y) && !blockedBySolid(g, x, y, m)
}
+function nearCampWall(g, m) {
+ return (g.entities || []).some((e) => {
+ if (e.type === 'campfence') return Math.hypot(e.x - m.x, e.y - m.y) < 44
+ if (e.type === 'campgate' && !e.mx?.open) return Math.hypot(e.x - m.x, e.y - m.y) < 52
+ return false
+ })
+}
+/** 每帧结算翻墙冷却;贴墙被挡时掷点,成功则数秒内可穿营地围栏 */
+function tickCampVault(m, g, dt) {
+ if (!m || !g.mon?.[m.kind]?.hostile) return
+ if ((m.campVaultT || 0) > 0) {
+ m.campVaultT = Math.max(0, m.campVaultT - dt)
+ m._campWallHit = false
+ return
+ }
+ m.campVaultCd = Math.max(0, (m.campVaultCd || 0) - dt)
+ if (!m._campWallHit) return
+ m._campWallHit = false
+ if (m.campVaultCd > 0 || !nearCampWall(g, m)) return
+ m.campVaultCd = 2.2 + Math.random() * 1.4
+ // 普通怪约 12%,Boss 约 30%(翻墙成功)
+ const chance = g.mon[m.kind]?.boss ? 0.3 : 0.12
+ if (Math.random() < chance) m.campVaultT = 3.5 + Math.random() * 1.5
+}
+
// 带陆地 + 栅栏碰撞的位移:先整体,再退化为单轴滑动
export function moveMob(m, dx, dy, g) {
+ const ox = m.x
+ const oy = m.y
const nx = m.x + dx
const ny = m.y + dy
- if (canStand(g, nx, ny)) {
+ if (canStand(g, nx, ny, m)) {
m.x = nx
m.y = ny
- } else if (canStand(g, nx, m.y)) {
+ } else if (canStand(g, nx, m.y, m)) {
m.x = nx
- } else if (canStand(g, m.x, ny)) {
+ } else if (canStand(g, m.x, ny, m)) {
m.y = ny
}
+ // 敌对野怪被挡住且贴着营墙 → 标记,本帧末掷点翻墙
+ if (m.x === ox && m.y === oy && g.mon?.[m.kind]?.hostile && nearCampWall(g, m)) {
+ m._campWallHit = true
+ }
}
function toward(m, tx, ty, speed, dt, g) {
const d = Math.hypot(tx - m.x, ty - m.y) || 1
@@ -56,7 +101,8 @@ function nearestVictim(m, g, playersOnly = false) {
})
if (!playersOnly) {
g.monsters.forEach((q) => {
- if (q.kind !== 'pig' || q === m) return
+ // 圈养动物不作为野怪猎物,避免牧场越杀越少
+ if (q.kind !== 'pig' || q === m || q.pen) return
const d = Math.hypot(q.x - m.x, q.y - m.y)
if (d < bd) { bd = d; best = { kind: 'm', ref: q } }
})
@@ -98,6 +144,7 @@ export function updateMob(m, dt, g) {
m.moving = false
if (m.shake > 0) m.shake -= dt
if (m.cd > 0) m.cd -= dt
+ tickCampVault(m, g, dt)
if (m.kind === 'shadow') updShadow(m, conf, dt, g)
else if (m.kind === 'spider') updSpider(m, conf, dt, g)
else if (m.kind === 'hound' || m.kind === 'icehound') updHound(m, conf, dt, g)
diff --git a/src/games/starve/mods/agri.js b/src/games/starve/mods/agri.js
index 3826b18..cbf6219 100644
--- a/src/games/starve/mods/agri.js
+++ b/src/games/starve/mods/agri.js
@@ -6,7 +6,7 @@
// - 磨坊:wheat×3 → flour×2
// - 框选农业区:可命名多块农田,区内自动铺正方形良田
// - 采草 12% 掉小麦种子、采浆果丛 12% 掉南瓜种子
-import { normalizeRect, clearZoneClutter, pointInAabb } from './zoneUtil.js'
+import { normalizeRect, clearZoneClutter, pointInAabb, zoneYieldMul, zoneFloors, zoneUpgradeCost, buildCostDetails, costDetailsAllOk, hasStockCost, payStockCost, drawNameplate, openZoneMenu, drawZoneMenu, handleZoneMenuClick } from './zoneUtil.js'
const TAU = Math.PI * 2
const GROW_TOTAL = 200 // 生长总时长(秒):比 farm.js 的 320 秒更快
@@ -18,8 +18,9 @@ const SEED_CROP = { wheatseeds: 'wheat', pumpkinseeds: 'pumpkin', berryseeds: 'b
const CROPS = ['wheat', 'pumpkin', 'berry', 'carrot']
// ---- 农业区框选状态(多块可命名;UI 模式不入档) ----
-let agriZones = [] // [{ id, name, x0, y0, x1, y1 }]
+let agriZones = [] // [{ id, name, level, x0, y0, x1, y1 }]
let nextAgriId = 1
+const MAX_ZONE_LEVEL = 10
let zoneMode = false
let drag = null // { x0, y0, x1, y1 }
let uiCamX = 0
@@ -35,7 +36,9 @@ function stageOf(t) {
}
function plant(p, plot, seedCode, g) {
+ const zoneId = plot.mx?.zoneId
plot.mx = { seed: SEED_CROP[seedCode], t: 0 }
+ if (zoneId != null) plot.mx.zoneId = zoneId
plot.state = 'grow0'
g.markDirty(plot)
g.swing(p, 'dig')
@@ -71,7 +74,7 @@ function fillFarmsInZone(g, z, opts = {}) {
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)
+ g.addEntity('agrifarm', x, y, { mx: { zoneId: z.id } })
added++
}
}
@@ -79,6 +82,152 @@ function fillFarmsInZone(g, z, opts = {}) {
return added
}
+function zoneOfPlot(plot) {
+ if (plot?.mx?.zoneId != null) {
+ const byId = agriZones.find((z) => z.id === plot.mx.zoneId)
+ if (byId) return byId
+ }
+ return agriZones.find((z) => pointInAabb(plot.x, plot.y, z)) || null
+}
+
+/** 农田升到 5 级自动在区旁建一座附属大仓库(每区一座) */
+function ensureAgriWarehouse(g, z) {
+ if (!z || (z.level || 1) < 5) return null
+ if (z.warehouseId != null) {
+ const exist = g.entities.find((e) => e.id === z.warehouseId && e.type === 'laborwarehouse')
+ if (exist) return exist
+ }
+ // 已有挂在本区旁的仓库则认领,避免重复盖
+ const near = g.entities.find((e) => {
+ if (e.type !== 'laborwarehouse') return false
+ if (e.mx?.agriZoneId === z.id) return true
+ return pointInAabb(e.x, e.y, {
+ x0: z.x0 - 120, y0: z.y0 - 120, x1: z.x1 + 120, y1: z.y1 + 120,
+ })
+ })
+ if (near) {
+ z.warehouseId = near.id
+ if (!near.mx) near.mx = {}
+ near.mx.agriZoneId = z.id
+ g.markDirty?.(near)
+ return near
+ }
+ const candidates = [
+ { x: z.x1 + 48, y: (z.y0 + z.y1) / 2 },
+ { x: z.x0 - 48, y: (z.y0 + z.y1) / 2 },
+ { x: (z.x0 + z.x1) / 2, y: z.y0 - 48 },
+ { x: (z.x0 + z.x1) / 2, y: z.y1 + 48 },
+ ]
+ let spot = null
+ for (const c of candidates) {
+ const land = g.findLand(c.x, c.y, 0, 60)
+ if (!land) continue
+ const crowded = g.entities.some((e) => !e.deco && !e.loot && Math.hypot(e.x - land.x, e.y - land.y) < 40)
+ if (!crowded) { spot = land; break }
+ if (!spot) spot = land
+ }
+ if (!spot) return null
+ const e = g.addEntity('laborwarehouse', spot.x, spot.y)
+ if (!e) return null
+ if (!e.mx) e.mx = {}
+ e.mx.agriZoneId = z.id
+ z.warehouseId = e.id
+ g.tip(`「${z.name}」解锁附属大仓库`)
+ return e
+}
+
+function upgradeAgriZone(g, z) {
+ const p = g.me()
+ if (!p) return false
+ const lv = z.level || 1
+ if (lv >= MAX_ZONE_LEVEL) {
+ g.tip(`「${z.name}」已满级`)
+ return true
+ }
+ const cost = zoneUpgradeCost(lv)
+ if (!hasStockCost(g, cost)) {
+ g.tip(`升级需要 木头×${cost.log} 石块×${cost.rock}${cost.gold ? ` 金块×${cost.gold}` : ''}(背包或仓库)`)
+ return true
+ }
+ payStockCost(g, cost)
+ z.level = lv + 1
+ syncAgrizoneMarkers(g)
+ const floors = zoneFloors(z.level)
+ const mul = zoneYieldMul(z.level)
+ if (z.level >= 5) ensureAgriWarehouse(g, z)
+ g.tip(`「${z.name}」升至 ${z.level} 级 · 产出 ×${mul.toFixed(2)} · 田力 ${floors}${z.level === 5 ? ' · 已解锁附属仓库' : ''}`)
+ return true
+}
+
+function deleteAgriZone(g, z) {
+ g.entities
+ .filter((e) => e.type === 'agrifarm' && (e.mx?.zoneId === z.id || pointInAabb(e.x, e.y, z)))
+ .forEach((e) => g.removeEntity(e))
+ agriZones = agriZones.filter((a) => a.id !== z.id)
+ syncAgrizoneMarkers(g)
+ g.tip(`已删除农田「${z.name}」`)
+ return true
+}
+
+/** 右键:弹出升级 / 改名 / 删除按钮菜单(含材料缺口) */
+function agriZoneRightAction(g, z) {
+ const lv = z.level || 1
+ const maxed = lv >= MAX_ZONE_LEVEL
+ const cost = maxed ? null : zoneUpgradeCost(lv)
+ const details = maxed
+ ? [{ text: '已满级,无法再升级', ok: true }]
+ : buildCostDetails(g, cost)
+ const canUp = !maxed && costDetailsAllOk(details)
+ const missHint = !maxed && !canUp
+ ? details.filter((d) => !d.ok).map((d) => `缺${d.name}×${d.miss}`).join(' · ')
+ : ''
+ openZoneMenu({
+ title: `「${z.name}」`,
+ subtitle: maxed ? `农田 · Lv.${lv}(满级)` : `农田 · Lv.${lv} → Lv.${lv + 1}`,
+ detailTitle: maxed ? '升级状态' : `升级所需(背包+仓库)${missHint ? ` · ${missHint}` : ''}`,
+ details,
+ actions: [
+ {
+ id: 'upgrade',
+ label: maxed ? '已满级' : (canUp ? '确认升级' : '材料不足'),
+ color: canUp ? '#5d7a3a' : '#5a4a3a',
+ disabled: maxed || !canUp,
+ },
+ { id: 'rename', label: '改名', color: '#4a6a8a' },
+ { id: 'delete', label: '删除区域', color: '#7a4a3a' },
+ { id: 'cancel', label: '取消', color: '#5a4630' },
+ ],
+ onPick(id) {
+ if (id === 'upgrade') {
+ upgradeAgriZone(g, z)
+ return
+ }
+ if (id === 'rename') {
+ const name = promptZoneName('农田', z.name)
+ z.name = name
+ syncAgrizoneMarkers(g)
+ g.tip(`农田已命名为「${name}」`)
+ return
+ }
+ if (id === 'delete') {
+ openZoneMenu({
+ title: `删除「${z.name}」?`,
+ subtitle: '区内良田将一并移除',
+ actions: [
+ { id: 'confirm', label: '确认删除', color: '#8a3030' },
+ { id: 'back', label: '返回', color: '#5a4630' },
+ ],
+ onPick(id2) {
+ if (id2 === 'confirm') deleteAgriZone(g, z)
+ else if (id2 === 'back') agriZoneRightAction(g, z)
+ },
+ })
+ }
+ },
+ })
+ return true
+}
+
function inside(x, y, r) {
return !!r && x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h
}
@@ -93,6 +242,13 @@ function renameZoneAt(g, wx, wy) {
return true
}
+/** 导出给劳工收获计算产出 */
+export function agriYieldAt(x, y, zoneId) {
+ let z = zoneId != null ? agriZones.find((a) => a.id === zoneId) : null
+ if (!z) z = agriZones.find((a) => pointInAabb(x, y, a))
+ return zoneYieldMul(z?.level || 1)
+}
+
export default {
id: 'agri',
name: '农业文明',
@@ -103,8 +259,8 @@ export default {
items: {
agrihoe: { name: '犁锄', equip: 'hand', tool: 'dig', dmg: 20, uses: 40, stack: 1 },
wheatseeds: { name: '小麦种子', stack: 40 },
- wheat: { name: '小麦', food: { hunger: 12.5, hp: 0, san: 0 }, perish: 10, veg: 1 },
- flour: { name: '面粉', food: { hunger: 12.5, hp: 1, san: 0 }, perish: 20 },
+ wheat: { name: '小麦', food: { hunger: 12.5, hp: 0, san: 0 }, perish: 10, veg: 1, grain: 1 },
+ flour: { name: '面粉', food: { hunger: 12.5, hp: 1, san: 0 }, perish: 20, grain: 1.5, veg: 0.5 },
bread: { name: '面包', food: { hunger: 62.5, hp: 8, san: 3 }, perish: 12 },
pumpkin: { name: '南瓜', food: { hunger: 37.5, hp: 3, san: 0 }, perish: 20, veg: 1 },
pumpkinseeds: { name: '南瓜种子', stack: 40 },
@@ -285,13 +441,15 @@ export default {
conf: { size: 52 },
init(e) {
if (!e.state) e.state = 'empty'
+ if (!e.mx) e.mx = {}
},
update(e, dt, g) {
if (!e.mx || e.state === 'empty' || e.state === 'ready') return
if (g.season === 'winter') return // 冬季暂停生长(与官方一致)
- // 灌溉加成:140px 内存在水井则生长 ×1.6
const watered = g.entities.some((o) => o.type === 'well' && Math.hypot(o.x - e.x, o.y - e.y) < 140)
- e.mx.t += dt * (watered ? 1.6 : 1)
+ const z = zoneOfPlot(e)
+ const lvMul = 1 + 0.06 * Math.max(0, (z?.level || 1) - 1)
+ e.mx.t += dt * (watered ? 1.6 : 1) * lvMul
const st = stageOf(e.mx.t)
if (st !== e.state) {
e.state = st
@@ -302,18 +460,20 @@ export default {
interact(p, e, g) {
if (e.state === 'ready') {
const crop = e.mx?.seed || 'wheat'
- const n = g.rand(3, 4)
+ const zoneId = e.mx?.zoneId
+ const mul = agriYieldAt(e.x, e.y, zoneId)
+ const n = Math.max(1, Math.round(g.rand(3, 4) * mul))
g.addItem(p, crop, n)
g.addScore(12, p)
g.swing(p, 'chop')
g.ptip(p, `收获${g.items[crop]?.name || '作物'} ×${n}`)
+ const keepId = e.mx?.zoneId
e.state = 'empty'
- e.mx = null
+ e.mx = keepId != null ? { zoneId: keepId } : {}
g.markDirty(e)
return
}
if (e.state === 'empty') {
- // 身上有种子就直接播种(berryseeds 依赖 farm.js,缺了就只是不能种)
const seed = ['wheatseeds', 'pumpkinseeds', 'berryseeds'].find((code) => g.items[code] && g.countItem(p, code) > 0)
if (seed) {
g.payCost(p, { [seed]: 1 })
@@ -327,24 +487,45 @@ export default {
g.ptip(p, `作物生长中…… ${pct}%(靠近水井更快)`)
},
draw(ctx, e) {
- // 正方形田块
- const s = 26
- ctx.fillStyle = '#6b4a2e'
+ const z = zoneOfPlot(e)
+ const lv = Math.max(1, z?.level || 1)
+ const grade = zoneFloors(lv) // 1~3:田力档,只改土色/垄数,不再叠「木箱层」
+ // 软边泥土田块(椭圆垄床),别画成方木箱
+ const soil = grade === 1 ? '#7a5a38' : grade === 2 ? '#6a4a2e' : '#5a3a24'
+ const rim = grade === 1 ? '#9a7a48' : grade === 2 ? '#8a6a3a' : '#7a5a30'
+ ctx.fillStyle = 'rgba(0,0,0,0.18)'
+ ctx.beginPath(); ctx.ellipse(0, 6, 24, 9, 0, 0, TAU); ctx.fill()
+ ctx.fillStyle = soil
ctx.strokeStyle = '#1d1409'
- ctx.lineWidth = 2
- ctx.beginPath()
- ctx.roundRect(-s, -s, s * 2, s * 2, 3)
- ctx.fill()
- ctx.stroke()
- // 田垄线
- ctx.strokeStyle = '#553a24'
ctx.lineWidth = 1.8
- ;[-10, 0, 10].forEach((y) => {
+ ctx.beginPath(); ctx.ellipse(0, 0, 24, 16, 0, 0, TAU); ctx.fill(); ctx.stroke()
+ // 田埂外缘
+ ctx.strokeStyle = rim
+ ctx.lineWidth = 2.2
+ ctx.beginPath(); ctx.ellipse(0, 0, 22, 14, 0, 0, TAU); ctx.stroke()
+ // 田垄线(高等级垄更多、更深)
+ ctx.strokeStyle = 'rgba(40, 24, 12, 0.55)'
+ ctx.lineWidth = 1.5
+ const rows = grade >= 3 ? [-8, -3, 2, 7] : grade >= 2 ? [-6, 0, 6] : [-4, 4]
+ rows.forEach((y) => {
+ const w = 18 - Math.abs(y) * 0.35
ctx.beginPath()
- ctx.moveTo(-s + 4, y)
- ctx.lineTo(s - 4, y)
+ ctx.moveTo(-w, y)
+ ctx.quadraticCurveTo(0, y + 1.2, w, y)
ctx.stroke()
})
+ // 高等级:两侧小石埂 / 水渠暗示
+ if (grade >= 2) {
+ ctx.fillStyle = '#8d9097'
+ ;[-20, 20].forEach((x) => {
+ ctx.beginPath(); ctx.ellipse(x, 4, 3.2, 2.2, 0, 0, TAU); ctx.fill()
+ })
+ }
+ if (grade >= 3) {
+ ctx.strokeStyle = 'rgba(90, 150, 180, 0.55)'
+ ctx.lineWidth = 2
+ ctx.beginPath(); ctx.moveTo(-18, 10); ctx.quadraticCurveTo(0, 13, 18, 10); ctx.stroke()
+ }
const st = e.state
if (st === 'empty' || !st) return
const crop = e.mx?.seed
@@ -433,6 +614,7 @@ export default {
g.ptip(p, '打了一瓢清水,理智 +5')
},
draw(ctx, e, env) {
+ drawNameplate(ctx, '水井', -48)
// 投影
ctx.fillStyle = 'rgba(0,0,0,0.25)'
ctx.beginPath(); ctx.ellipse(0, 4, 18, 6, 0, 0, TAU); ctx.fill()
@@ -522,6 +704,9 @@ export default {
g.ptip(p, '粮仓空着(点击存入身上的作物)')
},
draw(ctx, e, env) {
+ const store = e.mx?.store || {}
+ const total = Object.values(store).reduce((a, b) => a + (b || 0), 0)
+ drawNameplate(ctx, total > 0 ? `粮仓 · ${total}份` : '粮仓', -56)
// 投影
ctx.fillStyle = 'rgba(0,0,0,0.25)'
ctx.beginPath(); ctx.ellipse(0, 4, 20, 7, 0, 0, TAU); ctx.fill()
@@ -573,6 +758,7 @@ export default {
g.ptip(p, '磨出面粉 ×2')
},
draw(ctx, e, env) {
+ drawNameplate(ctx, '磨坊', -52)
// 投影
ctx.fillStyle = 'rgba(0,0,0,0.25)'
ctx.beginPath(); ctx.ellipse(0, 4, 18, 6, 0, 0, TAU); ctx.fill()
@@ -621,12 +807,18 @@ export default {
agriZones = data.zones.map((z, i) => ({
id: z.id || i + 1,
name: z.name || `农田${i + 1}`,
+ level: Math.max(1, z.level || 1),
+ warehouseId: z.warehouseId ?? null,
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 }]
+ agriZones = [{
+ id: 1, name: z.name || '农田1', level: z.level || 1,
+ warehouseId: z.warehouseId ?? null,
+ x0: z.x0, y0: z.y0, x1: z.x1, y1: z.y1,
+ }]
nextAgriId = 2
} else {
agriZones = []
@@ -634,6 +826,7 @@ export default {
}
syncAgrizoneMarkers(g)
agriZones.forEach((z) => fillFarmsInZone(g, z, { clear: false }))
+ agriZones.forEach((z) => ensureAgriWarehouse(g, z))
},
onUseItem(g, p, s, area, i) {
if (s.code === 'agrihoe') {
@@ -720,13 +913,21 @@ export default {
}
const def = `农田${agriZones.length + 1}`
const name = promptZoneName('农田', def)
- const z = { id: nextAgriId++, name, ...rect }
+ const z = { id: nextAgriId++, name, level: 1, ...rect }
agriZones.push(z)
const n = fillFarmsInZone(g, z)
- g.tip(`「${name}」已划定:清出杂项,铺设良田 ${n} 块`)
+ g.tip(`「${name}」已划定:铺设良田 ${n} 块(右键管理:升级/改名/删除)`)
return true
},
+ onRightClick(g, wx, wy, ev) {
+ if (zoneMode) return false
+ const z = agriZones.find((a) => pointInAabb(wx, wy, a))
+ if (!z) return false
+ if (ev?.shiftKey) return renameZoneAt(g, wx, wy)
+ return agriZoneRightAction(g, z)
+ },
onClick(g, wx, wy) {
+ if (handleZoneMenuClick(wx, wy)) return true
const sx = wx - uiCamX
const sy = wy - uiCamY
if (uiBtn && inside(sx, sy, uiBtn)) {
@@ -740,8 +941,6 @@ export default {
drag = null
return true
}
- // 非框选时点击已有农田虚线框可改名
- if (!zoneMode && renameZoneAt(g, wx, wy)) return true
return false
},
onDraw(g, ctx, env) {
@@ -758,24 +957,57 @@ export default {
ctx.fillRect(x, y, w, h)
ctx.strokeRect(x, y, w, h)
ctx.setLineDash([])
+ const label = `${z.name || '农田'} Lv.${z.level || 1}`
+ const lw = Math.min(140, Math.max(72, label.length * 8 + 16))
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.beginPath(); ctx.roundRect(x + 4, y + 4, lw, 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.fillText(label, 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.fillStyle = 'rgba(140, 220, 80, 0.12)'
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.setLineDash([])
+ // 预览良田行列
+ const half = FARM_SPACING / 2
+ let cols = 0
+ let rows = 0
+ let plots = 0
+ for (let y = r.y0 + half; y <= r.y1 - half; y += FARM_SPACING) {
+ rows++
+ let rowCols = 0
+ for (let x = r.x0 + half; x <= r.x1 - half; x += FARM_SPACING) {
+ rowCols++
+ plots++
+ const sx = x - env.camX
+ const sy = y - env.camY
+ ctx.strokeStyle = 'rgba(160, 230, 100, 0.85)'
+ ctx.fillStyle = 'rgba(120, 190, 70, 0.22)'
+ ctx.lineWidth = 1.4
+ ctx.beginPath(); ctx.roundRect(sx - 22, sy - 18, 44, 36, 3); ctx.fill(); ctx.stroke()
+ // 简易田埂
+ ctx.strokeStyle = 'rgba(90, 140, 50, 0.55)'
+ ctx.beginPath(); ctx.moveTo(sx - 14, sy); ctx.lineTo(sx + 14, sy); ctx.stroke()
+ }
+ cols = Math.max(cols, rowCols)
+ }
+ ctx.fillStyle = 'rgba(20, 40, 12, 0.78)'
+ ctx.beginPath(); ctx.roundRect((r.x0 + r.x1) / 2 - env.camX - 60, r.y0 - env.camY - 22, 120, 18, 4); ctx.fill()
+ ctx.fillStyle = '#e8f5c8'
+ ctx.font = 'bold 11px sans-serif'
+ ctx.textAlign = 'center'
+ ctx.textBaseline = 'middle'
+ ctx.fillText(plots > 0 ? `${rows} 行 × ${cols} 列 · ${plots} 块` : '再划大一点', (r.x0 + r.x1) / 2 - env.camX, r.y0 - env.camY - 13)
ctx.restore()
}
},
@@ -809,6 +1041,7 @@ export default {
uiCancel = null
}
ctx.restore()
+ drawZoneMenu(ctx, env)
},
},
}
diff --git a/src/games/starve/mods/camp.js b/src/games/starve/mods/camp.js
index adb3dcd..3bef56a 100644
--- a/src/games/starve/mods/camp.js
+++ b/src/games/starve/mods/camp.js
@@ -1,28 +1,728 @@
// 营地:出生点自带房屋、长明篝火与无敌守卫,可建造营地仓储与食堂
-// - 房屋 camphouse:按游戏日休息——夜里跳到次日清晨,白天回复体力
-// - 守卫 guard:无限血,营地半径内巡逻并拦截、击杀敌对生物
-// - 营地仓储 campstore:8 个箱子,每箱 6 种、每种最多 999(共 48 种容量)
-// - 食堂 canteen:厨师做菜存入,点击点餐
-// - 营地升级:采集/击杀/产业发展涨 XP,扩半径、暗处补永久篝火;8 级围栏+门
-// - 领地:以房屋为圆心动态半径,通过核心 onMobVictim 否决钩子让敌对生物不再锁定营地内的玩家
-// 时间/数值基准:三围 150/150/200,一天 160 秒
-import { rebuildFencePerimeter, findDarkSpots } from './zoneUtil.js'
+// - 房屋 camphouse:多外观随营地等级升级;可进入室内点床睡觉;员工可入驻
+// - 划定宅基:框选区域自动铺房屋地基,建筑工按计划盖房
+// - 守卫 / 仓储 / 食堂 / 营地升级
+import { rebuildFencePerimeter, normalizeRect, clearZoneClutter, pointInAabb, drawNameplate, openZoneMenu, drawZoneMenu, handleZoneMenuClick, closeZoneMenu, isZoneMenuOpen, zoneFloors } from './zoneUtil.js'
+import { skinByCode, drawPlayerFig } from '../../starveSkins.js'
const TAU = Math.PI * 2
const CAMP_R0 = 420 // 1 级营地安全半径
const BOXES = 8 // 箱子数
const BOX_KINDS = 6 // 每箱最多物品种类
-const BOX_CAP = 999 // 每种最多数量
+const BOX_CAP = 99999999 // 每种最多数量
const DAY_LEN = 160
// ---- 营地升级状态(入存档) ----
let campState = { level: 1, xp: 0, fenced: false }
+// ---- 宅基规划(入存档) ----
+let houseZones = [] // [{ id, name, x0, y0, x1, y1 }]
+let nextHouseZoneId = 1
+const HOUSE_SPACING = 120
+let houseZoneMode = false
+let houseDrag = null
+let uiHouseBtn = null
+let uiHouseCancel = null
+
// ---- 模块级面板状态(不入存档;面板只是一次交互会话) ----
let storeOpen = null // 当前打开的 campstore 实体 id
let canteenOpen = null
+let interiorOpen = null // 当前进入的 camphouse id
let uiCamX = 0
let uiCamY = 0
+let uiInteriorBtns = []
+// 室内子地图状态(不入存档)
+let interiorAvatar = null // { x, y, dir, walking, walkT, prompt, interactLatch }
+const ROOM_W = 420
+const ROOM_H = 280
+const AVATAR_R = 14
+const INTERIOR_SPEED = 150
+
+const HOUSE_MODELS = {
+ hut: { label: '茅屋', wall: '#b07f47', wall2: '#9a6a38', roof: '#6a4a28', trim: '#8a5f30', cap: 1 },
+ timber: { label: '木屋', wall: '#c49a5a', wall2: '#a87840', roof: '#7a5230', trim: '#8a5f30', cap: 2 },
+ stone: { label: '石屋', wall: '#9aa0a8', wall2: '#7a8088', roof: '#5a4a3a', trim: '#6f7278', cap: 3 },
+ manor: { label: '庄园', wall: '#d4c4a8', wall2: '#b8a888', roof: '#4a3030', trim: '#8a7050', cap: 4 },
+}
+
+export function getCampLevel() {
+ return Math.max(1, campState.level || 1)
+}
+
+export function houseModelForLevel(lv = getCampLevel()) {
+ if (lv >= 10) return 'manor'
+ if (lv >= 7) return 'stone'
+ if (lv >= 4) return 'timber'
+ return 'hut'
+}
+
+export function houseCapacity(e) {
+ const model = e?.mx?.model || houseModelForLevel()
+ return HOUSE_MODELS[model]?.cap || 1
+}
+
+export function listCampHouses(g) {
+ return (g.entities || []).filter((e) => e.type === 'camphouse')
+}
+
+export function getCampCenter(g) {
+ return campCenter(g)
+}
+
+export function isInCamp(g, x, y) {
+ return inCamp(g, x, y)
+}
+
+/** 按营地等级刷新所有房屋外观与层数 */
+export function syncHouseLooks(g) {
+ if (!g?.entities) return
+ const lv = getCampLevel()
+ const model = houseModelForLevel(lv)
+ const floors = Math.min(4, Math.max(1, zoneFloors(lv)))
+ listCampHouses(g).forEach((e) => {
+ if (!e.mx) e.mx = {}
+ e.mx.model = model
+ e.mx.floors = floors
+ if (!Array.isArray(e.mx.residents)) e.mx.residents = []
+ g.markDirty?.(e)
+ })
+}
+
+function normalizeHouseMx(e) {
+ if (!e.mx) e.mx = {}
+ if (e.mx.restDay == null) e.mx.restDay = 0
+ if (!e.mx.name) e.mx.name = '房屋'
+ if (!e.mx.model) e.mx.model = houseModelForLevel()
+ if (e.mx.floors == null) e.mx.floors = Math.min(4, zoneFloors(getCampLevel()))
+ if (!Array.isArray(e.mx.residents)) e.mx.residents = []
+ return e.mx
+}
+
+/** 给劳工找空房入驻;返回是否已有/新分配了家 */
+export function ensureWorkerHome(g, m) {
+ if (!m || !g) return false
+ if (m.homeHouseId != null) {
+ const h = g.entities.find((e) => e.id === m.homeHouseId && e.type === 'camphouse')
+ if (h) {
+ m.homeX = h.x
+ m.homeY = h.y + 36
+ return true
+ }
+ m.homeHouseId = null
+ }
+ const houses = listCampHouses(g)
+ for (const h of houses) {
+ const mx = normalizeHouseMx(h)
+ // 清掉已不存在的居民
+ mx.residents = mx.residents.filter((id) => g.monsters.some((x) => x.id === id))
+ if (mx.residents.length >= houseCapacity(h)) continue
+ mx.residents.push(m.id)
+ m.homeHouseId = h.id
+ m.homeX = h.x
+ m.homeY = h.y + 36
+ g.markDirty(h)
+ return true
+ }
+ return false
+}
+
+export function unassignWorkerHome(g, m) {
+ if (!m?.homeHouseId || !g) return
+ const h = g.entities.find((e) => e.id === m.homeHouseId && e.type === 'camphouse')
+ if (h?.mx?.residents) {
+ h.mx.residents = h.mx.residents.filter((id) => id !== m.id)
+ g.markDirty(h)
+ }
+ m.homeHouseId = null
+ m.homeX = null
+ m.homeY = null
+}
+
+function doHouseRest(g, p, e, viaBed) {
+ const mx = normalizeHouseMx(e)
+ if (mx.restDay === g.day) {
+ g.ptip(p, '今天已经休息过了')
+ return false
+ }
+ if (g.phase === 'night') {
+ if (typeof g.setDayTime === 'function') g.setDayTime(DAY_LEN)
+ mx.restDay = g.day
+ g.markDirty(e)
+ g.ptip(p, viaBed ? `在「${mx.name}」的床上睡到了天亮` : `在「${mx.name}」睡到了天亮`)
+ g.swing(p, 'eat')
+ return true
+ }
+ p.hunger = Math.min(150, p.hunger + 40)
+ p.hp = Math.min(150, p.hp + 25)
+ p.san = Math.min(200, p.san + 15)
+ mx.restDay = g.day
+ g.markDirty(e)
+ g.ptip(p, viaBed
+ ? `在「${mx.name}」的床上歇息:饥饿 +40、生命 +25、理智 +15`
+ : `在「${mx.name}」歇息:饥饿 +40、生命 +25、理智 +15`)
+ g.swing(p, 'eat')
+ return true
+}
+
+function drawHouseExterior(ctx, e, env) {
+ const mx = normalizeHouseMx(e)
+ const floors = Math.max(1, Math.min(4, mx.floors || 1))
+ const model = mx.model || 'hut'
+ const skin = HOUSE_MODELS[model] || HOUSE_MODELS.hut
+ const name = mx.name || '房屋'
+ const resN = (mx.residents || []).length
+ const cap = houseCapacity(e)
+ const label = `${name} · ${skin.label}`
+ ctx.fillStyle = 'rgba(20,16,10,0.78)'
+ const tw = Math.max(48, label.length * 8 + 14)
+ ctx.beginPath(); ctx.roundRect(-tw / 2, -88 - (floors - 1) * 28, tw, 16, 4); ctx.fill()
+ ctx.fillStyle = '#f4ead2'
+ ctx.font = 'bold 11px "PingFang SC","Microsoft YaHei",sans-serif'
+ ctx.textAlign = 'center'
+ ctx.textBaseline = 'middle'
+ ctx.fillText(label, 0, -80 - (floors - 1) * 28)
+ if (resN > 0) {
+ ctx.fillStyle = 'rgba(40,60,30,0.75)'
+ ctx.beginPath(); ctx.roundRect(-22, -72 - (floors - 1) * 28, 44, 12, 3); ctx.fill()
+ ctx.fillStyle = '#c8e8a0'
+ ctx.font = '9px sans-serif'
+ ctx.fillText(`住 ${resN}/${cap}`, 0, -66 - (floors - 1) * 28)
+ }
+ ctx.fillStyle = 'rgba(0,0,0,0.25)'
+ ctx.beginPath(); ctx.ellipse(0, 8, 34, 9, 0, 0, TAU); ctx.fill()
+ // 地基:石屋/庄园用厚石基
+ ctx.fillStyle = model === 'hut' ? '#8a7050' : '#8d9097'
+ ctx.strokeStyle = '#1d1409'
+ ctx.lineWidth = 2
+ ctx.beginPath(); ctx.roundRect(-30, -4, 60, 12, 2); ctx.fill(); ctx.stroke()
+ for (let f = 0; f < floors; f++) {
+ const baseY = -46 - f * 28
+ ctx.fillStyle = f % 2 ? skin.wall2 : skin.wall
+ ctx.strokeStyle = '#1d1409'
+ ctx.lineWidth = 2
+ ctx.beginPath(); ctx.roundRect(-26, baseY, 52, 44, model === 'manor' ? 2 : 3); ctx.fill(); ctx.stroke()
+ ctx.strokeStyle = skin.trim
+ ctx.lineWidth = 1.2
+ if (model === 'stone' || model === 'manor') {
+ // 砖缝
+ for (let row = 0; row < 4; row++) {
+ const y = baseY + 8 + row * 9
+ ctx.beginPath(); ctx.moveTo(-24, y); ctx.lineTo(24, y); ctx.stroke()
+ }
+ } else {
+ ;[-38, -30, -22, -14, -6].forEach((y) => {
+ ctx.beginPath(); ctx.moveTo(-26, baseY + 46 + y); ctx.lineTo(26, baseY + 46 + y); ctx.stroke()
+ })
+ }
+ ctx.fillStyle = model === 'manor' ? '#a8d8ff' : '#ffd98a'
+ ctx.strokeStyle = '#1d1409'
+ ctx.lineWidth = 2
+ ctx.beginPath(); ctx.roundRect(7, baseY + 6, 15, 13, 1.5); ctx.fill(); ctx.stroke()
+ if (model === 'manor') {
+ ctx.beginPath(); ctx.roundRect(-22, baseY + 6, 15, 13, 1.5); ctx.fill(); ctx.stroke()
+ }
+ if (f === 0) {
+ ctx.fillStyle = '#3a2a16'
+ ctx.beginPath(); ctx.roundRect(-11, -26, 20, 24, [3, 3, 0, 0]); ctx.fill(); ctx.stroke()
+ ctx.fillStyle = '#e0c060'
+ ctx.beginPath(); ctx.arc(4, -14, 1.6, 0, TAU); ctx.fill()
+ }
+ }
+ const roofY = -46 - (floors - 1) * 28
+ ctx.fillStyle = skin.roof
+ ctx.strokeStyle = '#1d1409'
+ ctx.lineWidth = 2
+ if (model === 'manor') {
+ ctx.beginPath(); ctx.moveTo(-36, roofY); ctx.lineTo(36, roofY); ctx.lineTo(28, roofY - 18); ctx.lineTo(-28, roofY - 18); ctx.closePath(); ctx.fill(); ctx.stroke()
+ ctx.fillStyle = '#3a2820'
+ ctx.beginPath(); ctx.moveTo(-28, roofY - 18); ctx.lineTo(28, roofY - 18); ctx.lineTo(0, roofY - 36); ctx.closePath(); ctx.fill(); ctx.stroke()
+ } else {
+ ctx.beginPath(); ctx.moveTo(-34, roofY); ctx.lineTo(34, roofY); ctx.lineTo(0, roofY - 30); ctx.closePath(); ctx.fill(); ctx.stroke()
+ }
+ ctx.fillStyle = model === 'stone' || model === 'manor' ? '#7a8088' : '#9a6a3c'
+ ctx.strokeStyle = '#1d1409'
+ ctx.lineWidth = 1.8
+ ctx.beginPath(); ctx.roundRect(12, roofY - 26, 11, 20, 2); ctx.fill(); ctx.stroke()
+ const t = env?.time || 0
+ ctx.fillStyle = 'rgba(220,220,220,0.4)'
+ for (let i = 0; i < 3; i++) {
+ const ph = (t * 0.5 + i * 0.5 + e.id * 0.1) % 1.5
+ ctx.beginPath()
+ ctx.arc(17 + Math.sin(t + i) * 3, roofY - 34 - ph * 22, 3 + ph * 3, 0, TAU)
+ ctx.fill()
+ }
+ ctx.fillStyle = '#9a6a3c'
+ ctx.strokeStyle = '#1d1409'
+ ctx.lineWidth = 1.6
+ ctx.beginPath(); ctx.roundRect(-13, -2, 24, 5, 1.5); ctx.fill(); ctx.stroke()
+}
+
+function houseFloorCount(e) {
+ return Math.max(1, Math.min(4, normalizeHouseMx(e).floors || 1))
+}
+
+/** 按楼层生成家具(多层像停车楼:侧边坡道楼梯换层,每层内容不同) */
+function roomFurniture(floor, floors) {
+ const f = Math.max(0, Math.min(floors - 1, floor | 0))
+ const furn = []
+ if (floors <= 1) {
+ return [
+ { id: 'bed', x: 36, y: ROOM_H - 118, w: 118, h: 62, solid: true, interact: true },
+ { id: 'table', x: ROOM_W / 2 - 36, y: ROOM_H - 108, w: 72, h: 44, solid: true, interact: true },
+ { id: 'shelf', x: ROOM_W - 108, y: 48, w: 72, h: 86, solid: true, interact: true },
+ { id: 'plant', x: 28, y: 52, w: 42, h: 52, solid: true, interact: true },
+ { id: 'door', x: ROOM_W / 2 - 40, y: ROOM_H - 28, w: 80, h: 28, solid: false, interact: true },
+ ]
+ }
+ // 右侧坡道柱(停车楼式)
+ if (f < floors - 1) {
+ furn.push({ id: 'stairs_up', x: ROOM_W - 78, y: 36, w: 58, h: 110, solid: false, interact: true, label: '上行坡道' })
+ }
+ if (f > 0) {
+ furn.push({ id: 'stairs_down', x: ROOM_W - 78, y: ROOM_H - 140, w: 58, h: 100, solid: false, interact: true, label: '下行坡道' })
+ }
+ if (f === 0) {
+ // 1F 门厅
+ furn.push({ id: 'door', x: ROOM_W / 2 - 40, y: ROOM_H - 28, w: 80, h: 28, solid: false, interact: true })
+ furn.push({ id: 'table', x: 48, y: ROOM_H - 120, w: 80, h: 48, solid: true, interact: true })
+ furn.push({ id: 'plant', x: 40, y: 48, w: 44, h: 54, solid: true, interact: true })
+ furn.push({ id: 'shelf', x: 160, y: 44, w: 70, h: 80, solid: true, interact: true })
+ furn.push({ id: 'crate', x: 250, y: ROOM_H - 100, w: 56, h: 48, solid: true, interact: true })
+ } else if (f === floors - 1) {
+ // 顶层卧室
+ furn.push({ id: 'bed', x: 32, y: ROOM_H - 120, w: 120, h: 64, solid: true, interact: true })
+ furn.push({ id: 'bed2', x: 170, y: ROOM_H - 120, w: 110, h: 64, solid: true, interact: true })
+ furn.push({ id: 'shelf', x: 40, y: 44, w: 72, h: 86, solid: true, interact: true })
+ furn.push({ id: 'plant', x: 280, y: 50, w: 42, h: 52, solid: true, interact: true })
+ } else {
+ // 中间层:起居 / 储物
+ furn.push({ id: 'sofa', x: 40, y: ROOM_H - 108, w: 130, h: 56, solid: true, interact: true })
+ furn.push({ id: 'table', x: 200, y: ROOM_H - 100, w: 78, h: 46, solid: true, interact: true })
+ furn.push({ id: 'crate', x: 40, y: 48, w: 60, h: 52, solid: true, interact: true })
+ furn.push({ id: 'crate2', x: 120, y: 48, w: 60, h: 52, solid: true, interact: true })
+ furn.push({ id: 'plant', x: 260, y: 50, w: 42, h: 52, solid: true, interact: true })
+ }
+ return furn
+}
+
+function currentFurn(e, av) {
+ const floors = houseFloorCount(e)
+ const floor = Math.max(0, Math.min(floors - 1, av?.floor ?? 0))
+ return { floors, floor, furn: roomFurniture(floor, floors) }
+}
+
+function interiorView(W, H) {
+ const pad = 24
+ const maxW = Math.min(W - pad * 2, 640)
+ const maxH = Math.min(H - pad * 2, 440)
+ const scale = Math.min(maxW / ROOM_W, maxH / ROOM_H)
+ const pw = ROOM_W * scale
+ const ph = ROOM_H * scale
+ const px = (W - pw) / 2
+ const py = (H - ph) / 2
+ return { px, py, pw, ph, scale }
+}
+
+function roomToScreen(view, rx, ry) {
+ return { x: view.px + rx * view.scale, y: view.py + ry * view.scale }
+}
+
+function circleHitsRect(cx, cy, r, rect) {
+ const nx = Math.max(rect.x, Math.min(cx, rect.x + rect.w))
+ const ny = Math.max(rect.y, Math.min(cy, rect.y + rect.h))
+ return Math.hypot(cx - nx, cy - ny) < r
+}
+
+function enterInterior(g, e) {
+ interiorOpen = e.id
+ interiorAvatar = {
+ x: ROOM_W / 2,
+ y: ROOM_H - 48,
+ floor: 0,
+ dir: 1,
+ walking: false,
+ walkT: 0,
+ prompt: null,
+ interactLatch: false,
+ escapeLatch: false,
+ }
+ storeOpen = null
+ canteenOpen = null
+ g.invalidate?.()
+}
+
+function leaveInterior(g) {
+ interiorOpen = null
+ interiorAvatar = null
+ g?.invalidate?.()
+}
+
+function placeOnStairs(av, e, goingUp) {
+ const { floors, floor, furn } = currentFurn(e, av)
+ const target = furn.find((f) => f.id === (goingUp ? 'stairs_down' : 'stairs_up'))
+ || furn.find((f) => f.id === 'door')
+ if (target) {
+ av.x = target.x + target.w / 2 - 30
+ av.y = target.y + target.h / 2
+ } else {
+ av.x = ROOM_W / 2
+ av.y = ROOM_H - 48
+ }
+ av.x = Math.max(AVATAR_R + 8, Math.min(ROOM_W - AVATAR_R - 8, av.x))
+ av.y = Math.max(AVATAR_R + 36, Math.min(ROOM_H - AVATAR_R - 6, av.y))
+ return { floors, floor }
+}
+
+function nearestInteriorPrompt(av, e) {
+ if (!av || !e) return null
+ const { furn } = currentFurn(e, av)
+ let best = null
+ let bd = Infinity
+ for (const f of furn) {
+ if (!f.interact) continue
+ const cx = f.x + f.w / 2
+ const cy = f.y + f.h / 2
+ const d = Math.hypot(cx - av.x, cy - av.y)
+ const reach = (f.id === 'door' || f.id.startsWith('stairs')) ? 46 : 48
+ if (d < reach && d < bd) { bd = d; best = f.id }
+ }
+ return best
+}
+
+function tryInteriorInteract(g, p, id) {
+ const e = g.entities.find((x) => x.id === interiorOpen && x.type === 'camphouse')
+ if (!e || !p) { leaveInterior(g); return true }
+ const av = interiorAvatar
+ const floors = houseFloorCount(e)
+ if (id === 'door' || id === 'leave') {
+ if (id === 'door' && (av?.floor || 0) > 0) {
+ g.ptip(p, '请先下到 1 楼再出门')
+ return true
+ }
+ leaveInterior(g)
+ g.ptip(p, '离开房屋')
+ return true
+ }
+ if (id === 'stairs_up' && av) {
+ if ((av.floor || 0) >= floors - 1) { g.ptip(p, '已是顶层'); return true }
+ av.floor = (av.floor || 0) + 1
+ placeOnStairs(av, e, true)
+ g.ptip(p, `上到 ${av.floor + 1} 楼`)
+ return true
+ }
+ if (id === 'stairs_down' && av) {
+ if ((av.floor || 0) <= 0) { g.ptip(p, '已是 1 楼'); return true }
+ av.floor = (av.floor || 0) - 1
+ placeOnStairs(av, e, false)
+ g.ptip(p, `下到 ${av.floor + 1} 楼`)
+ return true
+ }
+ if (id === 'bed' || id === 'bed2') { doHouseRest(g, p, e, true); return true }
+ if (id === 'table') { g.ptip(p, '一张结实的木桌'); return true }
+ if (id === 'shelf') { g.ptip(p, '橱柜里放着日用品'); return true }
+ if (id === 'plant') { g.ptip(p, '一盆绿意盎然的盆栽'); return true }
+ if (id === 'sofa') { g.ptip(p, '柔软的沙发,适合歇脚'); return true }
+ if (id === 'crate' || id === 'crate2') { g.ptip(p, '堆放杂物的木箱'); return true }
+ return false
+}
+
+function updateInterior(g, dt) {
+ if (interiorOpen == null) return
+ const e = g.entities.find((x) => x.id === interiorOpen && x.type === 'camphouse')
+ const p = g.me()
+ if (!e || !p || p.dead) { leaveInterior(g); return }
+ if (!interiorAvatar) enterInterior(g, e)
+ const av = interiorAvatar
+ if (av.floor == null) av.floor = 0
+ const floors = houseFloorCount(e)
+ if (av.floor >= floors) av.floor = floors - 1
+ const { furn } = currentFurn(e, av)
+ let dx = 0
+ let dy = 0
+ if (g.keyDown) {
+ dx = (g.keyDown('ArrowRight') || g.keyDown('d') || g.keyDown('D') ? 1 : 0)
+ - (g.keyDown('ArrowLeft') || g.keyDown('a') || g.keyDown('A') ? 1 : 0)
+ dy = (g.keyDown('ArrowDown') || g.keyDown('s') || g.keyDown('S') ? 1 : 0)
+ - (g.keyDown('ArrowUp') || g.keyDown('w') || g.keyDown('W') ? 1 : 0)
+ }
+ if ((!dx && !dy) && p.input) {
+ dx = p.input.dx || 0
+ dy = p.input.dy || 0
+ }
+ const len = Math.hypot(dx, dy)
+ if (len > 1e-3) {
+ dx /= len
+ dy /= len
+ const step = INTERIOR_SPEED * dt
+ let nx = av.x + dx * step
+ let ny = av.y + dy * step
+ nx = Math.max(AVATAR_R + 8, Math.min(ROOM_W - AVATAR_R - 8, nx))
+ ny = Math.max(AVATAR_R + 36, Math.min(ROOM_H - AVATAR_R - 6, ny))
+ const solids = furn.filter((f) => f.solid)
+ const blockedX = solids.some((f) => circleHitsRect(nx, av.y, AVATAR_R, f))
+ const blockedY = solids.some((f) => circleHitsRect(av.x, ny, AVATAR_R, f))
+ if (!blockedX) av.x = nx
+ if (!blockedY) av.y = ny
+ if (dx) av.dir = dx > 0 ? 1 : -1
+ av.walking = true
+ av.walkT += dt
+ } else {
+ av.walking = false
+ }
+ av.prompt = nearestInteriorPrompt(av, e)
+ const wantInteract = !!(g.keyDown?.('e') || g.keyDown?.('E') || g.keyDown?.(' ') || g.keyDown?.('Enter'))
+ if (wantInteract && !av.interactLatch && av.prompt) {
+ tryInteriorInteract(g, p, av.prompt)
+ }
+ av.interactLatch = wantInteract
+ const wantLeave = !!g.keyDown?.('Escape')
+ if (wantLeave && !av.escapeLatch) leaveInterior(g)
+ av.escapeLatch = wantLeave
+}
+
+function drawFurnPiece(ctx, view, f, env) {
+ const a = roomToScreen(view, f.x, f.y)
+ const w = f.w * view.scale
+ const h = f.h * view.scale
+ const id = f.id
+ ctx.save()
+ ctx.strokeStyle = '#1d1409'
+ ctx.lineWidth = 2
+ if (id === 'door') {
+ ctx.fillStyle = 'rgba(255, 220, 140, 0.28)'
+ ctx.fillRect(a.x, a.y, w, h)
+ ctx.fillStyle = '#3a2a16'
+ ctx.fillRect(a.x + 4, a.y + 2, w - 8, 6)
+ ctx.fillStyle = '#f4ead2'
+ ctx.font = '11px sans-serif'
+ ctx.textAlign = 'center'
+ ctx.fillText('大门', a.x + w / 2, a.y - 4)
+ } else if (id === 'stairs_up' || id === 'stairs_down') {
+ const up = id === 'stairs_up'
+ ctx.fillStyle = '#6a6e78'
+ ctx.beginPath(); ctx.roundRect(a.x, a.y, w, h, 4); ctx.fill(); ctx.stroke()
+ // 坡道斑马线
+ ctx.strokeStyle = 'rgba(240,220,140,0.55)'
+ ctx.lineWidth = 2
+ const steps = 6
+ for (let i = 0; i < steps; i++) {
+ const t = i / steps
+ const y = up ? a.y + h * (1 - t) : a.y + h * t
+ ctx.beginPath()
+ ctx.moveTo(a.x + 4, y)
+ ctx.lineTo(a.x + w - 4, y - (up ? 8 : -8) * view.scale)
+ ctx.stroke()
+ }
+ ctx.fillStyle = '#f0d79a'
+ ctx.font = `bold ${Math.max(10, 12 * view.scale)}px sans-serif`
+ ctx.textAlign = 'center'
+ ctx.textBaseline = 'middle'
+ ctx.fillText(up ? '↑ 上楼' : '↓ 下楼', a.x + w / 2, a.y + h / 2)
+ } else if (id === 'bed' || id === 'bed2') {
+ ctx.fillStyle = id === 'bed2' ? '#4a6a8a' : '#6a4a8a'
+ ctx.beginPath(); ctx.roundRect(a.x, a.y, w, h, 6); ctx.fill(); ctx.stroke()
+ ctx.fillStyle = '#e8e0f0'
+ ctx.beginPath(); ctx.roundRect(a.x + 8, a.y + 6, w - 16, h * 0.42, 4); ctx.fill()
+ ctx.fillStyle = '#f4ead2'
+ ctx.font = `bold ${Math.max(10, 12 * view.scale)}px sans-serif`
+ ctx.textAlign = 'center'
+ ctx.fillText('床', a.x + w / 2, a.y + h - 10)
+ } else if (id === 'table') {
+ ctx.fillStyle = '#a07040'
+ ctx.beginPath(); ctx.roundRect(a.x, a.y, w, h * 0.45, 4); ctx.fill(); ctx.stroke()
+ ctx.fillStyle = '#7a5030'
+ ctx.fillRect(a.x + 8, a.y + h * 0.45, 6, h * 0.55)
+ ctx.fillRect(a.x + w - 14, a.y + h * 0.45, 6, h * 0.55)
+ ctx.fillStyle = '#f4ead2'
+ ctx.font = '11px sans-serif'
+ ctx.textAlign = 'center'
+ ctx.fillText('木桌', a.x + w / 2, a.y - 6)
+ } else if (id === 'shelf') {
+ ctx.fillStyle = '#8a6038'
+ ctx.beginPath(); ctx.roundRect(a.x, a.y, w, h, 4); ctx.fill(); ctx.stroke()
+ ctx.strokeStyle = '#5a3a20'
+ ;[0.33, 0.66].forEach((k) => {
+ ctx.beginPath(); ctx.moveTo(a.x + 5, a.y + h * k); ctx.lineTo(a.x + w - 5, a.y + h * k); ctx.stroke()
+ })
+ ctx.fillStyle = '#f4ead2'
+ ctx.font = '11px sans-serif'
+ ctx.textAlign = 'center'
+ ctx.fillText('橱柜', a.x + w / 2, a.y - 6)
+ } else if (id === 'plant') {
+ ctx.fillStyle = '#8a5a3a'
+ ctx.beginPath(); ctx.roundRect(a.x + w * 0.2, a.y + h * 0.55, w * 0.6, h * 0.4, 3); ctx.fill(); ctx.stroke()
+ ctx.fillStyle = '#4a8a3a'
+ ctx.beginPath(); ctx.ellipse(a.x + w / 2, a.y + h * 0.4, w * 0.38, h * 0.28, 0, 0, TAU); ctx.fill()
+ const sway = Math.sin((env.time || 0) * 2.2) * 2
+ ctx.fillStyle = '#3a7a2a'
+ ctx.beginPath(); ctx.ellipse(a.x + w / 2 + sway, a.y + h * 0.28, w * 0.22, h * 0.18, 0, 0, TAU); ctx.fill()
+ ctx.fillStyle = '#f4ead2'
+ ctx.font = '11px sans-serif'
+ ctx.textAlign = 'center'
+ ctx.fillText('盆栽', a.x + w / 2, a.y - 4)
+ } else if (id === 'sofa') {
+ ctx.fillStyle = '#6a4a3a'
+ ctx.beginPath(); ctx.roundRect(a.x, a.y + h * 0.25, w, h * 0.75, 6); ctx.fill(); ctx.stroke()
+ ctx.fillStyle = '#8a6a50'
+ ctx.beginPath(); ctx.roundRect(a.x + 6, a.y, w - 12, h * 0.4, 5); ctx.fill(); ctx.stroke()
+ ctx.fillStyle = '#f4ead2'
+ ctx.font = '11px sans-serif'
+ ctx.textAlign = 'center'
+ ctx.fillText('沙发', a.x + w / 2, a.y - 4)
+ } else if (id === 'crate' || id === 'crate2') {
+ ctx.fillStyle = '#9a7a48'
+ ctx.beginPath(); ctx.roundRect(a.x, a.y, w, h, 4); ctx.fill(); ctx.stroke()
+ ctx.strokeStyle = '#6a4a28'
+ ctx.beginPath(); ctx.moveTo(a.x + 4, a.y + h / 2); ctx.lineTo(a.x + w - 4, a.y + h / 2); ctx.stroke()
+ ctx.fillStyle = '#f4ead2'
+ ctx.font = '11px sans-serif'
+ ctx.textAlign = 'center'
+ ctx.fillText('木箱', a.x + w / 2, a.y - 4)
+ }
+ ctx.restore()
+}
+
+function drawInterior(g, ctx, env) {
+ const e = g.entities.find((x) => x.id === interiorOpen && x.type === 'camphouse')
+ if (!e) { leaveInterior(g); return }
+ const p = g.me()
+ const mx = normalizeHouseMx(e)
+ const view = interiorView(env.W, env.H)
+ const skin = HOUSE_MODELS[mx.model] || HOUSE_MODELS.hut
+ if (!interiorAvatar) enterInterior(g, e)
+ const av = interiorAvatar
+ const { floors, floor, furn } = currentFurn(e, av)
+ uiInteriorBtns = furn.map((f) => {
+ const a = roomToScreen(view, f.x, f.y)
+ return { id: f.id, x: a.x, y: a.y, w: f.w * view.scale, h: f.h * view.scale }
+ })
+ const leaveBtn = {
+ id: 'leave',
+ x: view.px + view.pw / 2 - 50,
+ y: view.py + view.ph + 10,
+ w: 100,
+ h: 28,
+ }
+ uiInteriorBtns.push(leaveBtn)
+
+ ctx.save()
+ ctx.fillStyle = 'rgba(8, 6, 4, 0.72)'
+ ctx.fillRect(0, 0, env.W, env.H)
+
+ ctx.fillStyle = '#4a3224'
+ ctx.strokeStyle = '#1d1409'
+ ctx.lineWidth = 4
+ ctx.beginPath(); ctx.roundRect(view.px - 6, view.py - 6, view.pw + 12, view.ph + 12, 10); ctx.fill(); ctx.stroke()
+
+ // 楼层色差:越高越亮,像停车楼分层
+ const floorTint = 1 + floor * 0.04
+ ctx.fillStyle = skin.wall
+ ctx.globalAlpha = Math.min(1, 0.92 * floorTint)
+ ctx.fillRect(view.px, view.py, view.pw, view.ph * 0.42)
+ ctx.globalAlpha = 1
+ ctx.fillStyle = skin.wall2
+ ctx.fillRect(view.px, view.py + view.ph * 0.42 - 6, view.pw, 8)
+ const floorCols = ['#8a6a40', '#7a7248', '#6a6a50', '#5a5a58']
+ ctx.fillStyle = floorCols[floor % floorCols.length]
+ ctx.fillRect(view.px, view.py + view.ph * 0.42, view.pw, view.ph * 0.58)
+ ctx.strokeStyle = 'rgba(60,40,20,0.35)'
+ ctx.lineWidth = 1
+ const plank = 16 * view.scale
+ for (let y = view.py + view.ph * 0.42 + 4; y < view.py + view.ph - 4; y += plank) {
+ ctx.beginPath(); ctx.moveTo(view.px + 4, y); ctx.lineTo(view.px + view.pw - 4, y); ctx.stroke()
+ }
+
+ // 停车楼式楼层指示条
+ const chipW = 28
+ const chipGap = 6
+ const chipsW = floors * chipW + (floors - 1) * chipGap
+ let chipX = view.px + view.pw / 2 - chipsW / 2
+ const chipY = view.py + 8
+ for (let i = 0; i < floors; i++) {
+ const on = i === floor
+ ctx.fillStyle = on ? '#e0b060' : '#3a2a1a'
+ ctx.strokeStyle = on ? '#1d1409' : '#5a4a38'
+ ctx.lineWidth = on ? 2.2 : 1.4
+ ctx.beginPath(); ctx.roundRect(chipX, chipY, chipW, 18, 4); ctx.fill(); ctx.stroke()
+ ctx.fillStyle = on ? '#1d1409' : '#c9b48c'
+ ctx.font = 'bold 11px sans-serif'
+ ctx.textAlign = 'center'
+ ctx.textBaseline = 'middle'
+ ctx.fillText(`${i + 1}F`, chipX + chipW / 2, chipY + 10)
+ chipX += chipW + chipGap
+ }
+
+ ctx.fillStyle = '#f0d79a'
+ ctx.font = 'bold 15px "PingFang SC","Microsoft YaHei",sans-serif'
+ ctx.textAlign = 'center'
+ ctx.textBaseline = 'middle'
+ ctx.fillText(`${mx.name} · ${skin.label}`, view.px + view.pw / 2, view.py - 18)
+ ctx.fillStyle = '#c9b48c'
+ ctx.font = '11px sans-serif'
+ const floorHint = floors > 1
+ ? `第 ${floor + 1}/${floors} 层 · 走近坡道按 E 换层`
+ : 'WASD 走动 · E 互动 · Esc 离开'
+ ctx.fillText(`住人 ${(mx.residents || []).length}/${houseCapacity(e)} · ${floorHint}`, view.px + view.pw / 2, view.py - 4)
+
+ furn.forEach((f) => drawFurnPiece(ctx, view, f, env))
+
+ if (av) {
+ const pos = roomToScreen(view, av.x, av.y)
+ const figScale = Math.max(1.1, 1.35 * view.scale)
+ ctx.save()
+ ctx.translate(pos.x, pos.y)
+ ctx.scale(figScale, figScale)
+ ctx.fillStyle = 'rgba(0,0,0,0.28)'
+ ctx.beginPath(); ctx.ellipse(0, 2, 12, 4, 0, 0, TAU); ctx.fill()
+ const pSkin = skinByCode(p?.skin || 'wilson')
+ const breathe = Math.sin((env.time || 0) * 2.4) * 0.8
+ drawPlayerFig(ctx, pSkin, {
+ dir: av.dir,
+ walking: av.walking,
+ walkT: av.walkT,
+ breathe,
+ time: env.time || 0,
+ toolCode: p?.equip?.hand?.code || '',
+ })
+ ctx.restore()
+ if (av.prompt) {
+ const labels = {
+ bed: 'E 睡觉/休息', bed2: 'E 睡觉/休息', door: 'E 离开',
+ stairs_up: 'E 上一层', stairs_down: 'E 下一层',
+ table: 'E 查看', shelf: 'E 查看', plant: 'E 查看',
+ sofa: 'E 查看', crate: 'E 查看', crate2: 'E 查看',
+ }
+ ctx.fillStyle = 'rgba(20,16,10,0.82)'
+ ctx.beginPath(); ctx.roundRect(pos.x - 52, pos.y - 58, 104, 20, 5); ctx.fill()
+ ctx.fillStyle = '#f4ead2'
+ ctx.font = 'bold 11px sans-serif'
+ ctx.textAlign = 'center'
+ ctx.fillText(labels[av.prompt] || 'E 互动', pos.x, pos.y - 46)
+ }
+ }
+
+ ctx.fillStyle = '#5a4630'
+ ctx.strokeStyle = '#1d1409'
+ ctx.lineWidth = 2
+ ctx.beginPath(); ctx.roundRect(leaveBtn.x, leaveBtn.y, leaveBtn.w, leaveBtn.h, 6); ctx.fill(); ctx.stroke()
+ ctx.fillStyle = '#f2e8d0'
+ ctx.font = 'bold 13px sans-serif'
+ ctx.textAlign = 'center'
+ ctx.textBaseline = 'middle'
+ ctx.fillText('离开', leaveBtn.x + leaveBtn.w / 2, leaveBtn.y + leaveBtn.h / 2 + 1)
+ ctx.restore()
+}
+
+function handleInteriorClick(g, p, sx, sy) {
+ if (interiorOpen == null) return false
+ const e = g.entities.find((x) => x.id === interiorOpen && x.type === 'camphouse')
+ if (!e || !p) { leaveInterior(g); return true }
+ const hit = uiInteriorBtns.find((b) => sx >= b.x && sx <= b.x + b.w && sy >= b.y && sy <= b.y + b.h)
+ if (!hit) return true
+ tryInteriorInteract(g, p, hit.id)
+ return true
+}
+
let uiPanel = null
let uiClose = null
let uiRows = []
@@ -41,13 +741,160 @@ 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)
+ n += (e.mx.farmer || 0) + (e.mx.herder || 0) + (e.mx.builder || 0) + (e.mx.chef || 0) + (e.mx.lumber || 0) + (e.mx.miner || 0)
})
return n
}
+
+function promptHouseName(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 syncHousezoneMarkers(g) {
+ g.entities.filter((e) => e.type === 'housezone').forEach((e) => g.removeEntity(e))
+ houseZones.forEach((z) => {
+ g.addEntity('housezone', (z.x0 + z.x1) / 2, (z.y0 + z.y1) / 2, { mx: { ...z } })
+ })
+}
+
+function renameHouseZoneAt(g, wx, wy) {
+ const hit = houseZones.find((z) => pointInAabb(wx, wy, z))
+ if (!hit) return false
+ const name = promptHouseName(hit.name || '宅基')
+ hit.name = name
+ syncHousezoneMarkers(g)
+ g.tip(`宅基已命名为「${name}」`)
+ return true
+}
+
+function deleteHouseZone(g, z) {
+ // 未完工地基一并拆除;已建好的房屋保留
+ g.entities
+ .filter((e) => e.type === 'houseplan' && (e.mx?.zoneId === z.id || pointInAabb(e.x, e.y, z)))
+ .forEach((e) => g.removeEntity(e))
+ houseZones = houseZones.filter((a) => a.id !== z.id)
+ syncHousezoneMarkers(g)
+ g.tip(`已删除宅基「${z.name}」`)
+ return true
+}
+
+function houseZoneRightAction(g, z) {
+ openZoneMenu({
+ title: `「${z.name}」`,
+ subtitle: '宅基规划区',
+ actions: [
+ { id: 'rename', label: '改名', color: '#4a6a8a' },
+ { id: 'delete', label: '删除区域', color: '#7a4a3a' },
+ { id: 'cancel', label: '取消', color: '#5a4630' },
+ ],
+ onPick(id) {
+ if (id === 'rename') {
+ const name = promptHouseName(z.name || '宅基')
+ z.name = name
+ syncHousezoneMarkers(g)
+ g.tip(`宅基已命名为「${name}」`)
+ return
+ }
+ if (id === 'delete') {
+ openZoneMenu({
+ title: `删除「${z.name}」?`,
+ subtitle: '未建地基会清掉,已建房屋保留',
+ actions: [
+ { id: 'confirm', label: '确认删除', color: '#8a3030' },
+ { id: 'cancel', label: '返回', color: '#5a4630' },
+ ],
+ onPick(id2) {
+ if (id2 === 'confirm') deleteHouseZone(g, z)
+ },
+ })
+ }
+ },
+ })
+ return true
+}
+
+function placeHousePlans(g, z) {
+ clearZoneClutter(g, z)
+ let n = 0
+ let idx = 1
+ const half = HOUSE_SPACING / 2
+ for (let y = z.y0 + half; y <= z.y1 - half; y += HOUSE_SPACING) {
+ for (let x = z.x0 + half; x <= z.x1 - half; x += HOUSE_SPACING) {
+ if (!g.isLand(x, y)) continue
+ const crowded = g.entities.some((e) => (e.type === 'camphouse' || e.type === 'houseplan') && Math.hypot(e.x - x, e.y - y) < HOUSE_SPACING * 0.55)
+ if (crowded) continue
+ const name = `${z.name}${idx}`
+ g.addEntity('houseplan', x, y, { mx: { name, zoneId: z.id, done: false } })
+ idx++
+ n++
+ }
+ }
+ syncHousezoneMarkers(g)
+ return n
+}
function eternalFireCount(g) {
return (g.fires || []).filter((f) => f.eternal).length
}
+// 火堆有效照明半径约 180,用间距铺满营地圆
+const FIRE_SPACING = 160
+function desiredGuardCount() {
+ // 按营地半径扩编:约每 250 距离 1 名,最少 1、最多 12
+ return Math.min(12, Math.max(1, Math.round(campR() / 250)))
+}
+function guardCount(g) {
+ return (g.monsters || []).filter((m) => m.kind === 'guard').length
+}
+/** 按圆环网格补长明火,照亮整个营地范围 */
+function ensureCampFires(g) {
+ const c = campCenter(g)
+ const coverR = campR() * 0.92
+ const spots = []
+ for (let ring = 0; ring * FIRE_SPACING <= coverR + 1; ring++) {
+ if (ring === 0) {
+ spots.push({ x: c.x, y: c.y })
+ continue
+ }
+ const rad = ring * FIRE_SPACING
+ const n = Math.max(6, Math.round((TAU * rad) / FIRE_SPACING))
+ for (let i = 0; i < n; i++) {
+ const a = (i / n) * TAU + (ring % 2) * (Math.PI / n)
+ spots.push({ x: c.x + Math.cos(a) * rad, y: c.y + Math.sin(a) * rad })
+ }
+ }
+ let added = 0
+ spots.forEach((p) => {
+ const spot = g.findLand(p.x, p.y, 0, 48)
+ if (!spot || !g.isLand(spot.x, spot.y)) return
+ const near = (g.fires || []).some((f) => f.eternal && Math.hypot(f.x - spot.x, f.y - spot.y) < FIRE_SPACING * 0.55)
+ if (near) return
+ g.addFire(spot.x, spot.y, { pit: true, eternal: true, ttl: 150, max: 150 })
+ added++
+ })
+ return added
+}
+/** 守卫数量随营地范围自动补齐 */
+function ensureGuards(g) {
+ const need = desiredGuardCount()
+ let have = guardCount(g)
+ if (have >= need) return 0
+ const c = campCenter(g)
+ let added = 0
+ for (let i = 0; i < need - have; i++) {
+ const a = (i / Math.max(1, need)) * TAU + have * 0.7
+ const rad = 50 + (i % 3) * 40
+ const spot = g.findLand(c.x + Math.cos(a) * rad, c.y + Math.sin(a) * rad, 0, 80)
+ if (!spot) continue
+ g.spawnMob('guard', spot.x, spot.y, {})
+ added++
+ }
+ return added
+}
function addCampXp(g, base) {
if (!base || base <= 0) return
const mult = 1 + 0.08 * hiredCount(g)
@@ -60,13 +907,15 @@ function addCampXp(g, base) {
}
}
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} 座长明篝火` : ''}`)
+ const fires = ensureCampFires(g)
+ const guards = ensureGuards(g)
+ syncHouseLooks(g)
+ const parts = [`营地升至 ${campState.level} 级!领地扩大`]
+ const model = HOUSE_MODELS[houseModelForLevel()]?.label
+ if (model) parts.push(`房屋升级为${model}`)
+ if (fires) parts.push(`补了 ${fires} 座长明篝火`)
+ if (guards) parts.push(`增派 ${guards} 名守卫`)
+ g.tip(parts.join(' · '))
if (campState.level >= 8 && !campState.fenced) {
buildCampFence(g)
}
@@ -75,22 +924,9 @@ 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 })
+ rebuildFencePerimeter(g, z, { fenceType: 'campfence', gateType: 'campgate', step: 34 })
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 })
- })
+ g.tip('营地周围筑起了围栏与大门(野怪可能翻墙闯入,守卫会迎击)')
}
// 相机无关的“点是否在矩形内”
@@ -163,31 +999,87 @@ function withdrawBox(g, p, box) {
box.items = {}
return moved
}
-// 营地圆心(优先取第一座房屋,缺则退回世界中心)
+/**
+ * 宅基规划 + 全部房屋 的并集包围盒(两者都算进去,避免圈跑偏)
+ * @returns {{ x0, y0, x1, y1 } | null}
+ */
+function campPlanBounds(g) {
+ let x0 = Infinity
+ let y0 = Infinity
+ let x1 = -Infinity
+ let y1 = -Infinity
+ let any = false
+ houseZones.forEach((z) => {
+ if (!z || !Number.isFinite(z.x0) || !Number.isFinite(z.y0) || !Number.isFinite(z.x1) || !Number.isFinite(z.y1)) return
+ any = true
+ x0 = Math.min(x0, z.x0)
+ y0 = Math.min(y0, z.y0)
+ x1 = Math.max(x1, z.x1)
+ y1 = Math.max(y1, z.y1)
+ })
+ ;(g?.entities || []).forEach((e) => {
+ if (e.type !== 'camphouse' || !Number.isFinite(e.x) || !Number.isFinite(e.y)) return
+ any = true
+ x0 = Math.min(x0, e.x - 48)
+ y0 = Math.min(y0, e.y - 48)
+ x1 = Math.max(x1, e.x + 48)
+ y1 = Math.max(y1, e.y + 48)
+ })
+ if (!any) return null
+ return { x0, y0, x1, y1 }
+}
+
+/**
+ * 营地安全区:整片宅基/房屋并集只画一个圈
+ * 半径至少为营地等级半径,保证主屋附近一定能看见圈
+ * @returns {{ cx, cy, rx, ry }}
+ */
+function campTerritory(g) {
+ const pad = Math.max(120, campR())
+ const b = campPlanBounds(g)
+ if (!b) {
+ const house = (g?.entities || []).find((e) => e.type === 'camphouse')
+ const cx = house ? house.x : (g.worldW / 2)
+ const cy = house ? house.y : (g.worldH / 2)
+ return { cx, cy, rx: pad, ry: pad * 0.62 }
+ }
+ const cx = (b.x0 + b.x1) / 2
+ const cy = (b.y0 + b.y1) / 2
+ // 包住并集后再外扩一整圈营地半径
+ const rx = Math.max(pad, (b.x1 - b.x0) / 2 + pad)
+ const ry = Math.max(pad * 0.62, (b.y1 - b.y0) / 2 + pad * 0.62)
+ if (!Number.isFinite(cx) || !Number.isFinite(cy) || !Number.isFinite(rx) || !Number.isFinite(ry)) {
+ return { cx: g.worldW / 2, cy: g.worldH / 2, rx: pad, ry: pad * 0.62 }
+ }
+ return { cx, cy, rx, ry }
+}
+
+// 营地圆心(宅基/房屋并集中心)
function campCenter(g) {
- const house = g.entities.find((e) => e.type === 'camphouse')
- if (house) return { x: house.x, y: house.y }
- return { x: g.worldW / 2, y: g.worldH / 2 }
+ const t = campTerritory(g)
+ return { x: t.cx, y: t.cy }
}
-// 全部营地房屋(用于绘制每一座的安全区边界)
function campHouseList(g) {
- return g.entities.filter((e) => e.type === 'camphouse')
+ return (g?.entities || []).filter((e) => e.type === 'camphouse')
}
-// 玩家在「任意一座」营地房屋的动态半径内都算进入营地
+// 落在整片营地安全区内即算营地
function inCamp(g, x, y) {
- const r = campR()
- return g.entities.some((e) => e.type === 'camphouse' && Math.hypot(x - e.x, y - e.y) < r)
+ const t = campTerritory(g)
+ if (!t.rx || !t.ry) return false
+ const dx = (x - t.cx) / t.rx
+ const dy = (y - t.cy) / t.ry
+ return dx * dx + dy * dy <= 1
}
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: '可开关的营地大门' },
@@ -275,102 +1167,68 @@ export default {
},
},
ents: {
+ housezone: {
+ 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() {},
+ },
+ houseplan: {
+ name: '房屋地基',
+ conf: { size: 40, deco: true },
+ init(e) {
+ if (!e.mx) e.mx = { name: '新房', done: false }
+ if (!e.mx.name) e.mx.name = '新房'
+ },
+ interact(p, e, g) {
+ try {
+ const v = typeof window !== 'undefined' ? window.prompt('房屋名称', e.mx.name || '新房') : null
+ if (v != null) {
+ e.mx.name = String(v).trim().slice(0, 12) || e.mx.name
+ g.markDirty(e)
+ g.ptip(p, `地基命名为「${e.mx.name}」· 等建筑工来盖`)
+ }
+ } catch { /* */ }
+ },
+ draw(ctx, e) {
+ ctx.fillStyle = 'rgba(0,0,0,0.2)'
+ ctx.beginPath(); ctx.ellipse(0, 6, 28, 8, 0, 0, TAU); ctx.fill()
+ ctx.strokeStyle = '#c9a06a'
+ ctx.fillStyle = 'rgba(180, 140, 80, 0.25)'
+ ctx.lineWidth = 2
+ ctx.setLineDash([6, 4])
+ ctx.beginPath(); ctx.roundRect(-28, -36, 56, 42, 3); ctx.fill(); ctx.stroke()
+ ctx.setLineDash([])
+ ctx.strokeStyle = '#8a6a34'
+ ctx.beginPath(); ctx.moveTo(-20, -28); ctx.lineTo(20, -28); ctx.lineTo(0, -48); ctx.closePath(); ctx.stroke()
+ const name = e.mx?.name || '地基'
+ ctx.fillStyle = 'rgba(20,16,10,0.75)'
+ const tw = Math.max(36, name.length * 8 + 12)
+ ctx.beginPath(); ctx.roundRect(-tw / 2, -62, tw, 14, 4); ctx.fill()
+ ctx.fillStyle = '#f4ead2'
+ ctx.font = 'bold 10px sans-serif'
+ ctx.textAlign = 'center'
+ ctx.textBaseline = 'middle'
+ ctx.fillText(name, 0, -55)
+ },
+ },
camphouse: {
name: '房屋',
conf: { size: 52, struct: true },
init(e) {
- if (!e.mx) e.mx = { restDay: 0 }
- if (e.mx.restDay == null) e.mx.restDay = 0
+ normalizeHouseMx(e)
+ e.mx.model = houseModelForLevel()
+ e.mx.floors = Math.min(4, zoneFloors(getCampLevel()))
},
interact(p, e, g) {
- if (!e.mx) e.mx = { restDay: 0 }
- if (e.mx.restDay === g.day) {
- g.ptip(p, '今天已经休息过了')
- return
- }
- 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, '白天歇息:饥饿 +40、生命 +25、理智 +15')
- g.swing(p, 'eat')
+ normalizeHouseMx(e)
+ enterInterior(g, e)
+ g.ptip(p, `进入「${e.mx.name}」· WASD 走动 · 走近床按 E 睡觉`)
},
draw(ctx, e, env) {
- // 底部投影
- ctx.fillStyle = 'rgba(0,0,0,0.25)'
- ctx.beginPath(); ctx.ellipse(0, 8, 34, 9, 0, 0, TAU); ctx.fill()
- // 石砌地基
- ctx.fillStyle = '#8d9097'
- ctx.strokeStyle = '#1d1409'
- ctx.lineWidth = 2
- ctx.beginPath(); ctx.roundRect(-30, -4, 60, 12, 2); ctx.fill(); ctx.stroke()
- ctx.strokeStyle = '#6f7278'
- ctx.lineWidth = 1
- ;[-20, -10, 0, 10, 20].forEach((x) => { ctx.beginPath(); ctx.moveTo(x, -4); ctx.lineTo(x, 8); ctx.stroke() })
- // 木板墙
- ctx.fillStyle = '#b07f47'
- ctx.strokeStyle = '#1d1409'
- ctx.lineWidth = 2
- ctx.beginPath(); ctx.roundRect(-26, -46, 52, 44, 3); ctx.fill(); ctx.stroke()
- // 横向木纹
- ctx.strokeStyle = '#8a5f30'
- ctx.lineWidth = 1.2
- ;[-38, -30, -22, -14, -6].forEach((y) => { ctx.beginPath(); ctx.moveTo(-26, y); ctx.lineTo(26, y); ctx.stroke() })
- // 竖向立柱
- ;[-18, 0, 18].forEach((x) => { ctx.beginPath(); ctx.moveTo(x, -46); ctx.lineTo(x, -2); ctx.stroke() })
- // 门(深色木门 + 门把手)
- ctx.fillStyle = '#3a2a16'
- ctx.strokeStyle = '#1d1409'
- ctx.lineWidth = 2
- ctx.beginPath(); ctx.roundRect(-11, -26, 20, 24, [3, 3, 0, 0]); ctx.fill(); ctx.stroke()
- ctx.fillStyle = '#e0c060'
- ctx.beginPath(); ctx.arc(4, -14, 1.6, 0, TAU); ctx.fill()
- // 亮着暖光的窗
- ctx.fillStyle = '#ffd98a'
- ctx.strokeStyle = '#1d1409'
- ctx.lineWidth = 2
- ctx.beginPath(); ctx.roundRect(7, -40, 15, 13, 1.5); ctx.fill(); ctx.stroke()
- ctx.strokeStyle = '#8a6a34'
- ctx.lineWidth = 1.2
- ctx.beginPath(); ctx.moveTo(7, -33.5); ctx.lineTo(22, -33.5); ctx.moveTo(14.5, -40); ctx.lineTo(14.5, -27); ctx.stroke()
- // 斜屋顶(瓦片纹)
- ctx.fillStyle = '#7a5230'
- ctx.strokeStyle = '#1d1409'
- ctx.lineWidth = 2
- ctx.beginPath(); ctx.moveTo(-34, -46); ctx.lineTo(34, -46); ctx.lineTo(0, -76); ctx.closePath(); ctx.fill(); ctx.stroke()
- ctx.strokeStyle = '#5d3f24'
- ctx.lineWidth = 1.2
- ;[-26, -16, -6, 6, 16, 26].forEach((x) => { ctx.beginPath(); ctx.moveTo(x, -46); ctx.lineTo(x * 0.4, -72); ctx.stroke() })
- // 烟囱
- ctx.fillStyle = '#9a6a3c'
- ctx.strokeStyle = '#1d1409'
- ctx.lineWidth = 1.8
- ctx.beginPath(); ctx.roundRect(12, -72, 11, 20, 2); ctx.fill(); ctx.stroke()
- // 炊烟(缓慢飘动)
- const t = env?.time || 0
- ctx.fillStyle = 'rgba(220,220,220,0.4)'
- for (let i = 0; i < 3; i++) {
- const ph = (t * 0.5 + i * 0.5 + e.id * 0.1) % 1.5
- ctx.beginPath()
- ctx.arc(17 + Math.sin(t + i) * 3, -80 - ph * 22, 3 + ph * 3, 0, TAU)
- ctx.fill()
- }
- // 门口小台阶
- ctx.fillStyle = '#9a6a3c'
- ctx.strokeStyle = '#1d1409'
- ctx.lineWidth = 1.6
- ctx.beginPath(); ctx.roundRect(-13, -2, 24, 5, 1.5); ctx.fill(); ctx.stroke()
+ drawHouseExterior(ctx, e, env)
},
},
campstore: {
@@ -386,6 +1244,11 @@ export default {
g.ptip(p, '打开营地仓储(背包可堆叠物资可存入箱子)')
},
draw(ctx, e, env) {
+ let stored = 0
+ boxesOf(e).forEach((b) => {
+ Object.values(b.items || {}).forEach((k) => { stored += k || 0 })
+ })
+ drawNameplate(ctx, stored > 0 ? `营地仓储 · ${stored}件` : '营地仓储', -74)
// 底部投影
ctx.fillStyle = 'rgba(0,0,0,0.25)'
ctx.beginPath(); ctx.ellipse(0, 8, 28, 8, 0, 0, TAU); ctx.fill()
@@ -450,6 +1313,8 @@ export default {
g.ptip(p, '打开食堂菜单')
},
draw(ctx, e, env) {
+ const n = Object.values(e.mx?.menu || {}).reduce((a, b) => a + (b || 0), 0)
+ drawNameplate(ctx, n > 0 ? `食堂 · ${n}份` : '食堂', -68)
ctx.fillStyle = 'rgba(0,0,0,0.25)'
ctx.beginPath(); ctx.ellipse(0, 8, 28, 8, 0, 0, TAU); ctx.fill()
ctx.fillStyle = '#b07f47'
@@ -462,7 +1327,6 @@ export default {
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'
@@ -474,9 +1338,18 @@ export default {
campfence: {
name: '营地栅栏',
conf: { size: 24, struct: true, solid: true },
- draw(ctx) {
+ init(e) {
+ if (!e.mx) e.mx = { orient: 'h' }
+ if (!e.mx.orient) e.mx.orient = 'h'
+ },
+ draw(ctx, e) {
+ const vert = e.mx?.orient === 'v'
ctx.fillStyle = 'rgba(0,0,0,0.25)'
- ctx.beginPath(); ctx.ellipse(0, 4, 16, 5, 0, 0, TAU); ctx.fill()
+ ctx.beginPath()
+ ctx.ellipse(0, 4, vert ? 6 : 16, vert ? 16 : 5, 0, 0, TAU)
+ ctx.fill()
+ ctx.save()
+ if (vert) ctx.rotate(Math.PI / 2)
ctx.fillStyle = '#8d9097'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 2
@@ -485,6 +1358,7 @@ export default {
})
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()
+ ctx.restore()
},
},
campgate: {
@@ -536,28 +1410,33 @@ export default {
guard: {
name: '守卫',
friendly: true,
- conf: { hp: 9999, speed: 150, dmg: 60, score: 0, friendly: true, name: '守卫' },
+ conf: { hp: 9999, speed: 210, dmg: 70, score: 0, friendly: true, name: '守卫' },
drops: [],
update(m, dt, g) {
const conf = g.mon[m.kind] || this.conf
// 无限血:核心所有伤害路径都是 m.hp -= dmg 后判 <=0,每帧回满即可免疫
m.hp = 1e9
+ if (m.cd > 0) m.cd -= dt
const c = campCenter(g)
- // 找营地半径内的敌对生物
+ const engageR = campR() + 140
+ // 优先打离自己最近、已进入/逼近营地的敌对生物
let target = null
- let bd = campR()
+ let best = Infinity
g.monsters.forEach((q) => {
- if (q === m) return
+ if (q === m || q.kind === 'guard') return
if (!g.mon[q.kind]?.hostile) return
- const d = Math.hypot(q.x - c.x, q.y - c.y)
- if (d < bd) { bd = d; target = q }
+ const dCamp = Math.hypot(q.x - c.x, q.y - c.y)
+ if (dCamp > engageR) return
+ const dMe = Math.hypot(q.x - m.x, q.y - m.y)
+ const score = dMe + dCamp * 0.12
+ if (score < best) { best = score; target = q }
})
if (target) {
const d = Math.hypot(target.x - m.x, target.y - m.y)
- if (d > 34) {
- g.moveToward(m, target.x, target.y, conf.speed, dt)
+ if (d > 30) {
+ g.moveToward(m, target.x, target.y, conf.speed * 1.4, dt)
} else if ((m.cd || 0) <= 0) {
- m.cd = 0.9
+ m.cd = 0.35
g.damageMob(target, conf.dmg, null)
}
return
@@ -567,18 +1446,18 @@ export default {
if (m.wanderT <= 0) {
m.wanderT = 2 + Math.random() * 3
const a = Math.random() * TAU
- const r = 60 + Math.random() * 110
+ const r = 60 + Math.random() * Math.min(140, campR() * 0.35)
m.tx = c.x + Math.cos(a) * r
m.ty = c.y + Math.sin(a) * r
}
if (m.tx !== undefined && Math.hypot(m.tx - m.x, m.ty - m.y) > 8) {
- g.moveToward(m, m.tx, m.ty, conf.speed * 0.4, dt)
+ g.moveToward(m, m.tx, m.ty, conf.speed * 0.45, dt)
} else {
m.moving = false
}
},
interact(p, m, g) {
- g.ptip(p, '守卫挺直身板:营地范围里很安全')
+ g.ptip(p, `守卫:发现敌对会立刻迎击(当前编制 ${desiredGuardCount()} 人)`)
},
draw(ctx, m, time) {
ctx.save()
@@ -644,7 +1523,7 @@ export default {
},
hooks: {
save() {
- return { ...campState }
+ return { ...campState, houseZones, nextHouseZoneId }
},
load(g, data) {
campState = {
@@ -652,25 +1531,42 @@ export default {
xp: Math.max(0, data?.xp || 0),
fenced: !!data?.fenced,
}
+ houseZones = (data?.houseZones || []).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,
+ }))
+ nextHouseZoneId = data?.nextHouseZoneId || (Math.max(0, ...houseZones.map((z) => z.id)) + 1) || 1
+ houseZoneMode = false
+ houseDrag = null
storeOpen = null
canteenOpen = null
+ leaveInterior(null)
+ syncHousezoneMarkers(g)
+ // 给无名字的旧房屋补名
+ g.entities.filter((e) => e.type === 'camphouse').forEach((e, i) => {
+ if (!e.mx) e.mx = {}
+ if (!e.mx.name) e.mx.name = i === 0 ? '主屋' : `房屋${i + 1}`
+ })
+ // 旧档补齐照明与守卫编制
+ ensureCampFires(g)
+ ensureGuards(g)
},
onWorldGen(g) {
- // 出生点即世界中心,营地在中心附近错开摆位(避免卡住玩家)
const cx = g.worldW / 2
const cy = g.worldH / 2
const house = g.findLand(cx - 40, cy - 30, 0, 60)
- g.addEntity('camphouse', house.x, house.y)
- const gs = g.findLand(cx + 60, cy + 40, 0, 80)
- g.spawnMob('guard', gs.x, gs.y, {})
- placeStarterFires(g, house.x, house.y)
+ g.addEntity('camphouse', house.x, house.y, { mx: { restDay: 0, name: '主屋', floors: 1 } })
+ ensureCampFires(g)
+ ensureGuards(g)
const can = g.findLand(cx - 80, cy + 50, 0, 60)
g.addEntity('canteen', can.x, can.y)
- g.tip('营地已就绪:房屋、五座长明篝火、食堂与守卫')
+ g.tip(`营地已就绪:主屋、分散长明篝火、食堂与 ${desiredGuardCount()} 名守卫`)
},
+ // 墙外野怪不能隔墙打营内玩家;翻墙进来的可以打,由守卫迎击
onMobVictim(g, m, victim) {
if (!g.mon[m.kind]?.hostile) return
- if (inCamp(g, victim.x, victim.y)) return false
+ if (inCamp(g, victim.x, victim.y) && !inCamp(g, m.x, m.y)) return false
},
onGather(g) {
addCampXp(g, 2)
@@ -681,6 +1577,9 @@ export default {
addCampXp(g, Math.max(3, Math.round(score / 5)))
},
onNewDay(g) {
+ ensureCampFires(g)
+ ensureGuards(g)
+ syncHouseLooks(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))
@@ -691,12 +1590,36 @@ export default {
xp += Math.min(15, industry * 2)
if (xp > 0) addCampXp(g, xp)
},
+ allowsPlayerMove(g, p) {
+ if (interiorOpen != null) return false
+ },
onTick(g, dt) {
+ updateInterior(g, dt)
+ // Esc 关闭营地仓储 / 食堂 / 区域菜单
+ if (g.keyDown?.('Escape') && interiorOpen == null) {
+ if (storeOpen != null || canteenOpen != null) {
+ storeOpen = null
+ canteenOpen = null
+ }
+ if (isZoneMenuOpen()) closeZoneMenu()
+ }
g.entities.forEach((e) => {
if (e.type !== 'campgate') return
if (!e.mx) e.mx = { open: false, openT: 0 }
+ const near = (g.players || []).some((p) => !p.dead && Math.hypot(p.x - e.x, p.y - e.y) < 56)
+ || (g.monsters || []).some((m) => (m.kind === 'laborfarmer' || m.kind === 'laborherder' || m.kind === 'laborbuilder' || m.kind === 'laborchef' || m.kind === 'laborlumber' || m.kind === 'laborminer' || m.kind === 'campvillager') && Math.hypot(m.x - e.x, m.y - e.y) < 56)
+ if (near) {
+ if (!e.mx.open) {
+ e.mx.open = true
+ e.solid = false
+ g.markDirty(e)
+ }
+ e.mx.openT = Math.max(e.mx.openT || 0, 8)
+ e.solid = false
+ return
+ }
if (e.mx.open) {
- e.mx.openT = (e.mx.openT ?? 4) - dt
+ e.mx.openT = (e.mx.openT ?? 8) - dt
if (e.mx.openT <= 0) {
e.mx.open = false
e.mx.openT = 0
@@ -711,19 +1634,97 @@ export default {
})
},
onDraw(g, ctx, env) {
- const r = campR()
- const houses = campHouseList(g)
- const list = houses.length ? houses : [campCenter(g)]
+ // 整片营地安全区:一个圈包住宅基+房屋并集
+ const t = campTerritory(g)
+ const ex = t.cx - env.camX
+ const ey = t.cy - env.camY
ctx.save()
- ctx.strokeStyle = 'rgba(255,220,140,0.35)'
- ctx.lineWidth = 2
- ctx.setLineDash([10, 8])
- for (const h of list) {
- ctx.beginPath()
- ctx.ellipse(h.x - env.camX, h.y - env.camY, r, r * 0.62, 0, 0, TAU)
- ctx.stroke()
- }
+ ctx.fillStyle = 'rgba(255, 220, 140, 0.045)'
+ ctx.strokeStyle = 'rgba(255, 220, 140, 0.55)'
+ ctx.lineWidth = 2.5
+ ctx.setLineDash([12, 8])
+ ctx.beginPath()
+ ctx.ellipse(ex, ey, t.rx, t.ry, 0, 0, TAU)
+ ctx.fill()
+ ctx.stroke()
+ ctx.setLineDash([])
ctx.restore()
+ // 各块宅基规划虚线框
+ houseZones.forEach((z) => {
+ ctx.save()
+ ctx.strokeStyle = 'rgba(180, 160, 220, 0.55)'
+ ctx.fillStyle = 'rgba(140, 120, 200, 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(30, 24, 50, 0.75)'
+ ctx.beginPath(); ctx.roundRect(x + 4, y + 4, Math.min(120, z.x1 - z.x0 - 8), 18, 4); ctx.fill()
+ ctx.fillStyle = '#e8e0f8'
+ ctx.font = 'bold 12px sans-serif'
+ ctx.textAlign = 'left'
+ ctx.textBaseline = 'middle'
+ ctx.fillText(z.name || '宅基', x + 10, y + 13)
+ ctx.restore()
+ })
+ if (houseDrag) {
+ const r2 = normalizeRect(houseDrag.x0, houseDrag.y0, houseDrag.x1, houseDrag.y1)
+ ctx.save()
+ ctx.strokeStyle = 'rgba(200, 180, 255, 0.9)'
+ ctx.fillStyle = 'rgba(160, 140, 220, 0.15)'
+ ctx.lineWidth = 2
+ ctx.setLineDash([6, 4])
+ ctx.fillRect(r2.x0 - env.camX, r2.y0 - env.camY, r2.x1 - r2.x0, r2.y1 - r2.y0)
+ ctx.strokeRect(r2.x0 - env.camX, r2.y0 - env.camY, r2.x1 - r2.x0, r2.y1 - r2.y0)
+ ctx.restore()
+ }
+ },
+ onPointerDown(g, wx, wy) {
+ const sx = wx - uiCamX
+ const sy = wy - uiCamY
+ if (uiHouseBtn && inside(sx, sy, uiHouseBtn)) return false
+ if (uiHouseCancel && inside(sx, sy, uiHouseCancel)) return false
+ if (!houseZoneMode) return false
+ houseDrag = { x0: wx, y0: wy, x1: wx, y1: wy }
+ return true
+ },
+ onPointerMove(g, wx, wy) {
+ if (!houseZoneMode || !houseDrag) return false
+ houseDrag.x1 = wx
+ houseDrag.y1 = wy
+ return true
+ },
+ onPointerUp(g, wx, wy) {
+ if (!houseZoneMode) return false
+ if (!houseDrag) {
+ houseZoneMode = false
+ return true
+ }
+ houseDrag.x1 = wx
+ houseDrag.y1 = wy
+ const rect = normalizeRect(houseDrag.x0, houseDrag.y0, houseDrag.x1, houseDrag.y1)
+ houseDrag = null
+ houseZoneMode = false
+ if (rect.x1 - rect.x0 < 100 || rect.y1 - rect.y0 < 100) {
+ g.tip('宅基太小,再划大一点')
+ return true
+ }
+ const name = promptHouseName(`宅基${houseZones.length + 1}`)
+ const z = { id: nextHouseZoneId++, name, ...rect }
+ houseZones.push(z)
+ const n = placeHousePlans(g, z)
+ g.tip(`「${name}」已划定:铺设地基 ${n} 处,招聘建筑工自动盖房(右键管理)`)
+ return true
+ },
+ onRightClick(g, wx, wy, ev) {
+ if (houseZoneMode) return false
+ const z = houseZones.find((a) => pointInAabb(wx, wy, a))
+ if (!z) return false
+ if (ev?.shiftKey) return renameHouseZoneAt(g, wx, wy)
+ return houseZoneRightAction(g, z)
},
onDrawUI(g, ctx, env) {
// 记录相机位置,供 onClick 做世界→屏幕换算
@@ -743,7 +1744,30 @@ export default {
ctx.textAlign = 'left'
ctx.textBaseline = 'middle'
const need = xpNeed(campState.level)
- ctx.fillText(`营地 Lv.${campState.level} XP ${campState.xp}/${need}`, 18, 66)
+ ctx.fillText(`营地 Lv.${campState.level} XP ${campState.xp}/${need} 守卫 ${guardCount(g)}/${desiredGuardCount()} 篝火 ${eternalFireCount(g)}`, 18, 66)
+ // 划定宅基按钮
+ const bw = 92
+ const bh = 28
+ const bx = 10
+ const by = 92
+ uiHouseBtn = { x: bx, y: by, w: bw, h: bh }
+ ctx.fillStyle = houseZoneMode ? '#5a4a7a' : '#3a2a4a'
+ 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.fillText(houseZoneMode ? '框选中…' : '划定宅基', bx + bw / 2, by + bh / 2)
+ if (houseZoneMode) {
+ uiHouseCancel = { x: bx, y: by + bh + 6, w: bw, h: 22 }
+ ctx.fillStyle = '#5a3a28'
+ ctx.beginPath(); ctx.roundRect(uiHouseCancel.x, uiHouseCancel.y, uiHouseCancel.w, uiHouseCancel.h, 5); ctx.fill(); ctx.stroke()
+ ctx.fillStyle = '#f4ead2'
+ ctx.fillText('取消', uiHouseCancel.x + bw / 2, uiHouseCancel.y + 11)
+ } else {
+ uiHouseCancel = null
+ }
ctx.restore()
// 食堂面板
@@ -761,9 +1785,9 @@ export default {
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 panelH = Math.min(H - 16, headerH + Math.max(1, entries.length) * rowH + footerH + pad)
const px = Math.round((W - panelW) / 2)
- const py = Math.round((H - panelH) / 2)
+ const py = Math.round(Math.max(8, (H - panelH) / 2))
uiCanteenPanel = { x: px, y: py, w: panelW, h: panelH }
uiCanteenRows = []
ctx.save()
@@ -807,7 +1831,7 @@ export default {
}
const cw = 90
const ch = 26
- uiCanteenClose = { x: px + panelW / 2 - cw / 2, y: py + panelH - footerH + 8, w: cw, h: ch }
+ uiCanteenClose = { x: px + panelW / 2 - cw / 2, y: py + panelH - ch - 10, w: cw, h: ch }
ctx.fillStyle = '#7a2f24'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 2
@@ -824,114 +1848,138 @@ export default {
uiCanteenRows = []
}
- if (storeOpen == null) return
- const e = g.entities.find((x) => x.id === storeOpen)
- const p = g.me()
- if (!e || !p) {
- storeOpen = null
- return
- }
- const boxes = boxesOf(e)
- const rowH = 30
- const headerH = 40
- const footerH = 42
- const pad = 10
- const panelW = Math.min(470, W - 24)
- const panelH = headerH + BOXES * rowH + footerH + pad
- const px = Math.round((W - panelW) / 2)
- const py = Math.round((H - panelH) / 2)
- uiPanel = { x: px, y: py, w: panelW, h: panelH }
+ // 营地仓储面板(与室内/区域菜单解耦,勿 early-return)
+ if (storeOpen != null) {
+ const e = g.entities.find((x) => x.id === storeOpen)
+ const p = g.me()
+ if (!e || !p) {
+ storeOpen = null
+ uiPanel = null
+ uiClose = null
+ uiRows = []
+ } else {
+ const boxes = boxesOf(e)
+ const rowH = 30
+ const headerH = 40
+ const footerH = 42
+ const pad = 10
+ const panelW = Math.min(470, W - 24)
+ const panelH = Math.min(H - 16, headerH + BOXES * rowH + footerH + pad)
+ const px = Math.round((W - panelW) / 2)
+ const py = Math.round(Math.max(8, (H - panelH) / 2))
+ uiPanel = { x: px, y: py, w: panelW, h: panelH }
- // 深色木质面板
- 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.textAlign = 'center'
- ctx.textBaseline = 'middle'
- ctx.font = 'bold 17px system-ui, sans-serif'
- ctx.fillText('营地仓储', px + panelW / 2, py + headerH / 2 - 2)
- ctx.font = '11px system-ui, sans-serif'
- ctx.fillStyle = 'rgba(244,234,210,0.7)'
- ctx.fillText(`共 ${BOXES} 箱 · 每箱 ${BOX_KINDS} 种 · 每种上限 ${BOX_CAP}`, px + panelW / 2, py + headerH - 6)
-
- // 行
- uiRows = []
- const rightEdge = px + panelW - pad - 4
- const btnH = 20
- const depW = 66
- const witW = 60
- const gap = 6
- const fontSize = 13
- for (let i = 0; i < BOXES; i++) {
- const rowY = py + headerH + i * rowH
- const box = boxes[i]
- const used = usedKinds(box)
- // 交替底色
- ctx.fillStyle = i % 2 ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.14)'
- ctx.beginPath(); ctx.roundRect(px + pad, rowY + 2, panelW - pad * 2, rowH - 5, 4); ctx.fill()
- // 箱子 N + 用量
- ctx.textAlign = 'left'
- ctx.textBaseline = 'middle'
- ctx.fillStyle = used > 0 ? '#f4ead2' : 'rgba(244,234,210,0.5)'
- ctx.font = `bold ${fontSize}px system-ui, sans-serif`
- ctx.fillText(`箱子 ${i + 1}`, px + pad + 8, rowY + rowH / 2 - 1)
- ctx.fillStyle = 'rgba(255,220,140,0.85)'
- ctx.font = `11px system-ui, sans-serif`
- ctx.fillText(`${used}/${BOX_KINDS} 种`, px + pad + 62, rowY + rowH / 2 - 1)
- // 内容摘要(最多 3 种)
- const entries = Object.entries(box.items || {}).filter(([, n]) => n > 0)
- const shown = entries.slice(0, 3).map(([code, n]) => `${g.items[code]?.name || code}×${n}`)
- const more = entries.length > 3 ? ` 等${entries.length}种` : ''
- ctx.fillStyle = 'rgba(230,220,200,0.8)'
- ctx.font = `12px system-ui, sans-serif`
- ctx.fillText(entries.length ? shown.join(' ') + more : '(空)', px + pad + 108, rowY + rowH / 2 - 1)
- // 按钮
- const by = rowY + (rowH - 3 - btnH) / 2 + 1
- const dep = { x: rightEdge - witW - gap - depW, y: by, w: depW, h: btnH }
- const wit = { x: rightEdge - witW, y: by, w: witW, h: btnH }
- const drawBtn = (r, label, bg) => {
- ctx.fillStyle = bg
+ ctx.save()
+ ctx.fillStyle = 'rgba(0,0,0,0.45)'
+ ctx.fillRect(0, 0, W, H)
+ ctx.fillStyle = '#3a2a18'
ctx.strokeStyle = '#1d1409'
- ctx.lineWidth = 1.4
- ctx.beginPath(); ctx.roundRect(r.x, r.y, r.w, r.h, 5); ctx.fill(); ctx.stroke()
+ 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.textAlign = 'center'
- ctx.font = `bold 12px system-ui, sans-serif`
- ctx.fillText(label, r.x + r.w / 2, r.y + r.h / 2 + 0.5)
+ ctx.textBaseline = 'middle'
+ ctx.font = 'bold 17px system-ui, sans-serif'
+ ctx.fillText('营地仓储', px + panelW / 2, py + headerH / 2 - 2)
+ ctx.font = '11px system-ui, sans-serif'
+ ctx.fillStyle = 'rgba(244,234,210,0.7)'
+ ctx.fillText(`共 ${BOXES} 箱 · 每箱 ${BOX_KINDS} 种 · 每种上限 ${BOX_CAP}`, px + panelW / 2, py + headerH - 6)
+
+ uiRows = []
+ const rightEdge = px + panelW - pad - 4
+ const btnH = 20
+ const depW = 66
+ const witW = 60
+ const gap = 6
+ const fontSize = 13
+ const bodyBottom = py + panelH - footerH
+ for (let i = 0; i < BOXES; i++) {
+ const rowY = py + headerH + i * rowH
+ if (rowY + rowH > bodyBottom) break
+ const box = boxes[i]
+ const used = usedKinds(box)
+ ctx.fillStyle = i % 2 ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.14)'
+ ctx.beginPath(); ctx.roundRect(px + pad, rowY + 2, panelW - pad * 2, rowH - 5, 4); ctx.fill()
+ ctx.textAlign = 'left'
+ ctx.textBaseline = 'middle'
+ ctx.fillStyle = used > 0 ? '#f4ead2' : 'rgba(244,234,210,0.5)'
+ ctx.font = `bold ${fontSize}px system-ui, sans-serif`
+ ctx.fillText(`箱子 ${i + 1}`, px + pad + 8, rowY + rowH / 2 - 1)
+ ctx.fillStyle = 'rgba(255,220,140,0.85)'
+ ctx.font = `11px system-ui, sans-serif`
+ ctx.fillText(`${used}/${BOX_KINDS} 种`, px + pad + 62, rowY + rowH / 2 - 1)
+ const entries = Object.entries(box.items || {}).filter(([, n]) => n > 0)
+ const shown = entries.slice(0, 3).map(([code, n]) => `${g.items[code]?.name || code}×${n}`)
+ const more = entries.length > 3 ? ` 等${entries.length}种` : ''
+ ctx.fillStyle = 'rgba(230,220,200,0.8)'
+ ctx.font = `12px system-ui, sans-serif`
+ ctx.fillText(entries.length ? shown.join(' ') + more : '(空)', px + pad + 108, rowY + rowH / 2 - 1)
+ const by = rowY + (rowH - 3 - btnH) / 2 + 1
+ const dep = { x: rightEdge - witW - gap - depW, y: by, w: depW, h: btnH }
+ const wit = { x: rightEdge - witW, y: by, w: witW, h: btnH }
+ const drawBtn = (r, label, bg) => {
+ ctx.fillStyle = bg
+ ctx.strokeStyle = '#1d1409'
+ ctx.lineWidth = 1.4
+ ctx.beginPath(); ctx.roundRect(r.x, r.y, r.w, r.h, 5); ctx.fill(); ctx.stroke()
+ ctx.fillStyle = '#f4ead2'
+ ctx.textAlign = 'center'
+ ctx.font = `bold 12px system-ui, sans-serif`
+ ctx.fillText(label, r.x + r.w / 2, r.y + r.h / 2 + 0.5)
+ }
+ drawBtn(dep, '↓ 存入', '#3f6b34')
+ drawBtn(wit, '↑ 取出', '#8a6a34')
+ uiRows.push({ i, dep, wit })
+ }
+
+ // 关闭按钮始终贴在面板可视底部
+ const cw = 120
+ const ch = 26
+ const cx2 = px + panelW / 2 - cw / 2
+ const cy2 = py + panelH - ch - 10
+ uiClose = { x: cx2, y: cy2, w: cw, h: ch }
+ ctx.fillStyle = '#7a2f24'
+ ctx.strokeStyle = '#1d1409'
+ ctx.lineWidth = 2
+ ctx.beginPath(); ctx.roundRect(cx2, cy2, cw, ch, 6); ctx.fill(); ctx.stroke()
+ ctx.fillStyle = '#f4ead2'
+ ctx.textAlign = 'center'
+ ctx.textBaseline = 'middle'
+ ctx.font = 'bold 14px system-ui, sans-serif'
+ ctx.fillText('关闭', cx2 + cw / 2, cy2 + ch / 2 + 0.5)
+ ctx.restore()
}
- drawBtn(dep, '↓ 存入', '#3f6b34')
- drawBtn(wit, '↑ 取出', '#8a6a34')
- uiRows.push({ i, dep, wit })
+ } else {
+ uiPanel = null
+ uiClose = null
+ uiRows = []
}
- // 关闭按钮
- const cw = 120
- const ch = 26
- const cx2 = px + panelW / 2 - cw / 2
- const cy2 = py + panelH - footerH + 8
- uiClose = { x: cx2, y: cy2, w: cw, h: ch }
- ctx.fillStyle = '#7a2f24'
- ctx.strokeStyle = '#1d1409'
- ctx.lineWidth = 2
- ctx.beginPath(); ctx.roundRect(cx2, cy2, cw, ch, 6); ctx.fill(); ctx.stroke()
- ctx.fillStyle = '#f4ead2'
- ctx.textAlign = 'center'
- ctx.textBaseline = 'middle'
- ctx.font = 'bold 14px system-ui, sans-serif'
- ctx.fillText('关闭', cx2 + cw / 2, cy2 + ch / 2 + 0.5)
- ctx.restore()
+ // 室内子地图与区域菜单必须始终有机会绘制
+ if (interiorOpen != null) drawInterior(g, ctx, env)
+ drawZoneMenu(ctx, env)
},
onClick(g, wx, wy) {
+ if (handleZoneMenuClick(wx, wy)) return true
const sx = wx - uiCamX
const sy = wy - uiCamY
+ if (interiorOpen != null) {
+ return handleInteriorClick(g, g.me(), sx, sy)
+ }
+
+ if (uiHouseBtn && inside(sx, sy, uiHouseBtn)) {
+ houseZoneMode = !houseZoneMode
+ houseDrag = null
+ g.tip(houseZoneMode ? '拖拽框选宅基(松手命名,建筑工会盖房)' : '取消划定宅基')
+ return true
+ }
+ if (uiHouseCancel && inside(sx, sy, uiHouseCancel)) {
+ houseZoneMode = false
+ houseDrag = null
+ return true
+ }
// 食堂面板优先
if (canteenOpen != null) {
diff --git a/src/games/starve/mods/husbandry.js b/src/games/starve/mods/husbandry.js
index 9c4b9d7..661885d 100644
--- a/src/games/starve/mods/husbandry.js
+++ b/src/games/starve/mods/husbandry.js
@@ -1,4 +1,4 @@
-import { normalizeRect, pointInAabb, aabbArea, rebuildFencePerimeter, clearZoneClutter } from './zoneUtil.js'
+import { normalizeRect, pointInAabb, aabbArea, rebuildFencePerimeter, clearFencePerimeter, clearZoneClutter, zoneYieldMul, zoneFloors, zoneUpgradeCost, buildCostDetails, costDetailsAllOk, hasStockCost, payStockCost, drawNameplate, perimeterPoints, FENCE_STEP, openZoneMenu, drawZoneMenu, handleZoneMenuClick } from './zoneUtil.js'
// 牧业文明:栅栏圈养、饲料槽投喂 —— 羊毛与鸡蛋的牧场经济
// - 剪刀(科学机器合成,耐久 30):点击绵羊剪毛,每只羊每天一次(给羊毛 ×2)
@@ -8,11 +8,12 @@ import { normalizeRect, pointInAabb, aabbArea, rebuildFencePerimeter, clearZoneC
// - 羊毛可做便宜的保暖帽(保暖 60),鸡蛋/牛奶/奶酪补充食物
// 数值基准:三围 150/150/200,一天 160 秒,官方食物折算
const TAU = Math.PI * 2
-const GATE_AUTO_CLOSE = 4 // 牧场门打开后自动关闭秒数
+const GATE_AUTO_CLOSE = 8 // 牧场门打开后自动关闭秒数(附近有人会续时)
// ---- 牧场框选(多块可命名) ----
-let pastureZones = [] // [{ id, name, x0, y0, x1, y1 }]
+let pastureZones = [] // [{ id, name, level, x0, y0, x1, y1 }]
let nextPastureId = 1
+const MAX_PASTURE_LEVEL = 10
let zoneMode = false
let drag = null
let uiCamX = 0
@@ -51,6 +52,8 @@ function stockPasture(g, z) {
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 cx = (z.x0 + z.x1) / 2
+ const cy = (z.y0 + z.y1) / 2
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)
@@ -60,16 +63,22 @@ function stockPasture(g, z) {
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,
+ home: { x: cx, y: cy },
+ homeX: cx,
+ homeY: cy,
zone: { ...z },
})
- if (m) { m.pen = true; m.zoneId = z.id }
+ if (m) {
+ m.pen = true
+ m.zoneId = z.id
+ m.home = { x: cx, y: cy }
+ }
}
}
spawnIn('pig', Math.max(0, maxPig - countKind('pig')))
spawnIn('beefalo', Math.max(0, maxCow - countKind('beefalo')))
spawnIn('chicken', Math.max(0, maxChick - countKind('chicken')))
+ spawnIn('sheep', Math.max(0, Math.min(3, 1 + Math.floor(area / 90000)) - countKind('sheep')))
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)
@@ -78,15 +87,15 @@ function stockPasture(g, z) {
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,
+ homeX: cx,
+ homeY: cy,
})
}
syncPastureMarkers(g)
}
function applyPasture(g, rect, name) {
- const z = { id: nextPastureId++, name, ...rect }
+ const z = { id: nextPastureId++, name, level: 1, ...rect }
pastureZones.push(z)
clearZoneClutter(g, z)
rebuildFencePerimeter(g, z, { fenceType: 'fence', gateType: 'gate' })
@@ -94,6 +103,105 @@ function applyPasture(g, rect, name) {
return z
}
+function upgradePastureZone(g, z) {
+ const p = g.me()
+ if (!p) return false
+ const lv = z.level || 1
+ if (lv >= MAX_PASTURE_LEVEL) {
+ g.tip(`「${z.name}」已满级`)
+ return true
+ }
+ const cost = zoneUpgradeCost(lv)
+ if (!hasStockCost(g, cost)) {
+ g.tip(`升级需要 木头×${cost.log} 石块×${cost.rock}${cost.gold ? ` 金块×${cost.gold}` : ''}(背包或仓库)`)
+ return true
+ }
+ payStockCost(g, cost)
+ z.level = lv + 1
+ syncPastureMarkers(g)
+ g.tip(`「${z.name}」升至 ${z.level} 级 · 产出 ×${zoneYieldMul(z.level).toFixed(2)} · ${zoneFloors(z.level)} 层`)
+ return true
+}
+
+function deletePastureZone(g, z) {
+ clearFencePerimeter(g, z, { fenceType: 'fence', gateType: 'gate' })
+ // 移除圈养动物
+ ;(g.monsters || [])
+ .filter((m) => m.pen && (m.zoneId === z.id || pointInAabb(m.x, m.y, z)))
+ .forEach((m) => g.removeMob(m))
+ // 圈养实体(兔等)
+ g.entities
+ .filter((e) => e.mx?.pen && (e.mx?.zoneId === z.id || pointInAabb(e.x, e.y, z)))
+ .forEach((e) => g.removeEntity(e))
+ pastureZones = pastureZones.filter((a) => a.id !== z.id)
+ syncPastureMarkers(g)
+ g.tip(`已删除牧场「${z.name}」`)
+ return true
+}
+
+function pastureZoneRightAction(g, z) {
+ const lv = z.level || 1
+ const maxed = lv >= MAX_PASTURE_LEVEL
+ const cost = maxed ? null : zoneUpgradeCost(lv)
+ const details = maxed
+ ? [{ text: '已满级,无法再升级', ok: true }]
+ : buildCostDetails(g, cost)
+ const canUp = !maxed && costDetailsAllOk(details)
+ const missHint = !maxed && !canUp
+ ? details.filter((d) => !d.ok).map((d) => `缺${d.name}×${d.miss}`).join(' · ')
+ : ''
+ openZoneMenu({
+ title: `「${z.name}」`,
+ subtitle: maxed ? `牧场 · Lv.${lv}(满级)` : `牧场 · Lv.${lv} → Lv.${lv + 1}`,
+ detailTitle: maxed ? '升级状态' : `升级所需(背包+仓库)${missHint ? ` · ${missHint}` : ''}`,
+ details,
+ actions: [
+ {
+ id: 'upgrade',
+ label: maxed ? '已满级' : (canUp ? '确认升级' : '材料不足'),
+ color: canUp ? '#5d7a3a' : '#5a4a3a',
+ disabled: maxed || !canUp,
+ },
+ { id: 'rename', label: '改名', color: '#4a6a8a' },
+ { id: 'delete', label: '删除区域', color: '#7a4a3a' },
+ { id: 'cancel', label: '取消', color: '#5a4630' },
+ ],
+ onPick(id) {
+ if (id === 'upgrade') {
+ upgradePastureZone(g, z)
+ return
+ }
+ if (id === 'rename') {
+ const name = promptPastureName(z.name)
+ z.name = name
+ syncPastureMarkers(g)
+ g.tip(`牧场已命名为「${name}」`)
+ return
+ }
+ if (id === 'delete') {
+ openZoneMenu({
+ title: `删除「${z.name}」?`,
+ subtitle: '围栏与圈养将一并移除',
+ actions: [
+ { id: 'confirm', label: '确认删除', color: '#8a3030' },
+ { id: 'back', label: '返回', color: '#5a4630' },
+ ],
+ onPick(id2) {
+ if (id2 === 'confirm') deletePastureZone(g, z)
+ else if (id2 === 'back') pastureZoneRightAction(g, z)
+ },
+ })
+ }
+ },
+ })
+ return true
+}
+
+export function pastureYieldAt(x, y) {
+ const z = pastureZones.find((a) => pointInAabb(x, y, a))
+ return zoneYieldMul(z?.level || 1)
+}
+
function keepPenMobs(g, dt) {
if (!pastureZones.length) return
const zoneById = Object.fromEntries(pastureZones.map((z) => [z.id, z]))
@@ -101,8 +209,14 @@ function keepPenMobs(g, dt) {
if (!m.pen) return
const z = zoneById[m.zoneId] || m.zone || pastureZones[0]
if (!z) return
+ m.zone = z
const cx = (z.x0 + z.x1) / 2
const cy = (z.y0 + z.y1) / 2
+ if (!m.home) m.home = { x: cx, y: cy }
+ else { m.home.x = cx; m.home.y = cy }
+ // 圈养动物不离家、不受伤后失联:缓慢回血并拉回围栏
+ const maxHp = g.mon[m.kind]?.hp || m.hp
+ if (m.hp < maxHp) m.hp = Math.min(maxHp, m.hp + 4 * dt)
if (!pointInAabb(m.x, m.y, z)) {
g.moveToward(m, cx, cy, (g.mon[m.kind]?.speed || 80) * 0.8, dt)
}
@@ -123,6 +237,19 @@ function tickGates(g, dt) {
g.entities.forEach((e) => {
if (e.type !== 'gate') return
if (!e.mx) e.mx = { open: false, openT: 0 }
+ // 玩家 / 劳工靠近自动开门并续时
+ const near = (g.players || []).some((p) => !p.dead && Math.hypot(p.x - e.x, p.y - e.y) < 56)
+ || (g.monsters || []).some((m) => (m.kind?.startsWith?.('labor') || m.kind === 'campvillager') && Math.hypot(m.x - e.x, m.y - e.y) < 56)
+ if (near) {
+ if (!e.mx.open) {
+ e.mx.open = true
+ e.solid = false
+ g.markDirty(e)
+ }
+ e.mx.openT = Math.max(e.mx.openT || 0, GATE_AUTO_CLOSE)
+ e.solid = false
+ return
+ }
if (e.mx.open) {
e.mx.openT = (e.mx.openT ?? GATE_AUTO_CLOSE) - dt
if (e.mx.openT <= 0) {
@@ -154,8 +281,15 @@ function wander(m, dt, g, speed) {
if (m.wanderT <= 0) {
m.wanderT = 2 + Math.random() * 3
const a = Math.random() * TAU
- m.tx = m.x + Math.cos(a) * 80
- m.ty = m.y + Math.sin(a) * 80
+ const z = m.pen ? (m.zone || pastureZones.find((p) => p.id === m.zoneId)) : null
+ if (z) {
+ const pad = 40
+ m.tx = z.x0 + pad + Math.random() * Math.max(20, z.x1 - z.x0 - pad * 2)
+ m.ty = z.y0 + pad + Math.random() * Math.max(20, z.y1 - z.y0 - pad * 2)
+ } else {
+ m.tx = m.x + Math.cos(a) * 80
+ m.ty = m.y + Math.sin(a) * 80
+ }
}
if (m.tx !== undefined && Math.hypot(m.tx - m.x, m.ty - m.y) > 8) {
g.moveToward(m, m.tx, m.ty, speed, dt)
@@ -355,24 +489,32 @@ export default {
fence: {
name: '栅栏',
conf: { size: 24, struct: true, solid: true },
- draw(ctx) {
- // 底部投影
+ init(e) {
+ if (!e.mx) e.mx = { orient: 'h' }
+ if (!e.mx.orient) e.mx.orient = 'h'
+ },
+ draw(ctx, e) {
+ const vert = e.mx?.orient === 'v'
+ // 底部投影(竖向时拉长南北)
ctx.fillStyle = 'rgba(0,0,0,0.25)'
- ctx.beginPath(); ctx.ellipse(0, 4, 16, 5, 0, 0, TAU); ctx.fill()
- // 两根竖木桩
+ ctx.beginPath()
+ ctx.ellipse(0, 4, vert ? 6 : 16, vert ? 16 : 5, 0, 0, TAU)
+ ctx.fill()
+ ctx.save()
+ if (vert) ctx.rotate(Math.PI / 2)
+ // 两根竖木桩 + 两道横木(竖向时整体旋转,横木沿围墙延伸)
ctx.fillStyle = '#a9773c'
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()
- // 木纹
ctx.strokeStyle = '#7a5230'
ctx.lineWidth = 1.2
ctx.beginPath(); ctx.moveTo(-11, -28); ctx.lineTo(-11, 4); ctx.moveTo(11, -28); ctx.lineTo(11, 4); ctx.stroke()
+ ctx.restore()
},
},
gate: {
@@ -401,6 +543,7 @@ export default {
},
draw(ctx, e) {
const open = !!e.mx?.open
+ drawNameplate(ctx, open ? '牧场门 · 开' : '牧场门', -42)
ctx.fillStyle = 'rgba(0,0,0,0.25)'
ctx.beginPath(); ctx.ellipse(0, 4, 20, 5, 0, 0, TAU); ctx.fill()
ctx.save()
@@ -446,10 +589,11 @@ export default {
g.ptip(p, `投喂了一份饲料(现有 ${e.mx.feed} 份)`)
},
draw(ctx, e) {
+ const feed = e.mx?.feed || 0
+ drawNameplate(ctx, feed > 0 ? `饲料槽 · ${feed}` : '饲料槽', -36)
// 底部投影
ctx.fillStyle = 'rgba(0,0,0,0.25)'
ctx.beginPath(); ctx.ellipse(0, 4, 18, 5, 0, 0, TAU); ctx.fill()
- const feed = e.mx?.feed || 0
// 木质槽体
ctx.fillStyle = '#8a5f38'
ctx.strokeStyle = '#1d1409'
@@ -497,6 +641,8 @@ export default {
}
},
draw(ctx, e) {
+ const eggs = e.mx?.stored || 0
+ drawNameplate(ctx, eggs > 0 ? `鸡舍 · 蛋${eggs}` : '鸡舍', -48)
// 底部投影
ctx.fillStyle = 'rgba(0,0,0,0.25)'
ctx.beginPath(); ctx.ellipse(0, 5, 22, 7, 0, 0, TAU); ctx.fill()
@@ -673,18 +819,23 @@ export default {
pastureZones = data.zones.map((z, i) => ({
id: z.id || i + 1,
name: z.name || `牧场${i + 1}`,
+ level: Math.max(1, z.level || 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 }]
+ pastureZones = [{ id: 1, name: z.name || '牧场1', level: z.level || 1, x0: z.x0, y0: z.y0, x1: z.x1, y1: z.y1 }]
nextPastureId = 2
} else {
pastureZones = []
nextPastureId = 1
}
syncPastureMarkers(g)
+ // 旧档竖边空隙:按新步长与朝向重建围栏
+ pastureZones.forEach((z) => rebuildFencePerimeter(g, z, { fenceType: 'fence', gateType: 'gate' }))
+ // 生物不入档:读档后按牧场容量补齐圈养
+ pastureZones.forEach((z) => stockPasture(g, z))
},
onWorldGen(g) {
// 草原上散落绵羊与鸡(寻找邻近陆地投放)
@@ -705,7 +856,9 @@ export default {
tickGates(g, dt)
},
onNewDay(g) {
- // 结算每座鸡舍的囤蛋:附近有鸡或喂了饲料槽则翻倍,每日上限 4
+ // 每日按容量补齐圈养(死亡/跑丢后恢复)
+ pastureZones.forEach((z) => stockPasture(g, z))
+ // 结算每座鸡舍的囤蛋:附近有鸡或喂了饲料槽则翻倍;牧场等级提高上限与产出
g.entities.filter((e) => e.type === 'coop').forEach((coop) => {
if (!coop.mx) coop.mx = {}
if (coop.mx.stored == null) coop.mx.stored = 0
@@ -713,20 +866,24 @@ export default {
const trough = g.entities.find((t) => t.type === 'trough' && (t.mx?.feed || 0) > 0 && Math.hypot(t.x - coop.x, t.y - coop.y) < 160)
let inc = 1
if (chickenNear || trough) inc = 2
+ const mul = pastureYieldAt(coop.x, coop.y)
+ inc = Math.max(1, Math.round(inc * mul))
if (trough) {
trough.mx.feed -= 1
g.markDirty(trough)
}
- coop.mx.stored = Math.min(4, (coop.mx.stored || 0) + inc)
+ const cap = Math.min(8, 4 + Math.floor((pastureZones.find((z) => pointInAabb(coop.x, coop.y, z))?.level || 1) / 2))
+ coop.mx.stored = Math.min(cap, (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)
+ const n = Math.max(1, Math.round(pastureYieldAt(cow.x, cow.y)))
+ g.dropLoot(cow.x + g.rand(-16, 16), cow.y + 20, 'milk', n)
})
},
onPointerDown(g, wx, wy) {
@@ -761,10 +918,18 @@ export default {
}
const name = promptPastureName(`牧场${pastureZones.length + 1}`)
const z = applyPasture(g, rect, name)
- g.tip(`「${z.name}」已划定:杂项已清出,底边有可开关的栅栏门`)
+ g.tip(`「${z.name}」已划定:底边留有牧场门(右键管理:升级/改名/删除)`)
return true
},
+ onRightClick(g, wx, wy, ev) {
+ if (zoneMode) return false
+ const z = pastureZones.find((a) => pointInAabb(wx, wy, a))
+ if (!z) return false
+ if (ev?.shiftKey) return renamePastureAt(g, wx, wy)
+ return pastureZoneRightAction(g, z)
+ },
onClick(g, wx, wy) {
+ if (handleZoneMenuClick(wx, wy)) return true
const sx = wx - uiCamX
const sy = wy - uiCamY
if (uiBtn && inside(sx, sy, uiBtn)) {
@@ -778,39 +943,86 @@ export default {
drag = null
return true
}
- if (!zoneMode && renamePastureAt(g, wx, wy)) return true
return false
},
onDraw(g, ctx, env) {
pastureZones.forEach((z) => {
ctx.save()
+ const floors = zoneFloors(z.level || 1)
ctx.strokeStyle = 'rgba(200, 160, 80, 0.55)'
- ctx.fillStyle = 'rgba(180, 140, 60, 0.08)'
+ ctx.fillStyle = `rgba(180, 140, 60, ${0.06 + floors * 0.03})`
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)
+ // 多层围栏示意:高等级多画内圈
+ for (let f = 1; f < floors; f++) {
+ const pad = 10 * f
+ ctx.strokeStyle = `rgba(220, 180, 100, ${0.35 - f * 0.05})`
+ ctx.strokeRect(z.x0 - env.camX + pad, z.y0 - env.camY + pad, z.x1 - z.x0 - pad * 2, z.y1 - z.y0 - pad * 2)
+ }
ctx.setLineDash([])
const x = z.x0 - env.camX
const y = z.y0 - env.camY
+ const label = `${z.name || '牧场'} Lv.${z.level || 1}`
+ const lw = Math.min(140, Math.max(72, label.length * 8 + 16))
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.beginPath(); ctx.roundRect(x + 4, y + 4, lw, 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.fillText(label, 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.fillStyle = 'rgba(220, 170, 70, 0.12)'
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.setLineDash([])
+ // 预览围栏与门口
+ const pts = perimeterPoints(r, FENCE_STEP, true)
+ let gatePt = null
+ pts.forEach((p) => {
+ if (p.skip) return
+ const sx = p.x - env.camX
+ const sy = p.y - env.camY
+ if (p.gate) {
+ gatePt = p
+ ctx.fillStyle = 'rgba(224, 192, 96, 0.85)'
+ ctx.strokeStyle = '#1d1409'
+ ctx.lineWidth = 1.6
+ ctx.beginPath(); ctx.roundRect(sx - 14, sy - 18, 28, 22, 3); ctx.fill(); ctx.stroke()
+ ctx.fillStyle = '#1d1409'
+ ctx.font = 'bold 10px sans-serif'
+ ctx.textAlign = 'center'
+ ctx.textBaseline = 'middle'
+ ctx.fillText('门', sx, sy - 6)
+ return
+ }
+ ctx.fillStyle = 'rgba(169, 119, 60, 0.75)'
+ ctx.strokeStyle = 'rgba(29, 20, 9, 0.8)'
+ ctx.lineWidth = 1.2
+ if (p.orient === 'v') {
+ ctx.beginPath(); ctx.roundRect(sx - 4, sy - 14, 8, 28, 2); ctx.fill(); ctx.stroke()
+ } else {
+ ctx.beginPath(); ctx.roundRect(sx - 14, sy - 4, 28, 8, 2); ctx.fill(); ctx.stroke()
+ }
+ })
+ const w = Math.round(r.x1 - r.x0)
+ const h = Math.round(r.y1 - r.y0)
+ ctx.fillStyle = 'rgba(40, 28, 10, 0.75)'
+ ctx.beginPath(); ctx.roundRect((r.x0 + r.x1) / 2 - env.camX - 54, r.y0 - env.camY - 22, 108, 18, 4); ctx.fill()
+ ctx.fillStyle = '#f4ead2'
+ ctx.font = 'bold 11px sans-serif'
+ ctx.textAlign = 'center'
+ ctx.textBaseline = 'middle'
+ ctx.fillText(`${w}×${h}${gatePt ? ' · 底边有门' : ''}`, (r.x0 + r.x1) / 2 - env.camX, r.y0 - env.camY - 13)
ctx.restore()
}
},
@@ -844,6 +1056,7 @@ export default {
uiCancel = null
}
ctx.restore()
+ drawZoneMenu(ctx, env)
},
},
}
diff --git a/src/games/starve/mods/labor.js b/src/games/starve/mods/labor.js
index 14a1e8f..d501380 100644
--- a/src/games/starve/mods/labor.js
+++ b/src/games/starve/mods/labor.js
@@ -1,12 +1,10 @@
-// 劳务市场:用粮食招聘农民、牧工与厨师,替你自动开垦、播种、收割、入库、投喂与做饭
-// - 招聘所 laboroffice:打开面板招/解雇岗位(农民上限 4、牧工上限 4、厨师上限 2)
-// - 大仓库 laborwarehouse:12 个箱子 × 6 种 × 999,共 72 格容量,农民收获自动入库
-// - 农民 laborfarmer:收割 ready 良田 → 播种空良田 → 开垦新田(需农业 mod)→ 游荡
-// - 牧工 laborherder:投喂饲料槽 → 收鸡蛋入仓(需牧业 mod)→ 游荡
-// - 厨师 laborchef:从仓库取食材按烹饪锅规则做菜,存入食堂 canteen
-// - 每日工资:每亩人手 2 份粮食,优先扣仓库,付不出则全体罢工;补上即复工
-// - 货币「粮食」= 任何带 food 属性的物品(背包优先,不够再扣大仓库)
-// 数值基准:三围 150/150/200,一天 160 秒
+// 劳务市场:招聘农民/牧工/建筑工/厨师/伐木工/矿工;自然村民可分配职业
+// - 招聘所:招/解雇;村民点选分配职业(工资更低)
+// - 大仓库;劳工夜间回房;入驻营地房屋
+import { agriYieldAt } from './agri.js'
+import { drawNameplate, openZoneMenu, zoneFloors, drawZoneMenu, handleZoneMenuClick } from './zoneUtil.js'
+import { ensureWorkerHome, unassignWorkerHome, syncHouseLooks, getCampCenter, isInCamp, getCampLevel, houseModelForLevel } from './camp.js'
+
const TAU = Math.PI * 2
// 种子 → 成熟作物 code(agri.js 的 SEED_CROP 未导出,此处自备一份映射)
@@ -17,14 +15,33 @@ const FEEDS = ['wheat', 'pumpkin', 'carrot', 'berry']
// 岗位与经济
const MAX_WORKERS = 4
const MAX_CHEF = 2
+const MAX_BUILDER = 3
+const MAX_LUMBER = 4
+const MAX_MINER = 4
const FARMER_FEE = 8
const HERDER_FEE = 6
const CHEF_FEE = 10
-const WAGE = 2
-// 大仓库结构:12 箱 × 每箱 6 种 × 每种 999 = 72 格容量
+const BUILDER_FEE = 8
+const LUMBER_FEE = 7
+const MINER_FEE = 7
+const HIRED_WAGE = 3 // 招聘来的周薪
+const NATIVE_WAGE = 1 // 自然村民周薪
+const WAGE_EVERY = 7 // 每 N 天发一次工资
+const HOUSE_COST = { log: 12, rock: 8 }
+const LABOR_KINDS = ['laborfarmer', 'laborherder', 'laborbuilder', 'laborchef', 'laborlumber', 'laborminer']
+const MAX_VILLAGERS = 8
+function isLaborKind(k) {
+ return LABOR_KINDS.includes(k)
+}
+function wageOf(m) {
+ return m?.source === 'native' ? NATIVE_WAGE : HIRED_WAGE
+}
+// 大仓库结构:12 箱 × 每箱 6 种 × 每种最高 99999999
const BOX_COUNT = 12
const BOX_KINDS = 6
-const BOX_STACK = 999
+const BOX_STACK = 99999999
+const BOX_SLOT_COLS = 3
+const BOX_SLOT_ROWS = 2 // 3×2 大格展示
// 劳工姓名池(每人唯一)
const WORKER_NAMES = [
@@ -46,7 +63,7 @@ const OUTFIT_PALETTES = [
function pickUniqueName(g) {
const used = new Set(
g.monsters
- .filter((m) => m.kind === 'laborfarmer' || m.kind === 'laborherder' || m.kind === 'laborchef')
+ .filter((m) => isLaborKind(m.kind))
.map((m) => m.workerName)
.filter(Boolean),
)
@@ -57,7 +74,7 @@ function pickUniqueName(g) {
function pickOutfit(g) {
const used = g.monsters
- .filter((m) => m.kind === 'laborfarmer' || m.kind === 'laborherder' || m.kind === 'laborchef')
+ .filter((m) => isLaborKind(m.kind))
.map((m) => m.outfit | 0)
for (let i = 0; i < OUTFIT_PALETTES.length; i++) {
if (!used.includes(i)) return i
@@ -87,6 +104,7 @@ function cycleFarmerBind(p, m, g) {
// 当前打开的面板(存实体 id):招聘所 / 大仓库
let officeOpen = null
let storeOpen = null
+let whBoxSel = 0 // 大仓库当前选中的箱子下标
// onDrawUI 保存的屏幕参数,供 onClick(世界坐标)换算命中
let uiCamX = 0
let uiCamY = 0
@@ -97,6 +115,7 @@ let toldNoAgri = false
let toldNoHusbandry = false
let toldNoSeed = false
let toldNoCanteen = false
+let toldNoChefIng = false
// =====================================================================
// 通用工具
@@ -106,18 +125,21 @@ function entById(g, id) {
}
function mxOf(office) {
- if (!office.mx) office.mx = { hired: 0, farmer: 0, herder: 0, chef: 0, strike: 0 }
+ if (!office.mx) office.mx = { hired: 0, farmer: 0, herder: 0, builder: 0, chef: 0, lumber: 0, miner: 0, strike: 0 }
const mx = office.mx
if (mx.farmer == null) mx.farmer = 0
if (mx.herder == null) mx.herder = 0
+ if (mx.builder == null) mx.builder = 0
if (mx.chef == null) mx.chef = 0
+ if (mx.lumber == null) mx.lumber = 0
+ if (mx.miner == null) mx.miner = 0
if (mx.strike == null) mx.strike = 0
- mx.hired = (mx.farmer || 0) + (mx.herder || 0) + (mx.chef || 0)
+ mx.hired = (mx.farmer || 0) + (mx.herder || 0) + (mx.builder || 0) + (mx.chef || 0) + (mx.lumber || 0) + (mx.miner || 0)
return mx
}
function syncHired(mx) {
- mx.hired = (mx.farmer || 0) + (mx.herder || 0) + (mx.chef || 0)
+ mx.hired = (mx.farmer || 0) + (mx.herder || 0) + (mx.builder || 0) + (mx.chef || 0) + (mx.lumber || 0) + (mx.miner || 0)
}
// 取(并补齐)大仓库存放结构
@@ -264,28 +286,40 @@ function payerOf(g) {
// =====================================================================
// 大仓库存取
// =====================================================================
-// 存入,返回放不下的余量
+// 单箱软上限:避免「逮着一个箱子灌满」;总量仍可跨箱堆到 BOX_STACK
+const PER_BOX_SOFT = 999
+
+// 存入,返回放不下的余量(尽量分散到多个箱子)
function deposit(g, wh, code, n) {
if (!wh) return n
const boxes = boxesOf(wh)
let left = n
- // 先补已有该物品的箱子
- for (const b of boxes) {
- if (left <= 0) break
- const have = b.items[code]
- if (have != null) {
- const add = Math.min(BOX_STACK - have, left)
- if (add > 0) { b.items[code] = have + add; left -= add }
- }
- }
- // 再开新箱(该箱还需有空种类额度)
- for (const b of boxes) {
- if (left <= 0) break
- if (b.items[code] == null && Object.keys(b.items).length < BOX_KINDS) {
- const add = Math.min(BOX_STACK, left)
- b.items[code] = add
- left -= add
+ while (left > 0) {
+ let best = null
+ let bestScore = Infinity
+ for (const b of boxes) {
+ const have = b.items[code] || 0
+ const kinds = Object.keys(b.items || {}).length
+ if (have > 0 && have < PER_BOX_SOFT) {
+ // 已有该物:优先补最少的箱子,实现分摊
+ if (have < bestScore) { bestScore = have; best = b }
+ } else if (have === 0 && kinds < BOX_KINDS) {
+ // 新开种类:优先空箱 / 种类少的箱
+ const score = 100000 + kinds * 1000 + have
+ if (score < bestScore) { bestScore = score; best = b }
+ } else if (have >= PER_BOX_SOFT && have < BOX_STACK) {
+ // 软上限已满但总上限未满:仅当没有其它可放箱时再继续堆
+ const score = 200000 + have
+ if (score < bestScore) { bestScore = score; best = b }
+ }
}
+ if (!best) break
+ const have = best.items[code] || 0
+ const cap = have >= PER_BOX_SOFT ? BOX_STACK : PER_BOX_SOFT
+ const add = Math.min(cap - have, left)
+ if (add <= 0) break
+ best.items[code] = have + add
+ left -= add
}
if (left !== n) g.markDirty(wh)
return left
@@ -319,28 +353,25 @@ function warehouseCount(wh, code) {
// 把玩家身上的东西尽量塞进指定箱子(受该箱容量限制)
function boxDeposit(g, p, wh, bi) {
- const box = boxesOf(wh)[bi]
- if (!box) return
+ // 整箱存入也走分散逻辑,避免全堆进当前选中的那一格
+ void bi
let moved = 0
const areas = [p.slots, p.pack]
for (const arr of areas) {
if (!arr) continue
for (let i = 0; i < arr.length; i++) {
const s = arr[i]
- if (!s || !s.code) continue
- const have = box.items[s.code] || 0
- if (have <= 0 && Object.keys(box.items).length >= BOX_KINDS) continue
- const room = BOX_STACK - have
- const take = Math.min(room, s.n)
+ if (!s || !s.code || s.n <= 0) continue
+ const before = s.n
+ const left = deposit(g, wh, s.code, s.n)
+ const take = before - left
if (take <= 0) continue
- box.items[s.code] = have + take
- s.n -= take
+ s.n = left
moved += take
if (s.n <= 0) arr[i] = null
}
}
- if (moved > 0) g.markDirty(wh)
- g.ptip(p, moved > 0 ? `存入箱子 ${bi + 1}:×${moved}` : '这个箱子放不下了')
+ g.ptip(p, moved > 0 ? `分散存入 ×${moved}` : '仓库放不下了')
}
// 把指定箱子内容全部取回背包
@@ -358,7 +389,43 @@ function boxWithdraw(g, p, wh, bi) {
})
box.items = {}
g.markDirty(wh)
- g.ptip(p, `取出箱子 ${bi + 1}:×${moved}`)
+ g.ptip(p, `整箱取出 ${bi + 1}:×${moved}`)
+}
+
+/** 存入背包某一格(整堆)到仓库 */
+function depositInvSlot(g, p, wh, area, idx) {
+ const arr = area === 'pack' ? p.pack : p.slots
+ const s = arr?.[idx]
+ if (!s?.code || s.n <= 0) {
+ g.ptip(p, '这一格是空的')
+ return
+ }
+ const name = g.items[s.code]?.name || s.code
+ const before = s.n
+ const left = deposit(g, wh, s.code, s.n)
+ const moved = before - left
+ if (moved <= 0) {
+ g.ptip(p, '仓库放不下了(箱子种类已满)')
+ return
+ }
+ s.n = left
+ if (s.n <= 0) arr[idx] = null
+ g.ptip(p, `存入${name} ×${moved}`)
+}
+
+/** 从某箱取出某一种物品(整堆) */
+function withdrawItem(g, p, wh, bi, code) {
+ const box = boxesOf(wh)[bi]
+ if (!box) return
+ const n = box.items[code] || 0
+ if (n <= 0) {
+ g.ptip(p, '没有这件物品')
+ return
+ }
+ delete box.items[code]
+ g.addItem(p, code, n)
+ g.markDirty(wh)
+ g.ptip(p, `取出${g.items[code]?.name || code} ×${n}`)
}
// =====================================================================
@@ -367,7 +434,25 @@ 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: '牧工' },
+ builder: { kind: 'laborbuilder', key: 'builder', fee: BUILDER_FEE, max: MAX_BUILDER, label: '建筑工' },
chef: { kind: 'laborchef', key: 'chef', fee: CHEF_FEE, max: MAX_CHEF, label: '厨师' },
+ lumber: { kind: 'laborlumber', key: 'lumber', fee: LUMBER_FEE, max: MAX_LUMBER, label: '伐木工' },
+ miner: { kind: 'laborminer', key: 'miner', fee: MINER_FEE, max: MAX_MINER, label: '矿工' },
+}
+
+/** 夜间回房;已到家则站岗 */
+function tryGoHomeNight(m, dt, g) {
+ if (g.phase !== 'night') return false
+ ensureWorkerHome(g, m)
+ if (m.homeX == null) return false
+ const speed = g.mon[m.kind]?.speed || 160
+ const d = Math.hypot(m.homeX - m.x, m.homeY - m.y)
+ if (d > 30) {
+ g.moveToward(m, m.homeX, m.homeY, speed, dt)
+ return true
+ }
+ m.moving = false
+ return true
}
function hire(g, p, office, role) {
@@ -387,7 +472,7 @@ function hire(g, p, office, role) {
const spot = g.findLand(office.x, office.y + 50, 22, 70)
const workerName = pickUniqueName(g)
const outfit = pickOutfit(g)
- g.spawnMob(meta.kind, spot.x, spot.y, {
+ const m = g.spawnMob(meta.kind, spot.x, spot.y, {
owner: p,
office: office.id,
role: meta.key,
@@ -395,11 +480,13 @@ function hire(g, p, office, role) {
workerName,
outfit,
boundZoneId: null,
+ source: 'hired',
})
+ ensureWorkerHome(g, m)
mx[meta.key] = (mx[meta.key] || 0) + 1
syncHired(mx)
g.markDirty(office)
- g.ptip(p, `雇到了${workerName}(${meta.label}${meta.key === 'farmer' ? ' · 点他可绑定农田' : ''})`)
+ g.ptip(p, `雇到了${workerName}(${meta.label} · 周薪${HIRED_WAGE}${meta.key === 'farmer' ? ' · 点他可绑定农田' : ''})`)
}
function fire(g, p, office, role) {
@@ -414,13 +501,70 @@ function fire(g, p, office, role) {
g.ptip(p, '没有可以解雇的人手')
return
}
- g.removeMob(mine[mine.length - 1])
+ const victim = mine[mine.length - 1]
+ unassignWorkerHome(g, victim)
+ g.removeMob(victim)
mx[meta.key] = Math.max(0, (mx[meta.key] || 0) - 1)
syncHired(mx)
g.markDirty(office)
g.ptip(p, `解雇了一位${meta.label}`)
}
+/** 自然村民分配职业(低工资,无招聘费) */
+function assignVillagerJob(g, p, villager, role) {
+ const meta = ROLE_META[role]
+ if (!meta || !villager) return
+ const office = g.entities.find((e) => e.type === 'laboroffice')
+ if (!office) {
+ g.ptip(p, '需要先建造招聘所才能分配职业')
+ return
+ }
+ const mx = mxOf(office)
+ if ((mx[meta.key] || 0) >= meta.max) {
+ g.ptip(p, `该岗位最多 ${meta.max} 人`)
+ return
+ }
+ const name = villager.workerName || pickUniqueName(g)
+ const outfit = villager.outfit != null ? villager.outfit : pickOutfit(g)
+ const x = villager.x
+ const y = villager.y
+ g.removeMob(villager)
+ const m = g.spawnMob(meta.kind, x, y, {
+ owner: p,
+ office: office.id,
+ role: meta.key,
+ hiredAt: g.day,
+ workerName: name,
+ outfit,
+ boundZoneId: null,
+ source: 'native',
+ })
+ ensureWorkerHome(g, m)
+ mx[meta.key] = (mx[meta.key] || 0) + 1
+ syncHired(mx)
+ g.markDirty(office)
+ g.ptip(p, `${name}成为了${meta.label}(村民 · 周薪仅 ${NATIVE_WAGE})`)
+}
+
+function openVillagerAssign(g, p, m) {
+ openZoneMenu({
+ title: m.workerName || '村民',
+ subtitle: `自然村民 · 分配后周薪 ${NATIVE_WAGE}(招聘为 ${HIRED_WAGE})`,
+ actions: [
+ { id: 'farmer', label: '当农民', color: '#5d7a3a' },
+ { id: 'herder', label: '当牧工', color: '#5d7a3a' },
+ { id: 'builder', label: '当建筑工', color: '#5d7a3a' },
+ { id: 'chef', label: '当厨师', color: '#5d7a3a' },
+ { id: 'lumber', label: '当伐木工', color: '#5d7a3a' },
+ { id: 'miner', label: '当矿工', color: '#5d7a3a' },
+ { id: 'cancel', label: '取消', color: '#5a4630' },
+ ],
+ onPick(id) {
+ if (ROLE_META[id]) assignVillagerJob(g, p, m, id)
+ },
+ })
+}
+
function roleInfo(g, office, role) {
const meta = ROLE_META[role]
const mx = mxOf(office)
@@ -432,63 +576,101 @@ function roleInfo(g, office, role) {
// =====================================================================
// 农民劳作
// =====================================================================
-function pickFarmerJob(m, g, anchor, agriOn) {
- m.jobT = 8
+function pickFarmerJob(m, g) {
+ m.jobT = 4
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')
+ const ready = nearestEnt(g, m, 'agrifarm', (e) => e.state === 'ready' && inScope(e))
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')
+ const empty = nearestEnt(g, m, 'agrifarm', (e) => e.state === 'empty' && inScope(e))
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 }
- }
m.job = 'idle'
m.jobTarget = null
+ m.jobT = 0.4
}
function doHarvest(m, g, plot, wh) {
const crop = plot.mx?.seed || 'wheat'
- const n = g.rand(3, 4)
+ const mul = agriYieldAt(plot.x, plot.y, plot.mx?.zoneId)
+ const n = Math.max(1, Math.round(g.rand(3, 4) * mul))
let left = wh ? deposit(g, wh, crop, n) : n
if (left > 0) {
const bx = wh ? wh.x : plot.x
const by = wh ? wh.y : plot.y
g.dropLoot(bx + g.rand(-24, 24), by + g.rand(18, 36), crop, left)
}
+ const keepId = plot.mx?.zoneId
plot.state = 'empty'
- plot.mx = null
+ plot.mx = keepId != null ? { zoneId: keepId } : {}
g.markDirty(plot)
if (m.owner) g.ptip(m.owner, `${m.workerName || '农民'}收获${g.items[crop]?.name || '作物'} ×${n}`)
}
+function warehouseHasCost(wh, cost) {
+ if (!wh) return false
+ return Object.entries(cost).every(([code, n]) => warehouseCount(wh, code) >= n)
+}
+function warehousePay(g, wh, cost) {
+ Object.entries(cost).forEach(([code, n]) => withdraw(g, wh, code, n))
+}
+
+function pickBuilderJob(m, g) {
+ m.jobT = 4
+ const plan = nearestEnt(g, m, 'houseplan', (e) => e.mx && !e.mx.done)
+ if (!plan) { m.job = 'idle'; m.jobTarget = null; m.jobT = 0.5; return }
+ const wh = nearestWarehouse(g, m)
+ if (!warehouseHasCost(wh, HOUSE_COST) && !g.hasCost(m.owner || g.me(), HOUSE_COST)) {
+ if (m.owner) g.ptip(m.owner, `${m.workerName || '建筑工'}:缺木头/石块,盖不了房`)
+ m.job = 'idle'
+ m.jobTarget = null
+ return
+ }
+ m.job = 'build'
+ m.jobTarget = plan
+}
+
+function updateBuilder(m, dt, g) {
+ if (m.hp < 300) m.hp = 300
+ if (m.strike) { m.moving = false; return }
+ if (tryGoHomeNight(m, dt, g)) return
+ const speed = g.mon[m.kind]?.speed || 155
+ const anchor = anchorOf(g, m)
+ m.jobT = (m.jobT || 0) - dt
+ if (m.job === 'build' && (!m.jobTarget || !g.entities.includes(m.jobTarget))) m.job = null
+ if (!m.job || m.job === 'idle' || m.jobT <= 0) pickBuilderJob(m, g)
+ if (m.job === 'build') {
+ const t = m.jobTarget
+ const d = Math.hypot(t.x - m.x, t.y - m.y)
+ if (d > 38) { g.moveToward(m, t.x, t.y, speed, dt); return }
+ const wh = nearestWarehouse(g, m)
+ const p = m.owner || g.me()
+ if (warehouseHasCost(wh, HOUSE_COST)) warehousePay(g, wh, HOUSE_COST)
+ else if (p && g.hasCost(p, HOUSE_COST)) g.payCost(p, HOUSE_COST)
+ else {
+ if (p) g.ptip(p, '建材不够,房屋停工')
+ m.job = null
+ return
+ }
+ const name = t.mx?.name || '新房'
+ const model = houseModelForLevel(getCampLevel())
+ const floors = Math.min(4, zoneFloors(getCampLevel()))
+ g.addEntity('camphouse', t.x, t.y, { mx: { restDay: 0, name, floors, model, residents: [] } })
+ g.removeEntity(t)
+ syncHouseLooks(g)
+ if (p) g.ptip(p, `${m.workerName || '建筑工'}建成了「${name}」`)
+ m.job = null
+ m.jobT = 0
+ return
+ }
+ wanderAround(m, dt, g, anchor.x, anchor.y, 100, speed * 0.55)
+}
+
function doSow(m, g, plot, wh) {
let crop = 'wheat'
let used = false
@@ -513,49 +695,44 @@ function updateFarmer(m, dt, g) {
// 无限续航:不被野兽打死
if (m.hp < 300) m.hp = 300
if (m.strike) { m.moving = false; return }
- const agriOn = g.entities.some((e) => e.type === 'agrifarm')
+ if (tryGoHomeNight(m, dt, g)) return
+ const agriOn = g.entities.some((e) => e.type === 'agrifarm' || e.type === 'agrizone')
const anchor = anchorOf(g, m)
- const speed = g.mon[m.kind]?.speed || 118
+ const speed = g.mon[m.kind]?.speed || 165
if (!agriOn) {
if (!toldNoAgri) {
toldNoAgri = true
- if (m.owner) g.ptip(m.owner, '农民:需要农业文明 mod 才能耕作')
+ if (m.owner) g.ptip(m.owner, '农民:先划定农田,区内会自动铺田')
}
- wanderAround(m, dt, g, anchor.x, anchor.y, 110, speed * 0.5)
+ wanderAround(m, dt, g, anchor.x, anchor.y, 110, speed * 0.55)
return
}
const wh = nearestWarehouse(g, m)
m.jobT = (m.jobT || 0) - dt
if ((m.job === 'harvest' || m.job === 'sow') && (!m.jobTarget || !g.entities.includes(m.jobTarget))) m.job = null
- if (!m.job || m.jobT <= 0) pickFarmerJob(m, g, anchor, true)
+ // 取消旧的 till 乱锄任务
+ if (m.job === 'till') { m.job = null; m.jobTarget = null }
+ if (!m.job || m.job === 'idle' || m.jobT <= 0) pickFarmerJob(m, g)
if (m.job === 'harvest' || m.job === 'sow') {
const t = m.jobTarget
const d = Math.hypot(t.x - m.x, t.y - m.y)
- if (d > 40) { g.moveToward(m, t.x, t.y, speed, dt); return }
+ if (d > 36) { g.moveToward(m, t.x, t.y, speed, dt); return }
if (m.job === 'harvest') doHarvest(m, g, t, wh)
else doSow(m, g, t, wh)
m.job = null
m.jobT = 0
+ pickFarmerJob(m, g)
return
}
- if (m.job === 'till') {
- const t = m.jobTarget
- const d = Math.hypot(t.x - m.x, t.y - m.y)
- if (d > 34) { g.moveToward(m, t.x, t.y, speed, dt); return }
- g.addEntity('agrifarm', t.x, t.y)
- m.job = null
- m.jobT = 0
- return
- }
- wanderAround(m, dt, g, anchor.x, anchor.y, 110, speed * 0.5)
+ wanderAround(m, dt, g, anchor.x, anchor.y, 110, speed * 0.55)
}
// =====================================================================
// 牧工劳作
// =====================================================================
function pickHerderJob(m, g, wh) {
- m.jobT = 8
+ m.jobT = 4
// 1) 喂养:饲料槽 feed<4 且仓库有可投喂作物
const trough = nearestEnt(g, m, 'trough', (e) => ((e.mx?.feed) || 0) < 4)
if (trough && wh && FEEDS.some((c) => g.items[c] && warehouseCount(wh, c) > 0)) {
@@ -569,6 +746,7 @@ function pickHerderJob(m, g, wh) {
// 3) 游荡
m.job = 'idle'
m.jobTarget = null
+ m.jobT = 0.4
}
function doFeed(m, g, trough, wh) {
@@ -598,38 +776,44 @@ function doCollect(m, g, coop, wh) {
function updateHerder(m, dt, g) {
if (m.hp < 300) m.hp = 300
if (m.strike) { m.moving = false; return }
+ if (tryGoHomeNight(m, dt, g)) return
const husbOn = g.entities.some((e) => e.type === 'trough' || e.type === 'coop')
const anchor = anchorOf(g, m)
- const speed = g.mon[m.kind]?.speed || 118
+ const speed = g.mon[m.kind]?.speed || 165
if (!husbOn) {
if (!toldNoHusbandry) {
toldNoHusbandry = true
if (m.owner) g.ptip(m.owner, '牧工:需要牧业文明 mod 才能干活')
}
- wanderAround(m, dt, g, anchor.x, anchor.y, 110, speed * 0.5)
+ wanderAround(m, dt, g, anchor.x, anchor.y, 110, speed * 0.55)
return
}
const wh = nearestWarehouse(g, m)
m.jobT = (m.jobT || 0) - dt
if ((m.job === 'feed' || m.job === 'collect') && (!m.jobTarget || !g.entities.includes(m.jobTarget))) m.job = null
- if (!m.job || m.jobT <= 0) pickHerderJob(m, g, wh)
+ if (!m.job || m.job === 'idle' || m.jobT <= 0) pickHerderJob(m, g, wh)
if (m.job === 'feed' || m.job === 'collect') {
const t = m.jobTarget
const d = Math.hypot(t.x - m.x, t.y - m.y)
- if (d > 40) { g.moveToward(m, t.x, t.y, speed, dt); return }
+ if (d > 36) { g.moveToward(m, t.x, t.y, speed, dt); return }
if (m.job === 'feed') doFeed(m, g, t, wh)
else doCollect(m, g, t, wh)
m.job = null
m.jobT = 0
+ pickHerderJob(m, g, wh)
return
}
- wanderAround(m, dt, g, anchor.x, anchor.y, 110, speed * 0.5)
+ wanderAround(m, dt, g, anchor.x, anchor.y, 110, speed * 0.55)
}
// =====================================================================
-// 厨师:从仓库抽 4 份食材 → cookPot → 写入食堂
+// 厨师:从大仓库 + 粮仓抽 4 份食材 → cookPot → 写入食堂
+// 每种菜最多备 30 份,够用就不再取粮
// =====================================================================
+const COOKED_DISHES = ['meatballs', 'meatystew', 'honeyham', 'honeynuggets', 'taffy', 'monsterlasagna', 'wetgoop', 'bread', 'cheese']
+const CANTEEN_PER_DISH = 30
+
function nearestCanteen(g, ref) {
let best = null
let bd = Infinity
@@ -641,48 +825,101 @@ function nearestCanteen(g, ref) {
return best
}
-function listCookIngredients(g, wh) {
- if (!wh) return []
+/** 厨师取料点:大仓库优先,其次粮仓 */
+function cookStores(g, ref) {
+ const list = []
+ const wh = nearestWarehouse(g, ref)
+ if (wh) list.push({ kind: 'wh', e: wh })
+ g.entities.forEach((e) => {
+ if (e.type === 'granary') list.push({ kind: 'granary', e })
+ })
+ return list
+}
+
+function storeCount(g, store, code) {
+ if (!store) return 0
+ if (store.kind === 'wh') return warehouseCount(store.e, code)
+ return (store.e.mx?.store && store.e.mx.store[code]) || 0
+}
+
+function storeWithdraw(g, store, code, n) {
+ if (!store || n <= 0) return 0
+ if (store.kind === 'wh') return withdraw(g, store.e, code, n)
+ if (!store.e.mx) store.e.mx = {}
+ if (!store.e.mx.store) store.e.mx.store = {}
+ const have = store.e.mx.store[code] || 0
+ const take = Math.min(have, n)
+ if (take <= 0) return 0
+ store.e.mx.store[code] = have - take
+ if (store.e.mx.store[code] <= 0) delete store.e.mx.store[code]
+ g.markDirty(store.e)
+ return take
+}
+
+function storeDeposit(g, store, code, n) {
+ if (!store || n <= 0) return
+ if (store.kind === 'wh') {
+ deposit(g, store.e, code, n)
+ return
+ }
+ if (!store.e.mx) store.e.mx = {}
+ if (!store.e.mx.store) store.e.mx.store = {}
+ store.e.mx.store[code] = (store.e.mx.store[code] || 0) + n
+ g.markDirty(store.e)
+}
+
+function totalFoodCount(g, stores, code) {
+ return stores.reduce((n, s) => n + storeCount(g, s, code), 0)
+}
+
+function canteenMenu(canteen) {
+ if (!canteen.mx) canteen.mx = { menu: {} }
+ if (!canteen.mx.menu) canteen.mx.menu = {}
+ return canteen.mx.menu
+}
+
+function dishStock(canteen, dish) {
+ return canteenMenu(canteen)[dish] || 0
+}
+
+function listCookIngredients(g, stores) {
+ const counts = {}
+ stores.forEach((store) => {
+ if (store.kind === 'wh') {
+ boxesOf(store.e).forEach((box) => {
+ Object.entries(box.items || {}).forEach(([code, n]) => {
+ if (n > 0) counts[code] = (counts[code] || 0) + n
+ })
+ })
+ } else {
+ Object.entries(store.e.mx?.store || {}).forEach(([code, n]) => {
+ if (n > 0) counts[code] = (counts[code] || 0) + n
+ })
+ }
+ })
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)
- })
+ Object.entries(counts).forEach(([code, n]) => {
+ const it = g.items[code]
+ if (!it?.food || code === 'rot') return
+ if (COOKED_DISHES.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
- }
- // 尽量凑肉/甜以出正经菜:优先取肉度/甜度高的
+function chooseIngredients(g, stores) {
+ const pool = listCookIngredients(g, stores)
+ if (pool.length < 4) return null
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) }
+ return {
+ code,
+ s: (it.grain || 0) * 3 + (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) {
+ while (pick.length < 4 && totalFoodCount(g, stores, code) > pick.filter((c) => c === code).length) {
pick.push(code)
if (pick.length >= 4) break
}
@@ -690,86 +927,362 @@ function doCook(m, g, wh, canteen) {
}
while (pick.length < 4 && pool.length) {
const c = pool[Math.floor(Math.random() * pool.length)]
- if (warehouseCount(wh, c) > 0) pick.push(c)
+ if (totalFoodCount(g, stores, c) > pick.filter((x) => x === c).length) 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 pick.length >= 4 ? pick.slice(0, 4) : null
+}
+
+function predictDish(g, stores) {
+ const pick = chooseIngredients(g, stores)
+ if (!pick) return null
+ return typeof g.cookPot === 'function' ? g.cookPot(pick) : 'wetgoop'
+}
+
+/** 当前能做出的菜若已满 30,则不再取粮 */
+function canteenNeedsSupply(g, stores, canteen) {
+ const dish = predictDish(g, stores)
+ if (!dish) return false
+ return dishStock(canteen, dish) < CANTEEN_PER_DISH
+}
+
+function takeIngredients(g, stores, codes) {
+ const taken = []
+ for (const code of codes) {
+ let got = 0
+ for (const s of stores) {
+ if (got) break
+ got = storeWithdraw(g, s, code, 1)
+ }
+ if (!got) break
+ taken.push(code)
+ }
+ return taken
+}
+
+function doCook(m, g, stores, canteen) {
+ const pick = chooseIngredients(g, stores)
+ if (!pick) {
+ if (m.owner && !toldNoChefIng) {
+ toldNoChefIng = true
+ g.ptip(m.owner, '厨师:大仓库或粮仓里至少放 4 份小麦/面粉等食材才会做饭')
+ }
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
+ toldNoChefIng = false
+ const dish = typeof g.cookPot === 'function' ? g.cookPot(pick) : 'wetgoop'
+ if (dishStock(canteen, dish) >= CANTEEN_PER_DISH) return false
+ const used = takeIngredients(g, stores, pick)
+ if (used.length < 4) {
+ used.forEach((c) => {
+ if (stores[0]) storeDeposit(g, stores[0], c, 1)
+ })
+ return false
+ }
+ const menu = canteenMenu(canteen)
+ menu[dish] = (menu[dish] || 0) + 1
g.markDirty(canteen)
- if (m.owner) g.ptip(m.owner, `厨师做好了${g.items[dish]?.name || dish}`)
+ if (m.owner) g.ptip(m.owner, `厨师做好了${g.items[dish]?.name || dish}(${menu[dish]}/${CANTEEN_PER_DISH})`)
return true
}
function updateChef(m, dt, g) {
if (m.hp < 300) m.hp = 300
if (m.strike) { m.moving = false; return }
+ if (tryGoHomeNight(m, dt, g)) return
const anchor = anchorOf(g, m)
- const speed = g.mon[m.kind]?.speed || 118
+ const speed = g.mon[m.kind]?.speed || 160
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)
+ wanderAround(m, dt, g, anchor.x, anchor.y, 110, speed * 0.55)
return
}
- const wh = nearestWarehouse(g, m)
+ const stores = cookStores(g, m)
+ // 库存够用:在食堂附近闲逛,偶尔再检查,不持续取粮
+ if (!canteenNeedsSupply(g, stores, canteen)) {
+ m.gotIng = false
+ m.job = null
+ m.jobT = Math.max(m.jobT || 0, 8)
+ wanderAround(m, dt, g, canteen.x, canteen.y, 90, speed * 0.4)
+ return
+ }
+ // 去最近且有可烹饪食材的仓库/粮仓“取料”
+ let pantry = null
+ let pantryD = Infinity
+ stores.forEach((s) => {
+ if (listCookIngredients(g, [s]).length <= 0) return
+ const d = Math.hypot(s.e.x - m.x, s.e.y - m.y)
+ if (d < pantryD) { pantryD = d; pantry = s.e }
+ })
+ if (!pantry && stores[0]) pantry = stores[0].e
m.jobT = (m.jobT || 0) - dt
if (!m.job || m.jobT <= 0) {
m.job = 'cook'
- m.jobT = 10
+ m.jobT = 5
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)
+ if (pantry && Math.hypot(pantry.x - m.x, pantry.y - m.y) > 44 && !m.gotIng) {
+ g.moveToward(m, pantry.x, pantry.y, speed, dt)
return
}
- if (wh && !m.gotIng) {
+ if (pantry && !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) {
+ if (d > 38) {
g.moveToward(m, t.x, t.y, speed, dt)
return
}
- doCook(m, g, wh, t)
+ doCook(m, g, stores, t)
m.gotIng = false
m.job = null
- m.jobT = 2
+ m.jobT = 0.6
return
}
wanderAround(m, dt, g, anchor.x, anchor.y, 90, speed * 0.45)
}
+// =====================================================================
+// 伐木工:全图寻树砍伐 → 木头入库;树桩可刷新
+// =====================================================================
+function nearestTree(g, m) {
+ let best = null
+ let bd = Infinity
+ g.entities.forEach((e) => {
+ if (e.type !== 'tree' || e.stub) return
+ const d = Math.hypot(e.x - m.x, e.y - m.y)
+ if (d < bd) { bd = d; best = e }
+ })
+ return best
+}
+
+/** 全图游荡寻源:随机落点,尽量落在陆地上 */
+function exploreWorld(m, dt, g, speed) {
+ m.wanderT = (m.wanderT || 0) - dt
+ if (m.wanderT <= 0 || m.tx === undefined) {
+ m.wanderT = 5 + Math.random() * 7
+ let tx = g.rand(120, Math.max(200, g.worldW - 120))
+ let ty = g.rand(120, Math.max(200, g.worldH - 120))
+ if (typeof g.findLand === 'function') {
+ const land = g.findLand(tx, ty, 0, 220)
+ if (land) { tx = land.x; ty = land.y }
+ }
+ m.tx = tx
+ m.ty = ty
+ }
+ const d = Math.hypot(m.tx - m.x, m.ty - m.y)
+ if (d > 14) g.moveToward(m, m.tx, m.ty, speed, dt)
+ else m.wanderT = 0
+}
+
+function doChopTree(m, g, tree, wh) {
+ const n = (tree.drops && tree.drops.log) || 2
+ const x = tree.x
+ const y = tree.y
+ g.removeEntity(tree)
+ if (typeof g.addEntity === 'function') {
+ const stump = g.addEntity('stump', x, y)
+ if (stump) {
+ stump.respawnAs = 'tree'
+ stump.respawnT = 420 * (0.85 + Math.random() * 0.35)
+ }
+ }
+ let left = wh ? deposit(g, wh, 'log', n) : n
+ if (left > 0) {
+ const bx = wh ? wh.x : x
+ const by = wh ? wh.y : y
+ g.dropLoot(bx + g.rand(-20, 20), by + g.rand(14, 28), 'log', left)
+ }
+ if (m.owner) g.ptip(m.owner, `${m.workerName || '伐木工'}砍到木头 ×${n}`)
+}
+
+function updateLumber(m, dt, g) {
+ if (m.hp < 300) m.hp = 300
+ if (m.strike) { m.moving = false; return }
+ if (tryGoHomeNight(m, dt, g)) return
+ const speed = g.mon[m.kind]?.speed || 160
+ const wh = nearestWarehouse(g, m)
+ m.jobT = (m.jobT || 0) - dt
+ if (m.job === 'chop' && (!m.jobTarget || !g.entities.includes(m.jobTarget) || m.jobTarget.stub)) {
+ m.job = null
+ m.jobTarget = null
+ }
+ // 探索途中也持续扫树
+ if (!m.job || m.job === 'idle' || m.job === 'explore' || m.jobT <= 0) {
+ const tree = nearestTree(g, m)
+ if (tree) {
+ m.job = 'chop'
+ m.jobTarget = tree
+ m.jobT = 12
+ } else if (m.job !== 'explore') {
+ m.job = 'explore'
+ m.jobTarget = null
+ m.jobT = 4
+ m.wanderT = 0
+ }
+ }
+ if (m.job === 'chop') {
+ const t = m.jobTarget
+ const d = Math.hypot(t.x - m.x, t.y - m.y)
+ if (d > 40) {
+ g.moveToward(m, t.x, t.y, speed, dt)
+ return
+ }
+ m.chopProg = (m.chopProg || 0) + dt
+ if (m.chopProg < 0.55) return
+ m.chopProg = 0
+ doChopTree(m, g, t, wh)
+ m.job = null
+ m.jobT = 0.35
+ return
+ }
+ exploreWorld(m, dt, g, speed * 0.85)
+}
+
+// =====================================================================
+// 矿工:全图寻矿 → 入库;碎石可刷新
+// =====================================================================
+function nearestOre(g, m) {
+ let best = null
+ let bd = Infinity
+ g.entities.forEach((e) => {
+ if ((e.type !== 'rock' && e.type !== 'goldrock') || e.stub) return
+ const d = Math.hypot(e.x - m.x, e.y - m.y)
+ if (d < bd) { bd = d; best = e }
+ })
+ return best
+}
+
+function doMineOre(m, g, ore, wh) {
+ const drops = ore.drops || (ore.type === 'goldrock' ? { rock: 2, gold: 2 } : { rock: 2, flint: 1 })
+ const x = ore.x
+ const y = ore.y
+ const typ = ore.type
+ g.removeEntity(ore)
+ if (typeof g.addEntity === 'function') {
+ const rubble = g.addEntity('rubble', x, y)
+ if (rubble) {
+ rubble.respawnAs = typ
+ rubble.respawnT = 520 * (0.85 + Math.random() * 0.4)
+ }
+ }
+ Object.entries(drops).forEach(([code, n]) => {
+ const cnt = code === 'gold' ? Math.max(1, Math.floor(n * (0.5 + Math.random()))) : n
+ let left = wh ? deposit(g, wh, code, cnt) : cnt
+ if (left > 0) g.dropLoot(x + g.rand(-16, 16), y + g.rand(10, 24), code, left)
+ if (m.owner) g.ptip(m.owner, `${m.workerName || '矿工'}采到${g.items[code]?.name || code} ×${cnt}`)
+ })
+ if (typ === 'goldrock' && m.owner) { /* tip already */ }
+}
+
+function updateMiner(m, dt, g) {
+ if (m.hp < 300) m.hp = 300
+ if (m.strike) { m.moving = false; return }
+ if (tryGoHomeNight(m, dt, g)) return
+ const speed = g.mon[m.kind]?.speed || 155
+ const wh = nearestWarehouse(g, m)
+ m.jobT = (m.jobT || 0) - dt
+ if (m.job === 'mine' && (!m.jobTarget || !g.entities.includes(m.jobTarget) || m.jobTarget.stub)) {
+ m.job = null
+ m.jobTarget = null
+ }
+ if (!m.job || m.job === 'idle' || m.job === 'explore' || m.jobT <= 0) {
+ const ore = nearestOre(g, m)
+ if (ore) {
+ m.job = 'mine'
+ m.jobTarget = ore
+ m.jobT = 12
+ } else if (m.job !== 'explore') {
+ m.job = 'explore'
+ m.jobTarget = null
+ m.jobT = 4
+ m.wanderT = 0
+ }
+ }
+ if (m.job === 'mine') {
+ const t = m.jobTarget
+ const d = Math.hypot(t.x - m.x, t.y - m.y)
+ if (d > 40) {
+ g.moveToward(m, t.x, t.y, speed, dt)
+ return
+ }
+ m.mineProg = (m.mineProg || 0) + dt
+ if (m.mineProg < 0.7) return
+ m.mineProg = 0
+ doMineOre(m, g, t, wh)
+ m.job = null
+ m.jobT = 0.4
+ return
+ }
+ exploreWorld(m, dt, g, speed * 0.85)
+}
+
+function updateVillager(m, dt, g) {
+ if (m.hp < 200) m.hp = 200
+ const speed = g.mon[m.kind]?.speed || 120
+ const c = getCampCenter(g)
+ // 夜间靠近房屋门口闲逛
+ if (g.phase === 'night') {
+ const house = (g.entities || []).find((e) => e.type === 'camphouse')
+ const hx = house ? house.x : c.x
+ const hy = house ? house.y + 40 : c.y
+ if (Math.hypot(hx - m.x, hy - m.y) > 40) g.moveToward(m, hx, hy, speed * 0.7, dt)
+ else m.moving = false
+ return
+ }
+ wanderAround(m, dt, g, c.x, c.y, Math.min(220, 80 + getCampLevel() * 20), speed * 0.55)
+}
+
+function maybeSpawnVillagers(g) {
+ const lv = getCampLevel()
+ if (lv < 2) return
+ const natives = g.monsters.filter((m) => m.kind === 'campvillager').length
+ const max = Math.min(MAX_VILLAGERS, 1 + Math.floor(lv / 2))
+ if (natives >= max) return
+ // 每天最多来 1 个,概率随等级升
+ if (Math.random() > 0.35 + lv * 0.04) return
+ const c = getCampCenter(g)
+ const a = Math.random() * TAU
+ const r = 80 + Math.random() * 140
+ const spot = g.findLand(c.x + Math.cos(a) * r, c.y + Math.sin(a) * r, 20, 80)
+ if (!spot || !isInCamp(g, spot.x, spot.y)) return
+ const workerName = pickUniqueName(g)
+ const outfit = pickOutfit(g)
+ g.spawnMob('campvillager', spot.x, spot.y, {
+ workerName,
+ outfit,
+ source: 'native',
+ })
+ const p = g.me()
+ if (p) g.ptip(p, `村民${workerName}来到了营地(点击分配职业)`)
+}
+
// =====================================================================
// 面板布局(onDrawUI 与 onClick 共用同一套矩形,保证命中一致)
// =====================================================================
function storeLayout(W, H) {
const pw = Math.min(560, W * 0.86)
- const headH = 54
- const rowH = 72
- const footH = 78
- const nRows = 3
- const ph = headH + nRows * rowH + footH
+ const headH = 44
+ const footH = 70
+ const nRows = 6
+ const closeH = 34
+ // 按视口压缩行高,保证关闭按钮始终在屏内
+ const maxPh = Math.max(220, H - 12)
+ const rowH = Math.max(36, Math.min(50, Math.floor((maxPh - headH - footH) / nRows)))
+ const ph = Math.min(maxPh, headH + nRows * rowH + footH)
const px = (W - pw) / 2
- const py = Math.max(14, (H - ph) / 2)
+ const py = Math.max(6, Math.min((H - ph) / 2, H - ph - 6))
const bw = Math.min(88, pw * 0.22)
- const bh = 38
+ const bh = Math.min(28, rowH - 8)
const btns = []
- const roles = ['farmer', 'herder', 'chef']
+ const roles = ['farmer', 'herder', 'builder', 'chef', 'lumber', 'miner']
for (let i = 0; i < nRows; i++) {
const rowY = py + headH + i * rowH
const by = rowY + (rowH - bh) / 2
@@ -777,32 +1290,122 @@ function storeLayout(W, H) {
btns.push({ id: roles[i] + ':fire', x: px + pw - bw - 16, y: by, w: bw, h: bh })
}
const closeW = 104
- const closeH = 40
- btns.push({ id: 'close', x: px + (pw - closeW) / 2, y: py + ph - closeH - 14, w: closeW, h: closeH })
+ btns.push({ id: 'close', x: px + (pw - closeW) / 2, y: py + ph - closeH - 10, w: closeW, h: closeH })
return { px, py, pw, ph, headH, rowH, footH, bw, bh, btns }
}
-function whLayout(W, H) {
- const pw = Math.min(560, W * 0.86)
- const headH = 46
- const footH = 46
- const rowH = Math.max(16, Math.min(30, (H - headH - footH - 28) / BOX_COUNT))
- const ph = headH + BOX_COUNT * rowH + footH
- const px = (W - pw) / 2
- const py = Math.max(10, (H - ph) / 2)
- const bw = Math.min(62, pw * 0.16)
- const bh = Math.max(14, Math.min(20, rowH - 4))
- const btns = []
- for (let i = 0; i < BOX_COUNT; i++) {
- const rowY = py + headH + i * rowH
- const by = rowY + (rowH - bh) / 2
- btns.push({ id: 'dep:' + i, x: px + pw - bw * 2 - 20, y: by, w: bw, h: bh })
- btns.push({ id: 'wd:' + i, x: px + pw - bw - 10, y: by, w: bw, h: bh })
+function whLayout(W, H, p) {
+ const pw = Math.min(860, W * 0.96)
+ const headH = 40
+ const tabH = 28
+ const invLabelH = 16
+ const bulkH = 34
+ const footH = 40
+ const gap = 4
+ const maxPh = Math.max(260, H - 10)
+ const slotPad = 14
+ const slotGap = 10
+ // 旧格约 106×52;大格约三倍边长(约 150+),3×2 排布
+ const slotWMax = Math.floor((pw - slotPad * 2 - slotGap * (BOX_SLOT_COLS - 1)) / BOX_SLOT_COLS)
+ let slotSize = Math.min(slotWMax, 168)
+ let slotH = slotSize
+ let slotW = slotSize
+
+ let cell = 36
+ const main = p?.slots || []
+ const pack = p?.pack || []
+ const fitInv = (c, sH) => {
+ const cols = Math.max(8, Math.floor((pw - 28) / (c + gap)))
+ const mainRows = Math.max(1, Math.ceil(main.length / cols) || 1)
+ const packRows = pack.length ? Math.ceil(pack.length / cols) : 0
+ const invH = mainRows * (c + gap) + (packRows ? 6 + packRows * (c + gap) : 0)
+ const boxAreaH = BOX_SLOT_ROWS * sH + (BOX_SLOT_ROWS - 1) * slotGap
+ const ph = headH + tabH + 8 + boxAreaH + invLabelH + 4 + invH + bulkH + footH
+ return { cols, mainRows, packRows, invH, boxAreaH, ph }
}
+ let layout = fitInv(cell, slotH)
+ // 屏高不够时优先保大格,再压缩背包格
+ while (layout.ph > maxPh && slotSize > 110) {
+ slotSize -= 8
+ slotH = slotSize
+ slotW = slotSize
+ layout = fitInv(cell, slotH)
+ }
+ while (layout.ph > maxPh && cell > 28) {
+ cell -= 2
+ layout = fitInv(cell, slotH)
+ }
+ const ph = Math.min(maxPh, layout.ph)
+ const px = (W - pw) / 2
+ const py = Math.max(4, Math.min((H - ph) / 2, H - ph - 4))
+ const useCols = layout.cols
+ const btns = []
+ const tabW = Math.floor((pw - 24) / BOX_COUNT)
+ for (let i = 0; i < BOX_COUNT; i++) {
+ btns.push({
+ id: 'box:' + i,
+ x: px + 12 + i * tabW,
+ y: py + headH + 3,
+ w: Math.max(18, tabW - 2),
+ h: tabH - 6,
+ })
+ }
+ const slotY = py + headH + tabH + 8
+ const gridW = BOX_SLOT_COLS * slotW + (BOX_SLOT_COLS - 1) * slotGap
+ const slotX0 = px + (pw - gridW) / 2
+ for (let i = 0; i < BOX_KINDS; i++) {
+ const col = i % BOX_SLOT_COLS
+ const row = Math.floor(i / BOX_SLOT_COLS)
+ btns.push({
+ id: 'wslot:' + i,
+ x: slotX0 + col * (slotW + slotGap),
+ y: slotY + row * (slotH + slotGap),
+ w: slotW,
+ h: slotH,
+ })
+ }
+ const boxAreaH = BOX_SLOT_ROWS * slotH + (BOX_SLOT_ROWS - 1) * slotGap
+ const invY0 = slotY + boxAreaH + invLabelH + 4
+ const invBottom = py + ph - footH - bulkH
+ main.forEach((_, i) => {
+ const col = i % useCols
+ const row = Math.floor(i / useCols)
+ const y = invY0 + row * (cell + gap)
+ if (y + cell > invBottom) return
+ btns.push({
+ id: 'inv:main:' + i,
+ x: px + 14 + col * (cell + gap),
+ y,
+ w: cell,
+ h: cell,
+ })
+ })
+ const packY0 = invY0 + layout.mainRows * (cell + gap) + (layout.packRows ? 6 : 0)
+ pack.forEach((_, i) => {
+ const col = i % useCols
+ const row = Math.floor(i / useCols)
+ const y = packY0 + row * (cell + gap)
+ if (y + cell > invBottom) return
+ btns.push({
+ id: 'inv:pack:' + i,
+ x: px + 14 + col * (cell + gap),
+ y,
+ w: cell,
+ h: cell,
+ })
+ })
+ const bulkY = py + ph - footH - bulkH + 4
+ const bw = 100
+ const bh = 28
+ btns.push({ id: 'bulkdep', x: px + pw / 2 - bw - 8, y: bulkY, w: bw, h: bh })
+ btns.push({ id: 'bulkwd', x: px + pw / 2 + 8, y: bulkY, w: bw, h: bh })
const closeW = 100
const closeH = 30
- btns.push({ id: 'close', x: px + (pw - closeW) / 2, y: py + headH + BOX_COUNT * rowH + 6, w: closeW, h: closeH })
- return { px, py, pw, ph, headH, rowH, footH, btns }
+ btns.push({ id: 'close', x: px + (pw - closeW) / 2, y: py + ph - closeH - 8, w: closeW, h: closeH })
+ return {
+ px, py, pw, ph, headH, tabH, slotY, slotW, slotH, slotPad, slotGap,
+ invY0, cell, btns, bigSlot: true,
+ }
}
function clipText(ctx, text, maxW) {
@@ -824,6 +1427,61 @@ function drawBtn(ctx, b, label, color, fs) {
ctx.fillText(label, b.x + b.w / 2, b.y + b.h / 2 + 1)
}
+function fmtStack(n) {
+ const v = Math.max(0, Math.floor(Number(n) || 0))
+ if (v >= 10000000) return `${(v / 10000000).toFixed(v % 10000000 === 0 ? 0 : 1)}千万`
+ if (v >= 10000) return `${(v / 10000).toFixed(v % 10000 === 0 ? 0 : 1)}万`
+ return String(v)
+}
+
+function drawItemChip(ctx, g, b, code, n, emptyHint) {
+ const big = b.h >= 90
+ ctx.fillStyle = code ? '#4a3a28' : '#2a2218'
+ ctx.strokeStyle = code ? '#c9a06a' : '#5a4a38'
+ ctx.lineWidth = big ? 2.4 : 2
+ ctx.beginPath(); ctx.roundRect(b.x, b.y, b.w, b.h, big ? 10 : 6); ctx.fill(); ctx.stroke()
+ if (!code) {
+ ctx.fillStyle = '#6a5a48'
+ ctx.font = `${big ? 14 : 11}px "PingFang SC", "Microsoft YaHei", sans-serif`
+ ctx.textAlign = 'center'
+ ctx.textBaseline = 'middle'
+ ctx.fillText(emptyHint || '空', b.x + b.w / 2, b.y + b.h / 2)
+ return
+ }
+ const name = g.items[code]?.name || code
+ const icon = g.icons?.[code]
+ const cx = b.x + b.w / 2
+ if (big && icon) {
+ const iconSz = Math.min(b.w, b.h) * 0.48
+ ctx.drawImage(icon, cx - iconSz / 2, b.y + b.h * 0.12, iconSz, iconSz)
+ ctx.fillStyle = '#f2e8d0'
+ ctx.font = 'bold 13px "PingFang SC", "Microsoft YaHei", sans-serif'
+ ctx.textAlign = 'center'
+ ctx.textBaseline = 'middle'
+ ctx.fillText(clipText(ctx, name, b.w - 12), cx, b.y + b.h * 0.72)
+ ctx.fillStyle = '#a8d878'
+ ctx.font = 'bold 14px sans-serif'
+ ctx.fillText(`×${fmtStack(n)}`, cx, b.y + b.h * 0.88)
+ return
+ }
+ // 小格(背包):有图标则画图标,数量角标
+ if (icon) {
+ const iconSz = Math.min(b.w, b.h) * 0.72
+ ctx.drawImage(icon, cx - iconSz / 2, b.y + (b.h - iconSz) / 2 - 2, iconSz, iconSz)
+ } else {
+ ctx.fillStyle = '#f2e8d0'
+ ctx.font = 'bold 11px "PingFang SC", "Microsoft YaHei", sans-serif'
+ ctx.textAlign = 'center'
+ ctx.textBaseline = 'middle'
+ ctx.fillText(clipText(ctx, name, b.w - 6), cx, b.y + b.h / 2 - 6)
+ }
+ ctx.fillStyle = '#a8d878'
+ ctx.font = 'bold 11px sans-serif'
+ ctx.textAlign = 'right'
+ ctx.textBaseline = 'bottom'
+ ctx.fillText(fmtStack(n), b.x + b.w - 4, b.y + b.h - 3)
+}
+
function drawStore(g, ctx, office) {
const L = storeLayout(uiW, uiH)
ctx.save()
@@ -851,20 +1509,29 @@ function drawStore(g, ctx, office) {
const roles = [
{ role: 'farmer', label: '农民', fee: FARMER_FEE },
{ role: 'herder', label: '牧工', fee: HERDER_FEE },
+ { role: 'builder', label: '建筑工', fee: BUILDER_FEE },
{ role: 'chef', label: '厨师', fee: CHEF_FEE },
+ { role: 'lumber', label: '伐木工', fee: LUMBER_FEE },
+ { role: 'miner', label: '矿工', fee: MINER_FEE },
]
ctx.textAlign = 'left'
roles.forEach((r, idx) => {
const rowY = L.py + L.headH + idx * L.rowH
const info = roleInfo(g, office, r.role)
+ const mid = rowY + L.rowH / 2
ctx.fillStyle = '#e8d9b8'
- ctx.font = 'bold 16px "PingFang SC", "Microsoft YaHei", sans-serif'
- ctx.fillText(r.label, L.px + 18, rowY + 22)
+ ctx.font = 'bold 13px "PingFang SC", "Microsoft YaHei", sans-serif'
+ ctx.fillText(r.label, L.px + 18, mid - (L.rowH >= 44 ? 10 : 0))
ctx.fillStyle = '#c9b48c'
- ctx.font = '12px "PingFang SC", "Microsoft YaHei", sans-serif'
- ctx.fillText(`招聘费 ${r.fee} 份粮食 · 每日工资 ${WAGE} 份`, L.px + 18, rowY + 42)
- ctx.fillStyle = info.striking ? '#e08a6a' : '#a9c98a'
- ctx.fillText(`已有 ${info.n}/${info.max} · ${info.striking ? '罢工中' : '在岗'}`, L.px + 18, rowY + 60)
+ ctx.font = '11px "PingFang SC", "Microsoft YaHei", sans-serif'
+ if (L.rowH >= 44) {
+ ctx.fillText(`招聘费 ${r.fee} · 周薪 ${HIRED_WAGE}(村民 ${NATIVE_WAGE})`, L.px + 18, mid + 4)
+ ctx.fillStyle = info.striking ? '#e08a6a' : '#a9c98a'
+ ctx.fillText(`已有 ${info.n}/${info.max} · ${info.striking ? '罢工中' : '在岗'}`, L.px + 18, mid + 18)
+ } else {
+ ctx.fillStyle = info.striking ? '#e08a6a' : '#a9c98a'
+ ctx.fillText(`${info.n}/${info.max} · 费${r.fee}`, L.px + 70, mid)
+ }
})
// 岗位按钮
L.btns.forEach((b) => {
@@ -884,9 +1551,15 @@ function drawStore(g, ctx, office) {
}
function drawWarehouse(g, ctx, wh) {
- const L = whLayout(uiW, uiH)
+ const p = g.me()
+ const L = whLayout(uiW, uiH, p)
+ if (whBoxSel < 0 || whBoxSel >= BOX_COUNT) whBoxSel = 0
+ const boxes = boxesOf(wh)
+ const box = boxes[whBoxSel] || { items: {} }
+ const entries = Object.entries(box.items).filter(([, n]) => n > 0)
+
ctx.save()
- ctx.fillStyle = 'rgba(0,0,0,0.42)'
+ ctx.fillStyle = 'rgba(0,0,0,0.45)'
ctx.fillRect(0, 0, uiW, uiH)
ctx.fillStyle = '#3a2a1a'
ctx.strokeStyle = '#1d1409'
@@ -895,41 +1568,63 @@ function drawWarehouse(g, ctx, wh) {
ctx.strokeStyle = '#6b4a2e'
ctx.lineWidth = 1.5
ctx.beginPath(); ctx.roundRect(L.px + 6, L.py + 6, L.pw - 12, L.ph - 12, 7); ctx.stroke()
+
// 标题
ctx.fillStyle = '#f0d79a'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
- ctx.font = 'bold 20px "PingFang SC", "Microsoft YaHei", sans-serif'
- ctx.fillText('大 仓 库', L.px + L.pw / 2, L.py + L.headH / 2 + 2)
- // 12 行箱子
- const boxes = boxesOf(wh)
+ ctx.font = 'bold 18px "PingFang SC", "Microsoft YaHei", sans-serif'
+ ctx.fillText('大 仓 库', L.px + L.pw / 2, L.py + 18)
+ ctx.fillStyle = '#c9b48c'
+ ctx.font = '11px "PingFang SC", "Microsoft YaHei", sans-serif'
+ ctx.fillText(`大格预览 · 点击取出 · 下方背包点击存入 · 单种上限 ${BOX_STACK}`, L.px + L.pw / 2, L.py + 34)
+
+ // 页签
+ L.btns.filter((b) => b.id.startsWith('box:')).forEach((b) => {
+ const i = +b.id.slice(4)
+ const sel = i === whBoxSel
+ const filled = Object.keys(boxes[i]?.items || {}).length
+ ctx.fillStyle = sel ? '#6a4a28' : '#2e2418'
+ ctx.strokeStyle = sel ? '#e0b060' : '#5a4a38'
+ ctx.lineWidth = sel ? 2.2 : 1.4
+ ctx.beginPath(); ctx.roundRect(b.x, b.y, b.w, b.h, 4); ctx.fill(); ctx.stroke()
+ ctx.fillStyle = filled ? '#f0d79a' : '#8a7a5c'
+ ctx.font = `bold ${b.w < 28 ? 10 : 12}px sans-serif`
+ ctx.textAlign = 'center'
+ ctx.textBaseline = 'middle'
+ ctx.fillText(String(i + 1), b.x + b.w / 2, b.y + b.h / 2)
+ })
+
+ // 当前箱 6 格
+ for (let i = 0; i < BOX_KINDS; i++) {
+ const b = L.btns.find((x) => x.id === 'wslot:' + i)
+ if (!b) continue
+ const ent = entries[i]
+ drawItemChip(ctx, g, b, ent?.[0], ent?.[1], '空')
+ }
+
+ // 背包区标题
+ ctx.fillStyle = '#c9b48c'
+ ctx.font = '12px "PingFang SC", "Microsoft YaHei", sans-serif'
ctx.textAlign = 'left'
ctx.textBaseline = 'middle'
- for (let i = 0; i < BOX_COUNT; i++) {
- const b = boxes[i] || { items: {} }
- const rowY = L.py + L.headH + i * L.rowH
- const cy = rowY + L.rowH / 2
- ctx.fillStyle = i % 2 ? 'rgba(0,0,0,0.16)' : 'rgba(255,255,255,0.05)'
- ctx.beginPath(); ctx.roundRect(L.px + 10, rowY + 1, L.pw - 20, L.rowH - 2, 4); ctx.fill()
- const entries = Object.entries(b.items).filter(([, n]) => n > 0)
- ctx.fillStyle = '#e8d9b8'
- ctx.font = '13px "PingFang SC", "Microsoft YaHei", sans-serif'
- ctx.fillText(`箱子 ${i + 1}`, L.px + 18, cy)
- ctx.fillStyle = '#c9b48c'
- ctx.font = '12px "PingFang SC", "Microsoft YaHei", sans-serif'
- ctx.fillText(`${entries.length}/${BOX_KINDS} 种`, L.px + 82, cy)
- let sum = entries.slice(0, 3).map(([c, n]) => `${g.items[c]?.name || c}×${n}`).join('、')
- if (entries.length > 3) sum += ' 等'
- if (!sum) sum = '(空)'
- ctx.fillStyle = entries.length ? '#d8c9a6' : '#8a7a5c'
- ctx.font = '12px "PingFang SC", "Microsoft YaHei", sans-serif'
- ctx.fillText(clipText(ctx, sum, L.pw - 320), L.px + 150, cy)
+ ctx.fillText('背包(点击存入仓库)', L.px + 14, L.invY0 - 12)
+
+ const drawInv = (area, arr) => {
+ arr.forEach((s, i) => {
+ const b = L.btns.find((x) => x.id === `inv:${area}:${i}`)
+ if (!b) return
+ drawItemChip(ctx, g, b, s?.code, s?.n, '')
+ })
}
- // 按钮
+ drawInv('main', p?.slots || [])
+ drawInv('pack', p?.pack || [])
+
+ // 整箱 / 关闭
L.btns.forEach((b) => {
- if (b.id === 'close') { drawBtn(ctx, b, '关 闭', '#5a4630', 13); return }
- const isDep = b.id.startsWith('dep:')
- drawBtn(ctx, b, isDep ? '↓存入' : '↑取出', isDep ? '#4a6a8a' : '#5d7a3a', 11)
+ if (b.id === 'bulkdep') drawBtn(ctx, b, '整箱存入', '#4a6a8a', 12)
+ else if (b.id === 'bulkwd') drawBtn(ctx, b, '整箱取出', '#5d7a3a', 12)
+ else if (b.id === 'close') drawBtn(ctx, b, '关 闭', '#5a4630', 13)
})
ctx.restore()
}
@@ -947,7 +1642,7 @@ function drawLaborer(ctx, m, time, role) {
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' ? '牧工' : '厨师')
+ const label = m.workerName || (role === 'farmer' ? '农民' : role === 'herder' ? '牧工' : role === 'builder' ? '建筑工' : role === 'lumber' ? '伐木工' : role === 'miner' ? '矿工' : role === 'villager' ? '村民' : '厨师')
// 名牌(不随朝向翻转)
ctx.save()
ctx.fillStyle = 'rgba(20,16,10,0.72)'
@@ -958,7 +1653,11 @@ function drawLaborer(ctx, m, time, role) {
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText(label, 0, -55 + hop * 0.3)
- if (role === 'farmer' && m.boundZoneId != null) {
+ if (m.source === 'native' && role !== 'villager') {
+ ctx.fillStyle = '#a8d878'
+ ctx.font = '9px sans-serif'
+ ctx.fillText('村民', 0, -68 + hop * 0.3)
+ } else if (role === 'farmer' && m.boundZoneId != null) {
ctx.fillStyle = '#a8d878'
ctx.font = '9px sans-serif'
ctx.fillText('专管', 0, -68 + hop * 0.3)
@@ -1012,6 +1711,23 @@ function drawLaborer(ctx, m, time, role) {
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 if (role === 'builder') {
+ ctx.fillStyle = '#5a6a7a'
+ ctx.beginPath(); ctx.roundRect(-8, -44 + hop * 0.4, 16, 8, 2); ctx.fill(); ctx.stroke()
+ ctx.fillStyle = '#e0a040'
+ ctx.beginPath(); ctx.roundRect(-9, -40 + hop * 0.4, 18, 3, 1); ctx.fill()
+ } else if (role === 'lumber') {
+ ctx.fillStyle = '#6a5030'
+ 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()
+ } else if (role === 'miner') {
+ ctx.fillStyle = '#5a5a68'
+ ctx.beginPath(); ctx.roundRect(-8, -44 + hop * 0.4, 16, 8, 2); ctx.fill(); ctx.stroke()
+ ctx.fillStyle = '#c0c0c8'
+ ctx.beginPath(); ctx.roundRect(-9, -40 + hop * 0.4, 18, 3, 1); ctx.fill()
+ } else if (role === 'villager') {
+ ctx.fillStyle = '#c8a070'
+ ctx.beginPath(); ctx.ellipse(0, -40 + hop * 0.4, 11, 3.8, 0, 0, TAU); 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()
@@ -1042,6 +1758,45 @@ function drawLaborer(ctx, m, time, role) {
;[-3, 0, 3].forEach((o) => {
ctx.beginPath(); ctx.moveTo(13, -32 + hop * 0.3); ctx.lineTo(13 + o, -42 + hop * 0.3); ctx.stroke()
})
+ } else if (role === 'builder') {
+ // 锤子
+ ctx.save()
+ ctx.translate(8, -22 + hop * 0.3)
+ ctx.rotate(0.4)
+ ctx.strokeStyle = '#7a5b33'
+ ctx.lineWidth = 2.6
+ ctx.beginPath(); ctx.moveTo(0, 8); ctx.lineTo(0, -10); ctx.stroke()
+ ctx.fillStyle = '#8d9097'
+ ctx.strokeStyle = '#1d1409'
+ ctx.lineWidth = 1.4
+ ctx.beginPath(); ctx.roundRect(-7, -14, 14, 7, 1); ctx.fill(); ctx.stroke()
+ ctx.restore()
+ } else if (role === 'lumber') {
+ // 斧头
+ ctx.save()
+ ctx.translate(7, -20 + hop * 0.3)
+ ctx.rotate(0.55)
+ ctx.strokeStyle = '#7a5b33'
+ ctx.lineWidth = 2.6
+ ctx.beginPath(); ctx.moveTo(0, 10); ctx.lineTo(0, -8); ctx.stroke()
+ ctx.fillStyle = '#9aa0a8'
+ ctx.strokeStyle = '#1d1409'
+ ctx.lineWidth = 1.4
+ ctx.beginPath(); ctx.moveTo(-2, -10); ctx.lineTo(10, -4); ctx.lineTo(10, -12); ctx.lineTo(-1, -14); ctx.closePath(); ctx.fill(); ctx.stroke()
+ ctx.restore()
+ } else if (role === 'miner') {
+ // 镐
+ ctx.save()
+ ctx.translate(8, -22 + hop * 0.3)
+ ctx.rotate(-0.5)
+ ctx.strokeStyle = '#7a5b33'
+ ctx.lineWidth = 2.6
+ ctx.beginPath(); ctx.moveTo(0, 10); ctx.lineTo(0, -10); ctx.stroke()
+ ctx.fillStyle = '#8d9097'
+ ctx.strokeStyle = '#1d1409'
+ ctx.lineWidth = 1.4
+ ctx.beginPath(); ctx.moveTo(-8, -12); ctx.lineTo(8, -12); ctx.lineTo(0, -6); ctx.closePath(); ctx.fill(); ctx.stroke()
+ ctx.restore()
}
ctx.restore()
}
@@ -1049,13 +1804,13 @@ function drawLaborer(ctx, m, time, role) {
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: 'laborwarehouse', name: '大仓库', tab: 'labor', tech: 1, cost: { log: 12, rock: 6 }, build: true, desc: '12 个箱子 · 农民自动入库' },
+ { 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 箱×6 格 · 单种堆叠至 99999999 · 劳工自动入库' },
],
icons: {
laboroffice: (c) => {
@@ -1123,7 +1878,8 @@ export default {
name: '招聘所',
conf: { size: 44, struct: true },
init(e) {
- if (!e.mx) e.mx = { hired: 0, farmer: 0, herder: 0, chef: 0, strike: 0 }
+ if (!e.mx) e.mx = { hired: 0, farmer: 0, herder: 0, builder: 0, chef: 0, lumber: 0, miner: 0, strike: 0 }
+ mxOf(e)
},
interact(p, e, g) {
mxOf(e)
@@ -1133,6 +1889,7 @@ export default {
g.ptip(p, '劳务市场:点击面板按钮招聘 / 解雇')
},
draw(ctx, e) {
+ drawNameplate(ctx, e.mx?.name || '招聘所', -58)
// 投影
ctx.fillStyle = 'rgba(0,0,0,0.25)'
ctx.beginPath(); ctx.ellipse(0, 6, 26, 8, 0, 0, TAU); ctx.fill()
@@ -1190,11 +1947,17 @@ export default {
interact(p, e, g) {
boxesOf(e)
storeOpen = e.id
+ whBoxSel = 0
officeOpen = null
g.invalidate()
- g.ptip(p, '大仓库:12 个箱子 · 共 72 格物品容量')
+ g.ptip(p, '大仓库:点箱内物品取出,点下方背包格存入')
},
draw(ctx, e) {
+ let stored = 0
+ boxesOf(e).forEach((b) => {
+ Object.values(b.items || {}).forEach((k) => { stored += k || 0 })
+ })
+ drawNameplate(ctx, stored > 0 ? `大仓库 · ${stored}件` : '大仓库', -66)
// 投影
ctx.fillStyle = 'rgba(0,0,0,0.25)'
ctx.beginPath(); ctx.ellipse(0, 8, 32, 9, 0, 0, TAU); ctx.fill()
@@ -1255,7 +2018,7 @@ export default {
laborfarmer: {
name: '农民',
friendly: true,
- conf: { hp: 300, speed: 118, dmg: 5, score: 0, friendly: true, name: '农民' },
+ conf: { hp: 300, speed: 165, dmg: 5, score: 0, friendly: true, name: '农民' },
drops: [],
update(m, dt, g) {
updateFarmer(m, dt, g)
@@ -1274,7 +2037,7 @@ export default {
laborherder: {
name: '牧工',
friendly: true,
- conf: { hp: 300, speed: 118, dmg: 5, score: 0, friendly: true, name: '牧工' },
+ conf: { hp: 300, speed: 165, dmg: 5, score: 0, friendly: true, name: '牧工' },
drops: [],
update(m, dt, g) {
updateHerder(m, dt, g)
@@ -1288,10 +2051,27 @@ export default {
drawLaborer(ctx, m, time, 'herder')
},
},
+ laborbuilder: {
+ name: '建筑工',
+ friendly: true,
+ conf: { hp: 300, speed: 155, dmg: 5, score: 0, friendly: true, name: '建筑工' },
+ drops: [],
+ update(m, dt, g) {
+ updateBuilder(m, dt, g)
+ },
+ interact(p, m, g) {
+ g.ptip(p, m.owner
+ ? `${m.workerName || '建筑工'}会按宅基计划盖房(木头×12 石块×8)`
+ : '一位流落的建筑工')
+ },
+ draw(ctx, m, time) {
+ drawLaborer(ctx, m, time, 'builder')
+ },
+ },
laborchef: {
name: '厨师',
friendly: true,
- conf: { hp: 300, speed: 118, dmg: 5, score: 0, friendly: true, name: '厨师' },
+ conf: { hp: 300, speed: 160, dmg: 5, score: 0, friendly: true, name: '厨师' },
drops: [],
update(m, dt, g) {
updateChef(m, dt, g)
@@ -1305,8 +2085,65 @@ export default {
drawLaborer(ctx, m, time, 'chef')
},
},
+ laborlumber: {
+ name: '伐木工',
+ friendly: true,
+ conf: { hp: 300, speed: 160, dmg: 5, score: 0, friendly: true, name: '伐木工' },
+ drops: [],
+ update(m, dt, g) {
+ updateLumber(m, dt, g)
+ },
+ interact(p, m, g) {
+ g.ptip(p, m.owner
+ ? `${m.workerName || '伐木工'}正在附近砍树入库(可在招聘所解雇)`
+ : '一位流落的伐木工')
+ },
+ draw(ctx, m, time) {
+ drawLaborer(ctx, m, time, 'lumber')
+ },
+ },
+ laborminer: {
+ name: '矿工',
+ friendly: true,
+ conf: { hp: 300, speed: 155, dmg: 5, score: 0, friendly: true, name: '矿工' },
+ drops: [],
+ update(m, dt, g) {
+ updateMiner(m, dt, g)
+ },
+ interact(p, m, g) {
+ g.ptip(p, m.owner
+ ? `${m.workerName || '矿工'}正在采矿入库(可在招聘所解雇)`
+ : '一位流落的矿工')
+ },
+ draw(ctx, m, time) {
+ drawLaborer(ctx, m, time, 'miner')
+ },
+ },
+ campvillager: {
+ name: '村民',
+ friendly: true,
+ conf: { hp: 200, speed: 120, dmg: 0, score: 0, friendly: true, name: '村民' },
+ drops: [],
+ update(m, dt, g) {
+ updateVillager(m, dt, g)
+ },
+ interact(p, m, g) {
+ openVillagerAssign(g, p, m)
+ g.invalidate?.()
+ },
+ draw(ctx, m, time) {
+ drawLaborer(ctx, m, time, 'villager')
+ },
+ },
},
hooks: {
+ onTick(g) {
+ // Esc 关闭面板,避免关闭键被挤出屏外时卡住
+ if ((officeOpen != null || storeOpen != null) && g.keyDown?.('Escape')) {
+ officeOpen = null
+ storeOpen = null
+ }
+ },
onDrawUI(g, ctx, env) {
uiCamX = env.camX
uiCamY = env.camY
@@ -1321,8 +2158,10 @@ export default {
if (!wh) storeOpen = null
else drawWarehouse(g, ctx, wh)
}
+ drawZoneMenu(ctx, env)
},
onClick(g, wx, wy) {
+ if (handleZoneMenuClick(wx, wy)) return true
if (officeOpen == null && storeOpen == null) return undefined
const p = g.me()
// 世界坐标 → 屏幕坐标(面板画在屏幕系)
@@ -1335,61 +2174,87 @@ export default {
const hit = hitBtn(L.btns, sx, sy)
if (hit) {
if (hit.id === 'close') officeOpen = null
- else if (hit.id === 'farmer:hire') hire(g, p, office, 'farmer')
- 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')
+ else if (hit.id.endsWith(':hire') || hit.id.endsWith(':fire')) {
+ const [role, act] = hit.id.split(':')
+ if (act === 'hire') hire(g, p, office, role)
+ else fire(g, p, office, role)
+ }
return true
}
- if (!inRect(L, sx, sy)) officeOpen = null
+ // 点遮罩(面板外)关闭
+ officeOpen = null
return true
}
const wh = entById(g, storeOpen)
if (!wh) { storeOpen = null; return true }
- const L = whLayout(uiW, uiH)
+ const L = whLayout(uiW, uiH, p)
const hit = hitBtn(L.btns, sx, sy)
if (hit) {
if (hit.id === 'close') storeOpen = null
- else if (hit.id.startsWith('dep:')) boxDeposit(g, p, wh, +hit.id.slice(4))
- else if (hit.id.startsWith('wd:')) boxWithdraw(g, p, wh, +hit.id.slice(3))
+ else if (hit.id.startsWith('box:')) whBoxSel = +hit.id.slice(4)
+ else if (hit.id.startsWith('wslot:')) {
+ const si = +hit.id.slice(6)
+ const entries = Object.entries(boxesOf(wh)[whBoxSel]?.items || {}).filter(([, n]) => n > 0)
+ const ent = entries[si]
+ if (ent) withdrawItem(g, p, wh, whBoxSel, ent[0])
+ else g.ptip(p, '空格子')
+ }
+ else if (hit.id.startsWith('inv:')) {
+ const parts = hit.id.split(':') // inv:main:0
+ const area = parts[1]
+ const idx = +parts[2]
+ depositInvSlot(g, p, wh, area, idx)
+ }
+ else if (hit.id === 'bulkdep') boxDeposit(g, p, wh, whBoxSel)
+ else if (hit.id === 'bulkwd') boxWithdraw(g, p, wh, whBoxSel)
return true
}
- if (!inRect(L, sx, sy)) storeOpen = null
+ // 点遮罩关闭
+ storeOpen = null
return true
},
onNewDay(g) {
+ maybeSpawnVillagers(g)
+ // 每周发薪(第 1/8/15… 天);非发薪日只同步人数、解除误标罢工
+ const payday = ((g.day - 1) % WAGE_EVERY) === 0
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.kind === 'laborchef') && m.office === office.id)
+ const mine = g.monsters.filter((m) => isLaborKind(m.kind) && m.office === office.id)
const wh = nearestWarehouse(g, office)
- const total = mine.length * WAGE
+ const total = mine.reduce((s, m) => s + wageOf(m), 0)
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
+ if (payday && total > 0) {
+ if (availableFood(g, p, wh) < total) {
+ mine.forEach((m) => { m.strike = true })
+ mx.strike = 1
+ anyStrike = true
+ } else {
+ takeFood(g, p, wh, total, true)
+ mine.forEach((m) => { m.strike = false })
+ mx.strike = 0
+ g.tip(`发了本周工资:${total} 份粮食(招聘 ${HIRED_WAGE}/人 · 村民 ${NATIVE_WAGE}/人)`)
+ }
}
// 同步计数
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.builder = g.monsters.filter((m) => m.kind === 'laborbuilder' && m.office === office.id).length
mx.chef = g.monsters.filter((m) => m.kind === 'laborchef' && m.office === office.id).length
+ mx.lumber = g.monsters.filter((m) => m.kind === 'laborlumber' && m.office === office.id).length
+ mx.miner = g.monsters.filter((m) => m.kind === 'laborminer' && m.office === office.id).length
syncHired(mx)
g.markDirty(office)
})
- if (anyStrike) g.tip('劳工罢工了:仓库/背包粮食不够发工资')
+ // 劳工补分配住房
+ g.monsters.filter((m) => isLaborKind(m.kind)).forEach((m) => ensureWorkerHome(g, m))
+ 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' || m.kind === 'laborchef')
+ .filter((m) => isLaborKind(m.kind) || m.kind === 'campvillager')
.map((m) => ({
kind: m.kind,
x: Math.round(m.x),
@@ -1400,6 +2265,8 @@ export default {
workerName: m.workerName || '',
outfit: m.outfit | 0,
boundZoneId: m.boundZoneId != null ? m.boundZoneId : null,
+ source: m.source || 'hired',
+ homeHouseId: m.homeHouseId != null ? m.homeHouseId : null,
}))
if (!offices.length && !workers.length) return null
return { offices, workers }
@@ -1413,23 +2280,32 @@ export default {
})
;(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' : w.kind === 'laborchef' ? 'laborchef' : 'laborfarmer'
+ if (w.office != null && !office && w.kind !== 'campvillager') return
+ const kind = (LABOR_KINDS.includes(w.kind) || w.kind === 'campvillager') ? w.kind : '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' : kind === 'laborherder' ? 'herder' : 'chef'
+ m.role = kind === 'laborfarmer' ? 'farmer'
+ : kind === 'laborherder' ? 'herder'
+ : kind === 'laborbuilder' ? 'builder'
+ : kind === 'laborlumber' ? 'lumber'
+ : kind === 'laborminer' ? 'miner'
+ : kind === 'campvillager' ? 'villager' : '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.source = w.source || (kind === 'campvillager' ? 'native' : 'hired')
+ m.homeHouseId = w.homeHouseId != null ? w.homeHouseId : null
m.hp = g.mon[kind]?.hp || 300
if (w.seat != null) {
const owner = g.players.find((q) => q.seat === w.seat)
if (owner) m.owner = owner
}
- if (!m.owner) m.owner = g.me() || null
+ if (!m.owner && kind !== 'campvillager') m.owner = g.me() || null
+ if (isLaborKind(kind)) ensureWorkerHome(g, m)
})
+ syncHouseLooks(g)
},
},
}
diff --git a/src/games/starve/mods/zoneUtil.js b/src/games/starve/mods/zoneUtil.js
index 3ece7b0..6a7155c 100644
--- a/src/games/starve/mods/zoneUtil.js
+++ b/src/games/starve/mods/zoneUtil.js
@@ -1,5 +1,6 @@
// 分区框选 / 围栏贴边共用工具
-const FENCE_STEP = 48
+// 栅栏段视觉宽约 32,步长略小让横杆搭接;竖边用竖向朝向填缝
+const FENCE_STEP = 30
export function normalizeRect(x0, y0, x1, y1) {
return {
@@ -37,8 +38,121 @@ const ZONE_KEEP = new Set([
'well', 'granary', 'mill', 'trough', 'coop',
'sciencemachine', 'alchemyengine', 'crockpot', 'woodchest',
'pighouse', 'trap', 'canteen', 'resttent', 'camphq',
+ 'camphouse', 'campstore', 'houseplan', 'housezone',
+ 'laborwarehouse',
])
+/** 产出倍率:每级 +12% */
+export function zoneYieldMul(level) {
+ return 1 + 0.12 * Math.max(0, (level || 1) - 1)
+}
+
+/** 视觉层数:1 级 1 层,之后隔一级加一层(3→2层、5→3层…) */
+export function zoneFloors(level) {
+ return 1 + Math.floor(Math.max(0, (level || 1) - 1) / 2)
+}
+
+/** 升级费用:随等级上涨 */
+export function zoneUpgradeCost(level) {
+ const lv = Math.max(1, level || 1)
+ return { log: 4 * lv, rock: 2 * lv, gold: Math.max(0, lv - 1) }
+}
+
+/** 可参与升级扣费的仓储实体 */
+const STOCK_STORE_TYPES = new Set(['laborwarehouse', 'campstore'])
+
+function warehouseStockOf(g, code) {
+ let n = 0
+ ;(g?.entities || []).forEach((e) => {
+ if (!STOCK_STORE_TYPES.has(e.type)) return
+ const boxes = e.mx?.boxes
+ if (!Array.isArray(boxes)) return
+ boxes.forEach((b) => { n += b?.items?.[code] || 0 })
+ })
+ return n
+}
+
+/** 背包 + 仓库合计持有量 */
+export function stockCount(g, code) {
+ const p = g?.me?.()
+ const bag = (p && typeof g.countItem === 'function' ? g.countItem(p, code) : 0) || 0
+ return bag + warehouseStockOf(g, code)
+}
+
+/** 从所有仓库扣物品,返回实际扣到的数量 */
+export function withdrawFromWarehouses(g, code, amount) {
+ let need = Math.max(0, amount | 0)
+ if (need <= 0) return 0
+ let got = 0
+ ;(g?.entities || []).forEach((e) => {
+ if (need <= 0 || !STOCK_STORE_TYPES.has(e.type)) return
+ const boxes = e.mx?.boxes
+ if (!Array.isArray(boxes)) return
+ for (const b of boxes) {
+ if (need <= 0) break
+ if (!b.items) b.items = {}
+ const have = b.items[code] || 0
+ if (have <= 0) continue
+ const take = Math.min(have, need)
+ b.items[code] = have - take
+ if (b.items[code] <= 0) delete b.items[code]
+ need -= take
+ got += take
+ g.markDirty?.(e)
+ }
+ })
+ return got
+}
+
+export function hasStockCost(g, cost) {
+ if (!cost) return false
+ return Object.entries(cost).every(([code, n]) => n <= 0 || stockCount(g, code) >= n)
+}
+
+/** 优先扣仓库,不足再扣背包 */
+export function payStockCost(g, cost) {
+ const p = g?.me?.()
+ Object.entries(cost || {}).forEach(([code, n]) => {
+ if (n <= 0) return
+ const fromWh = withdrawFromWarehouses(g, code, n)
+ const left = n - fromWh
+ if (left > 0 && p && typeof g.payCost === 'function') g.payCost(p, { [code]: left })
+ })
+}
+
+/**
+ * 生成升级材料明细(背包+仓库合计),供模态框展示
+ * @returns {{ name: string, need: number, have: number, miss: number, ok: boolean, text: string }[]}
+ */
+export function buildCostDetails(g, cost) {
+ if (!cost) return []
+ return Object.entries(cost)
+ .filter(([, n]) => n > 0)
+ .map(([code, need]) => {
+ const bag = (typeof g.countItem === 'function' && g.me?.() ? g.countItem(g.me(), code) : 0) || 0
+ const wh = warehouseStockOf(g, code)
+ const have = bag + wh
+ const name = g.items?.[code]?.name || code
+ const miss = Math.max(0, need - have)
+ const where = wh > 0 ? `包${bag}+仓${wh}` : `有 ${have}`
+ return {
+ code,
+ name,
+ need,
+ have,
+ miss,
+ ok: have >= need,
+ text: miss > 0
+ ? `${name} ×${need}(${where},缺 ${miss})`
+ : `${name} ×${need}(${where} ✓)`,
+ }
+ })
+}
+
+export function costDetailsAllOk(details) {
+ return !!details?.length && details.every((d) => d.ok)
+}
+
function shouldKeepInZone(e) {
if (!e) return true
if (ZONE_KEEP.has(e.type)) return true
@@ -110,36 +224,76 @@ export function clearZoneClutter(g, z) {
return n
}
-/** 沿 AABB 外轮廓采样点;底边仅一个点作为门位(避免大门缺口过大) */
+/**
+ * 沿 AABB 外轮廓均匀采样;南北边横向(h)、东西边纵向(v);底边中点作门
+ * @returns {{x,y,side,orient,gate}[]}
+ */
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
+
+ function edge(ax, ay, bx, by, side, includeStart, includeEnd) {
+ const len = Math.hypot(bx - ax, by - ay)
+ const n = Math.max(1, Math.round(len / step))
+ const orient = side === 'e' || side === 'w' ? 'v' : 'h'
+ for (let i = 0; i <= n; i++) {
+ if (i === 0 && !includeStart) continue
+ if (i === n && !includeEnd) continue
+ const t = i / n
+ pts.push({
+ x: ax + (bx - ax) * t,
+ y: ay + (by - ay) * t,
+ side,
+ orient,
+ gate: false,
+ })
+ }
+ }
+ // 四角只由南北边给出,东西边不含端点,避免重复
+ edge(x0, y0, x1, y0, 'n', true, true)
+ edge(x1, y0, x1, y1, 'e', false, false)
+ edge(x1, y1, x0, y1, 's', true, true)
+ edge(x0, y1, x0, y0, 'w', false, false)
+
+ if (gateGap) {
+ const midX = (x0 + x1) / 2
+ let best = -1
let bd = Infinity
- bottom.forEach((p, i) => {
+ pts.forEach((p, i) => {
+ if (p.side !== 's') return
const d = Math.abs(p.x - midX)
if (d < bd) { bd = d; best = i }
})
- bottom[best].gate = true
+ if (best >= 0) {
+ pts[best].gate = true
+ // 门两侧各空出一段,避免邻接栅栏堵住门口
+ ;[best - 1, best + 1].forEach((i) => {
+ if (i >= 0 && i < pts.length && pts[i].side === 's') pts[i].skip = 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
}
+/**
+ * 清除矩形内围栏/门(不重建)
+ */
+export function clearFencePerimeter(g, z, opt = {}) {
+ if (!z || !g?.entities) return 0
+ const fenceType = opt.fenceType || 'fence'
+ const gateType = opt.gateType || 'gate'
+ const pad = opt.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))
+ return doomed.length
+}
+
/**
* 清除矩形内旧围栏/门,按外轮廓重建
* @param {object} g 游戏上下文
@@ -150,23 +304,18 @@ 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))
+ clearFencePerimeter(g, z, { fenceType, gateType, pad: 8 })
const pts = perimeterPoints(z, step, true)
pts.forEach((p) => {
+ if (p.skip) return
if (!g.isLand(p.x, p.y)) return
if (p.gate) {
- g.addEntity(gateType, p.x, p.y, { mx: { open: false, openT: 0 } })
+ g.addEntity(gateType, p.x, p.y, { mx: { open: false, openT: 0, orient: p.orient || 'h' } })
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)
+ const near = g.entities.some((e) => (e.type === fenceType || e.type === gateType) && Math.hypot(e.x - p.x, e.y - p.y) < step * 0.35)
if (near) return
- g.addEntity(fenceType, p.x, p.y)
+ g.addEntity(fenceType, p.x, p.y, { mx: { orient: p.orient || 'h' } })
})
}
@@ -185,4 +334,206 @@ export function findDarkSpots(g, cx, cy, radius, count, minDist = 90) {
return out
}
+/** 建筑/实体头顶名牌(世界坐标已由调用方 translate 到实体中心) */
+export function drawNameplate(ctx, text, y = -56) {
+ if (!text) return
+ const label = String(text)
+ ctx.save()
+ ctx.fillStyle = 'rgba(20,16,10,0.78)'
+ const tw = Math.max(40, label.length * 9 + 14)
+ ctx.beginPath()
+ if (ctx.roundRect) ctx.roundRect(-tw / 2, y - 8, tw, 16, 4)
+ else ctx.rect(-tw / 2, y - 8, tw, 16)
+ ctx.fill()
+ ctx.fillStyle = '#f4ead2'
+ ctx.font = 'bold 11px "PingFang SC","Microsoft YaHei",sans-serif'
+ ctx.textAlign = 'center'
+ ctx.textBaseline = 'middle'
+ ctx.fillText(label, 0, y)
+ ctx.restore()
+}
+
+// =====================================================================
+// 区域右键菜单(升级 / 改名 / 删除),替代 window.prompt
+// =====================================================================
+let zoneMenu = null // { title, subtitle, details, detailTitle, actions, onPick, ... }
+let zoneMenuDrawnAt = null
+let lastUiEnv = { camX: 0, camY: 0, W: 800, H: 600 }
+
+/**
+ * @param {{
+ * title: string,
+ * subtitle?: string,
+ * detailTitle?: string,
+ * details?: { text: string, ok?: boolean }[],
+ * actions: { id: string, label: string, color?: string, disabled?: boolean }[],
+ * onPick: (id: string) => void
+ * }} opts
+ */
+export function openZoneMenu(opts) {
+ zoneMenu = {
+ title: opts.title || '区域',
+ subtitle: opts.subtitle || '',
+ detailTitle: opts.detailTitle || '',
+ details: Array.isArray(opts.details) ? opts.details.slice() : [],
+ actions: (opts.actions || []).slice(),
+ onPick: typeof opts.onPick === 'function' ? opts.onPick : () => {},
+ camX: lastUiEnv.camX,
+ camY: lastUiEnv.camY,
+ W: lastUiEnv.W,
+ H: lastUiEnv.H,
+ btns: [],
+ panel: null,
+ }
+ layoutZoneMenu(zoneMenu.W, zoneMenu.H)
+ zoneMenuDrawnAt = null
+}
+
+export function closeZoneMenu() {
+ zoneMenu = null
+ zoneMenuDrawnAt = null
+}
+
+export function isZoneMenuOpen() {
+ return !!zoneMenu
+}
+
+function layoutZoneMenu(W, H) {
+ if (!zoneMenu) return
+ const n = Math.max(1, zoneMenu.actions.length)
+ const details = zoneMenu.details || []
+ const bw = 200
+ const gap = 8
+ const headH = zoneMenu.subtitle ? 52 : 40
+ const detailHead = details.length ? 20 : 0
+ const detailBody = details.length ? details.length * 18 + 8 : 0
+ let detailBlock = detailHead + detailBody
+ const pw = Math.max(260, details.length ? 280 : 220)
+ const maxPh = Math.max(160, H - 12)
+ // 压缩按钮高度,确保整块面板含「取消/关闭」落在屏内
+ let bh = 34
+ let ph = headH + detailBlock + n * (bh + gap) + 14
+ if (ph > maxPh) {
+ detailBlock = Math.min(detailBlock, Math.max(0, maxPh - headH - n * 28 - 14))
+ bh = Math.max(28, Math.floor((maxPh - headH - detailBlock - 14) / n) - gap)
+ ph = Math.min(maxPh, headH + detailBlock + n * (bh + gap) + 14)
+ }
+ const px = (W - pw) / 2
+ const py = Math.max(6, Math.min((H - ph) / 2, H - ph - 6))
+ zoneMenu.panel = { x: px, y: py, w: pw, h: ph }
+ const btnTop = py + headH + detailBlock
+ zoneMenu.btns = zoneMenu.actions.map((a, i) => ({
+ id: a.id,
+ label: a.label,
+ color: a.disabled ? '#4a4038' : (a.color || '#5a4630'),
+ disabled: !!a.disabled,
+ x: px + (pw - bw) / 2,
+ y: btnTop + i * (bh + gap),
+ w: bw,
+ h: bh,
+ }))
+ zoneMenu._detailTop = py + headH
+}
+
+/** 在 onDrawUI 中调用;同一帧多次调用只会画一次 */
+export function drawZoneMenu(ctx, env) {
+ lastUiEnv = { camX: env.camX, camY: env.camY, W: env.W, H: env.H }
+ if (!zoneMenu) return
+ const fid = env.time
+ if (zoneMenuDrawnAt === fid) return
+ zoneMenuDrawnAt = fid
+ zoneMenu.camX = env.camX
+ zoneMenu.camY = env.camY
+ zoneMenu.W = env.W
+ zoneMenu.H = env.H
+ layoutZoneMenu(env.W, env.H)
+ const p = zoneMenu.panel
+ const details = zoneMenu.details || []
+ ctx.save()
+ ctx.fillStyle = 'rgba(0,0,0,0.48)'
+ ctx.fillRect(0, 0, env.W, env.H)
+ ctx.fillStyle = '#3a2a1a'
+ ctx.strokeStyle = '#1d1409'
+ ctx.lineWidth = 3
+ ctx.beginPath()
+ if (ctx.roundRect) ctx.roundRect(p.x, p.y, p.w, p.h, 10)
+ else ctx.rect(p.x, p.y, p.w, p.h)
+ ctx.fill()
+ ctx.stroke()
+ ctx.fillStyle = '#f0d79a'
+ ctx.font = 'bold 16px "PingFang SC","Microsoft YaHei",sans-serif'
+ ctx.textAlign = 'center'
+ ctx.textBaseline = 'middle'
+ ctx.fillText(zoneMenu.title, p.x + p.w / 2, p.y + (zoneMenu.subtitle ? 20 : 24))
+ if (zoneMenu.subtitle) {
+ ctx.fillStyle = '#c9b48c'
+ ctx.font = '12px "PingFang SC","Microsoft YaHei",sans-serif'
+ ctx.fillText(zoneMenu.subtitle, p.x + p.w / 2, p.y + 40)
+ }
+ if (details.length) {
+ const boxY = zoneMenu._detailTop
+ const boxH = 22 + details.length * 20 + 8
+ ctx.fillStyle = 'rgba(0,0,0,0.28)'
+ ctx.beginPath()
+ if (ctx.roundRect) ctx.roundRect(p.x + 14, boxY, p.w - 28, boxH, 6)
+ else ctx.rect(p.x + 14, boxY, p.w - 28, boxH)
+ ctx.fill()
+ ctx.fillStyle = '#e0c080'
+ ctx.font = 'bold 12px "PingFang SC","Microsoft YaHei",sans-serif'
+ ctx.textAlign = 'left'
+ ctx.fillText(zoneMenu.detailTitle || '升级所需材料', p.x + 24, boxY + 14)
+ details.forEach((d, i) => {
+ const y = boxY + 34 + i * 20
+ ctx.fillStyle = d.ok ? '#8dce7a' : '#e08a6a'
+ ctx.font = '12px "PingFang SC","Microsoft YaHei",sans-serif'
+ ctx.fillText(d.text || '', p.x + 24, y)
+ })
+ }
+ zoneMenu.btns.forEach((b) => {
+ ctx.fillStyle = b.color
+ ctx.strokeStyle = '#1d1409'
+ ctx.lineWidth = 2
+ ctx.beginPath()
+ if (ctx.roundRect) ctx.roundRect(b.x, b.y, b.w, b.h, 6)
+ else ctx.rect(b.x, b.y, b.w, b.h)
+ ctx.fill()
+ ctx.stroke()
+ ctx.fillStyle = b.disabled ? '#a09080' : '#f2e8d0'
+ ctx.font = 'bold 14px "PingFang SC","Microsoft YaHei",sans-serif'
+ ctx.textAlign = 'center'
+ ctx.textBaseline = 'middle'
+ ctx.fillText(b.label, b.x + b.w / 2, b.y + b.h / 2 + 1)
+ })
+ ctx.restore()
+}
+
+/**
+ * 在 onClick 开头调用(世界坐标)。菜单打开时拦截点击。
+ * @returns {boolean}
+ */
+export function handleZoneMenuClick(wx, wy) {
+ if (!zoneMenu) return false
+ if (!zoneMenu.panel) layoutZoneMenu(zoneMenu.W || 800, zoneMenu.H || 600)
+ const sx = wx - zoneMenu.camX
+ const sy = wy - zoneMenu.camY
+ const hit = zoneMenu.btns.find((b) => sx >= b.x && sx <= b.x + b.w && sy >= b.y && sy <= b.y + b.h)
+ if (hit) {
+ if (hit.disabled) return true
+ const pick = zoneMenu.onPick
+ const id = hit.id
+ if (id === 'cancel') {
+ closeZoneMenu()
+ return true
+ }
+ // 先关再回调,避免回调里再 open 时被清掉
+ closeZoneMenu()
+ pick(id)
+ return true
+ }
+ const p = zoneMenu.panel
+ if (p && sx >= p.x && sx <= p.x + p.w && sy >= p.y && sy <= p.y + p.h) return true
+ closeZoneMenu()
+ return true
+}
+
export { FENCE_STEP }
diff --git a/src/games/starve/paint.js b/src/games/starve/paint.js
index 1a02fd6..f7ad9c4 100644
--- a/src/games/starve/paint.js
+++ b/src/games/starve/paint.js
@@ -682,6 +682,19 @@ export function drawEnt(ctx, ent, env) {
}
function drawWoodchest(ctx, ent) {
+ const n = (ent.mx?.slots || []).reduce((a, s) => a + (s?.n || 0), 0)
+ const label = n > 0 ? `木箱 · ${n}` : '木箱'
+ ctx.fillStyle = 'rgba(20,16,10,0.78)'
+ const tw = Math.max(40, label.length * 9 + 14)
+ ctx.beginPath()
+ if (ctx.roundRect) ctx.roundRect(-tw / 2, -42, tw, 16, 4)
+ else ctx.rect(-tw / 2, -42, tw, 16)
+ ctx.fill()
+ ctx.fillStyle = '#f4ead2'
+ ctx.font = 'bold 11px "PingFang SC","Microsoft YaHei",sans-serif'
+ ctx.textAlign = 'center'
+ ctx.textBaseline = 'middle'
+ ctx.fillText(label, 0, -34)
shadow(ctx, 0, 6, 18, 5)
const open = !!ent.mx?.open
ctx.fillStyle = '#7a5230'
diff --git a/src/games/starve/world.js b/src/games/starve/world.js
index c934472..c205835 100644
--- a/src/games/starve/world.js
+++ b/src/games/starve/world.js
@@ -59,6 +59,21 @@ export function findLandNear(tiles, x, y, rMin, rMax, tries = 24) {
return { x, y }
}
+/** 建造落点:优先精确点击处,否则螺旋找最近陆地(稳定、不抖) */
+export function snapToLand(tiles, x, y, maxR = 56) {
+ if (isLand(tiles, x, y)) return { x, y }
+ for (let r = 4; r <= maxR; r += 4) {
+ const n = Math.max(8, Math.round((Math.PI * 2 * r) / 6))
+ for (let i = 0; i < n; i++) {
+ const a = (i / n) * Math.PI * 2
+ const nx = x + Math.cos(a) * r
+ const ny = y + Math.sin(a) * r
+ if (isLand(tiles, nx, ny)) return { x: nx, y: ny }
+ }
+ }
+ return null
+}
+
// =====================================================================
// 岛屿 + 群系 + 道路生成
// =====================================================================