Files
nl-game/src/games/starve/mods/camp.js
2026-10-11 09:09:28 +08:00

3020 lines
114 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:多外观随营地等级升级;可进入室内点床睡觉;员工可入驻
// - 划定宅基:框选区域自动铺房屋地基,建筑工按计划盖房
// - 守卫 / 仓储 / 食堂 / 营地升级
import { rebuildFencePerimeter, normalizeRect, clearZoneClutter, pointInAabb, drawNameplate, openZoneMenu, drawZoneMenu, handleZoneMenuClick, closeZoneMenu, isZoneMenuOpen, zoneFloors } from './zoneUtil.js'
import { skinByCode, drawPlayerFig, drawGuard } from '../../starveSkins.js'
const TAU = Math.PI * 2
const CAMP_R0 = 420 // 1 级营地安全半径
const BOXES = 8 // 箱子数
const BOX_KINDS = 6 // 每箱最多物品种类
const BOX_CAP = 99999999 // 每种最多数量
const DAY_LEN = 160
// ---- 营地升级状态(入存档) ----
let campState = { level: 1, xp: 0, fenced: false }
// ---- 宅基规划(入存档) ----
let houseZones = [] // [{ id, name, x0, y0, x1, y1 }]
let nextHouseZoneId = 1
const HOUSE_SPACING = 120
let houseZoneMode = false
let houseDrag = null
let uiHouseBtn = null
let uiHouseCancel = null
// ---- 模块级面板状态(不入存档;面板只是一次交互会话) ----
let storeOpen = null // 当前打开的 campstore 实体 id
let canteenOpen = null
let interiorOpen = null // 当前进入的 camphouse id
let uiCamX = 0
let uiCamY = 0
let uiInteriorBtns = []
// 室内子地图状态(不入存档)
let interiorAvatar = null // { x, y, dir, walking, walkT, prompt, interactLatch }
const ROOM_W = 420
const ROOM_H = 280
const AVATAR_R = 14
const INTERIOR_SPEED = 150
// —— 屋内小猫(纯视觉彩蛋 + 撸猫回理智;不入档)——
const cat = { x: ROOM_W / 2, y: 200, tx: ROOM_W / 2, ty: 200, dir: 1, walkT: 0, moving: false, waitT: 2 }
let catPetDay = 0
let readDay = 0
let medDay = 0
let phonoDay = 0
function updateCat(dt) {
if (cat.moving) {
const dx = cat.tx - cat.x
const dy = cat.ty - cat.y
const d = Math.hypot(dx, dy)
if (d < 4) {
cat.moving = false
cat.waitT = 1.5 + Math.random() * 3
} else {
cat.x += (dx / d) * 46 * dt
cat.y += (dy / d) * 46 * dt
cat.walkT += dt
if (dx) cat.dir = dx > 0 ? 1 : -1
}
} else {
cat.waitT -= dt
if (cat.waitT <= 0) {
cat.tx = 40 + Math.random() * (ROOM_W - 80)
cat.ty = 150 + Math.random() * 100
cat.moving = true
}
}
}
const HOUSE_MODELS = {
hut: { label: '茅屋', wall: '#b07f47', wall2: '#9a6a38', roof: '#6a4a28', trim: '#8a5f30', cap: 1 },
timber: { label: '木屋', wall: '#c49a5a', wall2: '#a87840', roof: '#7a5230', trim: '#8a5f30', cap: 2 },
stone: { label: '石屋', wall: '#9aa0a8', wall2: '#7a8088', roof: '#5a4a3a', trim: '#6f7278', cap: 3 },
manor: { label: '庄园', wall: '#d4c4a8', wall2: '#b8a888', roof: '#4a3030', trim: '#8a7050', cap: 4 },
}
export function getCampLevel() {
return Math.max(1, campState.level || 1)
}
export function houseModelForLevel(lv = getCampLevel()) {
if (lv >= 10) return 'manor'
if (lv >= 7) return 'stone'
if (lv >= 4) return 'timber'
return 'hut'
}
export function houseCapacity(e) {
const model = e?.mx?.model || houseModelForLevel()
return HOUSE_MODELS[model]?.cap || 1
}
export function listCampHouses(g) {
return (g.entities || []).filter((e) => e.type === 'camphouse')
}
export function getCampCenter(g) {
return campCenter(g)
}
export function isInCamp(g, x, y) {
return inCamp(g, x, y)
}
/** 按营地等级刷新所有房屋外观与层数 */
export function syncHouseLooks(g) {
if (!g?.entities) return
const lv = getCampLevel()
const model = houseModelForLevel(lv)
const floors = Math.min(4, Math.max(1, zoneFloors(lv)))
listCampHouses(g).forEach((e) => {
if (!e.mx) e.mx = {}
e.mx.model = model
e.mx.floors = floors
if (!Array.isArray(e.mx.residents)) e.mx.residents = []
g.markDirty?.(e)
})
}
function normalizeHouseMx(e) {
if (!e.mx) e.mx = {}
if (e.mx.restDay == null) e.mx.restDay = 0
if (!e.mx.name) e.mx.name = '房屋'
if (!e.mx.model) e.mx.model = houseModelForLevel()
if (e.mx.floors == null) e.mx.floors = Math.min(4, zoneFloors(getCampLevel()))
if (!Array.isArray(e.mx.residents)) e.mx.residents = []
return e.mx
}
/** 给劳工找空房入驻;返回是否已有/新分配了家 */
export function ensureWorkerHome(g, m) {
if (!m || !g) return false
if (m.homeHouseId != null) {
const h = g.entities.find((e) => e.id === m.homeHouseId && e.type === 'camphouse')
if (h) {
m.homeX = h.x
m.homeY = h.y + 36
return true
}
m.homeHouseId = null
}
const houses = listCampHouses(g)
for (const h of houses) {
const mx = normalizeHouseMx(h)
// 清掉已不存在的居民
mx.residents = mx.residents.filter((id) => g.monsters.some((x) => x.id === id))
if (mx.residents.length >= houseCapacity(h)) continue
mx.residents.push(m.id)
m.homeHouseId = h.id
m.homeX = h.x
m.homeY = h.y + 36
g.markDirty(h)
return true
}
return false
}
export function unassignWorkerHome(g, m) {
if (!m?.homeHouseId || !g) return
const h = g.entities.find((e) => e.id === m.homeHouseId && e.type === 'camphouse')
if (h?.mx?.residents) {
h.mx.residents = h.mx.residents.filter((id) => id !== m.id)
g.markDirty(h)
}
m.homeHouseId = null
m.homeX = null
m.homeY = null
}
function doHouseRest(g, p, e, viaBed) {
const mx = normalizeHouseMx(e)
if (mx.restDay === g.day) {
g.ptip(p, '今天已经休息过了')
return false
}
if (g.phase === 'night') {
if (typeof g.setDayTime === 'function') g.setDayTime(DAY_LEN)
mx.restDay = g.day
g.markDirty(e)
g.ptip(p, viaBed ? `在「${mx.name}」的床上睡到了天亮` : `在「${mx.name}」睡到了天亮`)
g.swing(p, 'eat')
return true
}
p.hunger = Math.min(150, p.hunger + 40)
p.hp = Math.min(150, p.hp + 25)
p.san = Math.min(200, p.san + 15)
mx.restDay = g.day
g.markDirty(e)
g.ptip(p, viaBed
? `在「${mx.name}」的床上歇息:饥饿 +40、生命 +25、理智 +15`
: `在「${mx.name}」歇息:饥饿 +40、生命 +25、理智 +15`)
g.swing(p, 'eat')
return true
}
// ---- 房屋外观绘制(4 种模型细节化)----
// 墙体纹理:hut 编木抹泥 / timber 木板 / stone 砖石 / manor 都铎木架
function houseWallTex(ctx, model, skin, x, y, w, h, seed) {
ctx.fillStyle = skin.wall
if (model === 'hut') {
// 抹泥墙 + 编木痕 + 补丁
ctx.beginPath(); ctx.roundRect(x, y, w, h, 3); ctx.fill()
ctx.strokeStyle = 'rgba(90,60,30,0.4)'
ctx.lineWidth = 1
for (let i = 0; i < 4; i++) {
const yy = y + 6 + i * (h - 12) / 3
ctx.beginPath()
ctx.moveTo(x + 3, yy + (i % 2) * 2)
ctx.quadraticCurveTo(x + w / 2, yy - 2, x + w - 3, yy + (i % 2 ? 1 : -1))
ctx.stroke()
}
ctx.fillStyle = 'rgba(120,85,45,0.35)'
;[[x + 7, y + h * 0.28, 5, 3.4], [x + w - 12, y + h * 0.6, 6, 3], [x + w * 0.4, y + h - 9, 5, 3.2]].forEach(([px, py, pw, ph], i) => {
ctx.beginPath()
ctx.ellipse(px + (seed % (3 + i)), py, pw, ph, 0, 0, TAU)
ctx.fill()
})
} else if (model === 'timber') {
// 横向木板 + 钉
ctx.beginPath(); ctx.roundRect(x, y, w, h, 2); ctx.fill()
ctx.strokeStyle = 'rgba(90,58,28,0.55)'
ctx.lineWidth = 1.2
for (let py = y + 7; py < y + h; py += 8) {
ctx.beginPath(); ctx.moveTo(x + 1, py); ctx.lineTo(x + w - 1, py + 0.6); ctx.stroke()
}
ctx.fillStyle = 'rgba(60,40,20,0.5)'
;[[x + 4, y + 4], [x + w - 4, y + 4], [x + 4, y + h - 4], [x + w - 4, y + h - 4]].forEach(([px, py]) => {
ctx.beginPath(); ctx.arc(px, py, 0.9, 0, TAU); ctx.fill()
})
} else if (model === 'stone') {
// 错缝砖
ctx.beginPath(); ctx.roundRect(x, y, w, h, 2); ctx.fill()
ctx.strokeStyle = 'rgba(70,74,82,0.6)'
ctx.lineWidth = 1.1
const bh = 8
for (let row = 0; row * bh < h; row++) {
const yy = y + row * bh
ctx.beginPath(); ctx.moveTo(x, yy); ctx.lineTo(x + w, yy); ctx.stroke()
const off = row % 2 ? 0 : 8
for (let bx = x + off; bx < x + w; bx += 16) {
ctx.beginPath(); ctx.moveTo(bx, yy); ctx.lineTo(bx, Math.min(yy + bh, y + h)); ctx.stroke()
}
}
} else {
// manor:灰泥 + 都铎深木架
ctx.beginPath(); ctx.roundRect(x, y, w, h, 2); ctx.fill()
ctx.strokeStyle = '#6a5238'
ctx.lineWidth = 2.4
ctx.beginPath()
ctx.moveTo(x + 2, y + 1); ctx.lineTo(x + 2, y + h - 1)
ctx.moveTo(x + w - 2, y + 1); ctx.lineTo(x + w - 2, y + h - 1)
ctx.moveTo(x, y + 2); ctx.lineTo(x + w, y + 2)
ctx.moveTo(x, y + h - 2); ctx.lineTo(x + w, y + h - 2)
ctx.stroke()
ctx.lineWidth = 1.8
ctx.beginPath()
ctx.moveTo(x + w * 0.34, y + 3); ctx.lineTo(x + w * 0.26, y + h - 3)
ctx.moveTo(x + w * 0.66, y + 3); ctx.lineTo(x + w * 0.74, y + h - 3)
ctx.stroke()
}
}
function drawHouseExterior(ctx, e, env) {
const mx = normalizeHouseMx(e)
const floors = Math.max(1, Math.min(4, mx.floors || 1))
const model = mx.model || 'hut'
const skin = HOUSE_MODELS[model] || HOUSE_MODELS.hut
const name = mx.name || '房屋'
const resN = (mx.residents || []).length
const cap = houseCapacity(e)
const t = env?.time || 0
const label = `${name} · ${skin.label}`
const topY = -46 - (floors - 1) * 28
const seed = e.id | 0
// 底部投影 + 墙角 AO
ctx.fillStyle = 'rgba(0,0,0,0.25)'
ctx.beginPath(); ctx.ellipse(0, 8, 36, 9, 0, 0, TAU); ctx.fill()
// 地基
ctx.fillStyle = model === 'hut' ? '#8a7050' : '#8d9097'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 2
ctx.beginPath(); ctx.roundRect(-32, -4, 64, 13, 2); ctx.fill(); ctx.stroke()
ctx.strokeStyle = 'rgba(40,40,46,0.5)'
ctx.lineWidth = 1
for (let i = 0; i < 4; i++) {
ctx.beginPath(); ctx.moveTo(-28 + i * 15, -2); ctx.lineTo(-24 + i * 15, 7); ctx.stroke()
}
// 一层门前的石阶
ctx.fillStyle = '#9a9da4'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 1.4
ctx.beginPath(); ctx.roundRect(-14, 2, 26, 5, 1.5); ctx.fill(); ctx.stroke()
// 每层墙体 + 窗 + 门
for (let f = 0; f < floors; f++) {
const baseY = -46 - f * 28
// 左右侧板(伪 2.5D 体积)
ctx.fillStyle = skin.wall2
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 2
ctx.beginPath(); ctx.roundRect(-28, baseY, 56, 44, 2); ctx.fill(); ctx.stroke()
ctx.fillStyle = 'rgba(0,0,0,0.14)'
ctx.beginPath(); ctx.roundRect(20, baseY + 1, 7, 42, 2); ctx.fill()
ctx.fillStyle = 'rgba(255,255,255,0.1)'
ctx.beginPath(); ctx.roundRect(-27, baseY + 1, 6, 42, 2); ctx.fill()
houseWallTex(ctx, model, skin, -28, baseY, 56, 44, seed + f)
// 窗(带框、窗台、反光;manor 双窗)
const win = (wx, wy, ww, wh) => {
ctx.fillStyle = model === 'manor' ? '#a8d8f4' : '#ffd98a'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 1.8
ctx.beginPath(); ctx.roundRect(wx, wy, ww, wh, 1.5); ctx.fill(); ctx.stroke()
ctx.strokeStyle = 'rgba(255,255,255,0.55)'
ctx.lineWidth = 1.4
ctx.beginPath(); ctx.moveTo(wx + 2, wy + wh - 3); ctx.lineTo(wx + ww * 0.45, wy + 2); ctx.stroke()
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 1.2
ctx.beginPath()
ctx.moveTo(wx + ww / 2, wy); ctx.lineTo(wx + ww / 2, wy + wh)
ctx.moveTo(wx, wy + wh / 2); ctx.lineTo(wx + ww, wy + wh / 2)
ctx.stroke()
ctx.fillStyle = skin.trim
ctx.beginPath(); ctx.roundRect(wx - 2, wy + wh + 1, ww + 4, 2.6, 1); ctx.fill()
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 1
ctx.stroke()
}
if (f === 0) {
// 门(按模型分款)
if (model === 'stone') {
ctx.fillStyle = '#3a2a16'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 2
ctx.beginPath()
ctx.moveTo(-12, -2)
ctx.lineTo(-12, -18)
ctx.quadraticCurveTo(-1, -28, 10, -18)
ctx.lineTo(10, -2)
ctx.closePath()
ctx.fill(); ctx.stroke()
ctx.strokeStyle = 'rgba(200,170,120,0.4)'
ctx.lineWidth = 1.2
for (let i = 1; i < 3; i++) {
ctx.beginPath(); ctx.moveTo(-12 + i * 7.3, -4); ctx.lineTo(-12 + i * 7.3, -19); ctx.stroke()
}
} else if (model === 'manor') {
ctx.fillStyle = '#5d3f24'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 2
ctx.beginPath(); ctx.roundRect(-11, -27, 22, 25, [4, 4, 0, 0]); ctx.fill(); ctx.stroke()
ctx.strokeStyle = '#c9a86a'
ctx.lineWidth = 1.3
ctx.beginPath(); ctx.roundRect(-7.5, -23, 15, 8, 1.5); ctx.stroke()
ctx.beginPath(); ctx.roundRect(-7.5, -12, 15, 7, 1.5); ctx.stroke()
ctx.fillStyle = '#e0c060'
ctx.beginPath(); ctx.arc(6.5, -14, 1.5, 0, TAU); ctx.fill()
// 门灯
ctx.fillStyle = '#3a3f48'
ctx.beginPath(); ctx.roundRect(16, -24, 4, 6, 1); ctx.fill()
ctx.fillStyle = '#ffd98a'
ctx.beginPath(); ctx.arc(18, -21, 1.6, 0, TAU); ctx.fill()
ctx.strokeStyle = '#1d1409'; ctx.lineWidth = 1; ctx.stroke()
} else {
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.strokeStyle = 'rgba(160,120,70,0.5)'
ctx.lineWidth = 1.1
for (let i = 1; i < 3; i++) {
ctx.beginPath(); ctx.moveTo(-11 + i * 6.6, -26); ctx.lineTo(-11 + i * 6.6, -2); ctx.stroke()
}
ctx.fillStyle = '#e0c060'
ctx.beginPath(); ctx.arc(4.5, -14, 1.5, 0, TAU); ctx.fill()
}
if (model === 'manor') win(-24, baseY + 6, 14, 13)
else win(7, baseY + 6, 15, 13)
} else {
win(7, baseY + 6, 15, 13)
if (model === 'manor') win(-22, baseY + 6, 14, 13)
}
}
// 屋顶(按模型分款)
const roofY = -46 - (floors - 1) * 28
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 2
if (model === 'hut') {
// 茅草:三层垂檐
for (let i = 0; i < 3; i++) {
const ry = roofY - i * 9
const rw = 36 + i * 4
ctx.fillStyle = i % 2 ? skin.roof : '#7a5628'
ctx.beginPath()
ctx.moveTo(-rw, ry + 2)
ctx.quadraticCurveTo(0, ry - 14, rw, ry + 2)
ctx.quadraticCurveTo(rw * 0.6, ry - 2, 0, ry - 1)
ctx.quadraticCurveTo(-rw * 0.6, ry - 2, -rw, ry + 2)
ctx.closePath()
ctx.fill()
ctx.lineWidth = 1.6
ctx.stroke()
}
// 檐口垂草
ctx.strokeStyle = '#8a6a34'
ctx.lineWidth = 1.4
for (let i = -5; i <= 5; i++) {
const x = i * 7
ctx.beginPath()
ctx.moveTo(x, roofY + 1)
ctx.quadraticCurveTo(x + 1, roofY + 5, x + i * 0.4, roofY + 7)
ctx.stroke()
}
} else if (model === 'timber') {
ctx.fillStyle = skin.roof
ctx.beginPath(); ctx.moveTo(-38, roofY); ctx.lineTo(38, roofY); ctx.lineTo(0, roofY - 32); ctx.closePath(); ctx.fill(); ctx.stroke()
// 瓦片行
ctx.strokeStyle = 'rgba(40,26,14,0.55)'
ctx.lineWidth = 1.2
for (let r2 = 1; r2 <= 3; r2++) {
const yy = roofY - r2 * 8
const half = 38 * (1 - r2 * 0.25)
ctx.beginPath(); ctx.moveTo(-half, yy); ctx.lineTo(half, yy); ctx.stroke()
for (let x = -half + 4; x < half; x += 9) {
ctx.beginPath(); ctx.arc(x, yy, 3.4, Math.PI, 0); ctx.stroke()
}
}
} else if (model === 'stone') {
ctx.fillStyle = skin.roof
ctx.beginPath(); ctx.moveTo(-36, roofY); ctx.lineTo(36, roofY); ctx.lineTo(0, roofY - 30); ctx.closePath(); ctx.fill(); ctx.stroke()
ctx.strokeStyle = 'rgba(30,30,36,0.5)'
ctx.lineWidth = 1.2
for (let r2 = 1; r2 <= 3; r2++) {
const yy = roofY - r2 * 7.5
const half = 36 * (1 - r2 * 0.25)
ctx.beginPath(); ctx.moveTo(-half, yy); ctx.lineTo(half, yy); ctx.stroke()
}
// 屋脊
ctx.strokeStyle = '#3a343c'
ctx.lineWidth = 2.4
ctx.beginPath(); ctx.moveTo(-4, roofY - 29.4); ctx.lineTo(4, roofY - 29.4); ctx.stroke()
} else {
// manor:双坡顶 + 山墙封檐
ctx.fillStyle = skin.roof
ctx.beginPath(); ctx.moveTo(-38, roofY); ctx.lineTo(38, roofY); ctx.lineTo(30, roofY - 16); ctx.lineTo(-30, roofY - 16); ctx.closePath(); ctx.fill(); ctx.stroke()
ctx.fillStyle = '#3a2820'
ctx.beginPath(); ctx.moveTo(-30, roofY - 16); ctx.lineTo(30, roofY - 16); ctx.lineTo(0, roofY - 38); ctx.closePath(); ctx.fill(); ctx.stroke()
ctx.strokeStyle = 'rgba(20,12,8,0.45)'
ctx.lineWidth = 1.2
for (let r2 = 1; r2 <= 2; r2++) {
const yy = roofY - r2 * 5
const half = 38 - r2 * 2.4
ctx.beginPath(); ctx.moveTo(-half, yy); ctx.lineTo(half, yy); ctx.stroke()
}
}
// 烟囱 + 烟
const chx = model === 'manor' ? -14 : 14
const chTop = roofY - (model === 'manor' ? 34 : 28)
ctx.fillStyle = model === 'hut' ? '#9a6a3c' : '#7a8088'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 1.8
ctx.beginPath(); ctx.roundRect(chx - 5, chTop + 4, 11, 20, 2); ctx.fill(); ctx.stroke()
ctx.fillStyle = model === 'hut' ? '#7a5230' : '#8d9097'
ctx.beginPath(); ctx.roundRect(chx - 6.5, chTop, 14, 6, 1.5); ctx.fill(); ctx.stroke()
for (let i = 0; i < 3; i++) {
const ph = (t * 0.42 + i * 0.45 + (seed % 10) * 0.1) % 1.5
const px = chx + Math.sin(t * 0.9 + i * 2) * (3 + ph * 4)
ctx.fillStyle = `rgba(214,214,214,${0.4 * (1 - ph / 1.5)})`
ctx.beginPath()
ctx.arc(px, chTop - 4 - ph * 24, 2.6 + ph * 3.4, 0, TAU)
ctx.fill()
}
// 木质名字牌 + 入住徽章
const tw = Math.max(52, label.length * 8 + 18)
ctx.save()
ctx.translate(0, topY - 70)
ctx.fillStyle = '#8a5f34'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 1.6
ctx.beginPath(); ctx.roundRect(-tw / 2, -9, tw, 18, 3); ctx.fill(); ctx.stroke()
ctx.strokeStyle = 'rgba(60,40,20,0.45)'
ctx.lineWidth = 1
ctx.beginPath(); ctx.moveTo(-tw / 2 + 7, -8); ctx.lineTo(-tw / 2 + 7, 8); ctx.stroke()
ctx.fillStyle = '#c9a03c'
;[-tw / 2 + 4, tw / 2 - 4].forEach((x) => {
ctx.beginPath(); ctx.arc(x, 0, 1.4, 0, TAU); ctx.fill()
})
ctx.fillStyle = '#f4ead2'
ctx.font = 'bold 11px "PingFang SC","Microsoft YaHei",sans-serif'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText(label, 1, 0.5)
if (resN > 0) {
ctx.fillStyle = 'rgba(40,60,30,0.78)'
ctx.beginPath(); ctx.roundRect(-24, 12, 48, 13, 4); ctx.fill()
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 1
ctx.stroke()
ctx.fillStyle = '#c8e8a0'
ctx.font = 'bold 9px sans-serif'
ctx.fillText(`住 ${resN}/${cap}`, 0, 18.5)
}
ctx.restore()
}
function houseFloorCount(e) {
return Math.max(1, Math.min(4, normalizeHouseMx(e).floors || 1))
}
/** 按楼层生成家具(多层像停车楼:侧边坡道楼梯换层,每层内容不同) */
function roomFurniture(floor, floors) {
const f = Math.max(0, Math.min(floors - 1, floor | 0))
const furn = []
if (floors <= 1) {
return [
{ id: 'bed', x: 36, y: ROOM_H - 118, w: 118, h: 62, solid: true, interact: true },
{ id: 'table', x: ROOM_W / 2 - 36, y: ROOM_H - 108, w: 72, h: 44, solid: true, interact: true },
{ id: 'shelf', x: ROOM_W - 108, y: 48, w: 72, h: 86, solid: true, interact: true },
{ id: 'plant', x: 28, y: 52, w: 42, h: 52, solid: true, interact: true },
{ id: 'fireplace', x: 292, y: ROOM_H - 106, w: 84, h: 66, solid: true, interact: true },
{ id: 'wallclock', x: 150, y: 76, w: 38, h: 38, solid: false, interact: true },
{ id: 'medicine', x: 112, y: 82, w: 30, h: 34, solid: false, interact: true },
{ id: 'phonograph', x: 306, y: ROOM_H - 40, w: 44, h: 40, solid: true, interact: true },
{ id: 'diary', x: 252, y: ROOM_H - 102, w: 28, h: 22, solid: false, interact: true },
{ id: 'painting', x: 228, y: 50, w: 54, h: 38, solid: false, interact: false },
{ id: 'door', x: ROOM_W / 2 - 40, y: ROOM_H - 28, w: 80, h: 28, solid: false, interact: true },
]
}
// 右侧坡道柱(停车楼式)
if (f < floors - 1) {
furn.push({ id: 'stairs_up', x: ROOM_W - 78, y: 36, w: 58, h: 110, solid: false, interact: true, label: '上行坡道' })
}
if (f > 0) {
furn.push({ id: 'stairs_down', x: ROOM_W - 78, y: ROOM_H - 140, w: 58, h: 100, solid: false, interact: true, label: '下行坡道' })
}
if (f === 0) {
// 1F 门厅
furn.push({ id: 'door', x: ROOM_W / 2 - 40, y: ROOM_H - 28, w: 80, h: 28, solid: false, interact: true })
furn.push({ id: 'table', x: 48, y: ROOM_H - 120, w: 80, h: 48, solid: true, interact: true })
furn.push({ id: 'plant', x: 40, y: 48, w: 44, h: 54, solid: true, interact: true })
furn.push({ id: 'shelf', x: 160, y: 44, w: 70, h: 80, solid: true, interact: true })
furn.push({ id: 'crate', x: 250, y: ROOM_H - 100, w: 56, h: 48, solid: true, interact: true })
furn.push({ id: 'fireplace', x: 316, y: ROOM_H - 104, w: 78, h: 64, solid: true, interact: true })
furn.push({ id: 'wallclock', x: 250, y: 74, w: 38, h: 38, solid: false, interact: true })
furn.push({ id: 'medicine', x: 286, y: 78, w: 30, h: 34, solid: false, interact: true })
furn.push({ id: 'phonograph', x: 320, y: ROOM_H - 38, w: 44, h: 38, solid: true, interact: true })
furn.push({ id: 'diary', x: 258, y: ROOM_H - 30, w: 28, h: 22, solid: false, interact: true })
} else if (f === floors - 1) {
// 顶层卧室
furn.push({ id: 'bed', x: 32, y: ROOM_H - 120, w: 120, h: 64, solid: true, interact: true })
furn.push({ id: 'bed2', x: 170, y: ROOM_H - 120, w: 110, h: 64, solid: true, interact: true })
furn.push({ id: 'shelf', x: 40, y: 44, w: 72, h: 86, solid: true, interact: true })
furn.push({ id: 'plant', x: 280, y: 50, w: 42, h: 52, solid: true, interact: true })
furn.push({ id: 'painting', x: 130, y: 48, w: 54, h: 38, solid: false, interact: false })
furn.push({ id: 'wallclock', x: 206, y: 76, w: 38, h: 38, solid: false, interact: true })
furn.push({ id: 'medicine', x: 250, y: 52, w: 28, h: 34, solid: false, interact: true })
furn.push({ id: 'phonograph', x: 300, y: ROOM_H - 40, w: 44, h: 38, solid: true, interact: true })
furn.push({ id: 'diary', x: 286, y: ROOM_H - 152, w: 28, h: 22, solid: false, interact: true })
} else {
// 中间层:起居 / 储物
furn.push({ id: 'sofa', x: 40, y: ROOM_H - 108, w: 130, h: 56, solid: true, interact: true })
furn.push({ id: 'table', x: 200, y: ROOM_H - 100, w: 78, h: 46, solid: true, interact: true })
furn.push({ id: 'crate', x: 40, y: 48, w: 60, h: 52, solid: true, interact: true })
furn.push({ id: 'crate2', x: 120, y: 48, w: 60, h: 52, solid: true, interact: true })
furn.push({ id: 'plant', x: 260, y: 50, w: 42, h: 52, solid: true, interact: true })
furn.push({ id: 'fireplace', x: 316, y: ROOM_H - 104, w: 78, h: 64, solid: true, interact: true })
}
return furn
}
function currentFurn(e, av) {
const floors = houseFloorCount(e)
const floor = Math.max(0, Math.min(floors - 1, av?.floor ?? 0))
const furn = roomFurniture(floor, floors)
// 小猫作为可交互对象动态加入(走到哪都能撸)
furn.push({ id: 'cat', x: cat.x - 15, y: cat.y - 12, w: 30, h: 22, solid: false, interact: true })
return { floors, floor, furn }
}
function interiorView(W, H) {
const pad = 24
const maxW = Math.min(W - pad * 2, 640)
const maxH = Math.min(H - pad * 2, 440)
const scale = Math.min(maxW / ROOM_W, maxH / ROOM_H)
const pw = ROOM_W * scale
const ph = ROOM_H * scale
const px = (W - pw) / 2
const py = (H - ph) / 2
return { px, py, pw, ph, scale }
}
function roomToScreen(view, rx, ry) {
return { x: view.px + rx * view.scale, y: view.py + ry * view.scale }
}
function circleHitsRect(cx, cy, r, rect) {
const nx = Math.max(rect.x, Math.min(cx, rect.x + rect.w))
const ny = Math.max(rect.y, Math.min(cy, rect.y + rect.h))
return Math.hypot(cx - nx, cy - ny) < r
}
function enterInterior(g, e) {
interiorOpen = e.id
interiorAvatar = {
x: ROOM_W / 2,
y: ROOM_H - 48,
floor: 0,
dir: 1,
walking: false,
walkT: 0,
prompt: null,
interactLatch: false,
escapeLatch: false,
}
storeOpen = null
canteenOpen = null
g.invalidate?.()
}
function leaveInterior(g) {
interiorOpen = null
interiorAvatar = null
g?.invalidate?.()
}
function placeOnStairs(av, e, goingUp) {
const { floors, floor, furn } = currentFurn(e, av)
const target = furn.find((f) => f.id === (goingUp ? 'stairs_down' : 'stairs_up'))
|| furn.find((f) => f.id === 'door')
if (target) {
av.x = target.x + target.w / 2 - 30
av.y = target.y + target.h / 2
} else {
av.x = ROOM_W / 2
av.y = ROOM_H - 48
}
av.x = Math.max(AVATAR_R + 8, Math.min(ROOM_W - AVATAR_R - 8, av.x))
av.y = Math.max(AVATAR_R + 36, Math.min(ROOM_H - AVATAR_R - 6, av.y))
return { floors, floor }
}
function nearestInteriorPrompt(av, e) {
if (!av || !e) return null
const { furn } = currentFurn(e, av)
let best = null
let bd = Infinity
for (const f of furn) {
if (!f.interact) continue
const cx = f.x + f.w / 2
const cy = f.y + f.h / 2
const d = Math.hypot(cx - av.x, cy - av.y)
const reach = (f.id === 'door' || f.id.startsWith('stairs')) ? 46 : 48
if (d < reach && d < bd) { bd = d; best = f.id }
}
return best
}
function tryInteriorInteract(g, p, id) {
const e = g.entities.find((x) => x.id === interiorOpen && x.type === 'camphouse')
if (!e || !p) { leaveInterior(g); return true }
const av = interiorAvatar
const floors = houseFloorCount(e)
if (id === 'door' || id === 'leave') {
if (id === 'door' && (av?.floor || 0) > 0) {
g.ptip(p, '请先下到 1 楼再出门')
return true
}
leaveInterior(g)
g.ptip(p, '离开房屋')
return true
}
if (id === 'stairs_up' && av) {
if ((av.floor || 0) >= floors - 1) { g.ptip(p, '已是顶层'); return true }
av.floor = (av.floor || 0) + 1
placeOnStairs(av, e, true)
g.ptip(p, `上到 ${av.floor + 1} 楼`)
return true
}
if (id === 'stairs_down' && av) {
if ((av.floor || 0) <= 0) { g.ptip(p, '已是 1 楼'); return true }
av.floor = (av.floor || 0) - 1
placeOnStairs(av, e, false)
g.ptip(p, `下到 ${av.floor + 1} 楼`)
return true
}
if (id === 'bed' || id === 'bed2') { doHouseRest(g, p, e, true); return true }
if (id === 'fireplace') {
// 优先烤食物:背包有小肉就烤成烤串;否则烤手回状态
if (g.countItem(p, 'morsel') > 0) {
g.payCost(p, { morsel: 1 })
g.addItem(p, 'roastmorsel', 1)
g.swing(p, 'chop')
g.ptip(p, '架在壁炉上烤了串 · 烤串 +1(趁热吃更补)')
} else {
p.hp = Math.min(150, p.hp + 2)
p.san = Math.min(200, p.san + 6)
g.ptip(p, '在壁炉边烤了烤手 · 生命 +2 理智 +6(有小肉可以来烤串)')
}
return true
}
if (id === 'medicine') {
if (medDay !== g.day) {
medDay = g.day
p.hp = Math.min(150, p.hp + 15)
g.swing(p, 'eat')
g.ptip(p, '用药箱包扎了一下 · 生命 +15(每日一次)')
} else {
g.ptip(p, '药箱里的纱布用完了,明天再来')
}
return true
}
if (id === 'phonograph') {
if (phonoDay !== g.day) {
phonoDay = g.day
p.san = Math.min(200, p.san + 6)
g.swing(p, 'eat')
g.ptip(p, '唱片沙沙转着,屋子里响起旧时代的歌 · 理智 +6(每日一次)')
} else {
g.ptip(p, '唱片已经转到头了,明天再换一张')
}
return true
}
if (id === 'diary') {
g.ptip(p, `日记:来到这里已经 ${g.day} 天 · 生命 ${Math.round(p.hp)} · 饥饿 ${Math.round(p.hunger)} · 理智 ${Math.round(p.san)} · 得分 ${p.score || 0}`)
return true
}
if (id === 'cat') {
if (catPetDay !== g.day) {
catPetDay = g.day
p.san = Math.min(200, p.san + 4)
g.ptip(p, '小猫蹭了蹭你的裤脚 · 理智 +4')
} else {
g.ptip(p, '小猫打了个哈欠,蜷成一团睡着了')
}
return true
}
if (id === 'wallclock') {
const frac = Math.max(0, Math.min(1, (g.dayTime || 0) / 160))
const hh = Math.floor(frac * 24)
g.ptip(p, `挂钟指向 ${String(hh).padStart(2, '0')}:00(游戏内时间)`)
return true
}
if (id === 'shelf') {
if (readDay !== g.day) {
readDay = g.day
p.san = Math.min(200, p.san + 8)
g.ptip(p, '靠在书架旁读了一会书 · 理智 +8')
} else {
g.ptip(p, '今天的书都读完了,明天再来')
}
return true
}
if (id === 'table') { g.ptip(p, '一张结实的木桌'); return true }
if (id === 'plant') { g.ptip(p, '一盆绿意盎然的盆栽'); return true }
if (id === 'sofa') { g.ptip(p, '柔软的沙发,适合歇脚'); return true }
if (id === 'crate' || id === 'crate2') { g.ptip(p, '堆放杂物的木箱'); return true }
return false
}
function updateInterior(g, dt) {
if (interiorOpen == null) return
const e = g.entities.find((x) => x.id === interiorOpen && x.type === 'camphouse')
const p = g.me()
if (!e || !p || p.dead) { leaveInterior(g); return }
if (!interiorAvatar) enterInterior(g, e)
const av = interiorAvatar
if (av.floor == null) av.floor = 0
const floors = houseFloorCount(e)
if (av.floor >= floors) av.floor = floors - 1
const { furn } = currentFurn(e, av)
let dx = 0
let dy = 0
if (g.keyDown) {
dx = (g.keyDown('ArrowRight') || g.keyDown('d') || g.keyDown('D') ? 1 : 0)
- (g.keyDown('ArrowLeft') || g.keyDown('a') || g.keyDown('A') ? 1 : 0)
dy = (g.keyDown('ArrowDown') || g.keyDown('s') || g.keyDown('S') ? 1 : 0)
- (g.keyDown('ArrowUp') || g.keyDown('w') || g.keyDown('W') ? 1 : 0)
}
if ((!dx && !dy) && p.input) {
dx = p.input.dx || 0
dy = p.input.dy || 0
}
const len = Math.hypot(dx, dy)
if (len > 1e-3) {
dx /= len
dy /= len
const step = INTERIOR_SPEED * dt
let nx = av.x + dx * step
let ny = av.y + dy * step
nx = Math.max(AVATAR_R + 8, Math.min(ROOM_W - AVATAR_R - 8, nx))
ny = Math.max(AVATAR_R + 36, Math.min(ROOM_H - AVATAR_R - 6, ny))
const solids = furn.filter((f) => f.solid)
const blockedX = solids.some((f) => circleHitsRect(nx, av.y, AVATAR_R, f))
const blockedY = solids.some((f) => circleHitsRect(av.x, ny, AVATAR_R, f))
if (!blockedX) av.x = nx
if (!blockedY) av.y = ny
if (dx) av.dir = dx > 0 ? 1 : -1
av.walking = true
av.walkT += dt
} else {
av.walking = false
}
av.prompt = nearestInteriorPrompt(av, e)
const wantInteract = !!(g.keyDown?.('e') || g.keyDown?.('E') || g.keyDown?.(' ') || g.keyDown?.('Enter'))
if (wantInteract && !av.interactLatch && av.prompt) {
tryInteriorInteract(g, p, av.prompt)
}
av.interactLatch = wantInteract
const wantLeave = !!g.keyDown?.('Escape')
if (wantLeave && !av.escapeLatch) leaveInterior(g)
av.escapeLatch = wantLeave
// 屋内小猫散步
updateCat(dt)
}
function drawFurnPiece(ctx, view, f, env) {
const a = roomToScreen(view, f.x, f.y)
const w = f.w * view.scale
const h = f.h * view.scale
const id = f.id
const s = view.scale
const t = env.time || 0
ctx.save()
ctx.lineJoin = 'round'
const wood = (x, y, ww, hh, col = '#a07040', colLo = '#7a5030', r = 3) => {
ctx.fillStyle = col
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 2
ctx.beginPath(); ctx.roundRect(x, y, ww, hh, r); ctx.fill(); ctx.stroke()
ctx.fillStyle = 'rgba(255,255,255,0.14)'
ctx.beginPath(); ctx.roundRect(x + 2, y + 2, ww - 4, Math.max(2, hh * 0.28), 2); ctx.fill()
ctx.fillStyle = 'rgba(0,0,0,0.16)'
ctx.beginPath(); ctx.roundRect(x + 2, y + hh - Math.max(2, hh * 0.22) - 2, ww - 4, Math.max(2, hh * 0.22), 2); ctx.fill()
ctx.fillStyle = colLo
}
if (id === 'fireplace') {
// 石砌壁炉:摇曳火焰 + 暖光 + 炉柴 + 火星
ctx.fillStyle = '#8d9097'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 2
ctx.beginPath(); ctx.roundRect(a.x + 4, a.y + 6, w - 8, h - 8, 3); ctx.fill(); ctx.stroke()
ctx.fillStyle = '#6f7278'
ctx.beginPath(); ctx.roundRect(a.x + 8, a.y + 2, w - 16, 10, 2); ctx.fill(); ctx.stroke()
ctx.fillStyle = '#2a1c10'
ctx.beginPath(); ctx.roundRect(a.x + w * 0.22, a.y + h * 0.4, w * 0.56, h * 0.46, 3); ctx.fill()
const vigor = 0.7 + Math.sin(t * 9) * 0.2
const fx = a.x + w / 2
const fy = a.y + h * 0.88
const gl = ctx.createRadialGradient(fx, fy - h * 0.18, 2, fx, fy - h * 0.18, w * 0.75)
gl.addColorStop(0, `rgba(255,170,70,${0.32 * vigor})`)
gl.addColorStop(1, 'rgba(255,170,70,0)')
ctx.fillStyle = gl
ctx.beginPath(); ctx.arc(fx, fy - h * 0.18, w * 0.75, 0, TAU); ctx.fill()
const flame = (fh, fw, col) => {
ctx.beginPath()
ctx.moveTo(fx, fy - fh * vigor)
ctx.quadraticCurveTo(fx + fw, fy - fh * 0.4, fx, fy)
ctx.quadraticCurveTo(fx - fw, fy - fh * 0.4, fx, fy - fh * vigor)
ctx.closePath()
ctx.fillStyle = col
ctx.fill()
}
flame(h * 0.5, w * 0.16, 'rgba(236,110,36,0.9)')
flame(h * 0.34, w * 0.11, 'rgba(255,180,55,0.9)')
flame(h * 0.2, w * 0.06, 'rgba(255,238,160,0.95)')
ctx.strokeStyle = '#5b4128'
ctx.lineWidth = 3
ctx.beginPath(); ctx.moveTo(fx - 12, fy - 2); ctx.lineTo(fx + 12, fy - 6); ctx.stroke()
ctx.fillStyle = 'rgba(255,190,90,0.8)'
for (let i = 0; i < 2; i++) {
const ph = (t * 0.8 + i * 0.5) % 1
ctx.beginPath()
ctx.arc(fx + Math.sin(t * 3 + i * 4) * 4, fy - h * 0.6 - ph * 22, Math.max(0.2, 1.6 - ph), 0, TAU)
ctx.fill()
}
} else if (id === 'wallclock') {
// 挂钟:指针按游戏内时间(dayTime/160)走
const frac = Math.max(0, Math.min(1, (env.dayTime || 0) / 160))
const cx2 = a.x + w / 2
const cy2 = a.y + h / 2
const rr = Math.min(w, h) * 0.46
ctx.fillStyle = '#e8dcc0'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 2
ctx.beginPath(); ctx.arc(cx2, cy2, rr, 0, TAU); ctx.fill(); ctx.stroke()
ctx.fillStyle = '#8a5f34'
ctx.beginPath(); ctx.roundRect(cx2 - rr * 0.2, a.y - 4, rr * 0.4, 6, 2); ctx.fill()
ctx.strokeStyle = '#5d4432'
ctx.lineWidth = 1
for (let i = 0; i < 4; i++) {
const ang = (i / 4) * TAU
ctx.beginPath()
ctx.moveTo(cx2 + Math.cos(ang) * rr * 0.66, cy2 + Math.sin(ang) * rr * 0.66)
ctx.lineTo(cx2 + Math.cos(ang) * rr * 0.82, cy2 + Math.sin(ang) * rr * 0.82)
ctx.stroke()
}
const ang = frac * TAU - Math.PI / 2
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 2
ctx.beginPath()
ctx.moveTo(cx2, cy2)
ctx.lineTo(cx2 + Math.cos(ang) * rr * 0.62, cy2 + Math.sin(ang) * rr * 0.62)
ctx.stroke()
ctx.fillStyle = '#c43a3a'
ctx.beginPath(); ctx.arc(cx2, cy2, 2, 0, TAU); ctx.fill()
} else if (id === 'painting') {
// 墙上小画:篝火夜景
ctx.fillStyle = '#3a4a5e'
ctx.strokeStyle = '#8a5f34'
ctx.lineWidth = 3
ctx.beginPath(); ctx.roundRect(a.x + 2, a.y + 2, w - 4, h - 4, 2); ctx.fill(); ctx.stroke()
ctx.fillStyle = '#2a3448'
ctx.beginPath(); ctx.ellipse(a.x + w * 0.5, a.y + h * 0.74, w * 0.42, h * 0.2, 0, 0, TAU); ctx.fill()
ctx.fillStyle = '#e8e2d4'
;[[a.x + w * 0.2, a.y + h * 0.28], [a.x + w * 0.76, a.y + h * 0.22], [a.x + w * 0.55, a.y + h * 0.36]].forEach(([sx2, sy2]) => {
ctx.beginPath(); ctx.arc(sx2, sy2, 1, 0, TAU); ctx.fill()
})
ctx.fillStyle = '#ffd76a'
ctx.beginPath(); ctx.arc(a.x + w * 0.5, a.y + h * 0.6, 3, 0, TAU); ctx.fill()
ctx.strokeStyle = '#e0a030'
ctx.lineWidth = 1.2
ctx.beginPath(); ctx.arc(a.x + w * 0.5, a.y + h * 0.6, 5.6, 0.4, 2.7); ctx.stroke()
} else if (id === 'medicine') {
// 壁挂药箱:白箱红十字
ctx.fillStyle = '#e8e2d4'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 1.8
ctx.beginPath(); ctx.roundRect(a.x + 2, a.y + 4, w - 4, h - 8, 2); ctx.fill(); ctx.stroke()
ctx.fillStyle = '#c95a4a'
const mw = w * 0.14
ctx.fillRect(a.x + w / 2 - mw / 2, a.y + h * 0.3, mw, h * 0.42)
ctx.fillRect(a.x + w * 0.28, a.y + h * 0.44, w * 0.44, mw)
ctx.strokeStyle = '#8a8a94'
ctx.lineWidth = 1
ctx.strokeRect(a.x + w * 0.28, a.y + h * 0.44, w * 0.44, mw)
} else if (id === 'phonograph') {
// 留声机:木箱 + 旋转唱片 + 唱臂(今天听过歌会冒音符)
ctx.fillStyle = '#7a5230'
ctx.lineWidth = 2
ctx.beginPath(); ctx.roundRect(a.x + 4, a.y + h * 0.42, w - 8, h * 0.52, 3); ctx.fill(); ctx.stroke()
const discX = a.x + w * 0.36
const discY = a.y + h * 0.36
ctx.fillStyle = '#241a10'
ctx.beginPath(); ctx.arc(discX, discY, w * 0.3, 0, TAU); ctx.fill()
ctx.strokeStyle = '#c9a86a'
ctx.lineWidth = 1.1
const discRot = (phonoDay === g.day ? t : 0) * 1.4
for (let i = 0; i < 3; i++) {
ctx.beginPath()
ctx.arc(discX, discY, w * (0.1 + i * 0.075), discRot + i, discRot + i + 4.4)
ctx.stroke()
}
ctx.fillStyle = '#e0c060'
ctx.beginPath(); ctx.arc(discX, discY, 2, 0, TAU); ctx.fill()
// 唱臂
ctx.strokeStyle = '#c9a86a'
ctx.lineWidth = 1.8
ctx.beginPath()
ctx.moveTo(a.x + w * 0.78, a.y + h * 0.2)
ctx.quadraticCurveTo(a.x + w * 0.86, a.y + h * 0.34, discX + w * 0.2, discY)
ctx.stroke()
if (phonoDay === g.day) {
// 音符飘出
ctx.fillStyle = 'rgba(240,220,150,0.85)'
for (let i = 0; i < 2; i++) {
const ph = (t * 0.7 + i * 0.5) % 1
const nx = a.x + w * 0.6 + Math.sin(t * 2 + i * 3) * 6 + ph * 10
const ny = a.y + h * 0.3 - ph * 22
ctx.beginPath()
ctx.ellipse(nx, ny, 2.4, 1.7, -0.4, 0, TAU)
ctx.fill()
ctx.fillRect(nx + 1.6, ny - 9 * (1 - ph * 0.3) - 2, 1, 8)
}
}
} else if (id === 'diary') {
// 桌上的日记本(摊开 + 摇曳笔迹)
ctx.fillStyle = '#8a5f34'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 1.6
ctx.beginPath(); ctx.roundRect(a.x, a.y + h * 0.3, w, h * 0.6, 2); ctx.fill(); ctx.stroke()
ctx.fillStyle = '#f4ead2'
ctx.beginPath(); ctx.roundRect(a.x + 2, a.y + h * 0.36, w / 2 - 2.4, h * 0.48, 1.5); ctx.fill(); ctx.stroke()
ctx.beginPath(); ctx.roundRect(a.x + w / 2 + 0.8, a.y + h * 0.36, w / 2 - 2.4, h * 0.48, 1.5); ctx.fill(); ctx.stroke()
ctx.strokeStyle = 'rgba(90,60,30,0.55)'
ctx.lineWidth = 0.9
const wig = Math.sin(t * 2.4) * 0.6
;[0.16, 0.36, 0.56].forEach((k) => {
ctx.beginPath()
ctx.moveTo(a.x + 3, a.y + h * (0.42 + k * 0.5))
ctx.lineTo(a.x + w / 2 - 3, a.y + h * (0.44 + k * 0.5) + wig)
ctx.stroke()
ctx.beginPath()
ctx.moveTo(a.x + w / 2 + 2, a.y + h * (0.44 + k * 0.5))
ctx.lineTo(a.x + w - 3, a.y + h * (0.42 + k * 0.5) - wig)
ctx.stroke()
})
} else if (id === 'door') {
ctx.fillStyle = 'rgba(255, 220, 140, 0.28)'
ctx.fillRect(a.x, a.y, w, h)
wood(a.x + 4, a.y + 2, w - 8, 6, '#5d3f24', '#3a2a16')
ctx.fillStyle = '#f4ead2'
ctx.font = '11px sans-serif'
ctx.textAlign = 'center'
ctx.fillText('大门', a.x + w / 2, a.y - 4)
} else if (id === 'stairs_up' || id === 'stairs_down') {
const up = id === 'stairs_up'
// 木质台阶(越靠上越窄的透视阶梯)
ctx.fillStyle = '#8a6a44'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 2
ctx.beginPath(); ctx.roundRect(a.x, a.y, w, h, 5); ctx.fill(); ctx.stroke()
const steps = 5
for (let i = 0; i < steps; i++) {
const k = up ? 1 - i / steps : i / steps
const sy = a.y + 8 + k * (h - 26)
const inset = 6 + (up ? i : steps - 1 - i) * 3
ctx.fillStyle = i % 2 ? '#a07040' : '#946639'
ctx.beginPath(); ctx.roundRect(a.x + inset, sy, w - inset * 2, 9, 2); ctx.fill()
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 1.2
ctx.stroke()
}
ctx.fillStyle = '#f0d79a'
ctx.font = `bold ${Math.max(10, 12 * s)}px sans-serif`
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText(up ? '↑ 上楼' : '↓ 下楼', a.x + w / 2, a.y + h / 2)
} else if (id === 'bed' || id === 'bed2') {
// 床:木框 + 床垫 + 枕头 + 被子
wood(a.x, a.y, w, h, id === 'bed2' ? '#4a6a8a' : '#6a4a8a', '#3a2a4a', 5)
ctx.fillStyle = '#e8e0f0'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 1.4
ctx.beginPath(); ctx.roundRect(a.x + 5, a.y + 5, w - 10, h - 10, 4); ctx.fill(); ctx.stroke()
ctx.fillStyle = '#f8f6ee'
ctx.beginPath(); ctx.roundRect(a.x + 8, a.y + 7, w * 0.3, h * 0.34, 3); ctx.fill(); ctx.stroke()
ctx.fillStyle = id === 'bed2' ? '#5d80a4' : '#7d5aa4'
ctx.beginPath(); ctx.roundRect(a.x + 8 + w * 0.36, a.y + 7, w - 16 - w * 0.36, h - 14, 3); ctx.fill(); ctx.stroke()
ctx.strokeStyle = 'rgba(255,255,255,0.4)'
ctx.lineWidth = 1.2
ctx.beginPath(); ctx.moveTo(a.x + 10 + w * 0.4, a.y + h * 0.55); ctx.quadraticCurveTo(a.x + w * 0.6, a.y + h * 0.7, a.x + w - 12, a.y + h * 0.5); ctx.stroke()
} else if (id === 'table') {
// 木桌:桌面 + 四腿 + 桌上摆件
ctx.fillStyle = '#5d3f24'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 1.6
;[[a.x + 8, a.y + h * 0.4], [a.x + w - 14, a.y + h * 0.4]].forEach(([lx, ly]) => {
ctx.beginPath(); ctx.roundRect(lx, ly, 6, h * 0.58, 1.5); ctx.fill(); ctx.stroke()
})
wood(a.x, a.y, w, h * 0.45, '#a07040', '#7a5030', 4)
// 桌上:蜡烛 + 木杯
ctx.fillStyle = '#f4ead2'
ctx.beginPath(); ctx.roundRect(a.x + w * 0.2, a.y - 8 * s, 4, 9 * s, 1.5); ctx.fill()
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 1
ctx.stroke()
ctx.fillStyle = `rgba(255, 200, 90, ${0.75 + Math.sin(t * 6) * 0.2})`
ctx.beginPath(); ctx.ellipse(a.x + w * 0.2 + 2, a.y - 10 * s, 2, 3, 0, 0, TAU); ctx.fill()
ctx.fillStyle = '#8a6238'
ctx.beginPath(); ctx.roundRect(a.x + w * 0.62, a.y - 5 * s, 7, 6 * s, 1.5); ctx.fill()
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 1
ctx.stroke()
} else if (id === 'shelf') {
// 橱柜:双门 + 格层 + 罐子/书
wood(a.x, a.y, w, h, '#8a6038', '#5d3f24', 4)
ctx.strokeStyle = '#5a3a20'
ctx.lineWidth = 1.6
ctx.beginPath(); ctx.moveTo(a.x + w / 2, a.y + 4); ctx.lineTo(a.x + w / 2, a.y + h - 4); ctx.stroke()
;[0.35, 0.7].forEach((k) => {
ctx.beginPath(); ctx.moveTo(a.x + 4, a.y + h * k); ctx.lineTo(a.x + w - 4, a.y + h * k); ctx.stroke()
})
// 上层罐子、下层书脊
ctx.fillStyle = '#c9a36b'
ctx.beginPath(); ctx.roundRect(a.x + 8, a.y + h * 0.35 - 8 * s, 7, 8 * s, 1.5); ctx.fill()
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 1
ctx.stroke()
;['#a84c38', '#3a5a8a', '#6a7a3d'].forEach((bc, i) => {
ctx.fillStyle = bc
ctx.beginPath(); ctx.roundRect(a.x + 10 + i * 8, a.y + h * 0.72, 6, h * 0.24, 1); ctx.fill()
ctx.stroke()
})
ctx.fillStyle = '#c9a03c'
ctx.beginPath(); ctx.arc(a.x + w / 2 - 3, a.y + h / 2, 1.4, 0, TAU); ctx.fill()
ctx.beginPath(); ctx.arc(a.x + w / 2 + 3, a.y + h / 2, 1.4, 0, TAU); ctx.fill()
} else if (id === 'plant') {
// 陶盆 + 三层叶丛(轻摆)
const sway = Math.sin(t * 2.2) * 2 * s
ctx.fillStyle = '#8a5a3a'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 2
ctx.beginPath()
ctx.moveTo(a.x + w * 0.24, a.y + h * 0.6)
ctx.lineTo(a.x + w * 0.76, a.y + h * 0.6)
ctx.lineTo(a.x + w * 0.68, a.y + h)
ctx.lineTo(a.x + w * 0.32, a.y + h)
ctx.closePath()
ctx.fill(); ctx.stroke()
ctx.fillStyle = 'rgba(255,255,255,0.12)'
ctx.beginPath(); ctx.roundRect(a.x + w * 0.28, a.y + h * 0.63, w * 0.14, h * 0.3, 2); ctx.fill()
ctx.strokeStyle = '#3f6b35'
ctx.lineWidth = 2 * s
ctx.beginPath()
ctx.moveTo(a.x + w / 2, a.y + h * 0.62)
ctx.quadraticCurveTo(a.x + w / 2 + sway, a.y + h * 0.4, a.x + w / 2 + sway * 1.4, a.y + h * 0.26)
ctx.stroke()
;[[a.x + w * 0.3, a.y + h * 0.44, 0], [a.x + w * 0.7, a.y + h * 0.42, 0.6], [a.x + w * 0.5 + sway, a.y + h * 0.24, 0]].forEach(([cx2, cy2, rot]) => {
ctx.fillStyle = '#4a8a3a'
ctx.beginPath()
ctx.ellipse(cx2 + sway * 0.4, cy2, w * 0.2, h * 0.13, rot, 0, TAU)
ctx.fill()
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 1.2
ctx.stroke()
})
ctx.fillStyle = '#6aa84a'
ctx.beginPath()
ctx.ellipse(a.x + w * 0.5 + sway * 1.2, a.y + h * 0.18, w * 0.14, h * 0.09, 0, 0, TAU)
ctx.fill()
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 1
ctx.stroke()
} else if (id === 'sofa') {
// 布艺沙发:座垫 + 靠背 + 扶手 + 抱枕
wood(a.x + 4, a.y + h * 0.3, w - 8, h * 0.66, '#6a4a3a', '#4a3030', 6)
wood(a.x + 6, a.y, w - 12, h * 0.42, '#8a6a50', '#5d4432', 5)
ctx.fillStyle = '#a8825e'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 1.4
ctx.beginPath(); ctx.roundRect(a.x + 12, a.y + h * 0.36, w * 0.38, h * 0.3, 3); ctx.fill(); ctx.stroke()
ctx.beginPath(); ctx.roundRect(a.x + 12 + w * 0.44, a.y + h * 0.36, w * 0.36, h * 0.3, 3); ctx.fill(); ctx.stroke()
ctx.fillStyle = '#c96a5a'
ctx.beginPath(); ctx.roundRect(a.x + w * 0.16, a.y + h * 0.18, w * 0.2, h * 0.26, 3); ctx.fill(); ctx.stroke()
// 木脚
ctx.fillStyle = '#5d3f24'
;[[a.x + 8, a.y + h - 6], [a.x + w - 14, a.y + h - 6]].forEach(([lx, ly]) => {
ctx.beginPath(); ctx.roundRect(lx, ly, 6, 6, 1.5); ctx.fill()
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 1
ctx.stroke()
})
} else if (id === 'crate' || id === 'crate2') {
// 木箱:板条 + 交叉加固条
wood(a.x, a.y, w, h, '#9a7a48', '#6a4a28', 3)
ctx.strokeStyle = '#6a4a28'
ctx.lineWidth = 3
ctx.beginPath()
ctx.moveTo(a.x + 3, a.y + 3); ctx.lineTo(a.x + w - 3, a.y + h - 3)
ctx.moveTo(a.x + w - 3, a.y + 3); ctx.lineTo(a.x + 3, a.y + h - 3)
ctx.stroke()
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 1.2
ctx.strokeRect(a.x + 4, a.y + 4, w - 8, h - 8)
ctx.fillStyle = '#c9a03c'
ctx.beginPath(); ctx.arc(a.x + w / 2, a.y + h / 2, 2, 0, TAU); ctx.fill()
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 1
ctx.stroke()
}
ctx.restore()
}
/** 屋内小猫:四足小步跑 / 坐下摇尾,纯装饰可撸 */
function drawHouseCat(ctx, view, env) {
const t = env.time || 0
const pos = roomToScreen(view, cat.x, cat.y)
ctx.save()
ctx.translate(pos.x, pos.y)
ctx.scale(view.scale, view.scale)
ctx.scale(cat.dir, 1)
const bob = cat.moving ? Math.sin(cat.walkT * 12) : 0
// 影子
ctx.fillStyle = 'rgba(0,0,0,0.18)'
ctx.beginPath(); ctx.ellipse(0, 3, 9, 2.4, 0, 0, TAU); ctx.fill()
// 尾巴(走时轻摆,坐时大幅摇)
ctx.strokeStyle = '#c98a44'
ctx.lineWidth = 2.4
ctx.lineCap = 'round'
const tailAmp = cat.moving ? 2 : 4.5
const tail = Math.sin(t * (cat.moving ? 6 : 2.4)) * tailAmp
ctx.beginPath()
ctx.moveTo(-10, -4)
ctx.quadraticCurveTo(-16, -8 - tail, -14 - tail * 0.5, -13 - tail)
ctx.stroke()
// 身体
ctx.fillStyle = '#d8a860'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 1.5
ctx.beginPath(); ctx.ellipse(0, -6 + bob * 0.4, 10, 5.4, 0, 0, TAU); ctx.fill(); ctx.stroke()
// 虎斑纹
ctx.strokeStyle = 'rgba(160,110,50,0.7)'
ctx.lineWidth = 1.2
;[-4, 0, 4].forEach((bx2) => {
ctx.beginPath(); ctx.moveTo(bx2, -10 + bob * 0.4); ctx.lineTo(bx2 + 1.4, -5 + bob * 0.4); ctx.stroke()
})
// 头 + 耳
ctx.fillStyle = '#d8a860'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 1.5
ctx.beginPath(); ctx.arc(9, -11 + bob * 0.5, 4.6, 0, TAU); ctx.fill(); ctx.stroke()
ctx.beginPath()
ctx.moveTo(6.2, -13.8 + bob * 0.5); ctx.lineTo(7, -18.4 + bob * 0.5); ctx.lineTo(9.6, -14.6 + bob * 0.5)
ctx.moveTo(10.4, -14.6 + bob * 0.5); ctx.lineTo(12.4, -18 + bob * 0.5); ctx.lineTo(13.4, -13.8 + bob * 0.5)
ctx.closePath(); ctx.fill(); ctx.stroke()
// 脸(绿眼 + 鼻须)
ctx.fillStyle = '#5a8a3a'
ctx.beginPath(); ctx.arc(10.6, -11.4 + bob * 0.5, 1.1, 0, TAU); ctx.fill()
ctx.fillStyle = '#1d1409'
ctx.beginPath(); ctx.arc(10.9, -11.4 + bob * 0.5, 0.55, 0, TAU); ctx.fill()
ctx.strokeStyle = 'rgba(30,20,10,0.6)'
ctx.lineWidth = 0.8
ctx.beginPath()
ctx.moveTo(13, -10.4 + bob * 0.5); ctx.lineTo(15.4, -10 + bob * 0.5)
ctx.moveTo(13, -9.6 + bob * 0.5); ctx.lineTo(15.4, -9 + bob * 0.5)
ctx.stroke()
// 腿(走动交替)
ctx.strokeStyle = '#b88844'
ctx.lineWidth = 2
ctx.beginPath()
if (cat.moving) {
ctx.moveTo(-5, -2); ctx.lineTo(-5 + Math.sin(cat.walkT * 12) * 2.6, 3)
ctx.moveTo(5, -2); ctx.lineTo(5 - Math.sin(cat.walkT * 12) * 2.6, 3)
} else {
ctx.moveTo(-5, -2); ctx.lineTo(-5, 3)
ctx.moveTo(5, -2); ctx.lineTo(5, 3)
}
ctx.stroke()
ctx.restore()
}
function drawInterior(g, ctx, env) {
const e = g.entities.find((x) => x.id === interiorOpen && x.type === 'camphouse')
if (!e) { leaveInterior(g); return }
const p = g.me()
const mx = normalizeHouseMx(e)
const view = interiorView(env.W, env.H)
const skin = HOUSE_MODELS[mx.model] || HOUSE_MODELS.hut
if (!interiorAvatar) enterInterior(g, e)
const av = interiorAvatar
const { floors, floor, furn } = currentFurn(e, av)
uiInteriorBtns = furn.map((f) => {
const a = roomToScreen(view, f.x, f.y)
return { id: f.id, x: a.x, y: a.y, w: f.w * view.scale, h: f.h * view.scale }
})
const leaveBtn = {
id: 'leave',
x: view.px + view.pw / 2 - 50,
y: view.py + view.ph + 10,
w: 100,
h: 28,
}
uiInteriorBtns.push(leaveBtn)
ctx.save()
ctx.fillStyle = 'rgba(8, 6, 4, 0.72)'
ctx.fillRect(0, 0, env.W, env.H)
ctx.fillStyle = '#4a3224'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 4
ctx.beginPath(); ctx.roundRect(view.px - 6, view.py - 6, view.pw + 12, view.ph + 12, 10); ctx.fill(); ctx.stroke()
// 楼层色差:越高越亮,像停车楼分层
const floorTint = 1 + floor * 0.04
ctx.fillStyle = skin.wall
ctx.globalAlpha = Math.min(1, 0.92 * floorTint)
ctx.fillRect(view.px, view.py, view.pw, view.ph * 0.42)
ctx.globalAlpha = 1
// 墙面木架横梁 + 挂画/壁灯点缀
ctx.strokeStyle = 'rgba(70,45,25,0.4)'
ctx.lineWidth = 2
for (let bx = view.px + 30; bx < view.px + view.pw; bx += 90) {
ctx.beginPath(); ctx.moveTo(bx, view.py + 6); ctx.lineTo(bx, view.py + view.ph * 0.42 - 6); ctx.stroke()
}
// 窗光斜照(左上窗投下的暖光带)
const winX = view.px + view.pw * 0.12
const winY = view.py + view.ph * 0.08
ctx.save()
ctx.beginPath()
ctx.roundRect(winX, winY, 56 * view.scale, 42 * view.scale, 3)
ctx.clip()
// 窗外天色随昼夜相位变化(夜=星空 / 黄昏=暖橙 / 白天=天蓝)
const ph2 = g.phase || 'day'
if (ph2 === 'night') {
ctx.fillStyle = '#141d33'
ctx.fillRect(winX, winY, 56 * view.scale, 42 * view.scale)
ctx.fillStyle = '#e8ecf4'
;[[winX + 10 * view.scale, winY + 10 * view.scale], [winX + 40 * view.scale, winY + 16 * view.scale], [winX + 24 * view.scale, winY + 30 * view.scale], [winX + 48 * view.scale, winY + 8 * view.scale]].forEach(([sx2, sy2], i) => {
ctx.beginPath(); ctx.arc(sx2, sy2, (i % 2 ? 1 : 1.4) * view.scale, 0, TAU); ctx.fill()
})
ctx.fillStyle = '#f0e8c8'
ctx.beginPath(); ctx.arc(winX + 46 * view.scale, winY + 30 * view.scale, 5 * view.scale, 0, TAU); ctx.fill()
} else if (ph2 === 'dusk') {
const skyG = ctx.createLinearGradient(winX, winY, winX, winY + 42 * view.scale)
skyG.addColorStop(0, '#5a3a5e')
skyG.addColorStop(1, '#e8a05a')
ctx.fillStyle = skyG
ctx.fillRect(winX, winY, 56 * view.scale, 42 * view.scale)
ctx.fillStyle = 'rgba(255,220,140,0.9)'
ctx.beginPath(); ctx.arc(winX + 40 * view.scale, winY + 30 * view.scale, 5 * view.scale, 0, TAU); ctx.fill()
} else {
const skyG = ctx.createLinearGradient(winX, winY, winX, winY + 42 * view.scale)
skyG.addColorStop(0, '#a8d4f0')
skyG.addColorStop(1, '#e8d8a8')
ctx.fillStyle = skyG
ctx.fillRect(winX, winY, 56 * view.scale, 42 * view.scale)
ctx.fillStyle = 'rgba(255,255,255,0.75)'
ctx.beginPath()
ctx.ellipse(winX + (20 + Math.sin((env.time || 0) * 0.3) * 10) * view.scale, winY + 14 * view.scale, 9 * view.scale, 4 * view.scale, 0, 0, TAU)
ctx.fill()
}
ctx.strokeStyle = '#5d3f24'
ctx.lineWidth = 3
ctx.beginPath()
ctx.moveTo(winX + 28 * view.scale, winY)
ctx.lineTo(winX + 28 * view.scale, winY + 42 * view.scale)
ctx.moveTo(winX, winY + 21 * view.scale)
ctx.lineTo(winX + 56 * view.scale, winY + 21 * view.scale)
ctx.stroke()
ctx.restore()
ctx.strokeStyle = '#5d3f24'
ctx.lineWidth = 3
ctx.beginPath(); ctx.roundRect(winX, winY, 56 * view.scale, 42 * view.scale, 3); ctx.stroke()
// 窗光斜带(落到地板)
ctx.save()
ctx.globalAlpha = 0.14
ctx.fillStyle = '#ffe8b0'
ctx.beginPath()
ctx.moveTo(winX + 4, winY + 42 * view.scale)
ctx.lineTo(winX + 52 * view.scale, winY + 42 * view.scale)
ctx.lineTo(winX + 96 * view.scale, view.py + view.ph)
ctx.lineTo(winX - 18 * view.scale, view.py + view.ph)
ctx.closePath()
ctx.fill()
ctx.restore()
// 墙裙
ctx.fillStyle = skin.wall2
ctx.fillRect(view.px, view.py + view.ph * 0.42 - 6, view.pw, 8)
ctx.fillStyle = 'rgba(0,0,0,0.18)'
ctx.fillRect(view.px, view.py + view.ph * 0.42 + 2, view.pw, 3)
// 地板(拼花木板 + 接缝错位)
const floorCols = ['#8a6a40', '#7a7248', '#6a6a50', '#5a5a58']
ctx.fillStyle = floorCols[floor % floorCols.length]
ctx.fillRect(view.px, view.py + view.ph * 0.42, view.pw, view.ph * 0.58)
ctx.strokeStyle = 'rgba(60,40,20,0.35)'
ctx.lineWidth = 1
const plank = 16 * view.scale
let rowIdx = 0
for (let y = view.py + view.ph * 0.42 + 4; y < view.py + view.ph - 4; y += plank) {
ctx.beginPath(); ctx.moveTo(view.px + 4, y); ctx.lineTo(view.px + view.pw - 4, y); ctx.stroke()
// 每行错缝竖线
const off = (rowIdx++ % 3) * 26 * view.scale
for (let x = view.px + 10 + off; x < view.px + view.pw - 6; x += 78 * view.scale) {
ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x, Math.min(y + plank, view.py + view.ph - 4)); ctx.stroke()
}
}
// 中央地毯
const rugW = view.pw * 0.34
const rugH = view.ph * 0.26
const rugX = view.px + view.pw / 2 - rugW / 2
const rugY = view.py + view.ph * 0.6
ctx.fillStyle = '#8a4438'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 2
ctx.beginPath(); ctx.roundRect(rugX, rugY, rugW, rugH, 8); ctx.fill(); ctx.stroke()
ctx.strokeStyle = '#d8b478'
ctx.lineWidth = 2
ctx.beginPath(); ctx.roundRect(rugX + 6, rugY + 6, rugW - 12, rugH - 12, 6); ctx.stroke()
ctx.strokeStyle = 'rgba(216,180,120,0.55)'
ctx.lineWidth = 1.4
ctx.beginPath(); ctx.roundRect(rugX + 12, rugY + 12, rugW - 24, rugH - 24, 5); ctx.stroke()
ctx.fillStyle = 'rgba(0,0,0,0.12)'
;[[rugX + rugW * 0.5, rugY + rugH * 0.5, rugW * 0.1]].forEach(([cx2, cy2, r2]) => {
ctx.beginPath(); ctx.arc(cx2, cy2, r2, 0, TAU); ctx.fill()
})
// 停车楼式楼层指示条
const chipW = 28
const chipGap = 6
const chipsW = floors * chipW + (floors - 1) * chipGap
let chipX = view.px + view.pw / 2 - chipsW / 2
const chipY = view.py + 8
for (let i = 0; i < floors; i++) {
const on = i === floor
ctx.fillStyle = on ? '#e0b060' : '#3a2a1a'
ctx.strokeStyle = on ? '#1d1409' : '#5a4a38'
ctx.lineWidth = on ? 2.2 : 1.4
ctx.beginPath(); ctx.roundRect(chipX, chipY, chipW, 18, 4); ctx.fill(); ctx.stroke()
ctx.fillStyle = on ? '#1d1409' : '#c9b48c'
ctx.font = 'bold 11px sans-serif'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText(`${i + 1}F`, chipX + chipW / 2, chipY + 10)
chipX += chipW + chipGap
}
ctx.fillStyle = '#f0d79a'
ctx.font = 'bold 15px "PingFang SC","Microsoft YaHei",sans-serif'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText(`${mx.name} · ${skin.label}`, view.px + view.pw / 2, view.py - 18)
ctx.fillStyle = '#c9b48c'
ctx.font = '11px sans-serif'
const floorHint = floors > 1
? `第 ${floor + 1}/${floors} 层 · 走近坡道按 E 换层`
: 'WASD 走动 · E 互动 · Esc 离开'
ctx.fillText(`住人 ${(mx.residents || []).length}/${houseCapacity(e)} · ${floorHint}`, view.px + view.pw / 2, view.py - 4)
// 小猫走在地板上、家具之下
drawHouseCat(ctx, view, env)
// 家具环境注入昼夜信息(挂钟/窗户用)
const fenv = { ...env, dayTime: g.dayTime || 0, phase: g.phase || 'day' }
furn.forEach((f) => drawFurnPiece(ctx, view, f, fenv))
if (av) {
const pos = roomToScreen(view, av.x, av.y)
const figScale = Math.max(1.1, 1.35 * view.scale)
ctx.save()
ctx.translate(pos.x, pos.y)
ctx.scale(figScale, figScale)
ctx.fillStyle = 'rgba(0,0,0,0.28)'
ctx.beginPath(); ctx.ellipse(0, 2, 12, 4, 0, 0, TAU); ctx.fill()
const pSkin = skinByCode(p?.skin || 'wilson')
const breathe = Math.sin((env.time || 0) * 2.4) * 0.8
drawPlayerFig(ctx, pSkin, {
dir: av.dir,
walking: av.walking,
walkT: av.walkT,
breathe,
time: env.time || 0,
toolCode: p?.equip?.hand?.code || '',
})
ctx.restore()
if (av.prompt) {
const labels = {
bed: 'E 睡觉/休息', bed2: 'E 睡觉/休息', door: 'E 离开',
stairs_up: 'E 上一层', stairs_down: 'E 下一层',
table: 'E 查看', shelf: 'E 查看', plant: 'E 查看',
sofa: 'E 查看', crate: 'E 查看', crate2: 'E 查看',
}
ctx.fillStyle = 'rgba(20,16,10,0.82)'
ctx.beginPath(); ctx.roundRect(pos.x - 52, pos.y - 58, 104, 20, 5); ctx.fill()
ctx.fillStyle = '#f4ead2'
ctx.font = 'bold 11px sans-serif'
ctx.textAlign = 'center'
ctx.fillText(labels[av.prompt] || 'E 互动', pos.x, pos.y - 46)
}
}
ctx.fillStyle = '#5a4630'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 2
ctx.beginPath(); ctx.roundRect(leaveBtn.x, leaveBtn.y, leaveBtn.w, leaveBtn.h, 6); ctx.fill(); ctx.stroke()
ctx.fillStyle = '#f2e8d0'
ctx.font = 'bold 13px sans-serif'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText('离开', leaveBtn.x + leaveBtn.w / 2, leaveBtn.y + leaveBtn.h / 2 + 1)
ctx.restore()
}
function handleInteriorClick(g, p, sx, sy) {
if (interiorOpen == null) return false
const e = g.entities.find((x) => x.id === interiorOpen && x.type === 'camphouse')
if (!e || !p) { leaveInterior(g); return true }
const hit = uiInteriorBtns.find((b) => sx >= b.x && sx <= b.x + b.w && sy >= b.y && sy <= b.y + b.h)
if (!hit) return true
tryInteriorInteract(g, p, hit.id)
return true
}
let uiPanel = null
let uiClose = null
let uiRows = []
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.builder || 0) + (e.mx.chef || 0) + (e.mx.lumber || 0) + (e.mx.miner || 0)
})
return n
}
function promptHouseName(def) {
try {
const v = typeof window !== 'undefined' ? window.prompt('给这片宅基起个名字', def) : null
const name = (v == null ? def : String(v)).trim()
return name.slice(0, 12) || def
} catch {
return def
}
}
function syncHousezoneMarkers(g) {
g.entities.filter((e) => e.type === 'housezone').forEach((e) => g.removeEntity(e))
houseZones.forEach((z) => {
g.addEntity('housezone', (z.x0 + z.x1) / 2, (z.y0 + z.y1) / 2, { mx: { ...z } })
})
}
function renameHouseZoneAt(g, wx, wy) {
const hit = houseZones.find((z) => pointInAabb(wx, wy, z))
if (!hit) return false
const name = promptHouseName(hit.name || '宅基')
hit.name = name
syncHousezoneMarkers(g)
g.tip(`宅基已命名为「${name}」`)
return true
}
function deleteHouseZone(g, z) {
// 未完工地基一并拆除;已建好的房屋保留
g.entities
.filter((e) => e.type === 'houseplan' && (e.mx?.zoneId === z.id || pointInAabb(e.x, e.y, z)))
.forEach((e) => g.removeEntity(e))
houseZones = houseZones.filter((a) => a.id !== z.id)
syncHousezoneMarkers(g)
g.tip(`已删除宅基「${z.name}」`)
return true
}
function houseZoneRightAction(g, z) {
openZoneMenu({
title: `「${z.name}」`,
subtitle: '宅基规划区',
actions: [
{ id: 'rename', label: '改名', color: '#4a6a8a' },
{ id: 'delete', label: '删除区域', color: '#7a4a3a' },
{ id: 'cancel', label: '取消', color: '#5a4630' },
],
onPick(id) {
if (id === 'rename') {
const name = promptHouseName(z.name || '宅基')
z.name = name
syncHousezoneMarkers(g)
g.tip(`宅基已命名为「${name}」`)
return
}
if (id === 'delete') {
openZoneMenu({
title: `删除「${z.name}」?`,
subtitle: '未建地基会清掉,已建房屋保留',
actions: [
{ id: 'confirm', label: '确认删除', color: '#8a3030' },
{ id: 'cancel', label: '返回', color: '#5a4630' },
],
onPick(id2) {
if (id2 === 'confirm') deleteHouseZone(g, z)
},
})
}
},
})
return true
}
function placeHousePlans(g, z) {
clearZoneClutter(g, z)
let n = 0
let idx = 1
const half = HOUSE_SPACING / 2
for (let y = z.y0 + half; y <= z.y1 - half; y += HOUSE_SPACING) {
for (let x = z.x0 + half; x <= z.x1 - half; x += HOUSE_SPACING) {
if (!g.isLand(x, y)) continue
const crowded = g.entities.some((e) => (e.type === 'camphouse' || e.type === 'houseplan') && Math.hypot(e.x - x, e.y - y) < HOUSE_SPACING * 0.55)
if (crowded) continue
const name = `${z.name}${idx}`
g.addEntity('houseplan', x, y, { mx: { name, zoneId: z.id, done: false } })
idx++
n++
}
}
syncHousezoneMarkers(g)
return n
}
function eternalFireCount(g) {
return (g.fires || []).filter((f) => f.eternal).length
}
// 火堆有效照明半径约 180,用间距铺满营地圆
const FIRE_SPACING = 160
function desiredGuardCount() {
// 按营地半径扩编:约每 250 距离 1 名,最少 1、最多 12
return Math.min(12, Math.max(1, Math.round(campR() / 250)))
}
function guardCount(g) {
return (g.monsters || []).filter((m) => m.kind === 'guard').length
}
/** 按圆环网格补长明火,照亮整个营地范围 */
function ensureCampFires(g) {
const c = campCenter(g)
const coverR = campR() * 0.92
const spots = []
for (let ring = 0; ring * FIRE_SPACING <= coverR + 1; ring++) {
if (ring === 0) {
spots.push({ x: c.x, y: c.y })
continue
}
const rad = ring * FIRE_SPACING
const n = Math.max(6, Math.round((TAU * rad) / FIRE_SPACING))
for (let i = 0; i < n; i++) {
const a = (i / n) * TAU + (ring % 2) * (Math.PI / n)
spots.push({ x: c.x + Math.cos(a) * rad, y: c.y + Math.sin(a) * rad })
}
}
let added = 0
spots.forEach((p) => {
const spot = g.findLand(p.x, p.y, 0, 48)
if (!spot || !g.isLand(spot.x, spot.y)) return
const near = (g.fires || []).some((f) => f.eternal && Math.hypot(f.x - spot.x, f.y - spot.y) < FIRE_SPACING * 0.55)
if (near) return
g.addFire(spot.x, spot.y, { pit: true, eternal: true, ttl: 150, max: 150 })
added++
})
return added
}
/** 守卫数量随营地范围自动补齐 */
function ensureGuards(g) {
const need = desiredGuardCount()
let have = guardCount(g)
if (have >= need) return 0
const c = campCenter(g)
let added = 0
for (let i = 0; i < need - have; i++) {
const a = (i / Math.max(1, need)) * TAU + have * 0.7
const rad = 50 + (i % 3) * 40
const spot = g.findLand(c.x + Math.cos(a) * rad, c.y + Math.sin(a) * rad, 0, 80)
if (!spot) continue
g.spawnMob('guard', spot.x, spot.y, {})
added++
}
return added
}
function addCampXp(g, base) {
if (!base || base <= 0) return
const mult = 1 + 0.08 * hiredCount(g)
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 fires = ensureCampFires(g)
const guards = ensureGuards(g)
syncHouseLooks(g)
const parts = [`营地升至 ${campState.level} 级!领地扩大`]
const model = HOUSE_MODELS[houseModelForLevel()]?.label
if (model) parts.push(`房屋升级为${model}`)
if (fires) parts.push(`补了 ${fires} 座长明篝火`)
if (guards) parts.push(`增派 ${guards} 名守卫`)
g.tip(parts.join(' · '))
if (campState.level >= 8 && !campState.fenced) {
buildCampFence(g)
}
}
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: 34 })
campState.fenced = true
g.tip('营地周围筑起了围栏与大门(野怪可能翻墙闯入,守卫会迎击)')
}
// 相机无关的“点是否在矩形内”
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
}
/**
* 宅基规划 + 全部房屋 的并集包围盒(两者都算进去,避免圈跑偏)
* @returns {{ x0, y0, x1, y1 } | null}
*/
function campPlanBounds(g) {
let x0 = Infinity
let y0 = Infinity
let x1 = -Infinity
let y1 = -Infinity
let any = false
houseZones.forEach((z) => {
if (!z || !Number.isFinite(z.x0) || !Number.isFinite(z.y0) || !Number.isFinite(z.x1) || !Number.isFinite(z.y1)) return
any = true
x0 = Math.min(x0, z.x0)
y0 = Math.min(y0, z.y0)
x1 = Math.max(x1, z.x1)
y1 = Math.max(y1, z.y1)
})
;(g?.entities || []).forEach((e) => {
if (e.type !== 'camphouse' || !Number.isFinite(e.x) || !Number.isFinite(e.y)) return
any = true
x0 = Math.min(x0, e.x - 48)
y0 = Math.min(y0, e.y - 48)
x1 = Math.max(x1, e.x + 48)
y1 = Math.max(y1, e.y + 48)
})
if (!any) return null
return { x0, y0, x1, y1 }
}
/**
* 营地安全区:整片宅基/房屋并集只画一个圈
* 半径至少为营地等级半径,保证主屋附近一定能看见圈
* @returns {{ cx, cy, rx, ry }}
*/
function campTerritory(g) {
const pad = Math.max(120, campR())
const b = campPlanBounds(g)
if (!b) {
const house = (g?.entities || []).find((e) => e.type === 'camphouse')
const cx = house ? house.x : (g.worldW / 2)
const cy = house ? house.y : (g.worldH / 2)
return { cx, cy, rx: pad, ry: pad * 0.62 }
}
const cx = (b.x0 + b.x1) / 2
const cy = (b.y0 + b.y1) / 2
// 包住并集后再外扩一整圈营地半径
const rx = Math.max(pad, (b.x1 - b.x0) / 2 + pad)
const ry = Math.max(pad * 0.62, (b.y1 - b.y0) / 2 + pad * 0.62)
if (!Number.isFinite(cx) || !Number.isFinite(cy) || !Number.isFinite(rx) || !Number.isFinite(ry)) {
return { cx: g.worldW / 2, cy: g.worldH / 2, rx: pad, ry: pad * 0.62 }
}
return { cx, cy, rx, ry }
}
// 营地圆心(宅基/房屋并集中心)
function campCenter(g) {
const t = campTerritory(g)
return { x: t.cx, y: t.cy }
}
function campHouseList(g) {
return (g?.entities || []).filter((e) => e.type === 'camphouse')
}
// 落在整片营地安全区内即算营地
function inCamp(g, x, y) {
const t = campTerritory(g)
if (!t.rx || !t.ry) return false
const dx = (x - t.cx) / t.rx
const dy = (y - t.cy) / t.ry
return dx * dx + dy * dy <= 1
}
export default {
id: 'camp',
name: '营地',
desc: '房屋休息跳夜、宅基规划盖房、营地升级扩半径、食堂点餐',
factor: 0.75, // 大幅降低难度
tag: 'easy',
tabs: [{ code: 'camp', name: '营地', icon: 'camphouse' }],
items: {
// 壁炉烤出来的热食:拿小肉在屋里壁炉烤制
roastmorsel: { name: '烤串', food: { hunger: 25, hp: 3, san: 2 }, perish: 8 },
},
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: {
roastmorsel: (c) => {
// 竹签 + 烤肉块
c.strokeStyle = '#8a6a3a'
c.lineWidth = 2
c.beginPath(); c.moveTo(-11, 9); c.lineTo(9, -9); c.stroke()
c.fillStyle = '#b06a3a'
c.strokeStyle = '#1d1409'
c.lineWidth = 1.5
c.beginPath(); c.ellipse(-1, 1, 6, 5, -0.78, 0, TAU); c.fill(); c.stroke()
c.fillStyle = '#8a4a26'
c.beginPath(); c.ellipse(4, -4, 5, 4, -0.78, 0, TAU); c.fill(); c.stroke()
c.strokeStyle = 'rgba(255,220,150,0.7)'
c.lineWidth = 1
c.beginPath(); c.moveTo(-3, 3); c.lineTo(0, 0); c.stroke()
},
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: {
housezone: {
name: '宅基区',
conf: { size: 8, deco: true },
init(e) {
if (!e.mx) e.mx = { x0: e.x - 40, y0: e.y - 40, x1: e.x + 40, y1: e.y + 40 }
},
draw() {},
},
houseplan: {
name: '房屋地基',
conf: { size: 40, deco: true },
init(e) {
if (!e.mx) e.mx = { name: '新房', done: false }
if (!e.mx.name) e.mx.name = '新房'
},
interact(p, e, g) {
try {
const v = typeof window !== 'undefined' ? window.prompt('房屋名称', e.mx.name || '新房') : null
if (v != null) {
e.mx.name = String(v).trim().slice(0, 12) || e.mx.name
g.markDirty(e)
g.ptip(p, `地基命名为「${e.mx.name}」· 等建筑工来盖`)
}
} catch { /* */ }
},
draw(ctx, e) {
ctx.fillStyle = 'rgba(0,0,0,0.2)'
ctx.beginPath(); ctx.ellipse(0, 6, 28, 8, 0, 0, TAU); ctx.fill()
// 夯土工地:虚线边界 + 四角木桩 + 蓝图小屋
ctx.strokeStyle = '#c9a06a'
ctx.fillStyle = 'rgba(180, 140, 80, 0.22)'
ctx.lineWidth = 2
ctx.setLineDash([6, 4])
ctx.beginPath(); ctx.roundRect(-28, -36, 56, 42, 3); ctx.fill(); ctx.stroke()
ctx.setLineDash([])
ctx.strokeStyle = '#8a6a34'
ctx.lineWidth = 1.6
;[[-28, -36], [28, -36], [-28, 6], [28, 6]].forEach(([px, py]) => {
ctx.beginPath()
ctx.moveTo(px > 0 ? px - 6 : px + 6, py)
ctx.lineTo(px, py + (py < 0 ? 8 : -8))
ctx.stroke()
ctx.fillStyle = '#a8763e'
ctx.beginPath(); ctx.arc(px, py, 1.6, 0, TAU); ctx.fill()
ctx.strokeStyle = '#8a6a34'
})
ctx.strokeStyle = '#c9a06a'
ctx.lineWidth = 1.6
ctx.beginPath(); ctx.moveTo(-20, -28); ctx.lineTo(20, -28); ctx.lineTo(0, -48); ctx.closePath(); ctx.stroke()
ctx.strokeStyle = 'rgba(201,160,106,0.5)'
ctx.lineWidth = 1
ctx.beginPath(); ctx.moveTo(-20, -28); ctx.lineTo(-6, -38); ctx.moveTo(0, -48); ctx.lineTo(0, -28); ctx.stroke()
// 角落的沙堆与石料
ctx.fillStyle = '#c9b083'
ctx.beginPath(); ctx.ellipse(20, 2, 6, 3.4, 0, 0, TAU); ctx.fill()
ctx.strokeStyle = '#8a6a34'
ctx.lineWidth = 1
ctx.stroke()
ctx.fillStyle = '#9a9da4'
;[[16, -4], [22, -6]].forEach(([px, py]) => {
ctx.beginPath()
ctx.moveTo(px - 3, py + 2); ctx.lineTo(px - 2, py - 2); ctx.lineTo(px + 2, py - 2.4); ctx.lineTo(px + 3, py + 2)
ctx.closePath()
ctx.fill()
ctx.stroke()
})
const name = e.mx?.name || '地基'
ctx.fillStyle = 'rgba(20,16,10,0.75)'
const tw = Math.max(36, name.length * 8 + 12)
ctx.beginPath(); ctx.roundRect(-tw / 2, -62, tw, 14, 4); ctx.fill()
ctx.fillStyle = '#f4ead2'
ctx.font = 'bold 10px sans-serif'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText(name, 0, -55)
},
},
camphouse: {
name: '房屋',
conf: { size: 52, struct: true },
init(e) {
normalizeHouseMx(e)
e.mx.model = houseModelForLevel()
e.mx.floors = Math.min(4, zoneFloors(getCampLevel()))
},
interact(p, e, g) {
normalizeHouseMx(e)
enterInterior(g, e)
g.ptip(p, `进入「${e.mx.name}」· WASD 走动 · 走近床按 E 睡觉`)
},
draw(ctx, e, env) {
drawHouseExterior(ctx, e, env)
},
},
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) {
let stored = 0
boxesOf(e).forEach((b) => {
Object.values(b.items || {}).forEach((k) => { stored += k || 0 })
})
drawNameplate(ctx, stored > 0 ? `营地仓储 · ${stored}件` : '营地仓储', -78)
ctx.fillStyle = 'rgba(0,0,0,0.25)'
ctx.beginPath(); ctx.ellipse(0, 8, 28, 8, 0, 0, TAU); ctx.fill()
const t = env?.time || 0
// 仓房主体:石基 + 竖板墙
ctx.fillStyle = '#8d9097'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 2
ctx.beginPath(); ctx.roundRect(-27, -8, 54, 14, 2); ctx.fill(); ctx.stroke()
ctx.fillStyle = '#a9773c'
ctx.beginPath(); ctx.roundRect(-25, -42, 50, 36, 2); ctx.fill(); ctx.stroke()
ctx.strokeStyle = 'rgba(90,58,28,0.55)'
ctx.lineWidth = 1.2
for (let x = -19; x < 25; x += 8) {
ctx.beginPath(); ctx.moveTo(x, -41); ctx.lineTo(x, -7); ctx.stroke()
}
ctx.fillStyle = 'rgba(60,40,20,0.5)'
;[[-21, -38], [21, -38], [-21, -11], [21, -11]].forEach(([x, y]) => {
ctx.beginPath(); ctx.arc(x, y, 1, 0, TAU); ctx.fill()
})
// 大谷仓门(分叉滑门 + 铁件)
ctx.fillStyle = '#5d3f24'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 2
ctx.beginPath(); ctx.roundRect(-13, -30, 26, 28, [2, 2, 0, 0]); ctx.fill(); ctx.stroke()
ctx.strokeStyle = '#3a2818'
ctx.lineWidth = 2.4
ctx.beginPath()
ctx.moveTo(-13, -30); ctx.lineTo(13, -2)
ctx.moveTo(13, -30); ctx.lineTo(-13, -2)
ctx.stroke()
ctx.strokeStyle = '#c9a03c'
ctx.lineWidth = 1.6
ctx.beginPath(); ctx.moveTo(-13, -18); ctx.lineTo(13, -18); ctx.stroke()
ctx.fillStyle = '#e0c060'
ctx.beginPath(); ctx.arc(0, -18, 1.8, 0, TAU); ctx.fill()
ctx.strokeStyle = '#1d1409'; ctx.lineWidth = 1; ctx.stroke()
// 挑檐坡顶 + 阴影
ctx.fillStyle = 'rgba(0,0,0,0.2)'
ctx.beginPath(); ctx.moveTo(-31, -40); ctx.lineTo(31, -40); ctx.lineTo(0, -66); ctx.closePath(); ctx.fill()
ctx.fillStyle = '#7a5230'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 2
ctx.beginPath(); ctx.moveTo(-31, -40); ctx.lineTo(31, -40); ctx.lineTo(0, -66); ctx.closePath(); ctx.fill(); ctx.stroke()
ctx.strokeStyle = 'rgba(40,26,14,0.55)'
ctx.lineWidth = 1.2
for (let r2 = 1; r2 <= 2; r2++) {
const yy = -40 - r2 * 8
const half = 31 * (1 - r2 * 0.24)
ctx.beginPath(); ctx.moveTo(-half, yy); ctx.lineTo(half, yy); ctx.stroke()
}
// 门口码放的货箱(带斜撑木条)
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 = 1.8
ctx.beginPath(); ctx.roundRect(x, y, w, h, 1.5); ctx.fill(); ctx.stroke()
ctx.strokeStyle = '#8a5f30'
ctx.lineWidth = 2
ctx.beginPath()
ctx.moveTo(x + 2, y + 2); ctx.lineTo(x + w - 2, y + h - 2)
ctx.moveTo(x + w - 2, y + 2); ctx.lineTo(x + 2, y + h - 2)
ctx.stroke()
ctx.strokeStyle = '#6b4420'
ctx.lineWidth = 1.4
ctx.beginPath(); ctx.roundRect(x + 1, y + 1, w - 2, h - 2, 1); ctx.stroke()
}
crate(-31, -9, 16, 16)
crate(-14, -5, 16, 16)
crate(-31, -25, 16, 16)
crate(15, -11, 18, 18)
// 空中悬吊的油灯(微光摆动)
const sw = Math.sin(t * 1.8) * 2
ctx.strokeStyle = '#3a2a18'
ctx.lineWidth = 1.2
ctx.beginPath(); ctx.moveTo(26, -44); ctx.quadraticCurveTo(26 + sw, -40, 26 + sw, -36); ctx.stroke()
ctx.fillStyle = '#3a3f48'
ctx.beginPath(); ctx.roundRect(24 + sw, -36, 4.6, 7, 1.5); ctx.fill()
ctx.fillStyle = '#ffd98a'
ctx.beginPath(); ctx.arc(26.3 + sw, -32.5, 1.8, 0, TAU); ctx.fill()
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 1
ctx.stroke()
},
},
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) {
const n = Object.values(e.mx?.menu || {}).reduce((a, b) => a + (b || 0), 0)
drawNameplate(ctx, n > 0 ? `食堂 · ${n}份` : '食堂', -76)
ctx.fillStyle = 'rgba(0,0,0,0.25)'
ctx.beginPath(); ctx.ellipse(0, 8, 28, 8, 0, 0, TAU); ctx.fill()
const t = env?.time || 0
// 木屋小馆:圆木墙 + 大锅烟囱
ctx.fillStyle = '#b07f47'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 2
ctx.beginPath(); ctx.roundRect(-26, -38, 52, 42, 3); ctx.fill(); ctx.stroke()
ctx.strokeStyle = 'rgba(90,58,28,0.55)'
ctx.lineWidth = 1.2
for (let y = -32; y < 2; y += 7) {
ctx.beginPath(); ctx.moveTo(-25, y); ctx.lineTo(25, y + 0.5); ctx.stroke()
}
// 服务窗口 + 吊板
ctx.fillStyle = '#3a2a16'
ctx.beginPath(); ctx.roundRect(-14, -30, 28, 15, 2); ctx.fill(); ctx.stroke()
ctx.fillStyle = '#8a6a3a'
ctx.beginPath(); ctx.roundRect(-17, -16, 34, 4, 1.5); ctx.fill(); ctx.stroke()
ctx.fillStyle = '#e8c070'
ctx.beginPath(); ctx.ellipse(0, -22, 9, 3.6, 0, 0, TAU); ctx.fill(); ctx.stroke()
// 门
ctx.fillStyle = '#5d3f24'
ctx.beginPath(); ctx.roundRect(12, -14, 11, 16, [2, 2, 0, 0]); ctx.fill(); ctx.stroke()
// 条纹遮阳棚(波浪边)
ctx.save()
ctx.beginPath()
ctx.moveTo(-32, -40)
ctx.lineTo(32, -40)
ctx.lineTo(28, -50)
ctx.lineTo(-28, -50)
ctx.closePath()
ctx.fillStyle = '#c45a3a'
ctx.fill()
ctx.clip()
ctx.fillStyle = '#f4ead2'
for (let x = -32; x < 32; x += 12) {
ctx.fillRect(x + ((x / 12) % 2 ? 6 : 0), -52, 6, 14)
}
ctx.restore()
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 2
ctx.beginPath()
ctx.moveTo(-32, -40)
ctx.lineTo(32, -40)
ctx.lineTo(28, -50)
ctx.lineTo(-28, -50)
ctx.closePath()
ctx.stroke()
// 棚边波浪
ctx.fillStyle = '#c45a3a'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 1.4
for (let x = -32; x < 30; x += 8) {
ctx.beginPath()
ctx.arc(x + 4, -40, 4, 0, Math.PI)
ctx.fill()
ctx.stroke()
}
// 屋顶
ctx.fillStyle = '#7a5230'
ctx.beginPath(); ctx.moveTo(-28, -50); ctx.lineTo(28, -50); ctx.lineTo(0, -66); ctx.closePath(); ctx.fill(); ctx.stroke()
// 烟囱 + 烟
ctx.fillStyle = '#8d9097'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 1.6
ctx.beginPath(); ctx.roundRect(14, -62, 9, 14, 1.5); ctx.fill(); ctx.stroke()
for (let i = 0; i < 2; i++) {
const ph = (t * 0.4 + i * 0.5) % 1.4
ctx.fillStyle = `rgba(220,220,220,${0.35 * (1 - ph / 1.4)})`
ctx.beginPath()
ctx.arc(18.5 + Math.sin(t + i * 2) * 3, -66 - ph * 18, 2.4 + ph * 3, 0, TAU)
ctx.fill()
}
// 门口汤锅(冒着热气)
ctx.fillStyle = '#4a4f55'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 1.6
ctx.beginPath(); ctx.roundRect(-24, -10, 14, 9, 2); ctx.fill(); ctx.stroke()
ctx.fillStyle = '#6a7078'
ctx.beginPath(); ctx.ellipse(-17, -10.5, 7.5, 2.6, 0, 0, TAU); ctx.fill(); ctx.stroke()
for (let i = 0; i < 2; i++) {
const ph = (t * 0.7 + i * 0.5) % 1
ctx.fillStyle = `rgba(240,240,240,${0.4 * (1 - ph)})`
ctx.beginPath()
ctx.arc(-17 + Math.sin(t * 2 + i * 3) * 2.4, -14 - ph * 9, 1.6 + ph * 1.8, 0, TAU)
ctx.fill()
}
if (n > 0) {
ctx.fillStyle = 'rgba(20,16,10,0.75)'
ctx.beginPath(); ctx.roundRect(-18, 6, 36, 13, 4); ctx.fill()
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 1
ctx.stroke()
ctx.fillStyle = '#f4ead2'
ctx.font = 'bold 11px system-ui, sans-serif'
ctx.textAlign = 'center'
ctx.fillText(`菜×${n}`, 0, 14.5)
}
},
},
campfence: {
name: '营地栅栏',
conf: { size: 24, struct: true, solid: true },
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, vert ? 6 : 16, vert ? 16 : 5, 0, 0, TAU)
ctx.fill()
ctx.save()
if (vert) ctx.rotate(Math.PI / 2)
// 三根削尖圆木桩 + 两道横木 + 绑绳
const logPost = (x) => {
ctx.fillStyle = '#8a6238'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 1.8
ctx.beginPath()
ctx.moveTo(x - 4.5, 2)
ctx.lineTo(x - 4.5, -26)
ctx.lineTo(x, -34)
ctx.lineTo(x + 4.5, -26)
ctx.lineTo(x + 4.5, 2)
ctx.closePath()
ctx.fill()
ctx.stroke()
ctx.strokeStyle = 'rgba(60,40,20,0.5)'
ctx.lineWidth = 1
ctx.beginPath(); ctx.moveTo(x - 1.5, -24); ctx.lineTo(x - 1.5, 0); ctx.stroke()
ctx.fillStyle = '#a8763e'
ctx.beginPath(); ctx.arc(x, -30, 1.6, 0, TAU); ctx.fill()
}
logPost(-11)
logPost(11)
const rail = (y) => {
ctx.fillStyle = '#9a6a3c'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 1.8
ctx.beginPath(); ctx.roundRect(-16, y, 32, 5, 2); ctx.fill(); ctx.stroke()
ctx.strokeStyle = 'rgba(60,40,20,0.4)'
ctx.lineWidth = 0.8
ctx.beginPath(); ctx.moveTo(-14, y + 1.6); ctx.lineTo(14, y + 1.6); ctx.stroke()
}
rail(-20)
rail(-8)
ctx.strokeStyle = '#c9a86a'
ctx.lineWidth = 2
;[-11, 11].forEach((x) => {
ctx.beginPath()
ctx.moveTo(x - 5, -22)
ctx.quadraticCurveTo(x, -19, x + 5, -22)
ctx.stroke()
ctx.beginPath()
ctx.moveTo(x - 5, -9)
ctx.quadraticCurveTo(x, -6, x + 5, -9)
ctx.stroke()
})
ctx.restore()
},
},
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)
// 门柱(更高更粗,顶部尖头)
;[-14, 14].forEach((x) => {
ctx.fillStyle = '#7a5230'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 2
ctx.beginPath()
ctx.moveTo(x - 5.5, 4)
ctx.lineTo(x - 5.5, -30)
ctx.lineTo(x, -39)
ctx.lineTo(x + 5.5, -30)
ctx.lineTo(x + 5.5, 4)
ctx.closePath()
ctx.fill()
ctx.stroke()
ctx.fillStyle = '#a8763e'
ctx.beginPath(); ctx.arc(x, -34, 2, 0, TAU); ctx.fill()
})
// 双层横木门扇 + 斜撑
ctx.fillStyle = '#9a6a3c'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 2
ctx.beginPath(); ctx.roundRect(-20, -24, 40, 6, 2); ctx.fill(); ctx.stroke()
ctx.beginPath(); ctx.roundRect(-20, -11, 40, 6, 2); ctx.fill(); ctx.stroke()
ctx.strokeStyle = 'rgba(60,40,20,0.45)'
ctx.lineWidth = 1.2
ctx.beginPath(); ctx.moveTo(-16, -20); ctx.lineTo(12, -13); ctx.stroke()
// 铰链 + 绑绳
ctx.strokeStyle = '#c9a86a'
ctx.lineWidth = 2
;[-14, 14].forEach((x) => {
ctx.beginPath()
ctx.moveTo(x - 5.5, -26)
ctx.quadraticCurveTo(x, -23, x + 5.5, -26)
ctx.stroke()
})
// 门闩铜扣
ctx.fillStyle = '#e0c060'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 1.4
ctx.beginPath(); ctx.roundRect(open ? 8 : 10, -19, 10, 8, 2); ctx.fill(); ctx.stroke()
ctx.fillStyle = 'rgba(255,255,255,0.35)'
ctx.beginPath(); ctx.arc((open ? 8 : 10) + 3, -17, 1.4, 0, TAU); ctx.fill()
ctx.restore()
},
},
},
mobs: {
guard: {
name: '守卫',
friendly: true,
conf: { hp: 9999, speed: 210, dmg: 70, score: 0, friendly: true, name: '守卫' },
drops: [],
update(m, dt, g) {
const conf = g.mon[m.kind] || this.conf
// 无限血:核心所有伤害路径都是 m.hp -= dmg 后判 <=0,每帧回满即可免疫
m.hp = 1e9
if (m.cd > 0) m.cd -= dt
const c = campCenter(g)
const engageR = campR() + 140
// 优先打离自己最近、已进入/逼近营地的敌对生物
let target = null
let best = Infinity
g.monsters.forEach((q) => {
if (q === m || q.kind === 'guard') return
if (!g.mon[q.kind]?.hostile) return
const dCamp = Math.hypot(q.x - c.x, q.y - c.y)
if (dCamp > engageR) return
const dMe = Math.hypot(q.x - m.x, q.y - m.y)
const score = dMe + dCamp * 0.12
if (score < best) { best = score; target = q }
})
if (target) {
const d = Math.hypot(target.x - m.x, target.y - m.y)
if (d > 30) {
g.moveToward(m, target.x, target.y, conf.speed * 1.4, dt)
} else if ((m.cd || 0) <= 0) {
m.cd = 0.35
g.damageMob(target, conf.dmg, null)
g.fx?.('spark', target.x, target.y - 12, { dir: Math.atan2(target.y - m.y, target.x - m.x), spread: 0.7, n: 6 })
}
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() * Math.min(140, campR() * 0.35)
m.tx = c.x + Math.cos(a) * r
m.ty = c.y + Math.sin(a) * r
}
if (m.tx !== undefined && Math.hypot(m.tx - m.x, m.ty - m.y) > 8) {
g.moveToward(m, m.tx, m.ty, conf.speed * 0.45, dt)
} else {
m.moving = false
}
},
interact(p, m, g) {
g.ptip(p, `守卫:发现敌对会立刻迎击(当前编制 ${desiredGuardCount()} 人)`)
},
draw(ctx, m, time) {
// 骨骼化守卫:巡逻步态 / 待机呼吸 / 突刺由 m.cd 驱动(starveSkins.drawGuard)
drawGuard(ctx, {
dir: m.dir || 1,
moving: !!m.moving,
walkT: time * 1,
cd: m.cd > 0 ? m.cd : 0,
bob: m.bob || 0,
time,
})
},
},
},
hooks: {
save() {
return { ...campState, houseZones, nextHouseZoneId }
},
load(g, data) {
campState = {
level: Math.max(1, data?.level || 1),
xp: Math.max(0, data?.xp || 0),
fenced: !!data?.fenced,
}
houseZones = (data?.houseZones || []).map((z, i) => ({
id: z.id || i + 1,
name: z.name || `宅基${i + 1}`,
x0: z.x0, y0: z.y0, x1: z.x1, y1: z.y1,
}))
nextHouseZoneId = data?.nextHouseZoneId || (Math.max(0, ...houseZones.map((z) => z.id)) + 1) || 1
houseZoneMode = false
houseDrag = null
storeOpen = null
canteenOpen = null
leaveInterior(null)
syncHousezoneMarkers(g)
// 给无名字的旧房屋补名
g.entities.filter((e) => e.type === 'camphouse').forEach((e, i) => {
if (!e.mx) e.mx = {}
if (!e.mx.name) e.mx.name = i === 0 ? '主屋' : `房屋${i + 1}`
})
// 旧档补齐照明与守卫编制
ensureCampFires(g)
ensureGuards(g)
},
onWorldGen(g) {
const cx = g.worldW / 2
const cy = g.worldH / 2
const house = g.findLand(cx - 40, cy - 30, 0, 60)
g.addEntity('camphouse', house.x, house.y, { mx: { restDay: 0, name: '主屋', floors: 1 } })
ensureCampFires(g)
ensureGuards(g)
const can = g.findLand(cx - 80, cy + 50, 0, 60)
g.addEntity('canteen', can.x, can.y)
g.tip(`营地已就绪:主屋、分散长明篝火、食堂与 ${desiredGuardCount()} 名守卫`)
},
// 墙外野怪不能隔墙打营内玩家;翻墙进来的可以打,由守卫迎击
onMobVictim(g, m, victim) {
if (!g.mon[m.kind]?.hostile) return
if (inCamp(g, victim.x, victim.y) && !inCamp(g, m.x, m.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) {
ensureCampFires(g)
ensureGuards(g)
syncHouseLooks(g)
let xp = 0
const farms = g.entities.filter((e) => e.type === 'agrifarm')
xp += Math.min(20, farms.filter((e) => e.state === 'ready').length * 2 + Math.floor(farms.length / 4))
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)
},
allowsPlayerMove(g, p) {
if (interiorOpen != null) return false
},
onTick(g, dt) {
updateInterior(g, dt)
// Esc 关闭营地仓储 / 食堂 / 区域菜单
if (g.keyDown?.('Escape') && interiorOpen == null) {
if (storeOpen != null || canteenOpen != null) {
storeOpen = null
canteenOpen = null
}
if (isZoneMenuOpen()) closeZoneMenu()
}
g.entities.forEach((e) => {
if (e.type !== 'campgate') return
if (!e.mx) e.mx = { open: false, openT: 0 }
const near = (g.players || []).some((p) => !p.dead && Math.hypot(p.x - e.x, p.y - e.y) < 56)
|| (g.monsters || []).some((m) => (m.kind === 'laborfarmer' || m.kind === 'laborherder' || m.kind === 'laborbuilder' || m.kind === 'laborchef' || m.kind === 'laborlumber' || m.kind === 'laborminer' || m.kind === 'campvillager') && Math.hypot(m.x - e.x, m.y - e.y) < 56)
if (near) {
if (!e.mx.open) {
e.mx.open = true
e.solid = false
g.markDirty(e)
}
e.mx.openT = Math.max(e.mx.openT || 0, 8)
e.solid = false
return
}
if (e.mx.open) {
e.mx.openT = (e.mx.openT ?? 8) - 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 t = campTerritory(g)
const ex = t.cx - env.camX
const ey = t.cy - env.camY
ctx.save()
ctx.fillStyle = 'rgba(255, 220, 140, 0.045)'
ctx.strokeStyle = 'rgba(255, 220, 140, 0.55)'
ctx.lineWidth = 2.5
ctx.setLineDash([12, 8])
ctx.beginPath()
ctx.ellipse(ex, ey, t.rx, t.ry, 0, 0, TAU)
ctx.fill()
ctx.stroke()
ctx.setLineDash([])
ctx.restore()
// 夜晚:房屋窗光与门灯(世界坐标,画在实体之上、夜晚压暗层之下)
if (g.phase === 'night') {
g.entities.forEach((he) => {
if (he.type !== 'camphouse') return
const mx2 = normalizeHouseMx(he)
const floors2 = Math.max(1, Math.min(4, mx2.floors || 1))
const model2 = mx2.model || 'hut'
const sx = he.x - env.camX
const sy = he.y - env.camY
if (sx < -120 || sx > env.W + 120 || sy < -260 || sy > env.H + 60) return
for (let f2 = 0; f2 < floors2; f2++) {
const baseY2 = -46 - f2 * 28
const wins = model2 === 'manor' ? [[-15, 12.5], [14.5, 12.5]] : [[14.5, 12.5]]
wins.forEach(([wx, wy]) => {
const gx = sx + wx
const gy = sy + baseY2 + wy
const grd = ctx.createRadialGradient(gx, gy, 2, gx, gy, 26)
grd.addColorStop(0, 'rgba(255,200,110,0.32)')
grd.addColorStop(1, 'rgba(255,200,110,0)')
ctx.fillStyle = grd
ctx.beginPath()
ctx.arc(gx, gy, 26, 0, TAU)
ctx.fill()
ctx.fillStyle = 'rgba(255,214,130,0.5)'
ctx.beginPath()
ctx.roundRect(gx - 7, gy - 6, 14, 12, 1.5)
ctx.fill()
})
}
})
}
// 各块宅基规划虚线框
houseZones.forEach((z) => {
ctx.save()
ctx.strokeStyle = 'rgba(180, 160, 220, 0.55)'
ctx.fillStyle = 'rgba(140, 120, 200, 0.08)'
ctx.lineWidth = 2
ctx.setLineDash([8, 6])
ctx.fillRect(z.x0 - env.camX, z.y0 - env.camY, z.x1 - z.x0, z.y1 - z.y0)
ctx.strokeRect(z.x0 - env.camX, z.y0 - env.camY, z.x1 - z.x0, z.y1 - z.y0)
ctx.setLineDash([])
const x = z.x0 - env.camX
const y = z.y0 - env.camY
ctx.fillStyle = 'rgba(30, 24, 50, 0.75)'
ctx.beginPath(); ctx.roundRect(x + 4, y + 4, Math.min(120, z.x1 - z.x0 - 8), 18, 4); ctx.fill()
ctx.fillStyle = '#e8e0f8'
ctx.font = 'bold 12px sans-serif'
ctx.textAlign = 'left'
ctx.textBaseline = 'middle'
ctx.fillText(z.name || '宅基', x + 10, y + 13)
ctx.restore()
})
if (houseDrag) {
const r2 = normalizeRect(houseDrag.x0, houseDrag.y0, houseDrag.x1, houseDrag.y1)
ctx.save()
ctx.strokeStyle = 'rgba(200, 180, 255, 0.9)'
ctx.fillStyle = 'rgba(160, 140, 220, 0.15)'
ctx.lineWidth = 2
ctx.setLineDash([6, 4])
ctx.fillRect(r2.x0 - env.camX, r2.y0 - env.camY, r2.x1 - r2.x0, r2.y1 - r2.y0)
ctx.strokeRect(r2.x0 - env.camX, r2.y0 - env.camY, r2.x1 - r2.x0, r2.y1 - r2.y0)
ctx.restore()
}
},
onPointerDown(g, wx, wy) {
const sx = wx - uiCamX
const sy = wy - uiCamY
if (uiHouseBtn && inside(sx, sy, uiHouseBtn)) return false
if (uiHouseCancel && inside(sx, sy, uiHouseCancel)) return false
if (!houseZoneMode) return false
houseDrag = { x0: wx, y0: wy, x1: wx, y1: wy }
return true
},
onPointerMove(g, wx, wy) {
if (!houseZoneMode || !houseDrag) return false
houseDrag.x1 = wx
houseDrag.y1 = wy
return true
},
onPointerUp(g, wx, wy) {
if (!houseZoneMode) return false
if (!houseDrag) {
houseZoneMode = false
return true
}
houseDrag.x1 = wx
houseDrag.y1 = wy
const rect = normalizeRect(houseDrag.x0, houseDrag.y0, houseDrag.x1, houseDrag.y1)
houseDrag = null
houseZoneMode = false
if (rect.x1 - rect.x0 < 100 || rect.y1 - rect.y0 < 100) {
g.tip('宅基太小,再划大一点')
return true
}
const name = promptHouseName(`宅基${houseZones.length + 1}`)
const z = { id: nextHouseZoneId++, name, ...rect }
houseZones.push(z)
const n = placeHousePlans(g, z)
g.tip(`「${name}」已划定:铺设地基 ${n} 处,招聘建筑工自动盖房(右键管理)`)
return true
},
onRightClick(g, wx, wy, ev) {
if (houseZoneMode) return false
const z = houseZones.find((a) => pointInAabb(wx, wy, a))
if (!z) return false
if (ev?.shiftKey) return renameHouseZoneAt(g, wx, wy)
return houseZoneRightAction(g, z)
},
onDrawUI(g, ctx, env) {
// 记录相机位置,供 onClick 做世界→屏幕换算
uiCamX = env.camX
uiCamY = env.camY
const W = env.W
const H = env.H
// 营地等级 HUD(面板宽度按文字实测自适应,防溢出)
ctx.save()
ctx.font = 'bold 12px system-ui, sans-serif'
const need = xpNeed(campState.level)
const line = `营地 Lv.${campState.level} XP ${campState.xp}/${need} 守卫 ${guardCount(g)}/${desiredGuardCount()} 篝火 ${eternalFireCount(g)}`
const lineW = ctx.measureText(line).width
ctx.fillStyle = 'rgba(40,28,14,0.75)'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 2
ctx.beginPath(); ctx.roundRect(10, 64, lineW + 26, 36, 6); ctx.fill(); ctx.stroke()
ctx.fillStyle = '#f4ead2'
ctx.textAlign = 'left'
ctx.textBaseline = 'middle'
ctx.fillText(line, 24, 82)
// 划定宅基按钮
const bw = 92
const bh = 28
const bx = 10
const by = 110
uiHouseBtn = { x: bx, y: by, w: bw, h: bh }
ctx.fillStyle = houseZoneMode ? '#5a4a7a' : '#3a2a4a'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 2
ctx.beginPath(); ctx.roundRect(bx, by, bw, bh, 6); ctx.fill(); ctx.stroke()
ctx.fillStyle = '#f4ead2'
ctx.font = '12px sans-serif'
ctx.textAlign = 'center'
ctx.fillText(houseZoneMode ? '框选中…' : '划定宅基', bx + bw / 2, by + bh / 2)
if (houseZoneMode) {
uiHouseCancel = { x: bx, y: by + bh + 6, w: bw, h: 22 }
ctx.fillStyle = '#5a3a28'
ctx.beginPath(); ctx.roundRect(uiHouseCancel.x, uiHouseCancel.y, uiHouseCancel.w, uiHouseCancel.h, 5); ctx.fill(); ctx.stroke()
ctx.fillStyle = '#f4ead2'
ctx.fillText('取消', uiHouseCancel.x + bw / 2, uiHouseCancel.y + 11)
} else {
uiHouseCancel = null
}
ctx.restore()
// 食堂面板
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 = Math.min(H - 16, headerH + Math.max(1, entries.length) * rowH + footerH + pad)
const px = Math.round((W - panelW) / 2)
const py = Math.round(Math.max(8, (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 - ch - 10, 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 = []
}
// 营地仓储面板(与室内/区域菜单解耦,勿 early-return)
if (storeOpen != null) {
const e = g.entities.find((x) => x.id === storeOpen)
const p = g.me()
if (!e || !p) {
storeOpen = null
uiPanel = null
uiClose = null
uiRows = []
} else {
const boxes = boxesOf(e)
const rowH = 30
const headerH = 40
const footerH = 42
const pad = 10
const panelW = Math.min(470, W - 24)
const panelH = Math.min(H - 16, headerH + BOXES * rowH + footerH + pad)
const px = Math.round((W - panelW) / 2)
const py = Math.round(Math.max(8, (H - panelH) / 2))
uiPanel = { x: px, y: py, w: panelW, h: panelH }
ctx.save()
ctx.fillStyle = 'rgba(0,0,0,0.45)'
ctx.fillRect(0, 0, W, H)
ctx.fillStyle = '#3a2a18'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 3
ctx.beginPath(); ctx.roundRect(px, py, panelW, panelH, 10); ctx.fill(); ctx.stroke()
ctx.fillStyle = '#5a4022'
ctx.beginPath(); ctx.roundRect(px + 3, py + 3, panelW - 6, headerH - 6, [8, 8, 0, 0]); ctx.fill()
ctx.fillStyle = '#f4ead2'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.font = 'bold 17px system-ui, sans-serif'
ctx.fillText('营地仓储', px + panelW / 2, py + headerH / 2 - 2)
ctx.font = '11px system-ui, sans-serif'
ctx.fillStyle = 'rgba(244,234,210,0.7)'
ctx.fillText(`共 ${BOXES} 箱 · 每箱 ${BOX_KINDS} 种 · 每种上限 ${BOX_CAP}`, px + panelW / 2, py + headerH - 6)
uiRows = []
const rightEdge = px + panelW - pad - 4
const btnH = 20
const depW = 66
const witW = 60
const gap = 6
const fontSize = 13
const bodyBottom = py + panelH - footerH
for (let i = 0; i < BOXES; i++) {
const rowY = py + headerH + i * rowH
if (rowY + rowH > bodyBottom) break
const box = boxes[i]
const used = usedKinds(box)
ctx.fillStyle = i % 2 ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.14)'
ctx.beginPath(); ctx.roundRect(px + pad, rowY + 2, panelW - pad * 2, rowH - 5, 4); ctx.fill()
ctx.textAlign = 'left'
ctx.textBaseline = 'middle'
ctx.fillStyle = used > 0 ? '#f4ead2' : 'rgba(244,234,210,0.5)'
ctx.font = `bold ${fontSize}px system-ui, sans-serif`
ctx.fillText(`箱子 ${i + 1}`, px + pad + 8, rowY + rowH / 2 - 1)
ctx.fillStyle = 'rgba(255,220,140,0.85)'
ctx.font = `11px system-ui, sans-serif`
ctx.fillText(`${used}/${BOX_KINDS} 种`, px + pad + 62, rowY + rowH / 2 - 1)
const entries = Object.entries(box.items || {}).filter(([, n]) => n > 0)
const shown = entries.slice(0, 3).map(([code, n]) => `${g.items[code]?.name || code}×${n}`)
const more = entries.length > 3 ? ` 等${entries.length}种` : ''
ctx.fillStyle = 'rgba(230,220,200,0.8)'
ctx.font = `12px system-ui, sans-serif`
ctx.fillText(entries.length ? shown.join(' ') + more : '(空)', px + pad + 108, rowY + rowH / 2 - 1)
const by = rowY + (rowH - 3 - btnH) / 2 + 1
const dep = { x: rightEdge - witW - gap - depW, y: by, w: depW, h: btnH }
const wit = { x: rightEdge - witW, y: by, w: witW, h: btnH }
const drawBtn = (r, label, bg) => {
ctx.fillStyle = bg
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 1.4
ctx.beginPath(); ctx.roundRect(r.x, r.y, r.w, r.h, 5); ctx.fill(); ctx.stroke()
ctx.fillStyle = '#f4ead2'
ctx.textAlign = 'center'
ctx.font = `bold 12px system-ui, sans-serif`
ctx.fillText(label, r.x + r.w / 2, r.y + r.h / 2 + 0.5)
}
drawBtn(dep, '↓ 存入', '#3f6b34')
drawBtn(wit, '↑ 取出', '#8a6a34')
uiRows.push({ i, dep, wit })
}
// 关闭按钮始终贴在面板可视底部
const cw = 120
const ch = 26
const cx2 = px + panelW / 2 - cw / 2
const cy2 = py + panelH - ch - 10
uiClose = { x: cx2, y: cy2, w: cw, h: ch }
ctx.fillStyle = '#7a2f24'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 2
ctx.beginPath(); ctx.roundRect(cx2, cy2, cw, ch, 6); ctx.fill(); ctx.stroke()
ctx.fillStyle = '#f4ead2'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.font = 'bold 14px system-ui, sans-serif'
ctx.fillText('关闭', cx2 + cw / 2, cy2 + ch / 2 + 0.5)
ctx.restore()
}
} else {
uiPanel = null
uiClose = null
uiRows = []
}
// 室内子地图与区域菜单必须始终有机会绘制
if (interiorOpen != null) drawInterior(g, ctx, env)
drawZoneMenu(ctx, env)
},
onClick(g, wx, wy) {
if (handleZoneMenuClick(wx, wy)) return true
const sx = wx - uiCamX
const sy = wy - uiCamY
if (interiorOpen != null) {
return handleInteriorClick(g, g.me(), sx, sy)
}
if (uiHouseBtn && inside(sx, sy, uiHouseBtn)) {
houseZoneMode = !houseZoneMode
houseDrag = null
g.tip(houseZoneMode ? '拖拽框选宅基(松手命名,建筑工会盖房)' : '取消划定宅基')
return true
}
if (uiHouseCancel && inside(sx, sy, uiHouseCancel)) {
houseZoneMode = false
houseDrag = null
return true
}
// 食堂面板优先
if (canteenOpen != null) {
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
},
},
}