1008 lines
38 KiB
JavaScript
1008 lines
38 KiB
JavaScript
// 营地:出生点自带房屋、长明篝火与无敌守卫,可建造营地仓储与食堂
|
||
// - 房屋 camphouse:按游戏日休息——夜里跳到次日清晨,白天回复体力
|
||
// - 守卫 guard:无限血,营地半径内巡逻并拦截、击杀敌对生物
|
||
// - 营地仓储 campstore:8 个箱子,每箱 6 种、每种最多 999(共 48 种容量)
|
||
// - 食堂 canteen:厨师做菜存入,点击点餐
|
||
// - 营地升级:采集/击杀/产业发展涨 XP,扩半径、暗处补永久篝火;8 级围栏+门
|
||
// - 领地:以房屋为圆心动态半径,通过核心 onMobVictim 否决钩子让敌对生物不再锁定营地内的玩家
|
||
// 时间/数值基准:三围 150/150/200,一天 160 秒
|
||
import { rebuildFencePerimeter, findDarkSpots } from './zoneUtil.js'
|
||
|
||
const TAU = Math.PI * 2
|
||
const CAMP_R0 = 420 // 1 级营地安全半径
|
||
const BOXES = 8 // 箱子数
|
||
const BOX_KINDS = 6 // 每箱最多物品种类
|
||
const BOX_CAP = 999 // 每种最多数量
|
||
const DAY_LEN = 160
|
||
|
||
// ---- 营地升级状态(入存档) ----
|
||
let campState = { level: 1, xp: 0, fenced: false }
|
||
|
||
// ---- 模块级面板状态(不入存档;面板只是一次交互会话) ----
|
||
let storeOpen = null // 当前打开的 campstore 实体 id
|
||
let canteenOpen = null
|
||
let uiCamX = 0
|
||
let uiCamY = 0
|
||
let uiPanel = null
|
||
let uiClose = null
|
||
let uiRows = []
|
||
let uiCanteenPanel = null
|
||
let uiCanteenClose = null
|
||
let uiCanteenRows = []
|
||
|
||
function campR() {
|
||
const lv = Math.max(1, campState.level || 1)
|
||
return CAMP_R0 + (lv - 1) * 80
|
||
}
|
||
function xpNeed(level) {
|
||
return 80 * Math.max(1, level)
|
||
}
|
||
function hiredCount(g) {
|
||
let n = 0
|
||
g.entities.forEach((e) => {
|
||
if (e.type !== 'laboroffice' || !e.mx) return
|
||
n += (e.mx.farmer || 0) + (e.mx.herder || 0) + (e.mx.chef || 0)
|
||
})
|
||
return n
|
||
}
|
||
function eternalFireCount(g) {
|
||
return (g.fires || []).filter((f) => f.eternal).length
|
||
}
|
||
function addCampXp(g, base) {
|
||
if (!base || base <= 0) return
|
||
const mult = 1 + 0.08 * hiredCount(g)
|
||
campState.xp += Math.round(base * mult)
|
||
let guard = 0
|
||
while (campState.level < 20 && campState.xp >= xpNeed(campState.level) && guard++ < 10) {
|
||
campState.xp -= xpNeed(campState.level)
|
||
campState.level += 1
|
||
onCampLevelUp(g)
|
||
}
|
||
}
|
||
function onCampLevelUp(g) {
|
||
const r = campR()
|
||
const c = campCenter(g)
|
||
const softCap = 3 + campState.level
|
||
const need = Math.min(2, Math.max(0, softCap - eternalFireCount(g)))
|
||
const spots = findDarkSpots(g, c.x, c.y, r, need, 100)
|
||
spots.forEach((s) => g.addFire(s.x, s.y, { pit: true, eternal: true, ttl: 150, max: 150 }))
|
||
g.tip(`营地升至 ${campState.level} 级!领地扩大${spots.length ? `,新增 ${spots.length} 座长明篝火` : ''}`)
|
||
if (campState.level >= 8 && !campState.fenced) {
|
||
buildCampFence(g)
|
||
}
|
||
}
|
||
function buildCampFence(g) {
|
||
const c = campCenter(g)
|
||
const r = campR() * 0.92
|
||
const z = { x0: c.x - r, y0: c.y - r * 0.62, x1: c.x + r, y1: c.y + r * 0.62 }
|
||
rebuildFencePerimeter(g, z, { fenceType: 'campfence', gateType: 'campgate', step: 56 })
|
||
campState.fenced = true
|
||
g.tip('营地周围筑起了围栏与大门')
|
||
}
|
||
function placeStarterFires(g, cx, cy) {
|
||
const offsets = [
|
||
[90, 70],
|
||
[-100, 50],
|
||
[40, -110],
|
||
[-70, -80],
|
||
[120, -30],
|
||
]
|
||
offsets.forEach(([dx, dy]) => {
|
||
const spot = g.findLand(cx + dx, cy + dy, 0, 50)
|
||
if (spot) g.addFire(spot.x, spot.y, { pit: true, eternal: true, ttl: 150, max: 150 })
|
||
})
|
||
}
|
||
|
||
// 相机无关的“点是否在矩形内”
|
||
function inside(x, y, r) {
|
||
return !!r && x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h
|
||
}
|
||
// 可堆叠物品判定(装备/工具/护甲不算)
|
||
function isStackable(g, code) {
|
||
const def = g.items[code]
|
||
if (!def) return false
|
||
if (def.equip || def.tool || def.armor) return false
|
||
const max = def.stack || (g.tune && g.tune.stackMax) || 20
|
||
return max > 1
|
||
}
|
||
// 规范化营地的 8 个箱子(容忍旧存档 / 缺字段)
|
||
function boxesOf(e) {
|
||
if (!e.mx) e.mx = { boxes: [] }
|
||
if (!Array.isArray(e.mx.boxes) || e.mx.boxes.length !== BOXES) {
|
||
e.mx.boxes = Array.from({ length: BOXES }, () => ({ items: {} }))
|
||
}
|
||
// 逐个兜底:容忍旧存档 / 缺字段的箱子
|
||
for (let i = 0; i < BOXES; i++) {
|
||
if (!e.mx.boxes[i] || typeof e.mx.boxes[i] !== 'object') e.mx.boxes[i] = { items: {} }
|
||
if (!e.mx.boxes[i].items) e.mx.boxes[i].items = {}
|
||
}
|
||
return e.mx.boxes
|
||
}
|
||
function usedKinds(box) {
|
||
return Object.keys(box.items || {}).filter((k) => (box.items[k] || 0) > 0).length
|
||
}
|
||
// 把玩家背包里所有可堆叠物品存入指定箱子(尊重箱容量限制),返回实际存入件数
|
||
function depositBox(g, p, box) {
|
||
if (!box.items) box.items = {}
|
||
const codes = new Set()
|
||
;[p.slots, p.pack].filter(Boolean).forEach((area) => {
|
||
area.forEach((s) => {
|
||
if (s && isStackable(g, s.code)) codes.add(s.code)
|
||
})
|
||
})
|
||
let moved = 0
|
||
codes.forEach((code) => {
|
||
const have = g.countItem(p, code)
|
||
if (have <= 0) return
|
||
const cur = box.items[code] || 0
|
||
const kinds = usedKinds(box)
|
||
if (cur <= 0 && kinds >= BOX_KINDS) return // 新物种种类已满
|
||
const room = BOX_CAP - cur
|
||
if (room <= 0) return
|
||
const add = Math.min(have, room)
|
||
if (add <= 0) return
|
||
if (!g.payCost(p, { [code]: add })) return
|
||
box.items[code] = cur + add
|
||
moved += add
|
||
})
|
||
return moved
|
||
}
|
||
// 把指定箱子全部取出到玩家背包,返回取回件数
|
||
function withdrawBox(g, p, box) {
|
||
if (!box.items) {
|
||
box.items = {}
|
||
return 0
|
||
}
|
||
let moved = 0
|
||
Object.entries(box.items).forEach(([code, n]) => {
|
||
if (n > 0) {
|
||
g.addItem(p, code, n)
|
||
moved += n
|
||
}
|
||
})
|
||
box.items = {}
|
||
return moved
|
||
}
|
||
// 营地圆心(优先取第一座房屋,缺则退回世界中心)
|
||
function campCenter(g) {
|
||
const house = g.entities.find((e) => e.type === 'camphouse')
|
||
if (house) return { x: house.x, y: house.y }
|
||
return { x: g.worldW / 2, y: g.worldH / 2 }
|
||
}
|
||
// 全部营地房屋(用于绘制每一座的安全区边界)
|
||
function campHouseList(g) {
|
||
return g.entities.filter((e) => e.type === 'camphouse')
|
||
}
|
||
// 玩家在「任意一座」营地房屋的动态半径内都算进入营地
|
||
function inCamp(g, x, y) {
|
||
const r = campR()
|
||
return g.entities.some((e) => e.type === 'camphouse' && Math.hypot(x - e.x, y - e.y) < r)
|
||
}
|
||
export default {
|
||
id: 'camp',
|
||
name: '营地',
|
||
desc: '房屋休息跳夜、营地升级扩半径、食堂点餐与长明篝火',
|
||
factor: 0.75, // 大幅降低难度
|
||
tag: 'easy',
|
||
tabs: [{ code: 'camp', name: '营地', icon: 'camphouse' }],
|
||
recipes: [
|
||
{ code: 'campstore', name: '营地仓储', tab: 'camp', tech: 1, cost: { log: 10, rock: 4 }, build: true, desc: '8 个箱子,可建造多个' },
|
||
{ code: 'camphouse', name: '营地房屋', tab: 'camp', tech: 1, cost: { log: 12, rock: 8 }, build: true, desc: '按游戏日休息:夜跳昼 / 昼回体力' },
|
||
{ code: 'canteen', name: '食堂', tab: 'camp', tech: 1, cost: { log: 10, rock: 6, rope: 2 }, build: true, desc: '厨师做菜,点击点餐' },
|
||
{ code: 'campfence', name: '营地栅栏', tab: 'camp', tech: 2, cost: { log: 4, rock: 2 }, build: true, desc: '营地围栏(8 级自动筑墙)' },
|
||
{ code: 'campgate', name: '营地大门', tab: 'camp', tech: 2, cost: { log: 6, rock: 4, rope: 2 }, build: true, desc: '可开关的营地大门' },
|
||
],
|
||
icons: {
|
||
camphouse: (c) => {
|
||
// 石砌地基
|
||
c.fillStyle = '#8d9097'
|
||
c.strokeStyle = '#1d1409'
|
||
c.lineWidth = 1.6
|
||
c.beginPath(); c.roundRect(-14, 6, 28, 6, 1.5); c.fill(); c.stroke()
|
||
// 木板墙
|
||
c.fillStyle = '#b07f47'
|
||
c.beginPath(); c.roundRect(-11, -8, 22, 15, 1.5); c.fill(); c.stroke()
|
||
c.strokeStyle = '#8a5f30'
|
||
c.lineWidth = 1
|
||
;[-4, 0, 4].forEach((y) => { c.beginPath(); c.moveTo(-11, y); c.lineTo(11, y); c.stroke() })
|
||
// 门窗
|
||
c.fillStyle = '#3a2a16'
|
||
c.beginPath(); c.roundRect(-6, 0, 6, 7, 1); c.fill()
|
||
c.fillStyle = '#ffd98a'
|
||
c.beginPath(); c.roundRect(3, -4, 5, 5, 1); c.fill()
|
||
// 斜屋顶
|
||
c.fillStyle = '#7a5230'
|
||
c.strokeStyle = '#1d1409'
|
||
c.lineWidth = 1.8
|
||
c.beginPath(); c.moveTo(-15, -8); c.lineTo(15, -8); c.lineTo(0, -19); c.closePath(); c.fill(); c.stroke()
|
||
c.strokeStyle = '#5d3f24'
|
||
c.lineWidth = 1
|
||
;[-9, -4, 4, 9].forEach((x) => { c.beginPath(); c.moveTo(x, -8); c.lineTo(x * 0.42, -18); c.stroke() })
|
||
// 烟囱
|
||
c.fillStyle = '#9a6a3c'
|
||
c.beginPath(); c.roundRect(6, -18, 4, 7, 1); c.fill(); c.stroke()
|
||
},
|
||
campstore: (c) => {
|
||
// 仓房
|
||
c.fillStyle = '#a9773c'
|
||
c.strokeStyle = '#1d1409'
|
||
c.lineWidth = 1.8
|
||
c.beginPath(); c.roundRect(-13, -12, 26, 20, 2); c.fill(); c.stroke()
|
||
c.fillStyle = '#7a5230'
|
||
c.beginPath(); c.moveTo(-15, -12); c.lineTo(0, -22); c.lineTo(15, -12); c.closePath(); c.fill(); c.stroke()
|
||
c.fillStyle = '#2a1c10'
|
||
c.beginPath(); c.roundRect(-6, -2, 12, 10, 1); c.fill(); c.stroke()
|
||
// 门口木箱
|
||
c.fillStyle = '#c9a36b'
|
||
c.strokeStyle = '#1d1409'
|
||
c.lineWidth = 1.4
|
||
c.beginPath(); c.roundRect(-14, 6, 12, 10, 1.5); c.fill(); c.stroke()
|
||
c.beginPath(); c.roundRect(1, 8, 12, 8, 1.5); c.fill(); c.stroke()
|
||
c.strokeStyle = '#8a5f30'
|
||
c.lineWidth = 1
|
||
c.beginPath(); c.moveTo(-14, 11); c.lineTo(-2, 11); c.moveTo(1, 12); c.lineTo(13, 12); c.stroke()
|
||
},
|
||
canteen: (c) => {
|
||
c.fillStyle = '#b07f47'
|
||
c.strokeStyle = '#1d1409'
|
||
c.lineWidth = 1.8
|
||
c.beginPath(); c.roundRect(-14, -8, 28, 18, 2); c.fill(); c.stroke()
|
||
c.fillStyle = '#7a5230'
|
||
c.beginPath(); c.moveTo(-16, -8); c.lineTo(0, -18); c.lineTo(16, -8); c.closePath(); c.fill(); c.stroke()
|
||
c.fillStyle = '#3a2a16'
|
||
c.beginPath(); c.roundRect(-5, 0, 10, 10, 1); c.fill()
|
||
c.fillStyle = '#e8c070'
|
||
c.beginPath(); c.ellipse(0, -2, 6, 3, 0, 0, TAU); c.fill()
|
||
},
|
||
campfence: (c) => {
|
||
c.fillStyle = '#8d9097'
|
||
c.strokeStyle = '#1d1409'
|
||
c.lineWidth = 1.6
|
||
;[-10, 10].forEach((x) => {
|
||
c.beginPath(); c.roundRect(x - 3, -12, 6, 24, 1); c.fill(); c.stroke()
|
||
})
|
||
c.beginPath(); c.roundRect(-14, -5, 28, 4, 1); c.fill(); c.stroke()
|
||
c.beginPath(); c.roundRect(-14, 4, 28, 4, 1); c.fill(); c.stroke()
|
||
},
|
||
campgate: (c) => {
|
||
c.fillStyle = '#8d9097'
|
||
c.strokeStyle = '#1d1409'
|
||
c.lineWidth = 1.6
|
||
c.beginPath(); c.roundRect(-3, -14, 6, 28, 1); c.fill(); c.stroke()
|
||
c.beginPath(); c.roundRect(-14, -10, 16, 5, 1); c.fill(); c.stroke()
|
||
c.fillStyle = '#c9a34a'
|
||
c.beginPath(); c.arc(5, -2, 2, 0, TAU); c.fill()
|
||
},
|
||
},
|
||
ents: {
|
||
camphouse: {
|
||
name: '房屋',
|
||
conf: { size: 52, struct: true },
|
||
init(e) {
|
||
if (!e.mx) e.mx = { restDay: 0 }
|
||
if (e.mx.restDay == null) e.mx.restDay = 0
|
||
},
|
||
interact(p, e, g) {
|
||
if (!e.mx) e.mx = { restDay: 0 }
|
||
if (e.mx.restDay === g.day) {
|
||
g.ptip(p, '今天已经休息过了')
|
||
return
|
||
}
|
||
if (g.phase === 'night') {
|
||
// 推进到次日清晨(setDayTime(DAY_LEN) → day++ 且 dayTime=0)
|
||
if (typeof g.setDayTime === 'function') g.setDayTime(DAY_LEN)
|
||
e.mx.restDay = g.day
|
||
g.markDirty(e)
|
||
g.ptip(p, '睡到了天亮……新的一天开始了')
|
||
g.swing(p, 'eat')
|
||
return
|
||
}
|
||
// 白天:恢复体力(饥饿/生命/理智)
|
||
p.hunger = Math.min(150, p.hunger + 40)
|
||
p.hp = Math.min(150, p.hp + 25)
|
||
p.san = Math.min(200, p.san + 15)
|
||
e.mx.restDay = g.day
|
||
g.markDirty(e)
|
||
g.ptip(p, '白天歇息:饥饿 +40、生命 +25、理智 +15')
|
||
g.swing(p, 'eat')
|
||
},
|
||
draw(ctx, e, env) {
|
||
// 底部投影
|
||
ctx.fillStyle = 'rgba(0,0,0,0.25)'
|
||
ctx.beginPath(); ctx.ellipse(0, 8, 34, 9, 0, 0, TAU); ctx.fill()
|
||
// 石砌地基
|
||
ctx.fillStyle = '#8d9097'
|
||
ctx.strokeStyle = '#1d1409'
|
||
ctx.lineWidth = 2
|
||
ctx.beginPath(); ctx.roundRect(-30, -4, 60, 12, 2); ctx.fill(); ctx.stroke()
|
||
ctx.strokeStyle = '#6f7278'
|
||
ctx.lineWidth = 1
|
||
;[-20, -10, 0, 10, 20].forEach((x) => { ctx.beginPath(); ctx.moveTo(x, -4); ctx.lineTo(x, 8); ctx.stroke() })
|
||
// 木板墙
|
||
ctx.fillStyle = '#b07f47'
|
||
ctx.strokeStyle = '#1d1409'
|
||
ctx.lineWidth = 2
|
||
ctx.beginPath(); ctx.roundRect(-26, -46, 52, 44, 3); ctx.fill(); ctx.stroke()
|
||
// 横向木纹
|
||
ctx.strokeStyle = '#8a5f30'
|
||
ctx.lineWidth = 1.2
|
||
;[-38, -30, -22, -14, -6].forEach((y) => { ctx.beginPath(); ctx.moveTo(-26, y); ctx.lineTo(26, y); ctx.stroke() })
|
||
// 竖向立柱
|
||
;[-18, 0, 18].forEach((x) => { ctx.beginPath(); ctx.moveTo(x, -46); ctx.lineTo(x, -2); ctx.stroke() })
|
||
// 门(深色木门 + 门把手)
|
||
ctx.fillStyle = '#3a2a16'
|
||
ctx.strokeStyle = '#1d1409'
|
||
ctx.lineWidth = 2
|
||
ctx.beginPath(); ctx.roundRect(-11, -26, 20, 24, [3, 3, 0, 0]); ctx.fill(); ctx.stroke()
|
||
ctx.fillStyle = '#e0c060'
|
||
ctx.beginPath(); ctx.arc(4, -14, 1.6, 0, TAU); ctx.fill()
|
||
// 亮着暖光的窗
|
||
ctx.fillStyle = '#ffd98a'
|
||
ctx.strokeStyle = '#1d1409'
|
||
ctx.lineWidth = 2
|
||
ctx.beginPath(); ctx.roundRect(7, -40, 15, 13, 1.5); ctx.fill(); ctx.stroke()
|
||
ctx.strokeStyle = '#8a6a34'
|
||
ctx.lineWidth = 1.2
|
||
ctx.beginPath(); ctx.moveTo(7, -33.5); ctx.lineTo(22, -33.5); ctx.moveTo(14.5, -40); ctx.lineTo(14.5, -27); ctx.stroke()
|
||
// 斜屋顶(瓦片纹)
|
||
ctx.fillStyle = '#7a5230'
|
||
ctx.strokeStyle = '#1d1409'
|
||
ctx.lineWidth = 2
|
||
ctx.beginPath(); ctx.moveTo(-34, -46); ctx.lineTo(34, -46); ctx.lineTo(0, -76); ctx.closePath(); ctx.fill(); ctx.stroke()
|
||
ctx.strokeStyle = '#5d3f24'
|
||
ctx.lineWidth = 1.2
|
||
;[-26, -16, -6, 6, 16, 26].forEach((x) => { ctx.beginPath(); ctx.moveTo(x, -46); ctx.lineTo(x * 0.4, -72); ctx.stroke() })
|
||
// 烟囱
|
||
ctx.fillStyle = '#9a6a3c'
|
||
ctx.strokeStyle = '#1d1409'
|
||
ctx.lineWidth = 1.8
|
||
ctx.beginPath(); ctx.roundRect(12, -72, 11, 20, 2); ctx.fill(); ctx.stroke()
|
||
// 炊烟(缓慢飘动)
|
||
const t = env?.time || 0
|
||
ctx.fillStyle = 'rgba(220,220,220,0.4)'
|
||
for (let i = 0; i < 3; i++) {
|
||
const ph = (t * 0.5 + i * 0.5 + e.id * 0.1) % 1.5
|
||
ctx.beginPath()
|
||
ctx.arc(17 + Math.sin(t + i) * 3, -80 - ph * 22, 3 + ph * 3, 0, TAU)
|
||
ctx.fill()
|
||
}
|
||
// 门口小台阶
|
||
ctx.fillStyle = '#9a6a3c'
|
||
ctx.strokeStyle = '#1d1409'
|
||
ctx.lineWidth = 1.6
|
||
ctx.beginPath(); ctx.roundRect(-13, -2, 24, 5, 1.5); ctx.fill(); ctx.stroke()
|
||
},
|
||
},
|
||
campstore: {
|
||
name: '营地仓储',
|
||
conf: { size: 40, struct: true },
|
||
init(e) {
|
||
if (!e.mx) e.mx = { boxes: Array.from({ length: BOXES }, () => ({ items: {} })) }
|
||
boxesOf(e)
|
||
},
|
||
interact(p, e, g) {
|
||
boxesOf(e)
|
||
storeOpen = e.id
|
||
g.ptip(p, '打开营地仓储(背包可堆叠物资可存入箱子)')
|
||
},
|
||
draw(ctx, e, env) {
|
||
// 底部投影
|
||
ctx.fillStyle = 'rgba(0,0,0,0.25)'
|
||
ctx.beginPath(); ctx.ellipse(0, 8, 28, 8, 0, 0, TAU); ctx.fill()
|
||
// 木石仓房
|
||
ctx.fillStyle = '#a9773c'
|
||
ctx.strokeStyle = '#1d1409'
|
||
ctx.lineWidth = 2
|
||
ctx.beginPath(); ctx.roundRect(-24, -40, 48, 46, 3); ctx.fill(); ctx.stroke()
|
||
// 竖向木板纹
|
||
ctx.strokeStyle = '#8a5f30'
|
||
ctx.lineWidth = 1.2
|
||
;[-14, -4, 6, 16].forEach((x) => { ctx.beginPath(); ctx.moveTo(x, -40); ctx.lineTo(x, 6); ctx.stroke() })
|
||
// 大门
|
||
ctx.fillStyle = '#3a2a16'
|
||
ctx.strokeStyle = '#1d1409'
|
||
ctx.lineWidth = 2
|
||
ctx.beginPath(); ctx.roundRect(-12, -22, 24, 28, [3, 3, 0, 0]); ctx.fill(); ctx.stroke()
|
||
ctx.strokeStyle = '#5d3f24'
|
||
ctx.lineWidth = 1.2
|
||
ctx.beginPath(); ctx.moveTo(0, -22); ctx.lineTo(0, 6); ctx.stroke()
|
||
// 斜屋顶
|
||
ctx.fillStyle = '#7a5230'
|
||
ctx.strokeStyle = '#1d1409'
|
||
ctx.lineWidth = 2
|
||
ctx.beginPath(); ctx.moveTo(-30, -40); ctx.lineTo(30, -40); ctx.lineTo(0, -64); ctx.closePath(); ctx.fill(); ctx.stroke()
|
||
ctx.strokeStyle = '#5d3f24'
|
||
ctx.lineWidth = 1
|
||
;[-22, -12, 0, 12, 22].forEach((x) => { ctx.beginPath(); ctx.moveTo(x, -40); ctx.lineTo(x * 0.4, -61); ctx.stroke() })
|
||
// 门口码放的木箱(3~4 个叠放,体现"很多箱子")
|
||
ctx.fillStyle = 'rgba(0,0,0,0.18)'
|
||
ctx.beginPath(); ctx.ellipse(24, 8, 20, 6, 0, 0, TAU); ctx.fill()
|
||
const crate = (x, y, w, h) => {
|
||
ctx.fillStyle = '#c9a36b'
|
||
ctx.strokeStyle = '#1d1409'
|
||
ctx.lineWidth = 2
|
||
ctx.beginPath(); ctx.roundRect(x, y, w, h, 1.5); ctx.fill(); ctx.stroke()
|
||
ctx.strokeStyle = '#8a5f30'
|
||
ctx.lineWidth = 1.1
|
||
ctx.beginPath()
|
||
ctx.moveTo(x, y); ctx.lineTo(x + w, y + h)
|
||
ctx.moveTo(x + w, y); ctx.lineTo(x, y + h)
|
||
ctx.stroke()
|
||
}
|
||
crate(-30, -8, 16, 16) // 左前
|
||
crate(-14, -4, 16, 16) // 中间偏前
|
||
crate(-30, -24, 16, 16) // 左后上叠
|
||
crate(14, -10, 18, 18) // 右侧一只
|
||
},
|
||
},
|
||
canteen: {
|
||
name: '食堂',
|
||
conf: { size: 44, struct: true },
|
||
init(e) {
|
||
if (!e.mx) e.mx = { menu: {} }
|
||
if (!e.mx.menu || typeof e.mx.menu !== 'object') e.mx.menu = {}
|
||
},
|
||
interact(p, e, g) {
|
||
if (!e.mx) e.mx = { menu: {} }
|
||
if (!e.mx.menu) e.mx.menu = {}
|
||
canteenOpen = e.id
|
||
storeOpen = null
|
||
g.ptip(p, '打开食堂菜单')
|
||
},
|
||
draw(ctx, e, env) {
|
||
ctx.fillStyle = 'rgba(0,0,0,0.25)'
|
||
ctx.beginPath(); ctx.ellipse(0, 8, 28, 8, 0, 0, TAU); ctx.fill()
|
||
ctx.fillStyle = '#b07f47'
|
||
ctx.strokeStyle = '#1d1409'
|
||
ctx.lineWidth = 2
|
||
ctx.beginPath(); ctx.roundRect(-26, -36, 52, 40, 3); ctx.fill(); ctx.stroke()
|
||
ctx.fillStyle = '#7a5230'
|
||
ctx.beginPath(); ctx.moveTo(-30, -36); ctx.lineTo(0, -58); ctx.lineTo(30, -36); ctx.closePath(); ctx.fill(); ctx.stroke()
|
||
ctx.fillStyle = '#3a2a16'
|
||
ctx.beginPath(); ctx.roundRect(-10, -18, 20, 22, 2); ctx.fill(); ctx.stroke()
|
||
ctx.fillStyle = '#e8c070'
|
||
ctx.beginPath(); ctx.ellipse(0, -22, 10, 5, 0, 0, TAU); ctx.fill(); ctx.stroke()
|
||
const n = Object.values(e.mx?.menu || {}).reduce((a, b) => a + (b || 0), 0)
|
||
if (n > 0) {
|
||
ctx.fillStyle = '#f4ead2'
|
||
ctx.font = 'bold 11px system-ui, sans-serif'
|
||
ctx.textAlign = 'center'
|
||
ctx.fillText(`菜×${n}`, 0, 14)
|
||
}
|
||
},
|
||
},
|
||
campfence: {
|
||
name: '营地栅栏',
|
||
conf: { size: 24, struct: true, solid: true },
|
||
draw(ctx) {
|
||
ctx.fillStyle = 'rgba(0,0,0,0.25)'
|
||
ctx.beginPath(); ctx.ellipse(0, 4, 16, 5, 0, 0, TAU); ctx.fill()
|
||
ctx.fillStyle = '#8d9097'
|
||
ctx.strokeStyle = '#1d1409'
|
||
ctx.lineWidth = 2
|
||
;[-11, 11].forEach((x) => {
|
||
ctx.beginPath(); ctx.roundRect(x - 4, -28, 8, 32, 2); ctx.fill(); ctx.stroke()
|
||
})
|
||
ctx.beginPath(); ctx.roundRect(-16, -20, 32, 5, 2); ctx.fill(); ctx.stroke()
|
||
ctx.beginPath(); ctx.roundRect(-16, -8, 32, 5, 2); ctx.fill(); ctx.stroke()
|
||
},
|
||
},
|
||
campgate: {
|
||
name: '营地大门',
|
||
conf: { size: 44, struct: true, solid: true },
|
||
init(e) {
|
||
if (!e.mx) e.mx = { open: false, openT: 0 }
|
||
if (e.mx.open == null) e.mx.open = false
|
||
e.solid = !e.mx.open
|
||
},
|
||
interact(p, e, g) {
|
||
if (!e.mx) e.mx = { open: false, openT: 0 }
|
||
if (e.mx.open) {
|
||
e.mx.open = false
|
||
e.mx.openT = 0
|
||
e.solid = true
|
||
g.ptip(p, '关上了营地大门')
|
||
} else {
|
||
e.mx.open = true
|
||
e.mx.openT = 4
|
||
e.solid = false
|
||
g.ptip(p, '打开了营地大门(4 秒后自动关闭)')
|
||
}
|
||
g.markDirty(e)
|
||
},
|
||
draw(ctx, e) {
|
||
const open = !!e.mx?.open
|
||
ctx.fillStyle = 'rgba(0,0,0,0.25)'
|
||
ctx.beginPath(); ctx.ellipse(0, 4, 20, 5, 0, 0, TAU); ctx.fill()
|
||
ctx.save()
|
||
if (open) ctx.rotate(-0.7)
|
||
ctx.fillStyle = '#8d9097'
|
||
ctx.strokeStyle = '#1d1409'
|
||
ctx.lineWidth = 2
|
||
;[-14, 14].forEach((x) => {
|
||
ctx.beginPath(); ctx.roundRect(x - 4, -30, 8, 34, 2); ctx.fill(); ctx.stroke()
|
||
})
|
||
ctx.beginPath(); ctx.roundRect(-20, -22, 40, 6, 2); ctx.fill(); ctx.stroke()
|
||
ctx.beginPath(); ctx.roundRect(-20, -10, 40, 6, 2); ctx.fill(); ctx.stroke()
|
||
ctx.fillStyle = '#e0c060'
|
||
ctx.strokeStyle = '#1d1409'
|
||
ctx.lineWidth = 1.4
|
||
ctx.beginPath(); ctx.roundRect(open ? 8 : 10, -18, 10, 8, 2); ctx.fill(); ctx.stroke()
|
||
ctx.restore()
|
||
},
|
||
},
|
||
},
|
||
mobs: {
|
||
guard: {
|
||
name: '守卫',
|
||
friendly: true,
|
||
conf: { hp: 9999, speed: 150, dmg: 60, score: 0, friendly: true, name: '守卫' },
|
||
drops: [],
|
||
update(m, dt, g) {
|
||
const conf = g.mon[m.kind] || this.conf
|
||
// 无限血:核心所有伤害路径都是 m.hp -= dmg 后判 <=0,每帧回满即可免疫
|
||
m.hp = 1e9
|
||
const c = campCenter(g)
|
||
// 找营地半径内的敌对生物
|
||
let target = null
|
||
let bd = campR()
|
||
g.monsters.forEach((q) => {
|
||
if (q === m) return
|
||
if (!g.mon[q.kind]?.hostile) return
|
||
const d = Math.hypot(q.x - c.x, q.y - c.y)
|
||
if (d < bd) { bd = d; target = q }
|
||
})
|
||
if (target) {
|
||
const d = Math.hypot(target.x - m.x, target.y - m.y)
|
||
if (d > 34) {
|
||
g.moveToward(m, target.x, target.y, conf.speed, dt)
|
||
} else if ((m.cd || 0) <= 0) {
|
||
m.cd = 0.9
|
||
g.damageMob(target, conf.dmg, null)
|
||
}
|
||
return
|
||
}
|
||
// 无敌人:在房屋周围小范围巡逻待命
|
||
m.wanderT = (m.wanderT || 0) - dt
|
||
if (m.wanderT <= 0) {
|
||
m.wanderT = 2 + Math.random() * 3
|
||
const a = Math.random() * TAU
|
||
const r = 60 + Math.random() * 110
|
||
m.tx = c.x + Math.cos(a) * r
|
||
m.ty = c.y + Math.sin(a) * r
|
||
}
|
||
if (m.tx !== undefined && Math.hypot(m.tx - m.x, m.ty - m.y) > 8) {
|
||
g.moveToward(m, m.tx, m.ty, conf.speed * 0.4, dt)
|
||
} else {
|
||
m.moving = false
|
||
}
|
||
},
|
||
interact(p, m, g) {
|
||
g.ptip(p, '守卫挺直身板:营地范围里很安全')
|
||
},
|
||
draw(ctx, m, time) {
|
||
ctx.save()
|
||
ctx.scale(m.dir || 1, 1)
|
||
const hop = Math.sin(time * 7 + (m.bob || 0)) * (m.moving ? 1.8 : 0.5)
|
||
// 底部投影
|
||
ctx.fillStyle = 'rgba(0,0,0,0.25)'
|
||
ctx.beginPath(); ctx.ellipse(0, 5, 15, 6, 0, 0, TAU); ctx.fill()
|
||
// 腿(皮靴)
|
||
ctx.strokeStyle = '#5b3a1e'
|
||
ctx.lineWidth = 4.6
|
||
const leg = m.moving ? Math.sin(time * 9) * 2.6 : 0
|
||
ctx.beginPath()
|
||
ctx.moveTo(-5, -10); ctx.lineTo(-6 + leg, 3)
|
||
ctx.moveTo(5, -10); ctx.lineTo(6 - leg, 3)
|
||
ctx.stroke()
|
||
// 身体:皮甲(棕褐)
|
||
ctx.fillStyle = '#8a5f34'
|
||
ctx.strokeStyle = '#1d1409'
|
||
ctx.lineWidth = 2
|
||
ctx.beginPath(); ctx.roundRect(-11, -32 + hop * 0.3, 22, 24, 5); ctx.fill(); ctx.stroke()
|
||
// 皮甲护胸横条
|
||
ctx.strokeStyle = '#6b4a2a'
|
||
ctx.lineWidth = 1.4
|
||
;[-27, -22, -17].forEach((y) => { ctx.beginPath(); ctx.moveTo(-9, y); ctx.lineTo(9, y); ctx.stroke() })
|
||
// 肩甲
|
||
ctx.fillStyle = '#a97b47'
|
||
ctx.strokeStyle = '#1d1409'
|
||
ctx.lineWidth = 1.6
|
||
ctx.beginPath(); ctx.arc(-11, -30 + hop * 0.3, 5, 0, TAU); ctx.fill(); ctx.stroke()
|
||
ctx.beginPath(); ctx.arc(11, -30 + hop * 0.3, 5, 0, TAU); ctx.fill(); ctx.stroke()
|
||
// 长矛(斜握)
|
||
ctx.strokeStyle = '#7a5230'
|
||
ctx.lineWidth = 3
|
||
ctx.beginPath(); ctx.moveTo(8, 2); ctx.lineTo(24, -40); ctx.stroke()
|
||
ctx.fillStyle = '#c6ccd4'
|
||
ctx.strokeStyle = '#1d1409'
|
||
ctx.lineWidth = 1.4
|
||
ctx.beginPath(); ctx.moveTo(22, -38); ctx.lineTo(27, -47); ctx.lineTo(30, -40); ctx.closePath(); ctx.fill(); ctx.stroke()
|
||
// 头
|
||
ctx.fillStyle = '#e8c9a0'
|
||
ctx.strokeStyle = '#1d1409'
|
||
ctx.lineWidth = 2
|
||
ctx.beginPath(); ctx.arc(0, -40 + hop * 0.5, 7, 0, TAU); ctx.fill(); ctx.stroke()
|
||
// 金属头盔
|
||
ctx.fillStyle = '#9aa0a8'
|
||
ctx.strokeStyle = '#1d1409'
|
||
ctx.lineWidth = 1.8
|
||
ctx.beginPath(); ctx.arc(0, -42 + hop * 0.5, 8, Math.PI, TAU); ctx.fill(); ctx.stroke()
|
||
ctx.beginPath(); ctx.roundRect(-8, -43 + hop * 0.5, 16, 3, 1); ctx.fill(); ctx.stroke()
|
||
// 头盔红缨
|
||
ctx.fillStyle = '#cf3b34'
|
||
ctx.beginPath()
|
||
ctx.moveTo(0, -50 + hop * 0.5); ctx.quadraticCurveTo(4, -56 + hop * 0.5, 1, -60 + hop * 0.5)
|
||
ctx.quadraticCurveTo(-2, -56 + hop * 0.5, -2, -50 + hop * 0.5)
|
||
ctx.closePath(); ctx.fill(); ctx.stroke()
|
||
// 目光
|
||
ctx.fillStyle = '#1d1409'
|
||
ctx.beginPath(); ctx.arc(3, -40 + hop * 0.5, 1.2, 0, TAU); ctx.fill()
|
||
ctx.restore()
|
||
},
|
||
},
|
||
},
|
||
hooks: {
|
||
save() {
|
||
return { ...campState }
|
||
},
|
||
load(g, data) {
|
||
campState = {
|
||
level: Math.max(1, data?.level || 1),
|
||
xp: Math.max(0, data?.xp || 0),
|
||
fenced: !!data?.fenced,
|
||
}
|
||
storeOpen = null
|
||
canteenOpen = null
|
||
},
|
||
onWorldGen(g) {
|
||
// 出生点即世界中心,营地在中心附近错开摆位(避免卡住玩家)
|
||
const cx = g.worldW / 2
|
||
const cy = g.worldH / 2
|
||
const house = g.findLand(cx - 40, cy - 30, 0, 60)
|
||
g.addEntity('camphouse', house.x, house.y)
|
||
const gs = g.findLand(cx + 60, cy + 40, 0, 80)
|
||
g.spawnMob('guard', gs.x, gs.y, {})
|
||
placeStarterFires(g, house.x, house.y)
|
||
const can = g.findLand(cx - 80, cy + 50, 0, 60)
|
||
g.addEntity('canteen', can.x, can.y)
|
||
g.tip('营地已就绪:房屋、五座长明篝火、食堂与守卫')
|
||
},
|
||
onMobVictim(g, m, victim) {
|
||
if (!g.mon[m.kind]?.hostile) return
|
||
if (inCamp(g, victim.x, victim.y)) return false
|
||
},
|
||
onGather(g) {
|
||
addCampXp(g, 2)
|
||
},
|
||
onKillMob(g, m, credit) {
|
||
if (!credit) return
|
||
const score = g.mon[m.kind]?.score || 10
|
||
addCampXp(g, Math.max(3, Math.round(score / 5)))
|
||
},
|
||
onNewDay(g) {
|
||
let xp = 0
|
||
const farms = g.entities.filter((e) => e.type === 'agrifarm')
|
||
xp += Math.min(20, farms.filter((e) => e.state === 'ready').length * 2 + Math.floor(farms.length / 4))
|
||
const pens = g.monsters.filter((m) => m.pen).length
|
||
const coops = g.entities.filter((e) => e.type === 'coop').length
|
||
xp += Math.min(15, pens + coops)
|
||
const industry = g.entities.filter((e) => e.type === 'smelter' || e.type === 'miner' || e.type === 'factory').length
|
||
xp += Math.min(15, industry * 2)
|
||
if (xp > 0) addCampXp(g, xp)
|
||
},
|
||
onTick(g, dt) {
|
||
g.entities.forEach((e) => {
|
||
if (e.type !== 'campgate') return
|
||
if (!e.mx) e.mx = { open: false, openT: 0 }
|
||
if (e.mx.open) {
|
||
e.mx.openT = (e.mx.openT ?? 4) - dt
|
||
if (e.mx.openT <= 0) {
|
||
e.mx.open = false
|
||
e.mx.openT = 0
|
||
e.solid = true
|
||
g.markDirty(e)
|
||
} else {
|
||
e.solid = false
|
||
}
|
||
} else {
|
||
e.solid = true
|
||
}
|
||
})
|
||
},
|
||
onDraw(g, ctx, env) {
|
||
const r = campR()
|
||
const houses = campHouseList(g)
|
||
const list = houses.length ? houses : [campCenter(g)]
|
||
ctx.save()
|
||
ctx.strokeStyle = 'rgba(255,220,140,0.35)'
|
||
ctx.lineWidth = 2
|
||
ctx.setLineDash([10, 8])
|
||
for (const h of list) {
|
||
ctx.beginPath()
|
||
ctx.ellipse(h.x - env.camX, h.y - env.camY, r, r * 0.62, 0, 0, TAU)
|
||
ctx.stroke()
|
||
}
|
||
ctx.restore()
|
||
},
|
||
onDrawUI(g, ctx, env) {
|
||
// 记录相机位置,供 onClick 做世界→屏幕换算
|
||
uiCamX = env.camX
|
||
uiCamY = env.camY
|
||
const W = env.W
|
||
const H = env.H
|
||
|
||
// 营地等级 HUD
|
||
ctx.save()
|
||
ctx.fillStyle = 'rgba(40,28,14,0.75)'
|
||
ctx.strokeStyle = '#1d1409'
|
||
ctx.lineWidth = 2
|
||
ctx.beginPath(); ctx.roundRect(10, 48, 168, 36, 6); ctx.fill(); ctx.stroke()
|
||
ctx.fillStyle = '#f4ead2'
|
||
ctx.font = 'bold 12px system-ui, sans-serif'
|
||
ctx.textAlign = 'left'
|
||
ctx.textBaseline = 'middle'
|
||
const need = xpNeed(campState.level)
|
||
ctx.fillText(`营地 Lv.${campState.level} XP ${campState.xp}/${need}`, 18, 66)
|
||
ctx.restore()
|
||
|
||
// 食堂面板
|
||
if (canteenOpen != null) {
|
||
const e = g.entities.find((x) => x.id === canteenOpen)
|
||
const p = g.me()
|
||
if (!e || !p) {
|
||
canteenOpen = null
|
||
} else {
|
||
if (!e.mx) e.mx = { menu: {} }
|
||
const menu = e.mx.menu || {}
|
||
const entries = Object.entries(menu).filter(([, n]) => n > 0)
|
||
const rowH = 30
|
||
const headerH = 40
|
||
const footerH = 42
|
||
const pad = 10
|
||
const panelW = Math.min(360, W - 24)
|
||
const panelH = headerH + Math.max(1, entries.length) * rowH + footerH + pad
|
||
const px = Math.round((W - panelW) / 2)
|
||
const py = Math.round((H - panelH) / 2)
|
||
uiCanteenPanel = { x: px, y: py, w: panelW, h: panelH }
|
||
uiCanteenRows = []
|
||
ctx.save()
|
||
ctx.fillStyle = 'rgba(0,0,0,0.45)'
|
||
ctx.fillRect(0, 0, W, H)
|
||
ctx.fillStyle = '#3a2a18'
|
||
ctx.strokeStyle = '#1d1409'
|
||
ctx.lineWidth = 3
|
||
ctx.beginPath(); ctx.roundRect(px, py, panelW, panelH, 10); ctx.fill(); ctx.stroke()
|
||
ctx.fillStyle = '#5a4022'
|
||
ctx.beginPath(); ctx.roundRect(px + 3, py + 3, panelW - 6, headerH - 6, [8, 8, 0, 0]); ctx.fill()
|
||
ctx.fillStyle = '#f4ead2'
|
||
ctx.font = 'bold 15px system-ui, sans-serif'
|
||
ctx.textAlign = 'center'
|
||
ctx.textBaseline = 'middle'
|
||
ctx.fillText('食堂菜单', px + panelW / 2, py + headerH / 2)
|
||
if (!entries.length) {
|
||
ctx.fillStyle = '#c9b89a'
|
||
ctx.font = '13px system-ui, sans-serif'
|
||
ctx.fillText('暂无菜品(招聘厨师自动做饭)', px + panelW / 2, py + headerH + 20)
|
||
} else {
|
||
entries.forEach(([code, n], i) => {
|
||
const y = py + headerH + i * rowH
|
||
const name = g.items[code]?.name || code
|
||
ctx.fillStyle = i % 2 ? 'rgba(255,255,255,0.04)' : 'transparent'
|
||
ctx.fillRect(px + 6, y, panelW - 12, rowH)
|
||
ctx.fillStyle = '#f4ead2'
|
||
ctx.font = '13px system-ui, sans-serif'
|
||
ctx.textAlign = 'left'
|
||
ctx.fillText(`${name} ×${n}`, px + 14, y + rowH / 2)
|
||
const btn = { x: px + panelW - 78, y: y + 4, w: 64, h: 22, code }
|
||
uiCanteenRows.push(btn)
|
||
ctx.fillStyle = '#5d7a3a'
|
||
ctx.strokeStyle = '#1d1409'
|
||
ctx.lineWidth = 1.5
|
||
ctx.beginPath(); ctx.roundRect(btn.x, btn.y, btn.w, btn.h, 5); ctx.fill(); ctx.stroke()
|
||
ctx.fillStyle = '#f4ead2'
|
||
ctx.textAlign = 'center'
|
||
ctx.fillText('食用', btn.x + btn.w / 2, btn.y + btn.h / 2)
|
||
})
|
||
}
|
||
const cw = 90
|
||
const ch = 26
|
||
uiCanteenClose = { x: px + panelW / 2 - cw / 2, y: py + panelH - footerH + 8, w: cw, h: ch }
|
||
ctx.fillStyle = '#7a2f24'
|
||
ctx.strokeStyle = '#1d1409'
|
||
ctx.lineWidth = 2
|
||
ctx.beginPath(); ctx.roundRect(uiCanteenClose.x, uiCanteenClose.y, cw, ch, 6); ctx.fill(); ctx.stroke()
|
||
ctx.fillStyle = '#f4ead2'
|
||
ctx.textAlign = 'center'
|
||
ctx.font = 'bold 14px system-ui, sans-serif'
|
||
ctx.fillText('关闭', uiCanteenClose.x + cw / 2, uiCanteenClose.y + ch / 2)
|
||
ctx.restore()
|
||
}
|
||
} else {
|
||
uiCanteenPanel = null
|
||
uiCanteenClose = null
|
||
uiCanteenRows = []
|
||
}
|
||
|
||
if (storeOpen == null) return
|
||
const e = g.entities.find((x) => x.id === storeOpen)
|
||
const p = g.me()
|
||
if (!e || !p) {
|
||
storeOpen = null
|
||
return
|
||
}
|
||
const boxes = boxesOf(e)
|
||
const rowH = 30
|
||
const headerH = 40
|
||
const footerH = 42
|
||
const pad = 10
|
||
const panelW = Math.min(470, W - 24)
|
||
const panelH = headerH + BOXES * rowH + footerH + pad
|
||
const px = Math.round((W - panelW) / 2)
|
||
const py = Math.round((H - panelH) / 2)
|
||
uiPanel = { x: px, y: py, w: panelW, h: panelH }
|
||
|
||
// 深色木质面板
|
||
ctx.save()
|
||
ctx.fillStyle = 'rgba(0,0,0,0.45)'
|
||
ctx.fillRect(0, 0, W, H)
|
||
ctx.fillStyle = '#3a2a18'
|
||
ctx.strokeStyle = '#1d1409'
|
||
ctx.lineWidth = 3
|
||
ctx.beginPath(); ctx.roundRect(px, py, panelW, panelH, 10); ctx.fill(); ctx.stroke()
|
||
// 标题栏
|
||
ctx.fillStyle = '#5a4022'
|
||
ctx.beginPath(); ctx.roundRect(px + 3, py + 3, panelW - 6, headerH - 6, [8, 8, 0, 0]); ctx.fill()
|
||
ctx.fillStyle = '#f4ead2'
|
||
ctx.textAlign = 'center'
|
||
ctx.textBaseline = 'middle'
|
||
ctx.font = 'bold 17px system-ui, sans-serif'
|
||
ctx.fillText('营地仓储', px + panelW / 2, py + headerH / 2 - 2)
|
||
ctx.font = '11px system-ui, sans-serif'
|
||
ctx.fillStyle = 'rgba(244,234,210,0.7)'
|
||
ctx.fillText(`共 ${BOXES} 箱 · 每箱 ${BOX_KINDS} 种 · 每种上限 ${BOX_CAP}`, px + panelW / 2, py + headerH - 6)
|
||
|
||
// 行
|
||
uiRows = []
|
||
const rightEdge = px + panelW - pad - 4
|
||
const btnH = 20
|
||
const depW = 66
|
||
const witW = 60
|
||
const gap = 6
|
||
const fontSize = 13
|
||
for (let i = 0; i < BOXES; i++) {
|
||
const rowY = py + headerH + i * rowH
|
||
const box = boxes[i]
|
||
const used = usedKinds(box)
|
||
// 交替底色
|
||
ctx.fillStyle = i % 2 ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.14)'
|
||
ctx.beginPath(); ctx.roundRect(px + pad, rowY + 2, panelW - pad * 2, rowH - 5, 4); ctx.fill()
|
||
// 箱子 N + 用量
|
||
ctx.textAlign = 'left'
|
||
ctx.textBaseline = 'middle'
|
||
ctx.fillStyle = used > 0 ? '#f4ead2' : 'rgba(244,234,210,0.5)'
|
||
ctx.font = `bold ${fontSize}px system-ui, sans-serif`
|
||
ctx.fillText(`箱子 ${i + 1}`, px + pad + 8, rowY + rowH / 2 - 1)
|
||
ctx.fillStyle = 'rgba(255,220,140,0.85)'
|
||
ctx.font = `11px system-ui, sans-serif`
|
||
ctx.fillText(`${used}/${BOX_KINDS} 种`, px + pad + 62, rowY + rowH / 2 - 1)
|
||
// 内容摘要(最多 3 种)
|
||
const entries = Object.entries(box.items || {}).filter(([, n]) => n > 0)
|
||
const shown = entries.slice(0, 3).map(([code, n]) => `${g.items[code]?.name || code}×${n}`)
|
||
const more = entries.length > 3 ? ` 等${entries.length}种` : ''
|
||
ctx.fillStyle = 'rgba(230,220,200,0.8)'
|
||
ctx.font = `12px system-ui, sans-serif`
|
||
ctx.fillText(entries.length ? shown.join(' ') + more : '(空)', px + pad + 108, rowY + rowH / 2 - 1)
|
||
// 按钮
|
||
const by = rowY + (rowH - 3 - btnH) / 2 + 1
|
||
const dep = { x: rightEdge - witW - gap - depW, y: by, w: depW, h: btnH }
|
||
const wit = { x: rightEdge - witW, y: by, w: witW, h: btnH }
|
||
const drawBtn = (r, label, bg) => {
|
||
ctx.fillStyle = bg
|
||
ctx.strokeStyle = '#1d1409'
|
||
ctx.lineWidth = 1.4
|
||
ctx.beginPath(); ctx.roundRect(r.x, r.y, r.w, r.h, 5); ctx.fill(); ctx.stroke()
|
||
ctx.fillStyle = '#f4ead2'
|
||
ctx.textAlign = 'center'
|
||
ctx.font = `bold 12px system-ui, sans-serif`
|
||
ctx.fillText(label, r.x + r.w / 2, r.y + r.h / 2 + 0.5)
|
||
}
|
||
drawBtn(dep, '↓ 存入', '#3f6b34')
|
||
drawBtn(wit, '↑ 取出', '#8a6a34')
|
||
uiRows.push({ i, dep, wit })
|
||
}
|
||
|
||
// 关闭按钮
|
||
const cw = 120
|
||
const ch = 26
|
||
const cx2 = px + panelW / 2 - cw / 2
|
||
const cy2 = py + panelH - footerH + 8
|
||
uiClose = { x: cx2, y: cy2, w: cw, h: ch }
|
||
ctx.fillStyle = '#7a2f24'
|
||
ctx.strokeStyle = '#1d1409'
|
||
ctx.lineWidth = 2
|
||
ctx.beginPath(); ctx.roundRect(cx2, cy2, cw, ch, 6); ctx.fill(); ctx.stroke()
|
||
ctx.fillStyle = '#f4ead2'
|
||
ctx.textAlign = 'center'
|
||
ctx.textBaseline = 'middle'
|
||
ctx.font = 'bold 14px system-ui, sans-serif'
|
||
ctx.fillText('关闭', cx2 + cw / 2, cy2 + ch / 2 + 0.5)
|
||
ctx.restore()
|
||
},
|
||
onClick(g, wx, wy) {
|
||
const sx = wx - uiCamX
|
||
const sy = wy - uiCamY
|
||
|
||
// 食堂面板优先
|
||
if (canteenOpen != null) {
|
||
const e = g.entities.find((x) => x.id === canteenOpen)
|
||
const p = g.me()
|
||
if (!e || !p) {
|
||
canteenOpen = null
|
||
return true
|
||
}
|
||
if (inside(sx, sy, uiCanteenClose)) {
|
||
canteenOpen = null
|
||
return true
|
||
}
|
||
for (const btn of uiCanteenRows) {
|
||
if (inside(sx, sy, btn)) {
|
||
if (!e.mx.menu) e.mx.menu = {}
|
||
const n = e.mx.menu[btn.code] || 0
|
||
if (n <= 0) {
|
||
g.ptip(p, '这道菜已经没了')
|
||
return true
|
||
}
|
||
if (g.applyFood && g.applyFood(p, btn.code, 1)) {
|
||
e.mx.menu[btn.code] = n - 1
|
||
if (e.mx.menu[btn.code] <= 0) delete e.mx.menu[btn.code]
|
||
g.markDirty(e)
|
||
} else {
|
||
g.ptip(p, '无法食用')
|
||
}
|
||
return true
|
||
}
|
||
}
|
||
if (inside(sx, sy, uiCanteenPanel)) return true
|
||
canteenOpen = null
|
||
return true
|
||
}
|
||
|
||
if (storeOpen == null) return undefined // 没有面板:不干扰游戏点击
|
||
const e = g.entities.find((x) => x.id === storeOpen)
|
||
const p = g.me()
|
||
if (!e || !p) {
|
||
storeOpen = null
|
||
return true
|
||
}
|
||
// 关闭按钮
|
||
if (inside(sx, sy, uiClose)) {
|
||
storeOpen = null
|
||
return true
|
||
}
|
||
// 每行的存入/取出
|
||
for (const r of uiRows) {
|
||
if (inside(sx, sy, r.dep)) {
|
||
const box = boxesOf(e)[r.i]
|
||
const moved = depositBox(g, p, box)
|
||
g.markDirty(e)
|
||
g.ptip(p, moved > 0 ? `存入 ${moved} 件物资` : '背包里没有可堆入的物资')
|
||
return true
|
||
}
|
||
if (inside(sx, sy, r.wit)) {
|
||
const box = boxesOf(e)[r.i]
|
||
const moved = withdrawBox(g, p, box)
|
||
g.markDirty(e)
|
||
g.ptip(p, moved > 0 ? `取回 ${moved} 件物资` : '这个箱子是空的')
|
||
return true
|
||
}
|
||
}
|
||
// 面板内空白:吃掉点击
|
||
if (inside(sx, sy, uiPanel)) return true
|
||
// 面板外:关闭
|
||
storeOpen = null
|
||
return true
|
||
},
|
||
},
|
||
}
|