升级饥荒
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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 }
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// 岛屿 + 群系 + 道路生成
|
||||
// =====================================================================
|
||||
|
||||
Reference in New Issue
Block a user