Files
nl-game/src/games/starve/mods/camp.js
2026-09-24 17:04:27 +08:00

1008 lines
38 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 营地:出生点自带房屋、长明篝火与无敌守卫,可建造营地仓储与食堂
// - 房屋 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
},
},
}