3020 lines
114 KiB
JavaScript
3020 lines
114 KiB
JavaScript
// 营地:出生点自带房屋、长明篝火与无敌守卫,可建造营地仓储与食堂
|
||
// - 房屋 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
|
||
},
|
||
},
|
||
}
|