升级饥荒

This commit is contained in:
李琦
2026-09-30 08:42:22 +08:00
parent 7fe810a320
commit d87ad60492
10 changed files with 3946 additions and 681 deletions

View File

@@ -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) {
<svg viewBox="0 0 24 24"><ellipse cx="12" cy="12" rx="9" ry="8" fill="#d8a04a" stroke="#1d1409" stroke-width="1.6"/><path d="M7 12c1.6-2 3.2-2 5-.4 1.8 1.6 3.4 1.6 5 .1" fill="none" stroke="#1d1409" stroke-width="1.4"/></svg>
<b>{{ hunger }}</b>
</div>
<div class="badge food-badge" :class="{ low: foodBag + foodStore < 8 }" :title="`粮食(招聘/发工资):背包 ${foodBag} · 仓库/粮仓 ${foodStore}`">
<svg viewBox="0 0 24 24"><path d="M6 18c0-4 2.5-7 6-9 3.5 2 6 5 6 9H6z" fill="#c9a24a" stroke="#1d1409" stroke-width="1.5"/><path d="M12 4v5M9 7h6" fill="none" stroke="#1d1409" stroke-width="1.5"/></svg>
<b>{{ foodBag }}<small>/{{ foodStore }}</small></b>
</div>
<div class="badge" :class="{ low: san < 60 }" title="理智">
<svg viewBox="0 0 24 24"><path d="M12 3c4.4 0 8 3.2 8 7.4 0 2.7-1.4 4.6-3.4 5.8l.6 4-4-1.6c-.4 0-.8.1-1.2.1-4.4 0-8-3.2-8-7.4S7.6 3 12 3z" fill="#b48ad8" stroke="#1d1409" stroke-width="1.6"/><path d="M9 9.5c.8-1 2-1 2.8 0M13.5 9.5c.8-1 2-1 2.8 0" fill="none" stroke="#1d1409" stroke-width="1.3"/></svg>
<b>{{ san }}</b>
@@ -3825,34 +4188,85 @@ function freshClass(f) {
</div>
</div>
<!-- 木箱模态框 -->
<!-- 木箱模态框:左侧箱格 · 右侧物品栏快速放入 -->
<div v-if="playing && boxView" class="modal-mask" @click.self="closeBox">
<div class="modal-card box-modal">
<div class="modal-head">
<b>木箱</b>
<button type="button" class="modal-x" @click="closeBox">×</button>
</div>
<p class="modal-hint">点击物品栏格存入 · 点击箱内格取出</p>
<div class="modal-slots box-grid">
<button
v-for="(s, i) in boxView.slots"
:key="'bx' + i"
type="button"
class="slot"
:class="{ filled: s }"
:data-tip="tipTextForSlot(s)"
@click="clickBoxSlot(i)"
@mouseenter="showSlotTip($event, tipTextForSlot(s))"
@mousemove="showSlotTip($event, tipTextForSlot(s))"
@mouseleave="hideSlotTip"
>
<template v-if="s">
<img :src="ICONS.url[s.code]" :alt="s.name" />
<b v-if="s.n > 1" class="cnt">{{ s.n }}</b>
<i v-if="s.fresh >= 0" class="fresh" :class="freshClass(s.fresh)"></i>
<i v-if="s.dur >= 0" class="dur" :style="{ width: Math.max(4, s.dur * 100) + '%' }"></i>
</template>
</button>
<p class="modal-hint">左侧取出 · 右侧点物品栏快速放入</p>
<div class="box-split">
<div class="box-side">
<span class="box-side-label">箱内</span>
<div class="modal-slots box-grid">
<button
v-for="(s, i) in boxView.slots"
:key="'bx' + i"
type="button"
class="slot"
:class="{ filled: s }"
:data-tip="tipTextForSlot(s)"
@click="clickBoxSlot(i)"
@mouseenter="showSlotTip($event, tipTextForSlot(s))"
@mousemove="showSlotTip($event, tipTextForSlot(s))"
@mouseleave="hideSlotTip"
>
<template v-if="s">
<img :src="ICONS.url[s.code]" :alt="s.name" />
<b v-if="s.n > 1" class="cnt">{{ s.n }}</b>
<i v-if="s.fresh >= 0" class="fresh" :class="freshClass(s.fresh)"></i>
<i v-if="s.dur >= 0" class="dur" :style="{ width: Math.max(4, s.dur * 100) + '%' }"></i>
</template>
</button>
</div>
</div>
<div class="box-side inv-side">
<span class="box-side-label">物品栏 · 点击放入</span>
<div class="modal-slots inv-quick">
<button
v-for="(s, i) in uiSlots"
:key="'qi' + i"
type="button"
class="slot"
:class="{ filled: s }"
:data-tip="s ? ('放入 ' + tipTextForSlot(s)) : ''"
@click="clickSlot('main', i)"
@mouseenter="showSlotTip($event, s ? ('放入 ' + tipTextForSlot(s)) : '')"
@mousemove="showSlotTip($event, s ? ('放入 ' + tipTextForSlot(s)) : '')"
@mouseleave="hideSlotTip"
>
<span v-if="i < 9" class="key-tag">{{ i + 1 }}</span>
<template v-if="s">
<img :src="ICONS.url[s.code]" :alt="s.name" />
<b v-if="s.n > 1" class="cnt">{{ s.n }}</b>
<i v-if="s.fresh >= 0" class="fresh" :class="freshClass(s.fresh)"></i>
<i v-if="s.dur >= 0" class="dur" :style="{ width: Math.max(4, s.dur * 100) + '%' }"></i>
</template>
</button>
</div>
<div v-if="uiPack" class="modal-slots inv-quick pack-quick">
<button
v-for="(s, i) in uiPack"
:key="'qp' + i"
type="button"
class="slot"
:class="{ filled: s }"
:data-tip="s ? ('放入 ' + tipTextForSlot(s)) : ''"
@click="clickSlot('pack', i)"
@mouseenter="showSlotTip($event, s ? ('放入 ' + tipTextForSlot(s)) : '')"
@mousemove="showSlotTip($event, s ? ('放入 ' + tipTextForSlot(s)) : '')"
@mouseleave="hideSlotTip"
>
<template v-if="s">
<img :src="ICONS.url[s.code]" :alt="s.name" />
<b v-if="s.n > 1" class="cnt">{{ s.n }}</b>
<i v-if="s.fresh >= 0" class="fresh" :class="freshClass(s.fresh)"></i>
<i v-if="s.dur >= 0" class="dur" :style="{ width: Math.max(4, s.dur * 100) + '%' }"></i>
</template>
</button>
</div>
</div>
</div>
</div>
</div>
@@ -3881,12 +4295,13 @@ function freshClass(f) {
<span v-if="hint" class="hint-extra">{{ hint }}</span>
</div>
<button
v-show="playing && !dead && nearActions.length"
v-show="playing && !dead"
type="button"
class="quick-act"
:class="{ on: autoInteractOn }"
@click.stop="doQuickInteract"
title="快捷键 E · 缺少工具时自动装备/合成"
>一键交互 · E</button>
title="E:开关自动交互(不含篝火/房屋)"
>{{ autoInteractOn ? '自动交互 · 开' : '自动交互 · E' }}</button>
<!-- 季节横幅 -->
<div v-if="seasonBanner" class="season-banner" :style="{ borderColor: seasonTag.tint, color: seasonTag.tint }">{{ seasonBanner }}</div>
@@ -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;

View File

@@ -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 },

View File

@@ -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)

View File

@@ -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)
},
},
}

File diff suppressed because it is too large Load Diff

View File

@@ -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)
},
},
}

File diff suppressed because it is too large Load Diff

View File

@@ -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 }

View File

@@ -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'

View File

@@ -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
}
// =====================================================================
// 岛屿 + 群系 + 道路生成
// =====================================================================