diff --git a/src/games/components/StarveGame.vue b/src/games/components/StarveGame.vue index 1182e57..32251e9 100644 --- a/src/games/components/StarveGame.vue +++ b/src/games/components/StarveGame.vue @@ -9,7 +9,7 @@ import { skinByCode, drawPlayerFig } from '../starveSkins' import { CAP, DAY_LEN, cookPot, DECOR } from '../starve/data' import { seasonOf, daySplit, phaseOf, ambientTemp, rollWeather, tickWeather, updateBodyTemp } from '../starve/climate' import { - TILE, GW, GH, WORLD, BIOMES, tileIdx, isLand, biomeAt, onRoad, findLandNear, + TILE, GW, GH, WORLD, BIOMES, tileIdx, isLand, biomeAt, onRoad, findLandNear, snapToLand, genIsland, planEntities, genDecor, buildTilePatterns, drawTiles, configureWorld, } from '../starve/world' import { buildIcons, drawEnt, drawMob, drawFirePainter, drawDecor } from '../starve/paint' @@ -46,6 +46,8 @@ const ghostMode = ref(false) const deathStats = ref({ day: 1, score: 0, kills: 0 }) const hp = ref(CAP.hp) const hunger = ref(CAP.hunger) +const foodBag = ref(0) +const foodStore = ref(0) const san = ref(CAP.san) const temp = ref(20) const day = ref(1) @@ -54,6 +56,7 @@ const seasonTag = ref({ name: '秋', dayIn: 1, len: 20, tint: '#c98548', code: ' const tips = ref([]) const hint = ref('') const nearActions = ref([]) +const autoInteractOn = ref(false) const craftOpen = ref(false) const craftTab = ref('tools') const packOpen = ref(false) @@ -187,6 +190,13 @@ let flashRed = 0 let potOpenId = 0 let boxOpenId = 0 const BOX_SLOTS = 12 +// 建造/迁移:选位置放置(不落在脚下) +let placeMode = null // { code, name, cost, kind:'ent'|'fire', pit?, relocateId?, extra? } +let placeCursor = { x: 0, y: 0 } +const NO_RELOCATE = new Set([ + 'fence', 'gate', 'campfence', 'campgate', 'houseplan', 'agrizone', 'pasturezone', + 'housezone', 'agrifarm', 'stump', 'rubble', 'chest', 'startchest', +]) let invDirty = true let hudT = 0 let mapT = 0 @@ -296,10 +306,46 @@ function addItem(p, code, n = 1, fresh = 1, dur = null) { invDirty = true if (n > 0 && isHostRole()) dropLoot(p.x + randInt(-20, 20), p.y + randInt(10, 26), code, n, fresh) } +// 大仓库 / 营地仓储合计(合成、建造可共用) +const STOCK_STORE_TYPES = new Set(['laborwarehouse', 'campstore']) +function warehouseCount(code) { + let n = 0 + for (const e of entities) { + if (!STOCK_STORE_TYPES.has(e.type)) continue + const boxes = e.mx?.boxes + if (!Array.isArray(boxes)) continue + for (const b of boxes) n += b?.items?.[code] || 0 + } + return n +} +function withdrawWarehouse(code, amount) { + let need = Math.max(0, amount | 0) + if (need <= 0) return 0 + let got = 0 + for (const e of entities) { + if (need <= 0 || !STOCK_STORE_TYPES.has(e.type)) continue + const boxes = e.mx?.boxes + if (!Array.isArray(boxes)) continue + for (const b of boxes) { + if (need <= 0) break + if (!b.items) b.items = {} + const have = b.items[code] || 0 + if (have <= 0) continue + const take = Math.min(have, need) + b.items[code] = have - take + if (b.items[code] <= 0) delete b.items[code] + need -= take + got += take + markDirty(e) + } + } + return got +} function hasCost(p, cost) { - return Object.entries(cost).every(([code, n]) => countItem(p, code) >= n) + return Object.entries(cost).every(([code, n]) => countItem(p, code) + warehouseCount(code) >= n) } function payCost(p, cost) { + // 先扣背包,不够再扣仓库(避免「存入仓库」时误从仓里扣) Object.entries(cost).forEach(([code, need]) => { let n = need ;[p.slots, p.pack].filter(Boolean).forEach((area) => { @@ -313,8 +359,10 @@ function payCost(p, cost) { } } }) + if (n > 0) withdrawWarehouse(code, n) }) invDirty = true + return true } function slotArea(p, area) { return area === 'pack' ? p.pack : p.slots @@ -534,7 +582,7 @@ function makeEnt(type, x, y, extra = {}) { } if (type === 'tentacle') { e.state = 'lurk'; e.atkT = 0; e.cd = 0; e.dir = 1 } if (type === 'rabbit') { e.vx = 0; e.vy = 0; e.dir = 1; e.hop = Math.random() * 7; e.flee = false; e.wanderT = 0 } - if (type === 'rabbithole') { e.respawnT = 0; e.rabbitId = 0 } + if (type === 'rabbithole') { e.respawnT = 0; e.rabbitId = 0; e.solid = false } if (type === 'beehive') e.beeCd = 0 if (type === 'pighouse') { e.pigId = 0; e.respawnT = 0 } modRt.entKinds[type]?.init?.(e) @@ -715,19 +763,76 @@ function screenDeltaToLocal(dx, dy) { let suppressClick = false function onCanvasPointerDown(ev) { if (!playing.value || !player || player.dead || waitingWorld.value) return + // 右键:取消建造 / 开始迁移 / mod(升级农田牧场) + if (ev.button === 2) { + const { x: wxp, y: wyp } = eventToWorld(ev) + if (placeMode) { + cancelPlaceMode() + suppressClick = true + ev.preventDefault?.() + return + } + const mov = findRelocateAt(wxp, wyp) + if (mov) { + startPlaceMode(mov.type, RECIPES.find((x) => x.code === mov.type) || { name: NAMES[mov.type] || mov.type, cost: {} }, { + kind: 'ent', + relocateId: mov.id, + }) + suppressClick = true + ev.preventDefault?.() + return + } + if (callHandled(modRt, 'onRightClick', gCtx, wxp, wyp, ev)) { + suppressClick = true + ev.preventDefault?.() + } + return + } if (ev.button != null && ev.button !== 0) return const { x: wxp, y: wyp } = eventToWorld(ev) + if (placeMode) { + confirmPlaceAt(wxp, wyp) + suppressClick = true + ev.preventDefault?.() + return + } if (callHandled(modRt, 'onPointerDown', gCtx, wxp, wyp, ev)) { suppressClick = true ev.preventDefault?.() } } +function onCanvasContextMenu(ev) { + ev.preventDefault?.() + if (!playing.value || !player || player.dead || waitingWorld.value) return + const { x: wxp, y: wyp } = eventToWorld(ev) + if (placeMode) { + cancelPlaceMode() + suppressClick = true + return + } + const mov = findRelocateAt(wxp, wyp) + if (mov) { + startPlaceMode(mov.type, RECIPES.find((x) => x.code === mov.type) || { name: NAMES[mov.type] || mov.type, cost: {} }, { + kind: 'ent', + relocateId: mov.id, + }) + suppressClick = true + return + } + if (callHandled(modRt, 'onRightClick', gCtx, wxp, wyp, ev)) suppressClick = true +} function onCanvasPointerMove(ev) { if (!playing.value || !player || player.dead || waitingWorld.value) { updateCursor(ev) return } const { x: wxp, y: wyp } = eventToWorld(ev) + placeCursor.x = wxp + placeCursor.y = wyp + if (placeMode) { + updateCursor(ev) + return + } if (callHandled(modRt, 'onPointerMove', gCtx, wxp, wyp, ev)) { suppressClick = true ev.preventDefault?.() @@ -737,6 +842,7 @@ function onCanvasPointerMove(ev) { } function onCanvasPointerUp(ev) { if (!playing.value || !player || player.dead || waitingWorld.value) return + if (placeMode) return const { x: wxp, y: wyp } = eventToWorld(ev) if (callHandled(modRt, 'onPointerUp', gCtx, wxp, wyp, ev)) { suppressClick = true @@ -753,6 +859,10 @@ function onCanvasClick(ev) { } if (!playing.value || !player || player.dead || waitingWorld.value) return const { x: wxp, y: wyp } = eventToWorld(ev) + if (placeMode) { + confirmPlaceAt(wxp, wyp) + return + } // mod 点击钩子:任一 mod 处理并返回 true 则吃掉本次点击(不选中怪物/火/实体,也不点地移动) if (callHandled(modRt, 'onClick', gCtx, wxp, wyp)) return let mon = null @@ -768,7 +878,7 @@ function onCanvasClick(ev) { let ent = null let ed = Infinity entities.forEach((e) => { - if (e.deco && e.type !== 'stump') return + if (e.deco && e.type !== 'stump' && !modRt.entKinds[e.type]?.interact) return if (e.stub) return // 栅栏等 solid 不可点选;门始终可点以开关 if (e.solid && e.type !== 'gate' && e.type !== 'campgate') return @@ -844,7 +954,8 @@ function canInteractEnt(e) { if (!e || e.stub) return false // 关着的门仍可点开;其它 solid 不可交互 if (e.solid && e.type !== 'gate' && e.type !== 'campgate') return false - if (e.deco && e.type !== 'stump') return false + // deco 默认不可点;有 mod interact 的(如房屋地基)可点 + if (e.deco && e.type !== 'stump' && !modRt.entKinds[e.type]?.interact) return false return !!(e.loot || e.type === 'trap' || (e.pot && (e.state === 'done' || e.state === 'idle')) || (e.drops && !e.mobile) || e.type === 'stump' || e.struct || modRt.entKinds[e.type]?.interact) } @@ -884,15 +995,50 @@ function advanceQuickQueue() { quickAdvancing = false } } +function isAutoInteractSkipEnt(e) { + if (!e) return true + // 篝火另有添柴;房屋/地基休息改名不进自动交互 + if (e.type === 'camphouse' || e.type === 'houseplan' || e.type === 'resttent') return true + return false +} +function pickAutoInteractTarget() { + if (!player || player.dead) return null + let best = null + let bd = 150 + entities.forEach((e) => { + if (!canInteractEnt(e) || isAutoInteractSkipEnt(e)) return + const d = Math.hypot(e.x - player.x, e.y - player.y) + if (d < bd) { bd = d; best = { kind: 'ent', id: e.id } } + }) + // 可交互友好生物(劳工绑定等);不含篝火、不含纯攻击 + monsters.forEach((m) => { + if (!modRt.mobKinds[m.kind]?.interact) return + const d = Math.hypot(m.x - player.x, m.y - player.y) + if (d < Math.min(bd, 90)) { bd = d; best = { kind: 'mon', id: m.id } } + }) + return best +} +function tickAutoInteract() { + if (!autoInteractOn.value || !playing.value || !player || player.dead || waitingWorld.value) return + if (player.target) return + const t = pickAutoInteractTarget() + if (t) setMyTarget(t) +} function doQuickInteract() { if (!playing.value || !player || player.dead || waitingWorld.value) return - const list = nearActions.value - if (!list.length) { - doGather() - return + // E:自动交互开关(开=持续寻找可交互物,关=停止;不含篝火/房屋) + autoInteractOn.value = !autoInteractOn.value + if (autoInteractOn.value) { + tip('自动交互:开(再按 E 关闭)') + clearQuickQueue() + tickAutoInteract() + } else { + clearQuickQueue() + if (player.target && (player.target.kind === 'ent' || player.target.kind === 'mon' || player.target.kind === 'fire')) { + player.target = null + } + tip('自动交互:关') } - quickQueue = list.map((a) => ({ kind: a.kind, id: a.id })) - advanceQuickQueue() } function refreshNearActions() { if (!playing.value || !player || player.dead) { @@ -1114,7 +1260,9 @@ function hitEntity(p, e) { swingAct(p, 'dig') useToolDur(p) addItem(p, 'log', 1) - ptip(p, '挖出树桩 · 木头 +1') + ptip(p, '挖出树桩 · 木头 +1(此处不再长树)') + e.respawnAs = null + e.respawnT = 0 removeEntity(e) p.target = null return @@ -1170,11 +1318,16 @@ function hitEntity(p, e) { if (e.type === 'tree') { treesChopped++ removeEntity(e) - addEntity('stump', e.x, e.y) + const stump = addEntity('stump', e.x, e.y) + stump.respawnAs = 'tree' + stump.respawnT = 420 * (0.85 + Math.random() * 0.35) maybeTreeguard(p, e) } else if (e.type === 'rock' || e.type === 'goldrock') { + const was = e.type removeEntity(e) - addEntity('rubble', e.x, e.y) + const rubble = addEntity('rubble', e.x, e.y) + rubble.respawnAs = was + rubble.respawnT = 520 * (0.85 + Math.random() * 0.4) } else if (e.vanish) { removeEntity(e) } else if (e.regrow) { @@ -1486,11 +1639,118 @@ function doCraft(code) { } craftFor(player, code) } +function canRelocateEnt(e) { + if (!e || e.deco || e.loot || NO_RELOCATE.has(e.type)) return false + if (e.type === 'sciencemachine' || e.type === 'alchemyengine' || e.type === 'crockpot' || e.type === 'woodchest') return true + const r = RECIPES.find((x) => x.code === e.type) + return !!(r?.build) +} +function findRelocateAt(wx, wy) { + let best = null + let bd = Infinity + entities.forEach((e) => { + if (!canRelocateEnt(e)) return + const d = Math.hypot(e.x - wx, e.y - wy) + const r = Math.max(28, (e.size || 28) * 0.95) + if (d < r && d < bd) { bd = d; best = e } + }) + return best +} +function cancelPlaceMode(silent = false) { + if (!placeMode) return + placeMode = null + if (!silent) tip('已取消') +} +function startPlaceMode(code, r, opts = {}) { + placeMode = { + code, + name: r?.name || NAMES[code] || code, + cost: opts.relocateId ? {} : { ...(r?.cost || {}) }, + kind: opts.kind || 'ent', + pit: !!opts.pit, + relocateId: opts.relocateId || 0, + tech: r?.tech || 0, + extra: opts.extra || null, + } + craftOpen.value = false + packOpen.value = false + tip(opts.relocateId + ? `迁移「${placeMode.name}」:左键选新位置,右键取消` + : `放置「${placeMode.name}」:左键选位置,右键取消`) +} +function confirmPlaceAt(wx, wy) { + if (!placeMode || !player || player.dead) return false + const spot = snapToLand(tiles, wx, wy, 56) + if (!spot) { + tip('这里不能放,换块陆地') + return true + } + // 迁移:只改坐标 + if (placeMode.relocateId) { + const e = entities.find((x) => x.id === placeMode.relocateId) + if (!e) { + cancelPlaceMode(true) + tip('建筑已不存在') + return true + } + e.x = spot.x + e.y = spot.y + markDirty(e) + tip(`「${placeMode.name}」已迁到新位置`) + placeMode = null + return true + } + const cost = placeMode.cost || {} + if (Object.keys(cost).length && !hasCost(player, cost)) { + tip('材料不足(背包+仓库)') + placeMode = null + return true + } + if (Object.keys(cost).length) payCost(player, cost) + if (placeMode.tech > 0 && !player.unlocked.has(placeMode.code)) { + player.unlocked.add(placeMode.code) + tip(`原型打造成功 · ${placeMode.name}已永久解锁`) + } + if (placeMode.kind === 'fire') { + if (placeMode.pit) { + fires.push({ fid: fid++, x: spot.x, y: spot.y, ttl: 60, max: 150, pit: true }) + tip('石砌火堆造好了(永久建筑)') + } else { + fires.push({ fid: fid++, x: spot.x, y: spot.y, ttl: 50, max: 90, pit: false }) + tip('篝火燃起来了') + } + } else { + let extra = placeMode.extra || {} + if (placeMode.code === 'camphouse' && !extra.mx) { + let name = '房屋' + try { + const v = typeof window !== 'undefined' ? window.prompt('给这座房屋起个名字', `房屋${entities.filter((e) => e.type === 'camphouse').length + 1}`) : null + if (v != null) name = String(v).trim().slice(0, 12) || name + } catch { /* */ } + extra = { mx: { restDay: 0, name, floors: 1 } } + } + addEntity(placeMode.code, spot.x, spot.y, extra) + tip(placeMode.code === 'camphouse' && extra.mx?.name ? `「${extra.mx.name}」建好了` : `${placeMode.name}建好了`) + addScore(30, player) + } + invDirty = true + addScore(10, player) + placeMode = null + return true +} function craftFor(p, code) { const r = RECIPES.find((x) => x.code === code) if (!r) return if (!recipeAvailable(r, p)) return ptip(p, r.tech === 1 ? '需要靠近科学机器' : '需要靠近炼金引擎') - if (!hasCost(p, r.cost)) return ptip(p, '材料不足') + if (!hasCost(p, r.cost)) return ptip(p, '材料不足(背包+仓库)') + const isBuild = code === 'campfire' || code === 'firepit' || code === 'sciencemachine' || code === 'alchemyengine' || code === 'crockpot' || r.build + // 本地玩家:进入选点放置;联机他人/主机代打仍脚下建造 + if (isBuild && p === player) { + if (code === 'campfire') startPlaceMode(code, r, { kind: 'fire', pit: false }) + else if (code === 'firepit') startPlaceMode(code, r, { kind: 'fire', pit: true }) + else startPlaceMode(code, r, { kind: 'ent' }) + return + } payCost(p, r.cost) if (r.tech > 0 && !p.unlocked.has(r.code)) { p.unlocked.add(r.code) @@ -1504,8 +1764,17 @@ function craftFor(p, code) { ptip(p, '石砌火堆造好了(永久建筑)') } else if (code === 'sciencemachine' || code === 'alchemyengine' || code === 'crockpot' || r.build) { const spot = findLandNear(tiles, p.x + p.dir * 52, p.y, 0, 26) - addEntity(code, spot.x, spot.y) - ptip(p, `${r.name}建好了`) + let extra = {} + if (code === 'camphouse') { + let name = '房屋' + try { + const v = typeof window !== 'undefined' ? window.prompt('给这座房屋起个名字', `房屋${entities.filter((e) => e.type === 'camphouse').length + 1}`) : null + if (v != null) name = String(v).trim().slice(0, 12) || name + } catch { /* */ } + extra = { mx: { restDay: 0, name, floors: 1 } } + } + addEntity(code, spot.x, spot.y, extra) + ptip(p, code === 'camphouse' ? `「${extra.mx.name}」建好了` : `${r.name}建好了`) addScore(30, p) } else { const it = ITEMS[code] @@ -1621,7 +1890,7 @@ const gCtx = { }, swing: (p, act) => swingAct(p, act), heatAt: (p) => heatAt(p), - cookPot: (codes) => cookPot(codes), + cookPot: (codes) => cookPot(codes, ITEMS), // 按物品 code 对玩家施加 food 效果(食堂点餐等,不扣背包) applyFood: (p, code, fresh = 1) => { const it = ITEMS[code] @@ -1649,9 +1918,12 @@ const gCtx = { // 否决型钩子调用器(供 mod 使用):任一 mod 返回 false 即返回 false // 与核心其它钩子一致,gCtx 作为钩子首参自动前置,mod 侧签名统一为 (g, ...) allows: (name, ...args) => callAllows(modRt, name, gCtx, ...args), + keyDown: (k) => !!(keys && keys.has(k)), me: () => player, isHost: () => isHostRole(), invalidate: () => { invDirty = true }, + // 物品图标画布表(供 mod 面板预览) + get icons() { return ICONS.cv }, } function updateDayNight(dt) { @@ -1858,6 +2130,11 @@ function updatePlayers(dt) { p.input.dx = dx p.input.dy = dy } + // mod 可接管移动(如房屋室内子地图) + if (p === player && !callAllows(modRt, 'allowsPlayerMove', gCtx, p)) { + p.walking = false + return + } if (dx || dy) { p.target = null if (p === player) clearQuickQueue() @@ -1868,7 +2145,10 @@ function updatePlayers(dt) { p.walking = true } else if (!p.dead) { pursueTarget(p, dt) - if (p === player && !p.target) advanceQuickQueue() + if (p === player && !p.target) { + if (autoInteractOn.value) tickAutoInteract() + else advanceQuickQueue() + } } if (p.walking) p.walkT += dt }) @@ -1886,10 +2166,23 @@ function updateEntities(dt) { markDirty(e) } } + // 树桩 / 碎石刷新回树 / 石头 + if ((e.type === 'stump' || e.type === 'rubble') && e.respawnAs && e.respawnT != null) { + if (!winter && e.respawnT > 0) e.respawnT -= dt + if (e.respawnT <= 0) { + const typ = e.respawnAs + const x = e.x + const y = e.y + removeEntity(e) + addEntity(typ, x, y) + return + } + } const modEnt = modRt.entKinds[e.type] if (modEnt) { modEnt.update?.(e, dt, gCtx) } else if (e.type === 'rabbit') updateRabbit(e, dt) else if (e.type === 'rabbithole') { + if (e.solid) e.solid = false // 旧档洞不再挡路 if (e.respawnT > 0 && phaseName === 'day') { e.respawnT -= dt if (e.respawnT <= 0 && !e.rabbitId) spawnRabbit(e) @@ -2126,19 +2419,44 @@ function syncHud(force = false) { san.value = Math.max(0, Math.round(player.san)) temp.value = Math.round(player.temp) seasonTag.value = { name: seasonNow.name, dayIn: seasonNow.dayIn, len: seasonNow.len, tint: seasonNow.tint, code: seasonNow.code } - if (invDirty || force) { - invDirty = false - uiSlots.value = player.slots.map(packUiSlot) - uiPack.value = player.pack ? player.pack.map(packUiSlot) : null - uiEquip.value = { - hand: player.equip.hand ? packUiSlot({ ...player.equip.hand, n: 1 }) : null, - body: player.equip.body ? packUiSlot({ ...player.equip.body, n: 1 }) : null, - head: player.equip.head ? packUiSlot({ ...player.equip.head, n: 1 }) : null, + { + let bag = 0 + ;[player.slots, player.pack].filter(Boolean).forEach((area) => { + area.forEach((s) => { if (s && ITEMS[s.code]?.food) bag += s.n }) + }) + let store = 0 + entities.forEach((e) => { + if (e.type === 'laborwarehouse' && Array.isArray(e.mx?.boxes)) { + e.mx.boxes.forEach((b) => { + Object.entries(b.items || {}).forEach(([code, n]) => { + if (ITEMS[code]?.food) store += n || 0 + }) + }) + } else if (e.type === 'granary' && e.mx?.store) { + Object.values(e.mx.store).forEach((n) => { store += n || 0 }) + } + }) + foodBag.value = bag + foodStore.value = store + } + if (invDirty || force || craftOpen.value) { + if (invDirty || force) { + invDirty = false + uiSlots.value = player.slots.map(packUiSlot) + uiPack.value = player.pack ? player.pack.map(packUiSlot) : null + uiEquip.value = { + hand: player.equip.hand ? packUiSlot({ ...player.equip.hand, n: 1 }) : null, + body: player.equip.body ? packUiSlot({ ...player.equip.body, n: 1 }) : null, + head: player.equip.head ? packUiSlot({ ...player.equip.head, n: 1 }) : null, + } + uiUnlocked.value = Array.from(player.unlocked) } + // 合成面板打开时每帧刷新材料数(含仓库),避免仓里有货仍灰显 const counts = {} - RECIPES.forEach((r) => Object.keys(r.cost).forEach((c) => { counts[c] = countItem(player, c) })) + RECIPES.forEach((r) => Object.keys(r.cost).forEach((c) => { + counts[c] = countItem(player, c) + warehouseCount(c) + })) uiCounts.value = counts - uiUnlocked.value = Array.from(player.unlocked) } uiTech.value = techLevel(player) // 烹饪锅面板视图 @@ -2252,7 +2570,8 @@ function clientTick(dt) { } else if (t.kind === 'walk') p.target = null } } else if (!p.dead) { - advanceQuickQueue() + if (autoInteractOn.value) tickAutoInteract() + else advanceQuickQueue() } if (p.walking) p.walkT += dt moveSendT += dt @@ -2791,6 +3110,37 @@ function draw() { drawables.sort((a, b) => a.y - b.y) drawables.forEach((d) => d.fn()) callHook(modRt, 'onDraw', gCtx, ctx, { camX, camY, W, H, time }) + // 建造/迁移幽灵预览 + if (placeMode) { + const spot = snapToLand(tiles, placeCursor.x, placeCursor.y, 56) + const px = (spot?.x ?? placeCursor.x) - camX + const py = (spot?.y ?? placeCursor.y) - camY + const ok = !!spot + ctx.save() + ctx.globalAlpha = 0.55 + ctx.translate(px, py) + ctx.strokeStyle = ok ? '#8fd46a' : '#d46a6a' + ctx.fillStyle = ok ? 'rgba(120,200,80,0.18)' : 'rgba(200,80,80,0.18)' + ctx.lineWidth = 2 + ctx.setLineDash([5, 4]) + ctx.beginPath() + ctx.arc(0, 0, 22, 0, Math.PI * 2) + ctx.fill() + ctx.stroke() + ctx.setLineDash([]) + // 落脚点十字,标明建筑脚底对准点击处 + ctx.globalAlpha = 0.9 + ctx.beginPath() + ctx.moveTo(-10, 0); ctx.lineTo(10, 0) + ctx.moveTo(0, -10); ctx.lineTo(0, 10) + ctx.stroke() + ctx.fillStyle = '#f4ead2' + ctx.font = 'bold 12px "PingFang SC","Microsoft YaHei",sans-serif' + ctx.textAlign = 'center' + ctx.textBaseline = 'bottom' + ctx.fillText(placeMode.relocateId ? `迁移 ${placeMode.name}` : `建造 ${placeMode.name}`, 0, -28) + ctx.restore() + } drawWeather(W, H) drawNight(W, H) // 查理袭击红闪 @@ -3399,6 +3749,9 @@ function resetState() { entsDirty = new Set() dead.value = false ghostMode.value = false + placeMode = null + autoInteractOn.value = false + clearQuickQueue() craftOpen.value = false showMap.value = false tips.value = [] @@ -3633,11 +3986,16 @@ function tabRecipes(tab) { } function recipeState(r) { const okTech = r.tech === 0 || uiUnlocked.value.includes(r.code) || uiTech.value >= r.tech - const okCost = Object.entries(r.cost).every(([c, n]) => (uiCounts.value[c] || 0) >= n) + // 实时合计背包+仓库,不依赖可能过期的 uiCounts + const okCost = !!player && Object.entries(r.cost).every(([c, n]) => countItem(player, c) + warehouseCount(c) >= n) return { okTech, okCost, can: okTech && okCost } } function costText(r) { - return Object.entries(r.cost).map(([c, n]) => `${ITEMS[c]?.name}x${n}`).join(' ') + return Object.entries(r.cost).map(([c, n]) => { + const have = player ? countItem(player, c) + warehouseCount(c) : 0 + const name = ITEMS[c]?.name || c + return have >= n ? `${name}x${n}` : `${name}x${n}(有${have})` + }).join(' ') } function freshClass(f) { if (f < 0) return '' @@ -3652,6 +4010,7 @@ function freshClass(f) { class="starve-canvas" tabindex="-1" @click="onCanvasClick" + @contextmenu="onCanvasContextMenu" @mousedown="onCanvasPointerDown" @mousemove="onCanvasPointerMove" @mouseup="onCanvasPointerUp" @@ -3695,6 +4054,10 @@ function freshClass(f) { {{ hunger }} +
+ + {{ foodBag }}/{{ foodStore }} +
{{ san }} @@ -3825,34 +4188,85 @@ function freshClass(f) {
- + + title="E:开关自动交互(不含篝火/房屋)" + >{{ autoInteractOn ? '自动交互 · 开' : '自动交互 · E' }}
{{ seasonBanner }}
@@ -4335,6 +4750,12 @@ function freshClass(f) { touch-action: manipulation; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); } +.quick-act.on { + border-color: #8fd46a; + background: rgba(50, 90, 40, 0.92); + color: #dff5c8; + box-shadow: 0 0 0 1px rgba(143, 212, 106, 0.35); +} .quick-act:hover { border-color: #e8bc5a; } @@ -4459,6 +4880,51 @@ function freshClass(f) { padding: 12px 14px 16px; box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45); } +.modal-card.box-modal { + width: min(640px, calc(100% - 24px)); +} +.box-split { + display: flex; + gap: 14px; + align-items: flex-start; + justify-content: center; +} +.box-side { + flex: 1; + min-width: 0; +} +.box-side-label { + display: block; + margin-bottom: 6px; + color: #c9b89a; + font-size: 12px; + text-align: center; +} +.inv-side { + border-left: 1px solid rgba(240, 226, 190, 0.18); + padding-left: 14px; +} +.inv-quick { + display: grid; + grid-template-columns: repeat(5, 42px); + gap: 6px; + justify-content: center; +} +.pack-quick { + margin-top: 8px; + padding-top: 8px; + border-top: 1px dashed rgba(240, 226, 190, 0.18); +} +.badge.food-badge b small { + font-size: 11px; + font-weight: 600; + opacity: 0.75; + margin-left: 1px; +} +@media (max-width: 560px) { + .box-split { flex-direction: column; } + .inv-side { border-left: none; padding-left: 0; border-top: 1px solid rgba(240, 226, 190, 0.18); padding-top: 10px; width: 100%; } +} .modal-head { display: flex; align-items: center; diff --git a/src/games/starve/data.js b/src/games/starve/data.js index bc9005d..297dbf3 100644 --- a/src/games/starve/data.js +++ b/src/games/starve/data.js @@ -161,7 +161,7 @@ export const RECIPES = [ { code: 'winterhat', name: '冬帽', tab: 'dress', tech: 2, cost: { silk: 4, beefwool: 2 }, desc: '保暖 · 冬季生存必备' }, ] -// 烹饪锅配方:按优先级匹配(meatv 肉度 / sweet 甜度 / mm 怪物肉数) +// 烹饪锅配方:按优先级匹配(meatv/sweet/mm/veg/grain;itemTable 可传入 mod 合并后的物品表) const POT_RULES = [ { dish: 'taffy', ok: (s) => s.sweet >= 3 }, { dish: 'honeyham', ok: (s) => s.sweet >= 1 && s.meatv >= 1.5 }, @@ -169,16 +169,19 @@ const POT_RULES = [ { dish: 'meatystew', ok: (s) => s.meatv >= 3 }, { dish: 'monsterlasagna', ok: (s) => s.mm >= 2 }, { dish: 'meatballs', ok: (s) => s.meatv >= 0.5 }, + // 面食:面粉/小麦等(纯小麦×3+ 或 面粉×2+) + { dish: 'bread', ok: (s) => s.grain >= 2 || s.veg >= 3 }, ] // 输入 4 个食材 code,返回料理 code(不满足任何配方 = 湿哒哒糊糊) -export function cookPot(codes) { - const s = { meatv: 0, sweet: 0, mm: 0, veg: 0 } +export function cookPot(codes, itemTable = ITEMS) { + const s = { meatv: 0, sweet: 0, mm: 0, veg: 0, grain: 0 } codes.forEach((c) => { - const it = ITEMS[c] || {} + const it = itemTable[c] || ITEMS[c] || {} s.meatv += it.meatv || 0 s.sweet += it.sweet || 0 s.mm += it.mm || 0 s.veg += it.veg || 0 + s.grain += it.grain || 0 }) const hit = POT_RULES.find((r) => r.ok(s)) return hit ? hit.dish : 'wetgoop' @@ -230,7 +233,7 @@ export const RES = { flower: { hits: 1, drops: { petals: 1 }, size: 14, vanish: true }, flint: { hits: 1, drops: { flint: 1 }, size: 14, vanish: true }, rabbit: { hp: 25, drops: { morsel: 1 }, size: 16, mobile: true }, - rabbithole: { size: 18, solid: true }, + rabbithole: { size: 18 }, // 兔子洞不挡路(否则劳工会卡在洞边) spidernest: { hp: 200, size: 36, nest: true }, beehive: { hp: 100, size: 30, nest: true }, pighouse: { size: 40, struct: true }, diff --git a/src/games/starve/mobs.js b/src/games/starve/mobs.js index 6eda1a9..3f579e5 100644 --- a/src/games/starve/mobs.js +++ b/src/games/starve/mobs.js @@ -6,13 +6,27 @@ // removeMob(m) · killMob(m,credit) · smashStructure(ent) · tip(t) · ptip(p,t) · shake(amp) import { MON } from './data.js' -// 固体栅栏/关着的门挡路(开着的 gate/campgate 不挡) -function blockedBySolid(g, x, y) { +function isLaborMob(m) { + const k = m?.kind + return !!k && (k.startsWith('labor') || k === 'campvillager') +} +function isFenceLike(e) { + return e.type === 'fence' || e.type === 'gate' || e.type === 'campfence' || e.type === 'campgate' +} +// 固体栅栏/关着的门挡路(劳工穿栅栏;开着的门、兔子洞不挡) +function blockedBySolid(g, x, y, m = null) { if (!g.entities) return false + const labor = isLaborMob(m) for (const e of g.entities) { if (!e.solid) continue + // 旧档兔子洞可能仍带 solid,一律不挡路 + if (e.type === 'rabbithole') continue if ((e.type === 'gate' || e.type === 'campgate') && e.mx?.open) continue - const fenceLike = e.type === 'fence' || e.type === 'gate' || e.type === 'campfence' || e.type === 'campgate' + // 雇佣劳工可穿牧场/营地栅栏,避免卡在门外 + if (labor && isFenceLike(e)) continue + // 敌对野怪翻墙成功期间可穿营地围栏(见 tryCampVault) + if ((m?.campVaultT || 0) > 0 && (e.type === 'campfence' || e.type === 'campgate')) continue + const fenceLike = isFenceLike(e) const r = fenceLike ? Math.max(22, (e.size || 24) * 0.65) : Math.max(14, (e.size || 24) * 0.55) @@ -20,21 +34,52 @@ function blockedBySolid(g, x, y) { } return false } -function canStand(g, x, y) { - return g.isLand(x, y) && !blockedBySolid(g, x, y) +function canStand(g, x, y, m = null) { + return g.isLand(x, y) && !blockedBySolid(g, x, y, m) } +function nearCampWall(g, m) { + return (g.entities || []).some((e) => { + if (e.type === 'campfence') return Math.hypot(e.x - m.x, e.y - m.y) < 44 + if (e.type === 'campgate' && !e.mx?.open) return Math.hypot(e.x - m.x, e.y - m.y) < 52 + return false + }) +} +/** 每帧结算翻墙冷却;贴墙被挡时掷点,成功则数秒内可穿营地围栏 */ +function tickCampVault(m, g, dt) { + if (!m || !g.mon?.[m.kind]?.hostile) return + if ((m.campVaultT || 0) > 0) { + m.campVaultT = Math.max(0, m.campVaultT - dt) + m._campWallHit = false + return + } + m.campVaultCd = Math.max(0, (m.campVaultCd || 0) - dt) + if (!m._campWallHit) return + m._campWallHit = false + if (m.campVaultCd > 0 || !nearCampWall(g, m)) return + m.campVaultCd = 2.2 + Math.random() * 1.4 + // 普通怪约 12%,Boss 约 30%(翻墙成功) + const chance = g.mon[m.kind]?.boss ? 0.3 : 0.12 + if (Math.random() < chance) m.campVaultT = 3.5 + Math.random() * 1.5 +} + // 带陆地 + 栅栏碰撞的位移:先整体,再退化为单轴滑动 export function moveMob(m, dx, dy, g) { + const ox = m.x + const oy = m.y const nx = m.x + dx const ny = m.y + dy - if (canStand(g, nx, ny)) { + if (canStand(g, nx, ny, m)) { m.x = nx m.y = ny - } else if (canStand(g, nx, m.y)) { + } else if (canStand(g, nx, m.y, m)) { m.x = nx - } else if (canStand(g, m.x, ny)) { + } else if (canStand(g, m.x, ny, m)) { m.y = ny } + // 敌对野怪被挡住且贴着营墙 → 标记,本帧末掷点翻墙 + if (m.x === ox && m.y === oy && g.mon?.[m.kind]?.hostile && nearCampWall(g, m)) { + m._campWallHit = true + } } function toward(m, tx, ty, speed, dt, g) { const d = Math.hypot(tx - m.x, ty - m.y) || 1 @@ -56,7 +101,8 @@ function nearestVictim(m, g, playersOnly = false) { }) if (!playersOnly) { g.monsters.forEach((q) => { - if (q.kind !== 'pig' || q === m) return + // 圈养动物不作为野怪猎物,避免牧场越杀越少 + if (q.kind !== 'pig' || q === m || q.pen) return const d = Math.hypot(q.x - m.x, q.y - m.y) if (d < bd) { bd = d; best = { kind: 'm', ref: q } } }) @@ -98,6 +144,7 @@ export function updateMob(m, dt, g) { m.moving = false if (m.shake > 0) m.shake -= dt if (m.cd > 0) m.cd -= dt + tickCampVault(m, g, dt) if (m.kind === 'shadow') updShadow(m, conf, dt, g) else if (m.kind === 'spider') updSpider(m, conf, dt, g) else if (m.kind === 'hound' || m.kind === 'icehound') updHound(m, conf, dt, g) diff --git a/src/games/starve/mods/agri.js b/src/games/starve/mods/agri.js index 3826b18..cbf6219 100644 --- a/src/games/starve/mods/agri.js +++ b/src/games/starve/mods/agri.js @@ -6,7 +6,7 @@ // - 磨坊:wheat×3 → flour×2 // - 框选农业区:可命名多块农田,区内自动铺正方形良田 // - 采草 12% 掉小麦种子、采浆果丛 12% 掉南瓜种子 -import { normalizeRect, clearZoneClutter, pointInAabb } from './zoneUtil.js' +import { normalizeRect, clearZoneClutter, pointInAabb, zoneYieldMul, zoneFloors, zoneUpgradeCost, buildCostDetails, costDetailsAllOk, hasStockCost, payStockCost, drawNameplate, openZoneMenu, drawZoneMenu, handleZoneMenuClick } from './zoneUtil.js' const TAU = Math.PI * 2 const GROW_TOTAL = 200 // 生长总时长(秒):比 farm.js 的 320 秒更快 @@ -18,8 +18,9 @@ const SEED_CROP = { wheatseeds: 'wheat', pumpkinseeds: 'pumpkin', berryseeds: 'b const CROPS = ['wheat', 'pumpkin', 'berry', 'carrot'] // ---- 农业区框选状态(多块可命名;UI 模式不入档) ---- -let agriZones = [] // [{ id, name, x0, y0, x1, y1 }] +let agriZones = [] // [{ id, name, level, x0, y0, x1, y1 }] let nextAgriId = 1 +const MAX_ZONE_LEVEL = 10 let zoneMode = false let drag = null // { x0, y0, x1, y1 } let uiCamX = 0 @@ -35,7 +36,9 @@ function stageOf(t) { } function plant(p, plot, seedCode, g) { + const zoneId = plot.mx?.zoneId plot.mx = { seed: SEED_CROP[seedCode], t: 0 } + if (zoneId != null) plot.mx.zoneId = zoneId plot.state = 'grow0' g.markDirty(plot) g.swing(p, 'dig') @@ -71,7 +74,7 @@ function fillFarmsInZone(g, z, opts = {}) { if (!g.isLand(x, y)) continue const crowded = g.entities.some((e) => e.type === 'agrifarm' && Math.hypot(e.x - x, e.y - y) < FARM_SPACING * 0.48) if (crowded) continue - g.addEntity('agrifarm', x, y) + g.addEntity('agrifarm', x, y, { mx: { zoneId: z.id } }) added++ } } @@ -79,6 +82,152 @@ function fillFarmsInZone(g, z, opts = {}) { return added } +function zoneOfPlot(plot) { + if (plot?.mx?.zoneId != null) { + const byId = agriZones.find((z) => z.id === plot.mx.zoneId) + if (byId) return byId + } + return agriZones.find((z) => pointInAabb(plot.x, plot.y, z)) || null +} + +/** 农田升到 5 级自动在区旁建一座附属大仓库(每区一座) */ +function ensureAgriWarehouse(g, z) { + if (!z || (z.level || 1) < 5) return null + if (z.warehouseId != null) { + const exist = g.entities.find((e) => e.id === z.warehouseId && e.type === 'laborwarehouse') + if (exist) return exist + } + // 已有挂在本区旁的仓库则认领,避免重复盖 + const near = g.entities.find((e) => { + if (e.type !== 'laborwarehouse') return false + if (e.mx?.agriZoneId === z.id) return true + return pointInAabb(e.x, e.y, { + x0: z.x0 - 120, y0: z.y0 - 120, x1: z.x1 + 120, y1: z.y1 + 120, + }) + }) + if (near) { + z.warehouseId = near.id + if (!near.mx) near.mx = {} + near.mx.agriZoneId = z.id + g.markDirty?.(near) + return near + } + const candidates = [ + { x: z.x1 + 48, y: (z.y0 + z.y1) / 2 }, + { x: z.x0 - 48, y: (z.y0 + z.y1) / 2 }, + { x: (z.x0 + z.x1) / 2, y: z.y0 - 48 }, + { x: (z.x0 + z.x1) / 2, y: z.y1 + 48 }, + ] + let spot = null + for (const c of candidates) { + const land = g.findLand(c.x, c.y, 0, 60) + if (!land) continue + const crowded = g.entities.some((e) => !e.deco && !e.loot && Math.hypot(e.x - land.x, e.y - land.y) < 40) + if (!crowded) { spot = land; break } + if (!spot) spot = land + } + if (!spot) return null + const e = g.addEntity('laborwarehouse', spot.x, spot.y) + if (!e) return null + if (!e.mx) e.mx = {} + e.mx.agriZoneId = z.id + z.warehouseId = e.id + g.tip(`「${z.name}」解锁附属大仓库`) + return e +} + +function upgradeAgriZone(g, z) { + const p = g.me() + if (!p) return false + const lv = z.level || 1 + if (lv >= MAX_ZONE_LEVEL) { + g.tip(`「${z.name}」已满级`) + return true + } + const cost = zoneUpgradeCost(lv) + if (!hasStockCost(g, cost)) { + g.tip(`升级需要 木头×${cost.log} 石块×${cost.rock}${cost.gold ? ` 金块×${cost.gold}` : ''}(背包或仓库)`) + return true + } + payStockCost(g, cost) + z.level = lv + 1 + syncAgrizoneMarkers(g) + const floors = zoneFloors(z.level) + const mul = zoneYieldMul(z.level) + if (z.level >= 5) ensureAgriWarehouse(g, z) + g.tip(`「${z.name}」升至 ${z.level} 级 · 产出 ×${mul.toFixed(2)} · 田力 ${floors}${z.level === 5 ? ' · 已解锁附属仓库' : ''}`) + return true +} + +function deleteAgriZone(g, z) { + g.entities + .filter((e) => e.type === 'agrifarm' && (e.mx?.zoneId === z.id || pointInAabb(e.x, e.y, z))) + .forEach((e) => g.removeEntity(e)) + agriZones = agriZones.filter((a) => a.id !== z.id) + syncAgrizoneMarkers(g) + g.tip(`已删除农田「${z.name}」`) + return true +} + +/** 右键:弹出升级 / 改名 / 删除按钮菜单(含材料缺口) */ +function agriZoneRightAction(g, z) { + const lv = z.level || 1 + const maxed = lv >= MAX_ZONE_LEVEL + const cost = maxed ? null : zoneUpgradeCost(lv) + const details = maxed + ? [{ text: '已满级,无法再升级', ok: true }] + : buildCostDetails(g, cost) + const canUp = !maxed && costDetailsAllOk(details) + const missHint = !maxed && !canUp + ? details.filter((d) => !d.ok).map((d) => `缺${d.name}×${d.miss}`).join(' · ') + : '' + openZoneMenu({ + title: `「${z.name}」`, + subtitle: maxed ? `农田 · Lv.${lv}(满级)` : `农田 · Lv.${lv} → Lv.${lv + 1}`, + detailTitle: maxed ? '升级状态' : `升级所需(背包+仓库)${missHint ? ` · ${missHint}` : ''}`, + details, + actions: [ + { + id: 'upgrade', + label: maxed ? '已满级' : (canUp ? '确认升级' : '材料不足'), + color: canUp ? '#5d7a3a' : '#5a4a3a', + disabled: maxed || !canUp, + }, + { id: 'rename', label: '改名', color: '#4a6a8a' }, + { id: 'delete', label: '删除区域', color: '#7a4a3a' }, + { id: 'cancel', label: '取消', color: '#5a4630' }, + ], + onPick(id) { + if (id === 'upgrade') { + upgradeAgriZone(g, z) + return + } + if (id === 'rename') { + const name = promptZoneName('农田', z.name) + z.name = name + syncAgrizoneMarkers(g) + g.tip(`农田已命名为「${name}」`) + return + } + if (id === 'delete') { + openZoneMenu({ + title: `删除「${z.name}」?`, + subtitle: '区内良田将一并移除', + actions: [ + { id: 'confirm', label: '确认删除', color: '#8a3030' }, + { id: 'back', label: '返回', color: '#5a4630' }, + ], + onPick(id2) { + if (id2 === 'confirm') deleteAgriZone(g, z) + else if (id2 === 'back') agriZoneRightAction(g, z) + }, + }) + } + }, + }) + return true +} + function inside(x, y, r) { return !!r && x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h } @@ -93,6 +242,13 @@ function renameZoneAt(g, wx, wy) { return true } +/** 导出给劳工收获计算产出 */ +export function agriYieldAt(x, y, zoneId) { + let z = zoneId != null ? agriZones.find((a) => a.id === zoneId) : null + if (!z) z = agriZones.find((a) => pointInAabb(x, y, a)) + return zoneYieldMul(z?.level || 1) +} + export default { id: 'agri', name: '农业文明', @@ -103,8 +259,8 @@ export default { items: { agrihoe: { name: '犁锄', equip: 'hand', tool: 'dig', dmg: 20, uses: 40, stack: 1 }, wheatseeds: { name: '小麦种子', stack: 40 }, - wheat: { name: '小麦', food: { hunger: 12.5, hp: 0, san: 0 }, perish: 10, veg: 1 }, - flour: { name: '面粉', food: { hunger: 12.5, hp: 1, san: 0 }, perish: 20 }, + wheat: { name: '小麦', food: { hunger: 12.5, hp: 0, san: 0 }, perish: 10, veg: 1, grain: 1 }, + flour: { name: '面粉', food: { hunger: 12.5, hp: 1, san: 0 }, perish: 20, grain: 1.5, veg: 0.5 }, bread: { name: '面包', food: { hunger: 62.5, hp: 8, san: 3 }, perish: 12 }, pumpkin: { name: '南瓜', food: { hunger: 37.5, hp: 3, san: 0 }, perish: 20, veg: 1 }, pumpkinseeds: { name: '南瓜种子', stack: 40 }, @@ -285,13 +441,15 @@ export default { conf: { size: 52 }, init(e) { if (!e.state) e.state = 'empty' + if (!e.mx) e.mx = {} }, update(e, dt, g) { if (!e.mx || e.state === 'empty' || e.state === 'ready') return if (g.season === 'winter') return // 冬季暂停生长(与官方一致) - // 灌溉加成:140px 内存在水井则生长 ×1.6 const watered = g.entities.some((o) => o.type === 'well' && Math.hypot(o.x - e.x, o.y - e.y) < 140) - e.mx.t += dt * (watered ? 1.6 : 1) + const z = zoneOfPlot(e) + const lvMul = 1 + 0.06 * Math.max(0, (z?.level || 1) - 1) + e.mx.t += dt * (watered ? 1.6 : 1) * lvMul const st = stageOf(e.mx.t) if (st !== e.state) { e.state = st @@ -302,18 +460,20 @@ export default { interact(p, e, g) { if (e.state === 'ready') { const crop = e.mx?.seed || 'wheat' - const n = g.rand(3, 4) + const zoneId = e.mx?.zoneId + const mul = agriYieldAt(e.x, e.y, zoneId) + const n = Math.max(1, Math.round(g.rand(3, 4) * mul)) g.addItem(p, crop, n) g.addScore(12, p) g.swing(p, 'chop') g.ptip(p, `收获${g.items[crop]?.name || '作物'} ×${n}`) + const keepId = e.mx?.zoneId e.state = 'empty' - e.mx = null + e.mx = keepId != null ? { zoneId: keepId } : {} g.markDirty(e) return } if (e.state === 'empty') { - // 身上有种子就直接播种(berryseeds 依赖 farm.js,缺了就只是不能种) const seed = ['wheatseeds', 'pumpkinseeds', 'berryseeds'].find((code) => g.items[code] && g.countItem(p, code) > 0) if (seed) { g.payCost(p, { [seed]: 1 }) @@ -327,24 +487,45 @@ export default { g.ptip(p, `作物生长中…… ${pct}%(靠近水井更快)`) }, draw(ctx, e) { - // 正方形田块 - const s = 26 - ctx.fillStyle = '#6b4a2e' + const z = zoneOfPlot(e) + const lv = Math.max(1, z?.level || 1) + const grade = zoneFloors(lv) // 1~3:田力档,只改土色/垄数,不再叠「木箱层」 + // 软边泥土田块(椭圆垄床),别画成方木箱 + const soil = grade === 1 ? '#7a5a38' : grade === 2 ? '#6a4a2e' : '#5a3a24' + const rim = grade === 1 ? '#9a7a48' : grade === 2 ? '#8a6a3a' : '#7a5a30' + ctx.fillStyle = 'rgba(0,0,0,0.18)' + ctx.beginPath(); ctx.ellipse(0, 6, 24, 9, 0, 0, TAU); ctx.fill() + ctx.fillStyle = soil ctx.strokeStyle = '#1d1409' - ctx.lineWidth = 2 - ctx.beginPath() - ctx.roundRect(-s, -s, s * 2, s * 2, 3) - ctx.fill() - ctx.stroke() - // 田垄线 - ctx.strokeStyle = '#553a24' ctx.lineWidth = 1.8 - ;[-10, 0, 10].forEach((y) => { + ctx.beginPath(); ctx.ellipse(0, 0, 24, 16, 0, 0, TAU); ctx.fill(); ctx.stroke() + // 田埂外缘 + ctx.strokeStyle = rim + ctx.lineWidth = 2.2 + ctx.beginPath(); ctx.ellipse(0, 0, 22, 14, 0, 0, TAU); ctx.stroke() + // 田垄线(高等级垄更多、更深) + ctx.strokeStyle = 'rgba(40, 24, 12, 0.55)' + ctx.lineWidth = 1.5 + const rows = grade >= 3 ? [-8, -3, 2, 7] : grade >= 2 ? [-6, 0, 6] : [-4, 4] + rows.forEach((y) => { + const w = 18 - Math.abs(y) * 0.35 ctx.beginPath() - ctx.moveTo(-s + 4, y) - ctx.lineTo(s - 4, y) + ctx.moveTo(-w, y) + ctx.quadraticCurveTo(0, y + 1.2, w, y) ctx.stroke() }) + // 高等级:两侧小石埂 / 水渠暗示 + if (grade >= 2) { + ctx.fillStyle = '#8d9097' + ;[-20, 20].forEach((x) => { + ctx.beginPath(); ctx.ellipse(x, 4, 3.2, 2.2, 0, 0, TAU); ctx.fill() + }) + } + if (grade >= 3) { + ctx.strokeStyle = 'rgba(90, 150, 180, 0.55)' + ctx.lineWidth = 2 + ctx.beginPath(); ctx.moveTo(-18, 10); ctx.quadraticCurveTo(0, 13, 18, 10); ctx.stroke() + } const st = e.state if (st === 'empty' || !st) return const crop = e.mx?.seed @@ -433,6 +614,7 @@ export default { g.ptip(p, '打了一瓢清水,理智 +5') }, draw(ctx, e, env) { + drawNameplate(ctx, '水井', -48) // 投影 ctx.fillStyle = 'rgba(0,0,0,0.25)' ctx.beginPath(); ctx.ellipse(0, 4, 18, 6, 0, 0, TAU); ctx.fill() @@ -522,6 +704,9 @@ export default { g.ptip(p, '粮仓空着(点击存入身上的作物)') }, draw(ctx, e, env) { + const store = e.mx?.store || {} + const total = Object.values(store).reduce((a, b) => a + (b || 0), 0) + drawNameplate(ctx, total > 0 ? `粮仓 · ${total}份` : '粮仓', -56) // 投影 ctx.fillStyle = 'rgba(0,0,0,0.25)' ctx.beginPath(); ctx.ellipse(0, 4, 20, 7, 0, 0, TAU); ctx.fill() @@ -573,6 +758,7 @@ export default { g.ptip(p, '磨出面粉 ×2') }, draw(ctx, e, env) { + drawNameplate(ctx, '磨坊', -52) // 投影 ctx.fillStyle = 'rgba(0,0,0,0.25)' ctx.beginPath(); ctx.ellipse(0, 4, 18, 6, 0, 0, TAU); ctx.fill() @@ -621,12 +807,18 @@ export default { agriZones = data.zones.map((z, i) => ({ id: z.id || i + 1, name: z.name || `农田${i + 1}`, + level: Math.max(1, z.level || 1), + warehouseId: z.warehouseId ?? null, x0: z.x0, y0: z.y0, x1: z.x1, y1: z.y1, })) nextAgriId = data.nextId || (Math.max(0, ...agriZones.map((z) => z.id)) + 1) } else if (data?.zone) { const z = data.zone - agriZones = [{ id: 1, name: z.name || '农田1', x0: z.x0, y0: z.y0, x1: z.x1, y1: z.y1 }] + agriZones = [{ + id: 1, name: z.name || '农田1', level: z.level || 1, + warehouseId: z.warehouseId ?? null, + x0: z.x0, y0: z.y0, x1: z.x1, y1: z.y1, + }] nextAgriId = 2 } else { agriZones = [] @@ -634,6 +826,7 @@ export default { } syncAgrizoneMarkers(g) agriZones.forEach((z) => fillFarmsInZone(g, z, { clear: false })) + agriZones.forEach((z) => ensureAgriWarehouse(g, z)) }, onUseItem(g, p, s, area, i) { if (s.code === 'agrihoe') { @@ -720,13 +913,21 @@ export default { } const def = `农田${agriZones.length + 1}` const name = promptZoneName('农田', def) - const z = { id: nextAgriId++, name, ...rect } + const z = { id: nextAgriId++, name, level: 1, ...rect } agriZones.push(z) const n = fillFarmsInZone(g, z) - g.tip(`「${name}」已划定:清出杂项,铺设良田 ${n} 块`) + g.tip(`「${name}」已划定:铺设良田 ${n} 块(右键管理:升级/改名/删除)`) return true }, + onRightClick(g, wx, wy, ev) { + if (zoneMode) return false + const z = agriZones.find((a) => pointInAabb(wx, wy, a)) + if (!z) return false + if (ev?.shiftKey) return renameZoneAt(g, wx, wy) + return agriZoneRightAction(g, z) + }, onClick(g, wx, wy) { + if (handleZoneMenuClick(wx, wy)) return true const sx = wx - uiCamX const sy = wy - uiCamY if (uiBtn && inside(sx, sy, uiBtn)) { @@ -740,8 +941,6 @@ export default { drag = null return true } - // 非框选时点击已有农田虚线框可改名 - if (!zoneMode && renameZoneAt(g, wx, wy)) return true return false }, onDraw(g, ctx, env) { @@ -758,24 +957,57 @@ export default { ctx.fillRect(x, y, w, h) ctx.strokeRect(x, y, w, h) ctx.setLineDash([]) + const label = `${z.name || '农田'} Lv.${z.level || 1}` + const lw = Math.min(140, Math.max(72, label.length * 8 + 16)) ctx.fillStyle = 'rgba(20, 40, 12, 0.7)' - ctx.beginPath(); ctx.roundRect(x + 4, y + 4, Math.min(120, w - 8), 18, 4); ctx.fill() + ctx.beginPath(); ctx.roundRect(x + 4, y + 4, lw, 18, 4); ctx.fill() ctx.fillStyle = '#e8f5c8' ctx.font = 'bold 12px sans-serif' ctx.textAlign = 'left' ctx.textBaseline = 'middle' - ctx.fillText(z.name || '农田', x + 10, y + 13) + ctx.fillText(label, x + 10, y + 13) ctx.restore() }) if (drag) { const r = normalizeRect(drag.x0, drag.y0, drag.x1, drag.y1) ctx.save() ctx.strokeStyle = 'rgba(180, 255, 120, 0.9)' - ctx.fillStyle = 'rgba(140, 220, 80, 0.15)' + ctx.fillStyle = 'rgba(140, 220, 80, 0.12)' ctx.lineWidth = 2 ctx.setLineDash([6, 4]) ctx.fillRect(r.x0 - env.camX, r.y0 - env.camY, r.x1 - r.x0, r.y1 - r.y0) ctx.strokeRect(r.x0 - env.camX, r.y0 - env.camY, r.x1 - r.x0, r.y1 - r.y0) + ctx.setLineDash([]) + // 预览良田行列 + const half = FARM_SPACING / 2 + let cols = 0 + let rows = 0 + let plots = 0 + for (let y = r.y0 + half; y <= r.y1 - half; y += FARM_SPACING) { + rows++ + let rowCols = 0 + for (let x = r.x0 + half; x <= r.x1 - half; x += FARM_SPACING) { + rowCols++ + plots++ + const sx = x - env.camX + const sy = y - env.camY + ctx.strokeStyle = 'rgba(160, 230, 100, 0.85)' + ctx.fillStyle = 'rgba(120, 190, 70, 0.22)' + ctx.lineWidth = 1.4 + ctx.beginPath(); ctx.roundRect(sx - 22, sy - 18, 44, 36, 3); ctx.fill(); ctx.stroke() + // 简易田埂 + ctx.strokeStyle = 'rgba(90, 140, 50, 0.55)' + ctx.beginPath(); ctx.moveTo(sx - 14, sy); ctx.lineTo(sx + 14, sy); ctx.stroke() + } + cols = Math.max(cols, rowCols) + } + ctx.fillStyle = 'rgba(20, 40, 12, 0.78)' + ctx.beginPath(); ctx.roundRect((r.x0 + r.x1) / 2 - env.camX - 60, r.y0 - env.camY - 22, 120, 18, 4); ctx.fill() + ctx.fillStyle = '#e8f5c8' + ctx.font = 'bold 11px sans-serif' + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.fillText(plots > 0 ? `${rows} 行 × ${cols} 列 · ${plots} 块` : '再划大一点', (r.x0 + r.x1) / 2 - env.camX, r.y0 - env.camY - 13) ctx.restore() } }, @@ -809,6 +1041,7 @@ export default { uiCancel = null } ctx.restore() + drawZoneMenu(ctx, env) }, }, } diff --git a/src/games/starve/mods/camp.js b/src/games/starve/mods/camp.js index adb3dcd..3bef56a 100644 --- a/src/games/starve/mods/camp.js +++ b/src/games/starve/mods/camp.js @@ -1,28 +1,728 @@ // 营地:出生点自带房屋、长明篝火与无敌守卫,可建造营地仓储与食堂 -// - 房屋 camphouse:按游戏日休息——夜里跳到次日清晨,白天回复体力 -// - 守卫 guard:无限血,营地半径内巡逻并拦截、击杀敌对生物 -// - 营地仓储 campstore:8 个箱子,每箱 6 种、每种最多 999(共 48 种容量) -// - 食堂 canteen:厨师做菜存入,点击点餐 -// - 营地升级:采集/击杀/产业发展涨 XP,扩半径、暗处补永久篝火;8 级围栏+门 -// - 领地:以房屋为圆心动态半径,通过核心 onMobVictim 否决钩子让敌对生物不再锁定营地内的玩家 -// 时间/数值基准:三围 150/150/200,一天 160 秒 -import { rebuildFencePerimeter, findDarkSpots } from './zoneUtil.js' +// - 房屋 camphouse:多外观随营地等级升级;可进入室内点床睡觉;员工可入驻 +// - 划定宅基:框选区域自动铺房屋地基,建筑工按计划盖房 +// - 守卫 / 仓储 / 食堂 / 营地升级 +import { rebuildFencePerimeter, normalizeRect, clearZoneClutter, pointInAabb, drawNameplate, openZoneMenu, drawZoneMenu, handleZoneMenuClick, closeZoneMenu, isZoneMenuOpen, zoneFloors } from './zoneUtil.js' +import { skinByCode, drawPlayerFig } from '../../starveSkins.js' const TAU = Math.PI * 2 const CAMP_R0 = 420 // 1 级营地安全半径 const BOXES = 8 // 箱子数 const BOX_KINDS = 6 // 每箱最多物品种类 -const BOX_CAP = 999 // 每种最多数量 +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 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 +} + +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 label = `${name} · ${skin.label}` + ctx.fillStyle = 'rgba(20,16,10,0.78)' + const tw = Math.max(48, label.length * 8 + 14) + ctx.beginPath(); ctx.roundRect(-tw / 2, -88 - (floors - 1) * 28, tw, 16, 4); ctx.fill() + ctx.fillStyle = '#f4ead2' + ctx.font = 'bold 11px "PingFang SC","Microsoft YaHei",sans-serif' + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.fillText(label, 0, -80 - (floors - 1) * 28) + if (resN > 0) { + ctx.fillStyle = 'rgba(40,60,30,0.75)' + ctx.beginPath(); ctx.roundRect(-22, -72 - (floors - 1) * 28, 44, 12, 3); ctx.fill() + ctx.fillStyle = '#c8e8a0' + ctx.font = '9px sans-serif' + ctx.fillText(`住 ${resN}/${cap}`, 0, -66 - (floors - 1) * 28) + } + ctx.fillStyle = 'rgba(0,0,0,0.25)' + ctx.beginPath(); ctx.ellipse(0, 8, 34, 9, 0, 0, TAU); ctx.fill() + // 地基:石屋/庄园用厚石基 + ctx.fillStyle = model === 'hut' ? '#8a7050' : '#8d9097' + ctx.strokeStyle = '#1d1409' + ctx.lineWidth = 2 + ctx.beginPath(); ctx.roundRect(-30, -4, 60, 12, 2); ctx.fill(); ctx.stroke() + for (let f = 0; f < floors; f++) { + const baseY = -46 - f * 28 + ctx.fillStyle = f % 2 ? skin.wall2 : skin.wall + ctx.strokeStyle = '#1d1409' + ctx.lineWidth = 2 + ctx.beginPath(); ctx.roundRect(-26, baseY, 52, 44, model === 'manor' ? 2 : 3); ctx.fill(); ctx.stroke() + ctx.strokeStyle = skin.trim + ctx.lineWidth = 1.2 + if (model === 'stone' || model === 'manor') { + // 砖缝 + for (let row = 0; row < 4; row++) { + const y = baseY + 8 + row * 9 + ctx.beginPath(); ctx.moveTo(-24, y); ctx.lineTo(24, y); ctx.stroke() + } + } else { + ;[-38, -30, -22, -14, -6].forEach((y) => { + ctx.beginPath(); ctx.moveTo(-26, baseY + 46 + y); ctx.lineTo(26, baseY + 46 + y); ctx.stroke() + }) + } + ctx.fillStyle = model === 'manor' ? '#a8d8ff' : '#ffd98a' + ctx.strokeStyle = '#1d1409' + ctx.lineWidth = 2 + ctx.beginPath(); ctx.roundRect(7, baseY + 6, 15, 13, 1.5); ctx.fill(); ctx.stroke() + if (model === 'manor') { + ctx.beginPath(); ctx.roundRect(-22, baseY + 6, 15, 13, 1.5); ctx.fill(); ctx.stroke() + } + if (f === 0) { + ctx.fillStyle = '#3a2a16' + ctx.beginPath(); ctx.roundRect(-11, -26, 20, 24, [3, 3, 0, 0]); ctx.fill(); ctx.stroke() + ctx.fillStyle = '#e0c060' + ctx.beginPath(); ctx.arc(4, -14, 1.6, 0, TAU); ctx.fill() + } + } + const roofY = -46 - (floors - 1) * 28 + ctx.fillStyle = skin.roof + ctx.strokeStyle = '#1d1409' + ctx.lineWidth = 2 + if (model === 'manor') { + ctx.beginPath(); ctx.moveTo(-36, roofY); ctx.lineTo(36, roofY); ctx.lineTo(28, roofY - 18); ctx.lineTo(-28, roofY - 18); ctx.closePath(); ctx.fill(); ctx.stroke() + ctx.fillStyle = '#3a2820' + ctx.beginPath(); ctx.moveTo(-28, roofY - 18); ctx.lineTo(28, roofY - 18); ctx.lineTo(0, roofY - 36); ctx.closePath(); ctx.fill(); ctx.stroke() + } else { + ctx.beginPath(); ctx.moveTo(-34, roofY); ctx.lineTo(34, roofY); ctx.lineTo(0, roofY - 30); ctx.closePath(); ctx.fill(); ctx.stroke() + } + ctx.fillStyle = model === 'stone' || model === 'manor' ? '#7a8088' : '#9a6a3c' + ctx.strokeStyle = '#1d1409' + ctx.lineWidth = 1.8 + ctx.beginPath(); ctx.roundRect(12, roofY - 26, 11, 20, 2); ctx.fill(); ctx.stroke() + const t = env?.time || 0 + ctx.fillStyle = 'rgba(220,220,220,0.4)' + for (let i = 0; i < 3; i++) { + const ph = (t * 0.5 + i * 0.5 + e.id * 0.1) % 1.5 + ctx.beginPath() + ctx.arc(17 + Math.sin(t + i) * 3, roofY - 34 - ph * 22, 3 + ph * 3, 0, TAU) + ctx.fill() + } + ctx.fillStyle = '#9a6a3c' + ctx.strokeStyle = '#1d1409' + ctx.lineWidth = 1.6 + ctx.beginPath(); ctx.roundRect(-13, -2, 24, 5, 1.5); ctx.fill(); ctx.stroke() +} + +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: '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 }) + } 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 }) + } 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 }) + } + return furn +} + +function currentFurn(e, av) { + const floors = houseFloorCount(e) + const floor = Math.max(0, Math.min(floors - 1, av?.floor ?? 0)) + return { floors, floor, furn: roomFurniture(floor, floors) } +} + +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 === 'table') { g.ptip(p, '一张结实的木桌'); return true } + if (id === 'shelf') { 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 +} + +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 + ctx.save() + ctx.strokeStyle = '#1d1409' + ctx.lineWidth = 2 + if (id === 'door') { + ctx.fillStyle = 'rgba(255, 220, 140, 0.28)' + ctx.fillRect(a.x, a.y, w, h) + ctx.fillStyle = '#3a2a16' + ctx.fillRect(a.x + 4, a.y + 2, w - 8, 6) + 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 = '#6a6e78' + ctx.beginPath(); ctx.roundRect(a.x, a.y, w, h, 4); ctx.fill(); ctx.stroke() + // 坡道斑马线 + ctx.strokeStyle = 'rgba(240,220,140,0.55)' + ctx.lineWidth = 2 + const steps = 6 + for (let i = 0; i < steps; i++) { + const t = i / steps + const y = up ? a.y + h * (1 - t) : a.y + h * t + ctx.beginPath() + ctx.moveTo(a.x + 4, y) + ctx.lineTo(a.x + w - 4, y - (up ? 8 : -8) * view.scale) + ctx.stroke() + } + ctx.fillStyle = '#f0d79a' + ctx.font = `bold ${Math.max(10, 12 * view.scale)}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') { + ctx.fillStyle = id === 'bed2' ? '#4a6a8a' : '#6a4a8a' + ctx.beginPath(); ctx.roundRect(a.x, a.y, w, h, 6); ctx.fill(); ctx.stroke() + ctx.fillStyle = '#e8e0f0' + ctx.beginPath(); ctx.roundRect(a.x + 8, a.y + 6, w - 16, h * 0.42, 4); ctx.fill() + ctx.fillStyle = '#f4ead2' + ctx.font = `bold ${Math.max(10, 12 * view.scale)}px sans-serif` + ctx.textAlign = 'center' + ctx.fillText('床', a.x + w / 2, a.y + h - 10) + } else if (id === 'table') { + ctx.fillStyle = '#a07040' + ctx.beginPath(); ctx.roundRect(a.x, a.y, w, h * 0.45, 4); ctx.fill(); ctx.stroke() + ctx.fillStyle = '#7a5030' + ctx.fillRect(a.x + 8, a.y + h * 0.45, 6, h * 0.55) + ctx.fillRect(a.x + w - 14, a.y + h * 0.45, 6, h * 0.55) + ctx.fillStyle = '#f4ead2' + ctx.font = '11px sans-serif' + ctx.textAlign = 'center' + ctx.fillText('木桌', a.x + w / 2, a.y - 6) + } else if (id === 'shelf') { + ctx.fillStyle = '#8a6038' + ctx.beginPath(); ctx.roundRect(a.x, a.y, w, h, 4); ctx.fill(); ctx.stroke() + ctx.strokeStyle = '#5a3a20' + ;[0.33, 0.66].forEach((k) => { + ctx.beginPath(); ctx.moveTo(a.x + 5, a.y + h * k); ctx.lineTo(a.x + w - 5, a.y + h * k); ctx.stroke() + }) + ctx.fillStyle = '#f4ead2' + ctx.font = '11px sans-serif' + ctx.textAlign = 'center' + ctx.fillText('橱柜', a.x + w / 2, a.y - 6) + } else if (id === 'plant') { + ctx.fillStyle = '#8a5a3a' + ctx.beginPath(); ctx.roundRect(a.x + w * 0.2, a.y + h * 0.55, w * 0.6, h * 0.4, 3); ctx.fill(); ctx.stroke() + ctx.fillStyle = '#4a8a3a' + ctx.beginPath(); ctx.ellipse(a.x + w / 2, a.y + h * 0.4, w * 0.38, h * 0.28, 0, 0, TAU); ctx.fill() + const sway = Math.sin((env.time || 0) * 2.2) * 2 + ctx.fillStyle = '#3a7a2a' + ctx.beginPath(); ctx.ellipse(a.x + w / 2 + sway, a.y + h * 0.28, w * 0.22, h * 0.18, 0, 0, TAU); ctx.fill() + ctx.fillStyle = '#f4ead2' + ctx.font = '11px sans-serif' + ctx.textAlign = 'center' + ctx.fillText('盆栽', a.x + w / 2, a.y - 4) + } else if (id === 'sofa') { + ctx.fillStyle = '#6a4a3a' + ctx.beginPath(); ctx.roundRect(a.x, a.y + h * 0.25, w, h * 0.75, 6); ctx.fill(); ctx.stroke() + ctx.fillStyle = '#8a6a50' + ctx.beginPath(); ctx.roundRect(a.x + 6, a.y, w - 12, h * 0.4, 5); ctx.fill(); ctx.stroke() + ctx.fillStyle = '#f4ead2' + ctx.font = '11px sans-serif' + ctx.textAlign = 'center' + ctx.fillText('沙发', a.x + w / 2, a.y - 4) + } else if (id === 'crate' || id === 'crate2') { + ctx.fillStyle = '#9a7a48' + ctx.beginPath(); ctx.roundRect(a.x, a.y, w, h, 4); ctx.fill(); ctx.stroke() + ctx.strokeStyle = '#6a4a28' + ctx.beginPath(); ctx.moveTo(a.x + 4, a.y + h / 2); ctx.lineTo(a.x + w - 4, a.y + h / 2); ctx.stroke() + ctx.fillStyle = '#f4ead2' + ctx.font = '11px sans-serif' + ctx.textAlign = 'center' + ctx.fillText('木箱', a.x + w / 2, a.y - 4) + } + 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.fillStyle = skin.wall2 + ctx.fillRect(view.px, view.py + view.ph * 0.42 - 6, view.pw, 8) + 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 + 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 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) + + furn.forEach((f) => drawFurnPiece(ctx, view, f, env)) + + 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 = [] @@ -41,13 +741,160 @@ function hiredCount(g) { let n = 0 g.entities.forEach((e) => { if (e.type !== 'laboroffice' || !e.mx) return - n += (e.mx.farmer || 0) + (e.mx.herder || 0) + (e.mx.chef || 0) + 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) @@ -60,13 +907,15 @@ function addCampXp(g, base) { } } function onCampLevelUp(g) { - const r = campR() - const c = campCenter(g) - const softCap = 3 + campState.level - const need = Math.min(2, Math.max(0, softCap - eternalFireCount(g))) - const spots = findDarkSpots(g, c.x, c.y, r, need, 100) - spots.forEach((s) => g.addFire(s.x, s.y, { pit: true, eternal: true, ttl: 150, max: 150 })) - g.tip(`营地升至 ${campState.level} 级!领地扩大${spots.length ? `,新增 ${spots.length} 座长明篝火` : ''}`) + 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) } @@ -75,22 +924,9 @@ function buildCampFence(g) { const c = campCenter(g) const r = campR() * 0.92 const z = { x0: c.x - r, y0: c.y - r * 0.62, x1: c.x + r, y1: c.y + r * 0.62 } - rebuildFencePerimeter(g, z, { fenceType: 'campfence', gateType: 'campgate', step: 56 }) + rebuildFencePerimeter(g, z, { fenceType: 'campfence', gateType: 'campgate', step: 34 }) campState.fenced = true - g.tip('营地周围筑起了围栏与大门') -} -function placeStarterFires(g, cx, cy) { - const offsets = [ - [90, 70], - [-100, 50], - [40, -110], - [-70, -80], - [120, -30], - ] - offsets.forEach(([dx, dy]) => { - const spot = g.findLand(cx + dx, cy + dy, 0, 50) - if (spot) g.addFire(spot.x, spot.y, { pit: true, eternal: true, ttl: 150, max: 150 }) - }) + g.tip('营地周围筑起了围栏与大门(野怪可能翻墙闯入,守卫会迎击)') } // 相机无关的“点是否在矩形内” @@ -163,31 +999,87 @@ function withdrawBox(g, p, box) { 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 house = g.entities.find((e) => e.type === 'camphouse') - if (house) return { x: house.x, y: house.y } - return { x: g.worldW / 2, y: g.worldH / 2 } + const t = campTerritory(g) + return { x: t.cx, y: t.cy } } -// 全部营地房屋(用于绘制每一座的安全区边界) function campHouseList(g) { - return g.entities.filter((e) => e.type === 'camphouse') + return (g?.entities || []).filter((e) => e.type === 'camphouse') } -// 玩家在「任意一座」营地房屋的动态半径内都算进入营地 +// 落在整片营地安全区内即算营地 function inCamp(g, x, y) { - const r = campR() - return g.entities.some((e) => e.type === 'camphouse' && Math.hypot(x - e.x, y - e.y) < r) + 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: '房屋休息跳夜、营地升级扩半径、食堂点餐与长明篝火', + desc: '房屋休息跳夜、宅基规划盖房、营地升级扩半径、食堂点餐', factor: 0.75, // 大幅降低难度 tag: 'easy', tabs: [{ code: 'camp', name: '营地', icon: 'camphouse' }], recipes: [ { code: 'campstore', name: '营地仓储', tab: 'camp', tech: 1, cost: { log: 10, rock: 4 }, build: true, desc: '8 个箱子,可建造多个' }, - { code: 'camphouse', name: '营地房屋', tab: 'camp', tech: 1, cost: { log: 12, rock: 8 }, build: true, desc: '按游戏日休息:夜跳昼 / 昼回体力' }, + { code: '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: '可开关的营地大门' }, @@ -275,102 +1167,68 @@ export default { }, }, 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.25)' + 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.beginPath(); ctx.moveTo(-20, -28); ctx.lineTo(20, -28); ctx.lineTo(0, -48); ctx.closePath(); 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) { - if (!e.mx) e.mx = { restDay: 0 } - if (e.mx.restDay == null) e.mx.restDay = 0 + normalizeHouseMx(e) + e.mx.model = houseModelForLevel() + e.mx.floors = Math.min(4, zoneFloors(getCampLevel())) }, interact(p, e, g) { - if (!e.mx) e.mx = { restDay: 0 } - if (e.mx.restDay === g.day) { - g.ptip(p, '今天已经休息过了') - return - } - if (g.phase === 'night') { - // 推进到次日清晨(setDayTime(DAY_LEN) → day++ 且 dayTime=0) - if (typeof g.setDayTime === 'function') g.setDayTime(DAY_LEN) - e.mx.restDay = g.day - g.markDirty(e) - g.ptip(p, '睡到了天亮……新的一天开始了') - g.swing(p, 'eat') - return - } - // 白天:恢复体力(饥饿/生命/理智) - p.hunger = Math.min(150, p.hunger + 40) - p.hp = Math.min(150, p.hp + 25) - p.san = Math.min(200, p.san + 15) - e.mx.restDay = g.day - g.markDirty(e) - g.ptip(p, '白天歇息:饥饿 +40、生命 +25、理智 +15') - g.swing(p, 'eat') + normalizeHouseMx(e) + enterInterior(g, e) + g.ptip(p, `进入「${e.mx.name}」· WASD 走动 · 走近床按 E 睡觉`) }, draw(ctx, e, env) { - // 底部投影 - ctx.fillStyle = 'rgba(0,0,0,0.25)' - ctx.beginPath(); ctx.ellipse(0, 8, 34, 9, 0, 0, TAU); ctx.fill() - // 石砌地基 - ctx.fillStyle = '#8d9097' - ctx.strokeStyle = '#1d1409' - ctx.lineWidth = 2 - ctx.beginPath(); ctx.roundRect(-30, -4, 60, 12, 2); ctx.fill(); ctx.stroke() - ctx.strokeStyle = '#6f7278' - ctx.lineWidth = 1 - ;[-20, -10, 0, 10, 20].forEach((x) => { ctx.beginPath(); ctx.moveTo(x, -4); ctx.lineTo(x, 8); ctx.stroke() }) - // 木板墙 - ctx.fillStyle = '#b07f47' - ctx.strokeStyle = '#1d1409' - ctx.lineWidth = 2 - ctx.beginPath(); ctx.roundRect(-26, -46, 52, 44, 3); ctx.fill(); ctx.stroke() - // 横向木纹 - ctx.strokeStyle = '#8a5f30' - ctx.lineWidth = 1.2 - ;[-38, -30, -22, -14, -6].forEach((y) => { ctx.beginPath(); ctx.moveTo(-26, y); ctx.lineTo(26, y); ctx.stroke() }) - // 竖向立柱 - ;[-18, 0, 18].forEach((x) => { ctx.beginPath(); ctx.moveTo(x, -46); ctx.lineTo(x, -2); ctx.stroke() }) - // 门(深色木门 + 门把手) - ctx.fillStyle = '#3a2a16' - ctx.strokeStyle = '#1d1409' - ctx.lineWidth = 2 - ctx.beginPath(); ctx.roundRect(-11, -26, 20, 24, [3, 3, 0, 0]); ctx.fill(); ctx.stroke() - ctx.fillStyle = '#e0c060' - ctx.beginPath(); ctx.arc(4, -14, 1.6, 0, TAU); ctx.fill() - // 亮着暖光的窗 - ctx.fillStyle = '#ffd98a' - ctx.strokeStyle = '#1d1409' - ctx.lineWidth = 2 - ctx.beginPath(); ctx.roundRect(7, -40, 15, 13, 1.5); ctx.fill(); ctx.stroke() - ctx.strokeStyle = '#8a6a34' - ctx.lineWidth = 1.2 - ctx.beginPath(); ctx.moveTo(7, -33.5); ctx.lineTo(22, -33.5); ctx.moveTo(14.5, -40); ctx.lineTo(14.5, -27); ctx.stroke() - // 斜屋顶(瓦片纹) - ctx.fillStyle = '#7a5230' - ctx.strokeStyle = '#1d1409' - ctx.lineWidth = 2 - ctx.beginPath(); ctx.moveTo(-34, -46); ctx.lineTo(34, -46); ctx.lineTo(0, -76); ctx.closePath(); ctx.fill(); ctx.stroke() - ctx.strokeStyle = '#5d3f24' - ctx.lineWidth = 1.2 - ;[-26, -16, -6, 6, 16, 26].forEach((x) => { ctx.beginPath(); ctx.moveTo(x, -46); ctx.lineTo(x * 0.4, -72); ctx.stroke() }) - // 烟囱 - ctx.fillStyle = '#9a6a3c' - ctx.strokeStyle = '#1d1409' - ctx.lineWidth = 1.8 - ctx.beginPath(); ctx.roundRect(12, -72, 11, 20, 2); ctx.fill(); ctx.stroke() - // 炊烟(缓慢飘动) - const t = env?.time || 0 - ctx.fillStyle = 'rgba(220,220,220,0.4)' - for (let i = 0; i < 3; i++) { - const ph = (t * 0.5 + i * 0.5 + e.id * 0.1) % 1.5 - ctx.beginPath() - ctx.arc(17 + Math.sin(t + i) * 3, -80 - ph * 22, 3 + ph * 3, 0, TAU) - ctx.fill() - } - // 门口小台阶 - ctx.fillStyle = '#9a6a3c' - ctx.strokeStyle = '#1d1409' - ctx.lineWidth = 1.6 - ctx.beginPath(); ctx.roundRect(-13, -2, 24, 5, 1.5); ctx.fill(); ctx.stroke() + drawHouseExterior(ctx, e, env) }, }, campstore: { @@ -386,6 +1244,11 @@ export default { 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}件` : '营地仓储', -74) // 底部投影 ctx.fillStyle = 'rgba(0,0,0,0.25)' ctx.beginPath(); ctx.ellipse(0, 8, 28, 8, 0, 0, TAU); ctx.fill() @@ -450,6 +1313,8 @@ export default { 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}份` : '食堂', -68) ctx.fillStyle = 'rgba(0,0,0,0.25)' ctx.beginPath(); ctx.ellipse(0, 8, 28, 8, 0, 0, TAU); ctx.fill() ctx.fillStyle = '#b07f47' @@ -462,7 +1327,6 @@ export default { ctx.beginPath(); ctx.roundRect(-10, -18, 20, 22, 2); ctx.fill(); ctx.stroke() ctx.fillStyle = '#e8c070' ctx.beginPath(); ctx.ellipse(0, -22, 10, 5, 0, 0, TAU); ctx.fill(); ctx.stroke() - const n = Object.values(e.mx?.menu || {}).reduce((a, b) => a + (b || 0), 0) if (n > 0) { ctx.fillStyle = '#f4ead2' ctx.font = 'bold 11px system-ui, sans-serif' @@ -474,9 +1338,18 @@ export default { campfence: { name: '营地栅栏', conf: { size: 24, struct: true, solid: true }, - draw(ctx) { + init(e) { + if (!e.mx) e.mx = { orient: 'h' } + if (!e.mx.orient) e.mx.orient = 'h' + }, + draw(ctx, e) { + const vert = e.mx?.orient === 'v' ctx.fillStyle = 'rgba(0,0,0,0.25)' - ctx.beginPath(); ctx.ellipse(0, 4, 16, 5, 0, 0, TAU); ctx.fill() + ctx.beginPath() + ctx.ellipse(0, 4, vert ? 6 : 16, vert ? 16 : 5, 0, 0, TAU) + ctx.fill() + ctx.save() + if (vert) ctx.rotate(Math.PI / 2) ctx.fillStyle = '#8d9097' ctx.strokeStyle = '#1d1409' ctx.lineWidth = 2 @@ -485,6 +1358,7 @@ export default { }) ctx.beginPath(); ctx.roundRect(-16, -20, 32, 5, 2); ctx.fill(); ctx.stroke() ctx.beginPath(); ctx.roundRect(-16, -8, 32, 5, 2); ctx.fill(); ctx.stroke() + ctx.restore() }, }, campgate: { @@ -536,28 +1410,33 @@ export default { guard: { name: '守卫', friendly: true, - conf: { hp: 9999, speed: 150, dmg: 60, score: 0, friendly: true, name: '守卫' }, + 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 bd = campR() + let best = Infinity g.monsters.forEach((q) => { - if (q === m) return + if (q === m || q.kind === 'guard') return if (!g.mon[q.kind]?.hostile) return - const d = Math.hypot(q.x - c.x, q.y - c.y) - if (d < bd) { bd = d; target = q } + 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 > 34) { - g.moveToward(m, target.x, target.y, conf.speed, dt) + if (d > 30) { + g.moveToward(m, target.x, target.y, conf.speed * 1.4, dt) } else if ((m.cd || 0) <= 0) { - m.cd = 0.9 + m.cd = 0.35 g.damageMob(target, conf.dmg, null) } return @@ -567,18 +1446,18 @@ export default { if (m.wanderT <= 0) { m.wanderT = 2 + Math.random() * 3 const a = Math.random() * TAU - const r = 60 + Math.random() * 110 + 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.4, dt) + g.moveToward(m, m.tx, m.ty, conf.speed * 0.45, dt) } else { m.moving = false } }, interact(p, m, g) { - g.ptip(p, '守卫挺直身板:营地范围里很安全') + g.ptip(p, `守卫:发现敌对会立刻迎击(当前编制 ${desiredGuardCount()} 人)`) }, draw(ctx, m, time) { ctx.save() @@ -644,7 +1523,7 @@ export default { }, hooks: { save() { - return { ...campState } + return { ...campState, houseZones, nextHouseZoneId } }, load(g, data) { campState = { @@ -652,25 +1531,42 @@ export default { 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) - const gs = g.findLand(cx + 60, cy + 40, 0, 80) - g.spawnMob('guard', gs.x, gs.y, {}) - placeStarterFires(g, house.x, house.y) + 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('营地已就绪:房屋、五座长明篝火、食堂与守卫') + g.tip(`营地已就绪:主屋、分散长明篝火、食堂与 ${desiredGuardCount()} 名守卫`) }, + // 墙外野怪不能隔墙打营内玩家;翻墙进来的可以打,由守卫迎击 onMobVictim(g, m, victim) { if (!g.mon[m.kind]?.hostile) return - if (inCamp(g, victim.x, victim.y)) return false + if (inCamp(g, victim.x, victim.y) && !inCamp(g, m.x, m.y)) return false }, onGather(g) { addCampXp(g, 2) @@ -681,6 +1577,9 @@ export default { 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)) @@ -691,12 +1590,36 @@ export default { 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 ?? 4) - dt + e.mx.openT = (e.mx.openT ?? 8) - dt if (e.mx.openT <= 0) { e.mx.open = false e.mx.openT = 0 @@ -711,19 +1634,97 @@ export default { }) }, onDraw(g, ctx, env) { - const r = campR() - const houses = campHouseList(g) - const list = houses.length ? houses : [campCenter(g)] + // 整片营地安全区:一个圈包住宅基+房屋并集 + const t = campTerritory(g) + const ex = t.cx - env.camX + const ey = t.cy - env.camY ctx.save() - ctx.strokeStyle = 'rgba(255,220,140,0.35)' - ctx.lineWidth = 2 - ctx.setLineDash([10, 8]) - for (const h of list) { - ctx.beginPath() - ctx.ellipse(h.x - env.camX, h.y - env.camY, r, r * 0.62, 0, 0, TAU) - ctx.stroke() - } + ctx.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() + // 各块宅基规划虚线框 + 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 做世界→屏幕换算 @@ -743,7 +1744,30 @@ export default { ctx.textAlign = 'left' ctx.textBaseline = 'middle' const need = xpNeed(campState.level) - ctx.fillText(`营地 Lv.${campState.level} XP ${campState.xp}/${need}`, 18, 66) + ctx.fillText(`营地 Lv.${campState.level} XP ${campState.xp}/${need} 守卫 ${guardCount(g)}/${desiredGuardCount()} 篝火 ${eternalFireCount(g)}`, 18, 66) + // 划定宅基按钮 + const bw = 92 + const bh = 28 + const bx = 10 + const by = 92 + 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() // 食堂面板 @@ -761,9 +1785,9 @@ export default { const footerH = 42 const pad = 10 const panelW = Math.min(360, W - 24) - const panelH = headerH + Math.max(1, entries.length) * rowH + footerH + pad + const 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((H - panelH) / 2) + const py = Math.round(Math.max(8, (H - panelH) / 2)) uiCanteenPanel = { x: px, y: py, w: panelW, h: panelH } uiCanteenRows = [] ctx.save() @@ -807,7 +1831,7 @@ export default { } const cw = 90 const ch = 26 - uiCanteenClose = { x: px + panelW / 2 - cw / 2, y: py + panelH - footerH + 8, w: cw, h: ch } + 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 @@ -824,114 +1848,138 @@ export default { uiCanteenRows = [] } - if (storeOpen == null) return - const e = g.entities.find((x) => x.id === storeOpen) - const p = g.me() - if (!e || !p) { - storeOpen = null - return - } - const boxes = boxesOf(e) - const rowH = 30 - const headerH = 40 - const footerH = 42 - const pad = 10 - const panelW = Math.min(470, W - 24) - const panelH = headerH + BOXES * rowH + footerH + pad - const px = Math.round((W - panelW) / 2) - const py = Math.round((H - panelH) / 2) - uiPanel = { x: px, y: py, w: panelW, h: panelH } + // 营地仓储面板(与室内/区域菜单解耦,勿 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 - for (let i = 0; i < BOXES; i++) { - const rowY = py + headerH + i * rowH - const box = boxes[i] - const used = usedKinds(box) - // 交替底色 - ctx.fillStyle = i % 2 ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.14)' - ctx.beginPath(); ctx.roundRect(px + pad, rowY + 2, panelW - pad * 2, rowH - 5, 4); ctx.fill() - // 箱子 N + 用量 - ctx.textAlign = 'left' - ctx.textBaseline = 'middle' - ctx.fillStyle = used > 0 ? '#f4ead2' : 'rgba(244,234,210,0.5)' - ctx.font = `bold ${fontSize}px system-ui, sans-serif` - ctx.fillText(`箱子 ${i + 1}`, px + pad + 8, rowY + rowH / 2 - 1) - ctx.fillStyle = 'rgba(255,220,140,0.85)' - ctx.font = `11px system-ui, sans-serif` - ctx.fillText(`${used}/${BOX_KINDS} 种`, px + pad + 62, rowY + rowH / 2 - 1) - // 内容摘要(最多 3 种) - const entries = Object.entries(box.items || {}).filter(([, n]) => n > 0) - const shown = entries.slice(0, 3).map(([code, n]) => `${g.items[code]?.name || code}×${n}`) - const more = entries.length > 3 ? ` 等${entries.length}种` : '' - ctx.fillStyle = 'rgba(230,220,200,0.8)' - ctx.font = `12px system-ui, sans-serif` - ctx.fillText(entries.length ? shown.join(' ') + more : '(空)', px + pad + 108, rowY + rowH / 2 - 1) - // 按钮 - const by = rowY + (rowH - 3 - btnH) / 2 + 1 - const dep = { x: rightEdge - witW - gap - depW, y: by, w: depW, h: btnH } - const wit = { x: rightEdge - witW, y: by, w: witW, h: btnH } - const drawBtn = (r, label, bg) => { - ctx.fillStyle = bg + ctx.save() + ctx.fillStyle = 'rgba(0,0,0,0.45)' + ctx.fillRect(0, 0, W, H) + ctx.fillStyle = '#3a2a18' 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.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.font = `bold 12px system-ui, sans-serif` - ctx.fillText(label, r.x + r.w / 2, r.y + r.h / 2 + 0.5) + 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() } - drawBtn(dep, '↓ 存入', '#3f6b34') - drawBtn(wit, '↑ 取出', '#8a6a34') - uiRows.push({ i, dep, wit }) + } else { + uiPanel = null + uiClose = null + uiRows = [] } - // 关闭按钮 - const cw = 120 - const ch = 26 - const cx2 = px + panelW / 2 - cw / 2 - const cy2 = py + panelH - footerH + 8 - uiClose = { x: cx2, y: cy2, w: cw, h: ch } - ctx.fillStyle = '#7a2f24' - ctx.strokeStyle = '#1d1409' - ctx.lineWidth = 2 - ctx.beginPath(); ctx.roundRect(cx2, cy2, cw, ch, 6); ctx.fill(); ctx.stroke() - ctx.fillStyle = '#f4ead2' - ctx.textAlign = 'center' - ctx.textBaseline = 'middle' - ctx.font = 'bold 14px system-ui, sans-serif' - ctx.fillText('关闭', cx2 + cw / 2, cy2 + ch / 2 + 0.5) - ctx.restore() + // 室内子地图与区域菜单必须始终有机会绘制 + 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) { diff --git a/src/games/starve/mods/husbandry.js b/src/games/starve/mods/husbandry.js index 9c4b9d7..661885d 100644 --- a/src/games/starve/mods/husbandry.js +++ b/src/games/starve/mods/husbandry.js @@ -1,4 +1,4 @@ -import { normalizeRect, pointInAabb, aabbArea, rebuildFencePerimeter, clearZoneClutter } from './zoneUtil.js' +import { normalizeRect, pointInAabb, aabbArea, rebuildFencePerimeter, clearFencePerimeter, clearZoneClutter, zoneYieldMul, zoneFloors, zoneUpgradeCost, buildCostDetails, costDetailsAllOk, hasStockCost, payStockCost, drawNameplate, perimeterPoints, FENCE_STEP, openZoneMenu, drawZoneMenu, handleZoneMenuClick } from './zoneUtil.js' // 牧业文明:栅栏圈养、饲料槽投喂 —— 羊毛与鸡蛋的牧场经济 // - 剪刀(科学机器合成,耐久 30):点击绵羊剪毛,每只羊每天一次(给羊毛 ×2) @@ -8,11 +8,12 @@ import { normalizeRect, pointInAabb, aabbArea, rebuildFencePerimeter, clearZoneC // - 羊毛可做便宜的保暖帽(保暖 60),鸡蛋/牛奶/奶酪补充食物 // 数值基准:三围 150/150/200,一天 160 秒,官方食物折算 const TAU = Math.PI * 2 -const GATE_AUTO_CLOSE = 4 // 牧场门打开后自动关闭秒数 +const GATE_AUTO_CLOSE = 8 // 牧场门打开后自动关闭秒数(附近有人会续时) // ---- 牧场框选(多块可命名) ---- -let pastureZones = [] // [{ id, name, x0, y0, x1, y1 }] +let pastureZones = [] // [{ id, name, level, x0, y0, x1, y1 }] let nextPastureId = 1 +const MAX_PASTURE_LEVEL = 10 let zoneMode = false let drag = null let uiCamX = 0 @@ -51,6 +52,8 @@ function stockPasture(g, z) { const inZ = (m) => m.pen && m.zoneId === z.id const countKind = (kind) => g.monsters.filter((m) => m.kind === kind && inZ(m)).length const countRabbit = () => g.entities.filter((e) => e.type === 'rabbit' && e.mx?.pen && e.mx.zoneId === z.id).length + const cx = (z.x0 + z.x1) / 2 + const cy = (z.y0 + z.y1) / 2 const spawnIn = (kind, n) => { for (let i = 0; i < n; i++) { const x = z.x0 + 50 + Math.random() * Math.max(20, z.x1 - z.x0 - 100) @@ -60,16 +63,22 @@ function stockPasture(g, z) { const m = g.spawnMob(kind, spot.x, spot.y, { pen: true, zoneId: z.id, - homeX: (z.x0 + z.x1) / 2, - homeY: (z.y0 + z.y1) / 2, + home: { x: cx, y: cy }, + homeX: cx, + homeY: cy, zone: { ...z }, }) - if (m) { m.pen = true; m.zoneId = z.id } + if (m) { + m.pen = true + m.zoneId = z.id + m.home = { x: cx, y: cy } + } } } spawnIn('pig', Math.max(0, maxPig - countKind('pig'))) spawnIn('beefalo', Math.max(0, maxCow - countKind('beefalo'))) spawnIn('chicken', Math.max(0, maxChick - countKind('chicken'))) + spawnIn('sheep', Math.max(0, Math.min(3, 1 + Math.floor(area / 90000)) - countKind('sheep'))) const needRab = Math.max(0, maxRabbit - countRabbit()) for (let i = 0; i < needRab; i++) { const x = z.x0 + 50 + Math.random() * Math.max(20, z.x1 - z.x0 - 100) @@ -78,15 +87,15 @@ function stockPasture(g, z) { if (!spot) continue g.addEntity('rabbit', spot.x, spot.y, { mx: { pen: true, zoneId: z.id, zone: { ...z } }, - homeX: (z.x0 + z.x1) / 2, - homeY: (z.y0 + z.y1) / 2, + homeX: cx, + homeY: cy, }) } syncPastureMarkers(g) } function applyPasture(g, rect, name) { - const z = { id: nextPastureId++, name, ...rect } + const z = { id: nextPastureId++, name, level: 1, ...rect } pastureZones.push(z) clearZoneClutter(g, z) rebuildFencePerimeter(g, z, { fenceType: 'fence', gateType: 'gate' }) @@ -94,6 +103,105 @@ function applyPasture(g, rect, name) { return z } +function upgradePastureZone(g, z) { + const p = g.me() + if (!p) return false + const lv = z.level || 1 + if (lv >= MAX_PASTURE_LEVEL) { + g.tip(`「${z.name}」已满级`) + return true + } + const cost = zoneUpgradeCost(lv) + if (!hasStockCost(g, cost)) { + g.tip(`升级需要 木头×${cost.log} 石块×${cost.rock}${cost.gold ? ` 金块×${cost.gold}` : ''}(背包或仓库)`) + return true + } + payStockCost(g, cost) + z.level = lv + 1 + syncPastureMarkers(g) + g.tip(`「${z.name}」升至 ${z.level} 级 · 产出 ×${zoneYieldMul(z.level).toFixed(2)} · ${zoneFloors(z.level)} 层`) + return true +} + +function deletePastureZone(g, z) { + clearFencePerimeter(g, z, { fenceType: 'fence', gateType: 'gate' }) + // 移除圈养动物 + ;(g.monsters || []) + .filter((m) => m.pen && (m.zoneId === z.id || pointInAabb(m.x, m.y, z))) + .forEach((m) => g.removeMob(m)) + // 圈养实体(兔等) + g.entities + .filter((e) => e.mx?.pen && (e.mx?.zoneId === z.id || pointInAabb(e.x, e.y, z))) + .forEach((e) => g.removeEntity(e)) + pastureZones = pastureZones.filter((a) => a.id !== z.id) + syncPastureMarkers(g) + g.tip(`已删除牧场「${z.name}」`) + return true +} + +function pastureZoneRightAction(g, z) { + const lv = z.level || 1 + const maxed = lv >= MAX_PASTURE_LEVEL + const cost = maxed ? null : zoneUpgradeCost(lv) + const details = maxed + ? [{ text: '已满级,无法再升级', ok: true }] + : buildCostDetails(g, cost) + const canUp = !maxed && costDetailsAllOk(details) + const missHint = !maxed && !canUp + ? details.filter((d) => !d.ok).map((d) => `缺${d.name}×${d.miss}`).join(' · ') + : '' + openZoneMenu({ + title: `「${z.name}」`, + subtitle: maxed ? `牧场 · Lv.${lv}(满级)` : `牧场 · Lv.${lv} → Lv.${lv + 1}`, + detailTitle: maxed ? '升级状态' : `升级所需(背包+仓库)${missHint ? ` · ${missHint}` : ''}`, + details, + actions: [ + { + id: 'upgrade', + label: maxed ? '已满级' : (canUp ? '确认升级' : '材料不足'), + color: canUp ? '#5d7a3a' : '#5a4a3a', + disabled: maxed || !canUp, + }, + { id: 'rename', label: '改名', color: '#4a6a8a' }, + { id: 'delete', label: '删除区域', color: '#7a4a3a' }, + { id: 'cancel', label: '取消', color: '#5a4630' }, + ], + onPick(id) { + if (id === 'upgrade') { + upgradePastureZone(g, z) + return + } + if (id === 'rename') { + const name = promptPastureName(z.name) + z.name = name + syncPastureMarkers(g) + g.tip(`牧场已命名为「${name}」`) + return + } + if (id === 'delete') { + openZoneMenu({ + title: `删除「${z.name}」?`, + subtitle: '围栏与圈养将一并移除', + actions: [ + { id: 'confirm', label: '确认删除', color: '#8a3030' }, + { id: 'back', label: '返回', color: '#5a4630' }, + ], + onPick(id2) { + if (id2 === 'confirm') deletePastureZone(g, z) + else if (id2 === 'back') pastureZoneRightAction(g, z) + }, + }) + } + }, + }) + return true +} + +export function pastureYieldAt(x, y) { + const z = pastureZones.find((a) => pointInAabb(x, y, a)) + return zoneYieldMul(z?.level || 1) +} + function keepPenMobs(g, dt) { if (!pastureZones.length) return const zoneById = Object.fromEntries(pastureZones.map((z) => [z.id, z])) @@ -101,8 +209,14 @@ function keepPenMobs(g, dt) { if (!m.pen) return const z = zoneById[m.zoneId] || m.zone || pastureZones[0] if (!z) return + m.zone = z const cx = (z.x0 + z.x1) / 2 const cy = (z.y0 + z.y1) / 2 + if (!m.home) m.home = { x: cx, y: cy } + else { m.home.x = cx; m.home.y = cy } + // 圈养动物不离家、不受伤后失联:缓慢回血并拉回围栏 + const maxHp = g.mon[m.kind]?.hp || m.hp + if (m.hp < maxHp) m.hp = Math.min(maxHp, m.hp + 4 * dt) if (!pointInAabb(m.x, m.y, z)) { g.moveToward(m, cx, cy, (g.mon[m.kind]?.speed || 80) * 0.8, dt) } @@ -123,6 +237,19 @@ function tickGates(g, dt) { g.entities.forEach((e) => { if (e.type !== 'gate') return if (!e.mx) e.mx = { open: false, openT: 0 } + // 玩家 / 劳工靠近自动开门并续时 + const near = (g.players || []).some((p) => !p.dead && Math.hypot(p.x - e.x, p.y - e.y) < 56) + || (g.monsters || []).some((m) => (m.kind?.startsWith?.('labor') || m.kind === 'campvillager') && Math.hypot(m.x - e.x, m.y - e.y) < 56) + if (near) { + if (!e.mx.open) { + e.mx.open = true + e.solid = false + g.markDirty(e) + } + e.mx.openT = Math.max(e.mx.openT || 0, GATE_AUTO_CLOSE) + e.solid = false + return + } if (e.mx.open) { e.mx.openT = (e.mx.openT ?? GATE_AUTO_CLOSE) - dt if (e.mx.openT <= 0) { @@ -154,8 +281,15 @@ function wander(m, dt, g, speed) { if (m.wanderT <= 0) { m.wanderT = 2 + Math.random() * 3 const a = Math.random() * TAU - m.tx = m.x + Math.cos(a) * 80 - m.ty = m.y + Math.sin(a) * 80 + const z = m.pen ? (m.zone || pastureZones.find((p) => p.id === m.zoneId)) : null + if (z) { + const pad = 40 + m.tx = z.x0 + pad + Math.random() * Math.max(20, z.x1 - z.x0 - pad * 2) + m.ty = z.y0 + pad + Math.random() * Math.max(20, z.y1 - z.y0 - pad * 2) + } else { + m.tx = m.x + Math.cos(a) * 80 + m.ty = m.y + Math.sin(a) * 80 + } } if (m.tx !== undefined && Math.hypot(m.tx - m.x, m.ty - m.y) > 8) { g.moveToward(m, m.tx, m.ty, speed, dt) @@ -355,24 +489,32 @@ export default { fence: { name: '栅栏', conf: { size: 24, struct: true, solid: true }, - draw(ctx) { - // 底部投影 + init(e) { + if (!e.mx) e.mx = { orient: 'h' } + if (!e.mx.orient) e.mx.orient = 'h' + }, + draw(ctx, e) { + const vert = e.mx?.orient === 'v' + // 底部投影(竖向时拉长南北) ctx.fillStyle = 'rgba(0,0,0,0.25)' - ctx.beginPath(); ctx.ellipse(0, 4, 16, 5, 0, 0, TAU); ctx.fill() - // 两根竖木桩 + ctx.beginPath() + ctx.ellipse(0, 4, vert ? 6 : 16, vert ? 16 : 5, 0, 0, TAU) + ctx.fill() + ctx.save() + if (vert) ctx.rotate(Math.PI / 2) + // 两根竖木桩 + 两道横木(竖向时整体旋转,横木沿围墙延伸) ctx.fillStyle = '#a9773c' ctx.strokeStyle = '#1d1409' ctx.lineWidth = 2 ;[-11, 11].forEach((x) => { ctx.beginPath(); ctx.roundRect(x - 4, -28, 8, 32, 2); ctx.fill(); ctx.stroke() }) - // 两道横木 ctx.beginPath(); ctx.roundRect(-16, -20, 32, 5, 2); ctx.fill(); ctx.stroke() ctx.beginPath(); ctx.roundRect(-16, -8, 32, 5, 2); ctx.fill(); ctx.stroke() - // 木纹 ctx.strokeStyle = '#7a5230' ctx.lineWidth = 1.2 ctx.beginPath(); ctx.moveTo(-11, -28); ctx.lineTo(-11, 4); ctx.moveTo(11, -28); ctx.lineTo(11, 4); ctx.stroke() + ctx.restore() }, }, gate: { @@ -401,6 +543,7 @@ export default { }, draw(ctx, e) { const open = !!e.mx?.open + drawNameplate(ctx, open ? '牧场门 · 开' : '牧场门', -42) ctx.fillStyle = 'rgba(0,0,0,0.25)' ctx.beginPath(); ctx.ellipse(0, 4, 20, 5, 0, 0, TAU); ctx.fill() ctx.save() @@ -446,10 +589,11 @@ export default { g.ptip(p, `投喂了一份饲料(现有 ${e.mx.feed} 份)`) }, draw(ctx, e) { + const feed = e.mx?.feed || 0 + drawNameplate(ctx, feed > 0 ? `饲料槽 · ${feed}` : '饲料槽', -36) // 底部投影 ctx.fillStyle = 'rgba(0,0,0,0.25)' ctx.beginPath(); ctx.ellipse(0, 4, 18, 5, 0, 0, TAU); ctx.fill() - const feed = e.mx?.feed || 0 // 木质槽体 ctx.fillStyle = '#8a5f38' ctx.strokeStyle = '#1d1409' @@ -497,6 +641,8 @@ export default { } }, draw(ctx, e) { + const eggs = e.mx?.stored || 0 + drawNameplate(ctx, eggs > 0 ? `鸡舍 · 蛋${eggs}` : '鸡舍', -48) // 底部投影 ctx.fillStyle = 'rgba(0,0,0,0.25)' ctx.beginPath(); ctx.ellipse(0, 5, 22, 7, 0, 0, TAU); ctx.fill() @@ -673,18 +819,23 @@ export default { pastureZones = data.zones.map((z, i) => ({ id: z.id || i + 1, name: z.name || `牧场${i + 1}`, + level: Math.max(1, z.level || 1), x0: z.x0, y0: z.y0, x1: z.x1, y1: z.y1, })) nextPastureId = data.nextId || (Math.max(0, ...pastureZones.map((z) => z.id)) + 1) } else if (data?.zone) { const z = data.zone - pastureZones = [{ id: 1, name: z.name || '牧场1', x0: z.x0, y0: z.y0, x1: z.x1, y1: z.y1 }] + pastureZones = [{ id: 1, name: z.name || '牧场1', level: z.level || 1, x0: z.x0, y0: z.y0, x1: z.x1, y1: z.y1 }] nextPastureId = 2 } else { pastureZones = [] nextPastureId = 1 } syncPastureMarkers(g) + // 旧档竖边空隙:按新步长与朝向重建围栏 + pastureZones.forEach((z) => rebuildFencePerimeter(g, z, { fenceType: 'fence', gateType: 'gate' })) + // 生物不入档:读档后按牧场容量补齐圈养 + pastureZones.forEach((z) => stockPasture(g, z)) }, onWorldGen(g) { // 草原上散落绵羊与鸡(寻找邻近陆地投放) @@ -705,7 +856,9 @@ export default { tickGates(g, dt) }, onNewDay(g) { - // 结算每座鸡舍的囤蛋:附近有鸡或喂了饲料槽则翻倍,每日上限 4 + // 每日按容量补齐圈养(死亡/跑丢后恢复) + pastureZones.forEach((z) => stockPasture(g, z)) + // 结算每座鸡舍的囤蛋:附近有鸡或喂了饲料槽则翻倍;牧场等级提高上限与产出 g.entities.filter((e) => e.type === 'coop').forEach((coop) => { if (!coop.mx) coop.mx = {} if (coop.mx.stored == null) coop.mx.stored = 0 @@ -713,20 +866,24 @@ export default { const trough = g.entities.find((t) => t.type === 'trough' && (t.mx?.feed || 0) > 0 && Math.hypot(t.x - coop.x, t.y - coop.y) < 160) let inc = 1 if (chickenNear || trough) inc = 2 + const mul = pastureYieldAt(coop.x, coop.y) + inc = Math.max(1, Math.round(inc * mul)) if (trough) { trough.mx.feed -= 1 g.markDirty(trough) } - coop.mx.stored = Math.min(4, (coop.mx.stored || 0) + inc) + const cap = Math.min(8, 4 + Math.floor((pastureZones.find((z) => pointInAabb(coop.x, coop.y, z))?.level || 1) / 2)) + coop.mx.stored = Math.min(cap, (coop.mx.stored || 0) + inc) g.markDirty(coop) }) - // 牧场牛产奶:靠近有饲料的饲料槽 + // 牧场牛产奶:靠近有饲料的饲料槽(高等级多掉) g.monsters.filter((m) => m.kind === 'beefalo' && m.pen).forEach((cow) => { const trough = g.entities.find((t) => t.type === 'trough' && (t.mx?.feed || 0) > 0 && Math.hypot(t.x - cow.x, t.y - cow.y) < 140) if (!trough) return trough.mx.feed -= 1 g.markDirty(trough) - g.dropLoot(cow.x + g.rand(-16, 16), cow.y + 20, 'milk', 1) + const n = Math.max(1, Math.round(pastureYieldAt(cow.x, cow.y))) + g.dropLoot(cow.x + g.rand(-16, 16), cow.y + 20, 'milk', n) }) }, onPointerDown(g, wx, wy) { @@ -761,10 +918,18 @@ export default { } const name = promptPastureName(`牧场${pastureZones.length + 1}`) const z = applyPasture(g, rect, name) - g.tip(`「${z.name}」已划定:杂项已清出,底边有可开关的栅栏门`) + g.tip(`「${z.name}」已划定:底边留有牧场门(右键管理:升级/改名/删除)`) return true }, + onRightClick(g, wx, wy, ev) { + if (zoneMode) return false + const z = pastureZones.find((a) => pointInAabb(wx, wy, a)) + if (!z) return false + if (ev?.shiftKey) return renamePastureAt(g, wx, wy) + return pastureZoneRightAction(g, z) + }, onClick(g, wx, wy) { + if (handleZoneMenuClick(wx, wy)) return true const sx = wx - uiCamX const sy = wy - uiCamY if (uiBtn && inside(sx, sy, uiBtn)) { @@ -778,39 +943,86 @@ export default { drag = null return true } - if (!zoneMode && renamePastureAt(g, wx, wy)) return true return false }, onDraw(g, ctx, env) { pastureZones.forEach((z) => { ctx.save() + const floors = zoneFloors(z.level || 1) ctx.strokeStyle = 'rgba(200, 160, 80, 0.55)' - ctx.fillStyle = 'rgba(180, 140, 60, 0.08)' + ctx.fillStyle = `rgba(180, 140, 60, ${0.06 + floors * 0.03})` ctx.lineWidth = 2 ctx.setLineDash([8, 6]) ctx.fillRect(z.x0 - env.camX, z.y0 - env.camY, z.x1 - z.x0, z.y1 - z.y0) ctx.strokeRect(z.x0 - env.camX, z.y0 - env.camY, z.x1 - z.x0, z.y1 - z.y0) + // 多层围栏示意:高等级多画内圈 + for (let f = 1; f < floors; f++) { + const pad = 10 * f + ctx.strokeStyle = `rgba(220, 180, 100, ${0.35 - f * 0.05})` + ctx.strokeRect(z.x0 - env.camX + pad, z.y0 - env.camY + pad, z.x1 - z.x0 - pad * 2, z.y1 - z.y0 - pad * 2) + } ctx.setLineDash([]) const x = z.x0 - env.camX const y = z.y0 - env.camY + const label = `${z.name || '牧场'} Lv.${z.level || 1}` + const lw = Math.min(140, Math.max(72, label.length * 8 + 16)) ctx.fillStyle = 'rgba(40, 28, 10, 0.72)' - ctx.beginPath(); ctx.roundRect(x + 4, y + 4, Math.min(120, z.x1 - z.x0 - 8), 18, 4); ctx.fill() + ctx.beginPath(); ctx.roundRect(x + 4, y + 4, lw, 18, 4); ctx.fill() ctx.fillStyle = '#f4ead2' ctx.font = 'bold 12px sans-serif' ctx.textAlign = 'left' ctx.textBaseline = 'middle' - ctx.fillText(z.name || '牧场', x + 10, y + 13) + ctx.fillText(label, x + 10, y + 13) ctx.restore() }) if (drag) { const r = normalizeRect(drag.x0, drag.y0, drag.x1, drag.y1) ctx.save() ctx.strokeStyle = 'rgba(255, 200, 100, 0.9)' - ctx.fillStyle = 'rgba(220, 170, 70, 0.15)' + ctx.fillStyle = 'rgba(220, 170, 70, 0.12)' ctx.lineWidth = 2 ctx.setLineDash([6, 4]) ctx.fillRect(r.x0 - env.camX, r.y0 - env.camY, r.x1 - r.x0, r.y1 - r.y0) ctx.strokeRect(r.x0 - env.camX, r.y0 - env.camY, r.x1 - r.x0, r.y1 - r.y0) + ctx.setLineDash([]) + // 预览围栏与门口 + const pts = perimeterPoints(r, FENCE_STEP, true) + let gatePt = null + pts.forEach((p) => { + if (p.skip) return + const sx = p.x - env.camX + const sy = p.y - env.camY + if (p.gate) { + gatePt = p + ctx.fillStyle = 'rgba(224, 192, 96, 0.85)' + ctx.strokeStyle = '#1d1409' + ctx.lineWidth = 1.6 + ctx.beginPath(); ctx.roundRect(sx - 14, sy - 18, 28, 22, 3); ctx.fill(); ctx.stroke() + ctx.fillStyle = '#1d1409' + ctx.font = 'bold 10px sans-serif' + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.fillText('门', sx, sy - 6) + return + } + ctx.fillStyle = 'rgba(169, 119, 60, 0.75)' + ctx.strokeStyle = 'rgba(29, 20, 9, 0.8)' + ctx.lineWidth = 1.2 + if (p.orient === 'v') { + ctx.beginPath(); ctx.roundRect(sx - 4, sy - 14, 8, 28, 2); ctx.fill(); ctx.stroke() + } else { + ctx.beginPath(); ctx.roundRect(sx - 14, sy - 4, 28, 8, 2); ctx.fill(); ctx.stroke() + } + }) + const w = Math.round(r.x1 - r.x0) + const h = Math.round(r.y1 - r.y0) + ctx.fillStyle = 'rgba(40, 28, 10, 0.75)' + ctx.beginPath(); ctx.roundRect((r.x0 + r.x1) / 2 - env.camX - 54, r.y0 - env.camY - 22, 108, 18, 4); ctx.fill() + ctx.fillStyle = '#f4ead2' + ctx.font = 'bold 11px sans-serif' + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.fillText(`${w}×${h}${gatePt ? ' · 底边有门' : ''}`, (r.x0 + r.x1) / 2 - env.camX, r.y0 - env.camY - 13) ctx.restore() } }, @@ -844,6 +1056,7 @@ export default { uiCancel = null } ctx.restore() + drawZoneMenu(ctx, env) }, }, } diff --git a/src/games/starve/mods/labor.js b/src/games/starve/mods/labor.js index 14a1e8f..d501380 100644 --- a/src/games/starve/mods/labor.js +++ b/src/games/starve/mods/labor.js @@ -1,12 +1,10 @@ -// 劳务市场:用粮食招聘农民、牧工与厨师,替你自动开垦、播种、收割、入库、投喂与做饭 -// - 招聘所 laboroffice:打开面板招/解雇岗位(农民上限 4、牧工上限 4、厨师上限 2) -// - 大仓库 laborwarehouse:12 个箱子 × 6 种 × 999,共 72 格容量,农民收获自动入库 -// - 农民 laborfarmer:收割 ready 良田 → 播种空良田 → 开垦新田(需农业 mod)→ 游荡 -// - 牧工 laborherder:投喂饲料槽 → 收鸡蛋入仓(需牧业 mod)→ 游荡 -// - 厨师 laborchef:从仓库取食材按烹饪锅规则做菜,存入食堂 canteen -// - 每日工资:每亩人手 2 份粮食,优先扣仓库,付不出则全体罢工;补上即复工 -// - 货币「粮食」= 任何带 food 属性的物品(背包优先,不够再扣大仓库) -// 数值基准:三围 150/150/200,一天 160 秒 +// 劳务市场:招聘农民/牧工/建筑工/厨师/伐木工/矿工;自然村民可分配职业 +// - 招聘所:招/解雇;村民点选分配职业(工资更低) +// - 大仓库;劳工夜间回房;入驻营地房屋 +import { agriYieldAt } from './agri.js' +import { drawNameplate, openZoneMenu, zoneFloors, drawZoneMenu, handleZoneMenuClick } from './zoneUtil.js' +import { ensureWorkerHome, unassignWorkerHome, syncHouseLooks, getCampCenter, isInCamp, getCampLevel, houseModelForLevel } from './camp.js' + const TAU = Math.PI * 2 // 种子 → 成熟作物 code(agri.js 的 SEED_CROP 未导出,此处自备一份映射) @@ -17,14 +15,33 @@ const FEEDS = ['wheat', 'pumpkin', 'carrot', 'berry'] // 岗位与经济 const MAX_WORKERS = 4 const MAX_CHEF = 2 +const MAX_BUILDER = 3 +const MAX_LUMBER = 4 +const MAX_MINER = 4 const FARMER_FEE = 8 const HERDER_FEE = 6 const CHEF_FEE = 10 -const WAGE = 2 -// 大仓库结构:12 箱 × 每箱 6 种 × 每种 999 = 72 格容量 +const BUILDER_FEE = 8 +const LUMBER_FEE = 7 +const MINER_FEE = 7 +const HIRED_WAGE = 3 // 招聘来的周薪 +const NATIVE_WAGE = 1 // 自然村民周薪 +const WAGE_EVERY = 7 // 每 N 天发一次工资 +const HOUSE_COST = { log: 12, rock: 8 } +const LABOR_KINDS = ['laborfarmer', 'laborherder', 'laborbuilder', 'laborchef', 'laborlumber', 'laborminer'] +const MAX_VILLAGERS = 8 +function isLaborKind(k) { + return LABOR_KINDS.includes(k) +} +function wageOf(m) { + return m?.source === 'native' ? NATIVE_WAGE : HIRED_WAGE +} +// 大仓库结构:12 箱 × 每箱 6 种 × 每种最高 99999999 const BOX_COUNT = 12 const BOX_KINDS = 6 -const BOX_STACK = 999 +const BOX_STACK = 99999999 +const BOX_SLOT_COLS = 3 +const BOX_SLOT_ROWS = 2 // 3×2 大格展示 // 劳工姓名池(每人唯一) const WORKER_NAMES = [ @@ -46,7 +63,7 @@ const OUTFIT_PALETTES = [ function pickUniqueName(g) { const used = new Set( g.monsters - .filter((m) => m.kind === 'laborfarmer' || m.kind === 'laborherder' || m.kind === 'laborchef') + .filter((m) => isLaborKind(m.kind)) .map((m) => m.workerName) .filter(Boolean), ) @@ -57,7 +74,7 @@ function pickUniqueName(g) { function pickOutfit(g) { const used = g.monsters - .filter((m) => m.kind === 'laborfarmer' || m.kind === 'laborherder' || m.kind === 'laborchef') + .filter((m) => isLaborKind(m.kind)) .map((m) => m.outfit | 0) for (let i = 0; i < OUTFIT_PALETTES.length; i++) { if (!used.includes(i)) return i @@ -87,6 +104,7 @@ function cycleFarmerBind(p, m, g) { // 当前打开的面板(存实体 id):招聘所 / 大仓库 let officeOpen = null let storeOpen = null +let whBoxSel = 0 // 大仓库当前选中的箱子下标 // onDrawUI 保存的屏幕参数,供 onClick(世界坐标)换算命中 let uiCamX = 0 let uiCamY = 0 @@ -97,6 +115,7 @@ let toldNoAgri = false let toldNoHusbandry = false let toldNoSeed = false let toldNoCanteen = false +let toldNoChefIng = false // ===================================================================== // 通用工具 @@ -106,18 +125,21 @@ function entById(g, id) { } function mxOf(office) { - if (!office.mx) office.mx = { hired: 0, farmer: 0, herder: 0, chef: 0, strike: 0 } + if (!office.mx) office.mx = { hired: 0, farmer: 0, herder: 0, builder: 0, chef: 0, lumber: 0, miner: 0, strike: 0 } const mx = office.mx if (mx.farmer == null) mx.farmer = 0 if (mx.herder == null) mx.herder = 0 + if (mx.builder == null) mx.builder = 0 if (mx.chef == null) mx.chef = 0 + if (mx.lumber == null) mx.lumber = 0 + if (mx.miner == null) mx.miner = 0 if (mx.strike == null) mx.strike = 0 - mx.hired = (mx.farmer || 0) + (mx.herder || 0) + (mx.chef || 0) + mx.hired = (mx.farmer || 0) + (mx.herder || 0) + (mx.builder || 0) + (mx.chef || 0) + (mx.lumber || 0) + (mx.miner || 0) return mx } function syncHired(mx) { - mx.hired = (mx.farmer || 0) + (mx.herder || 0) + (mx.chef || 0) + mx.hired = (mx.farmer || 0) + (mx.herder || 0) + (mx.builder || 0) + (mx.chef || 0) + (mx.lumber || 0) + (mx.miner || 0) } // 取(并补齐)大仓库存放结构 @@ -264,28 +286,40 @@ function payerOf(g) { // ===================================================================== // 大仓库存取 // ===================================================================== -// 存入,返回放不下的余量 +// 单箱软上限:避免「逮着一个箱子灌满」;总量仍可跨箱堆到 BOX_STACK +const PER_BOX_SOFT = 999 + +// 存入,返回放不下的余量(尽量分散到多个箱子) function deposit(g, wh, code, n) { if (!wh) return n const boxes = boxesOf(wh) let left = n - // 先补已有该物品的箱子 - for (const b of boxes) { - if (left <= 0) break - const have = b.items[code] - if (have != null) { - const add = Math.min(BOX_STACK - have, left) - if (add > 0) { b.items[code] = have + add; left -= add } - } - } - // 再开新箱(该箱还需有空种类额度) - for (const b of boxes) { - if (left <= 0) break - if (b.items[code] == null && Object.keys(b.items).length < BOX_KINDS) { - const add = Math.min(BOX_STACK, left) - b.items[code] = add - left -= add + while (left > 0) { + let best = null + let bestScore = Infinity + for (const b of boxes) { + const have = b.items[code] || 0 + const kinds = Object.keys(b.items || {}).length + if (have > 0 && have < PER_BOX_SOFT) { + // 已有该物:优先补最少的箱子,实现分摊 + if (have < bestScore) { bestScore = have; best = b } + } else if (have === 0 && kinds < BOX_KINDS) { + // 新开种类:优先空箱 / 种类少的箱 + const score = 100000 + kinds * 1000 + have + if (score < bestScore) { bestScore = score; best = b } + } else if (have >= PER_BOX_SOFT && have < BOX_STACK) { + // 软上限已满但总上限未满:仅当没有其它可放箱时再继续堆 + const score = 200000 + have + if (score < bestScore) { bestScore = score; best = b } + } } + if (!best) break + const have = best.items[code] || 0 + const cap = have >= PER_BOX_SOFT ? BOX_STACK : PER_BOX_SOFT + const add = Math.min(cap - have, left) + if (add <= 0) break + best.items[code] = have + add + left -= add } if (left !== n) g.markDirty(wh) return left @@ -319,28 +353,25 @@ function warehouseCount(wh, code) { // 把玩家身上的东西尽量塞进指定箱子(受该箱容量限制) function boxDeposit(g, p, wh, bi) { - const box = boxesOf(wh)[bi] - if (!box) return + // 整箱存入也走分散逻辑,避免全堆进当前选中的那一格 + void bi let moved = 0 const areas = [p.slots, p.pack] for (const arr of areas) { if (!arr) continue for (let i = 0; i < arr.length; i++) { const s = arr[i] - if (!s || !s.code) continue - const have = box.items[s.code] || 0 - if (have <= 0 && Object.keys(box.items).length >= BOX_KINDS) continue - const room = BOX_STACK - have - const take = Math.min(room, s.n) + if (!s || !s.code || s.n <= 0) continue + const before = s.n + const left = deposit(g, wh, s.code, s.n) + const take = before - left if (take <= 0) continue - box.items[s.code] = have + take - s.n -= take + s.n = left moved += take if (s.n <= 0) arr[i] = null } } - if (moved > 0) g.markDirty(wh) - g.ptip(p, moved > 0 ? `存入箱子 ${bi + 1}:×${moved}` : '这个箱子放不下了') + g.ptip(p, moved > 0 ? `分散存入 ×${moved}` : '仓库放不下了') } // 把指定箱子内容全部取回背包 @@ -358,7 +389,43 @@ function boxWithdraw(g, p, wh, bi) { }) box.items = {} g.markDirty(wh) - g.ptip(p, `取出箱子 ${bi + 1}:×${moved}`) + g.ptip(p, `整箱取出 ${bi + 1}:×${moved}`) +} + +/** 存入背包某一格(整堆)到仓库 */ +function depositInvSlot(g, p, wh, area, idx) { + const arr = area === 'pack' ? p.pack : p.slots + const s = arr?.[idx] + if (!s?.code || s.n <= 0) { + g.ptip(p, '这一格是空的') + return + } + const name = g.items[s.code]?.name || s.code + const before = s.n + const left = deposit(g, wh, s.code, s.n) + const moved = before - left + if (moved <= 0) { + g.ptip(p, '仓库放不下了(箱子种类已满)') + return + } + s.n = left + if (s.n <= 0) arr[idx] = null + g.ptip(p, `存入${name} ×${moved}`) +} + +/** 从某箱取出某一种物品(整堆) */ +function withdrawItem(g, p, wh, bi, code) { + const box = boxesOf(wh)[bi] + if (!box) return + const n = box.items[code] || 0 + if (n <= 0) { + g.ptip(p, '没有这件物品') + return + } + delete box.items[code] + g.addItem(p, code, n) + g.markDirty(wh) + g.ptip(p, `取出${g.items[code]?.name || code} ×${n}`) } // ===================================================================== @@ -367,7 +434,25 @@ function boxWithdraw(g, p, wh, bi) { const ROLE_META = { farmer: { kind: 'laborfarmer', key: 'farmer', fee: FARMER_FEE, max: MAX_WORKERS, label: '农民' }, herder: { kind: 'laborherder', key: 'herder', fee: HERDER_FEE, max: MAX_WORKERS, label: '牧工' }, + builder: { kind: 'laborbuilder', key: 'builder', fee: BUILDER_FEE, max: MAX_BUILDER, label: '建筑工' }, chef: { kind: 'laborchef', key: 'chef', fee: CHEF_FEE, max: MAX_CHEF, label: '厨师' }, + lumber: { kind: 'laborlumber', key: 'lumber', fee: LUMBER_FEE, max: MAX_LUMBER, label: '伐木工' }, + miner: { kind: 'laborminer', key: 'miner', fee: MINER_FEE, max: MAX_MINER, label: '矿工' }, +} + +/** 夜间回房;已到家则站岗 */ +function tryGoHomeNight(m, dt, g) { + if (g.phase !== 'night') return false + ensureWorkerHome(g, m) + if (m.homeX == null) return false + const speed = g.mon[m.kind]?.speed || 160 + const d = Math.hypot(m.homeX - m.x, m.homeY - m.y) + if (d > 30) { + g.moveToward(m, m.homeX, m.homeY, speed, dt) + return true + } + m.moving = false + return true } function hire(g, p, office, role) { @@ -387,7 +472,7 @@ function hire(g, p, office, role) { const spot = g.findLand(office.x, office.y + 50, 22, 70) const workerName = pickUniqueName(g) const outfit = pickOutfit(g) - g.spawnMob(meta.kind, spot.x, spot.y, { + const m = g.spawnMob(meta.kind, spot.x, spot.y, { owner: p, office: office.id, role: meta.key, @@ -395,11 +480,13 @@ function hire(g, p, office, role) { workerName, outfit, boundZoneId: null, + source: 'hired', }) + ensureWorkerHome(g, m) mx[meta.key] = (mx[meta.key] || 0) + 1 syncHired(mx) g.markDirty(office) - g.ptip(p, `雇到了${workerName}(${meta.label}${meta.key === 'farmer' ? ' · 点他可绑定农田' : ''})`) + g.ptip(p, `雇到了${workerName}(${meta.label} · 周薪${HIRED_WAGE}${meta.key === 'farmer' ? ' · 点他可绑定农田' : ''})`) } function fire(g, p, office, role) { @@ -414,13 +501,70 @@ function fire(g, p, office, role) { g.ptip(p, '没有可以解雇的人手') return } - g.removeMob(mine[mine.length - 1]) + const victim = mine[mine.length - 1] + unassignWorkerHome(g, victim) + g.removeMob(victim) mx[meta.key] = Math.max(0, (mx[meta.key] || 0) - 1) syncHired(mx) g.markDirty(office) g.ptip(p, `解雇了一位${meta.label}`) } +/** 自然村民分配职业(低工资,无招聘费) */ +function assignVillagerJob(g, p, villager, role) { + const meta = ROLE_META[role] + if (!meta || !villager) return + const office = g.entities.find((e) => e.type === 'laboroffice') + if (!office) { + g.ptip(p, '需要先建造招聘所才能分配职业') + return + } + const mx = mxOf(office) + if ((mx[meta.key] || 0) >= meta.max) { + g.ptip(p, `该岗位最多 ${meta.max} 人`) + return + } + const name = villager.workerName || pickUniqueName(g) + const outfit = villager.outfit != null ? villager.outfit : pickOutfit(g) + const x = villager.x + const y = villager.y + g.removeMob(villager) + const m = g.spawnMob(meta.kind, x, y, { + owner: p, + office: office.id, + role: meta.key, + hiredAt: g.day, + workerName: name, + outfit, + boundZoneId: null, + source: 'native', + }) + ensureWorkerHome(g, m) + mx[meta.key] = (mx[meta.key] || 0) + 1 + syncHired(mx) + g.markDirty(office) + g.ptip(p, `${name}成为了${meta.label}(村民 · 周薪仅 ${NATIVE_WAGE})`) +} + +function openVillagerAssign(g, p, m) { + openZoneMenu({ + title: m.workerName || '村民', + subtitle: `自然村民 · 分配后周薪 ${NATIVE_WAGE}(招聘为 ${HIRED_WAGE})`, + actions: [ + { id: 'farmer', label: '当农民', color: '#5d7a3a' }, + { id: 'herder', label: '当牧工', color: '#5d7a3a' }, + { id: 'builder', label: '当建筑工', color: '#5d7a3a' }, + { id: 'chef', label: '当厨师', color: '#5d7a3a' }, + { id: 'lumber', label: '当伐木工', color: '#5d7a3a' }, + { id: 'miner', label: '当矿工', color: '#5d7a3a' }, + { id: 'cancel', label: '取消', color: '#5a4630' }, + ], + onPick(id) { + if (ROLE_META[id]) assignVillagerJob(g, p, m, id) + }, + }) +} + function roleInfo(g, office, role) { const meta = ROLE_META[role] const mx = mxOf(office) @@ -432,63 +576,101 @@ function roleInfo(g, office, role) { // ===================================================================== // 农民劳作 // ===================================================================== -function pickFarmerJob(m, g, anchor, agriOn) { - m.jobT = 8 +function pickFarmerJob(m, g) { + m.jobT = 4 const zones = agriZoneList(g) const bound = m.boundZoneId != null ? zones.find((z) => z.id === m.boundZoneId) : null - // 未绑定:负责全部;已绑定:只在该区内干活(不再回落到区外) + // 只在已有良田上收割/播种;开垦由「划定农田」自动铺田,农民不再乱锄地 const inScope = (e) => { if (!bound?.mx) return true const z = bound.mx return e.x >= z.x0 && e.x <= z.x1 && e.y >= z.y0 && e.y <= z.y1 } - let ready = nearestEnt(g, m, 'agrifarm', (e) => e.state === 'ready' && inScope(e)) - if (!bound && !ready) ready = nearestEnt(g, m, 'agrifarm', (e) => e.state === 'ready') + const ready = nearestEnt(g, m, 'agrifarm', (e) => e.state === 'ready' && inScope(e)) if (ready) { m.job = 'harvest'; m.jobTarget = ready; return } - let empty = nearestEnt(g, m, 'agrifarm', (e) => e.state === 'empty' && inScope(e)) - if (!bound && !empty) empty = nearestEnt(g, m, 'agrifarm', (e) => e.state === 'empty') + const empty = nearestEnt(g, m, 'agrifarm', (e) => e.state === 'empty' && inScope(e)) if (empty) { m.job = 'sow'; m.jobTarget = empty; return } - const emptyPlots = g.entities.filter((e) => e.type === 'agrifarm' && e.state === 'empty' && inScope(e)).length - if (agriOn && emptyPlots < 3) { - let spot = null - const searchZones = bound ? [bound.mx] : zones.map((z) => z.mx).filter(Boolean) - for (const z of searchZones) { - for (let i = 0; i < 8 && !spot; i++) { - const tx = z.x0 + 40 + Math.random() * Math.max(20, z.x1 - z.x0 - 80) - const ty = z.y0 + 40 + Math.random() * Math.max(20, z.y1 - z.y0 - 80) - const s = g.findLand(tx, ty, 0, 40) - if (!s) continue - const crowded = g.entities.some((e) => !e.deco && !e.loot && Math.hypot(e.x - s.x, e.y - s.y) < 34) - if (!crowded) spot = s - } - if (spot) break - } - if (!bound && !spot) { - const s = g.findLand(anchor.x, anchor.y, 30, 320) - const crowded = g.entities.some((e) => !e.deco && !e.loot && Math.hypot(e.x - s.x, e.y - s.y) < 34) - if (!crowded) spot = s - } - if (spot) { m.job = 'till'; m.jobTarget = { x: spot.x, y: spot.y }; return } - } m.job = 'idle' m.jobTarget = null + m.jobT = 0.4 } function doHarvest(m, g, plot, wh) { const crop = plot.mx?.seed || 'wheat' - const n = g.rand(3, 4) + const mul = agriYieldAt(plot.x, plot.y, plot.mx?.zoneId) + const n = Math.max(1, Math.round(g.rand(3, 4) * mul)) let left = wh ? deposit(g, wh, crop, n) : n if (left > 0) { const bx = wh ? wh.x : plot.x const by = wh ? wh.y : plot.y g.dropLoot(bx + g.rand(-24, 24), by + g.rand(18, 36), crop, left) } + const keepId = plot.mx?.zoneId plot.state = 'empty' - plot.mx = null + plot.mx = keepId != null ? { zoneId: keepId } : {} g.markDirty(plot) if (m.owner) g.ptip(m.owner, `${m.workerName || '农民'}收获${g.items[crop]?.name || '作物'} ×${n}`) } +function warehouseHasCost(wh, cost) { + if (!wh) return false + return Object.entries(cost).every(([code, n]) => warehouseCount(wh, code) >= n) +} +function warehousePay(g, wh, cost) { + Object.entries(cost).forEach(([code, n]) => withdraw(g, wh, code, n)) +} + +function pickBuilderJob(m, g) { + m.jobT = 4 + const plan = nearestEnt(g, m, 'houseplan', (e) => e.mx && !e.mx.done) + if (!plan) { m.job = 'idle'; m.jobTarget = null; m.jobT = 0.5; return } + const wh = nearestWarehouse(g, m) + if (!warehouseHasCost(wh, HOUSE_COST) && !g.hasCost(m.owner || g.me(), HOUSE_COST)) { + if (m.owner) g.ptip(m.owner, `${m.workerName || '建筑工'}:缺木头/石块,盖不了房`) + m.job = 'idle' + m.jobTarget = null + return + } + m.job = 'build' + m.jobTarget = plan +} + +function updateBuilder(m, dt, g) { + if (m.hp < 300) m.hp = 300 + if (m.strike) { m.moving = false; return } + if (tryGoHomeNight(m, dt, g)) return + const speed = g.mon[m.kind]?.speed || 155 + const anchor = anchorOf(g, m) + m.jobT = (m.jobT || 0) - dt + if (m.job === 'build' && (!m.jobTarget || !g.entities.includes(m.jobTarget))) m.job = null + if (!m.job || m.job === 'idle' || m.jobT <= 0) pickBuilderJob(m, g) + if (m.job === 'build') { + const t = m.jobTarget + const d = Math.hypot(t.x - m.x, t.y - m.y) + if (d > 38) { g.moveToward(m, t.x, t.y, speed, dt); return } + const wh = nearestWarehouse(g, m) + const p = m.owner || g.me() + if (warehouseHasCost(wh, HOUSE_COST)) warehousePay(g, wh, HOUSE_COST) + else if (p && g.hasCost(p, HOUSE_COST)) g.payCost(p, HOUSE_COST) + else { + if (p) g.ptip(p, '建材不够,房屋停工') + m.job = null + return + } + const name = t.mx?.name || '新房' + const model = houseModelForLevel(getCampLevel()) + const floors = Math.min(4, zoneFloors(getCampLevel())) + g.addEntity('camphouse', t.x, t.y, { mx: { restDay: 0, name, floors, model, residents: [] } }) + g.removeEntity(t) + syncHouseLooks(g) + if (p) g.ptip(p, `${m.workerName || '建筑工'}建成了「${name}」`) + m.job = null + m.jobT = 0 + return + } + wanderAround(m, dt, g, anchor.x, anchor.y, 100, speed * 0.55) +} + function doSow(m, g, plot, wh) { let crop = 'wheat' let used = false @@ -513,49 +695,44 @@ function updateFarmer(m, dt, g) { // 无限续航:不被野兽打死 if (m.hp < 300) m.hp = 300 if (m.strike) { m.moving = false; return } - const agriOn = g.entities.some((e) => e.type === 'agrifarm') + if (tryGoHomeNight(m, dt, g)) return + const agriOn = g.entities.some((e) => e.type === 'agrifarm' || e.type === 'agrizone') const anchor = anchorOf(g, m) - const speed = g.mon[m.kind]?.speed || 118 + const speed = g.mon[m.kind]?.speed || 165 if (!agriOn) { if (!toldNoAgri) { toldNoAgri = true - if (m.owner) g.ptip(m.owner, '农民:需要农业文明 mod 才能耕作') + if (m.owner) g.ptip(m.owner, '农民:先划定农田,区内会自动铺田') } - wanderAround(m, dt, g, anchor.x, anchor.y, 110, speed * 0.5) + wanderAround(m, dt, g, anchor.x, anchor.y, 110, speed * 0.55) return } const wh = nearestWarehouse(g, m) m.jobT = (m.jobT || 0) - dt if ((m.job === 'harvest' || m.job === 'sow') && (!m.jobTarget || !g.entities.includes(m.jobTarget))) m.job = null - if (!m.job || m.jobT <= 0) pickFarmerJob(m, g, anchor, true) + // 取消旧的 till 乱锄任务 + if (m.job === 'till') { m.job = null; m.jobTarget = null } + if (!m.job || m.job === 'idle' || m.jobT <= 0) pickFarmerJob(m, g) if (m.job === 'harvest' || m.job === 'sow') { const t = m.jobTarget const d = Math.hypot(t.x - m.x, t.y - m.y) - if (d > 40) { g.moveToward(m, t.x, t.y, speed, dt); return } + if (d > 36) { g.moveToward(m, t.x, t.y, speed, dt); return } if (m.job === 'harvest') doHarvest(m, g, t, wh) else doSow(m, g, t, wh) m.job = null m.jobT = 0 + pickFarmerJob(m, g) return } - if (m.job === 'till') { - const t = m.jobTarget - const d = Math.hypot(t.x - m.x, t.y - m.y) - if (d > 34) { g.moveToward(m, t.x, t.y, speed, dt); return } - g.addEntity('agrifarm', t.x, t.y) - m.job = null - m.jobT = 0 - return - } - wanderAround(m, dt, g, anchor.x, anchor.y, 110, speed * 0.5) + wanderAround(m, dt, g, anchor.x, anchor.y, 110, speed * 0.55) } // ===================================================================== // 牧工劳作 // ===================================================================== function pickHerderJob(m, g, wh) { - m.jobT = 8 + m.jobT = 4 // 1) 喂养:饲料槽 feed<4 且仓库有可投喂作物 const trough = nearestEnt(g, m, 'trough', (e) => ((e.mx?.feed) || 0) < 4) if (trough && wh && FEEDS.some((c) => g.items[c] && warehouseCount(wh, c) > 0)) { @@ -569,6 +746,7 @@ function pickHerderJob(m, g, wh) { // 3) 游荡 m.job = 'idle' m.jobTarget = null + m.jobT = 0.4 } function doFeed(m, g, trough, wh) { @@ -598,38 +776,44 @@ function doCollect(m, g, coop, wh) { function updateHerder(m, dt, g) { if (m.hp < 300) m.hp = 300 if (m.strike) { m.moving = false; return } + if (tryGoHomeNight(m, dt, g)) return const husbOn = g.entities.some((e) => e.type === 'trough' || e.type === 'coop') const anchor = anchorOf(g, m) - const speed = g.mon[m.kind]?.speed || 118 + const speed = g.mon[m.kind]?.speed || 165 if (!husbOn) { if (!toldNoHusbandry) { toldNoHusbandry = true if (m.owner) g.ptip(m.owner, '牧工:需要牧业文明 mod 才能干活') } - wanderAround(m, dt, g, anchor.x, anchor.y, 110, speed * 0.5) + wanderAround(m, dt, g, anchor.x, anchor.y, 110, speed * 0.55) return } const wh = nearestWarehouse(g, m) m.jobT = (m.jobT || 0) - dt if ((m.job === 'feed' || m.job === 'collect') && (!m.jobTarget || !g.entities.includes(m.jobTarget))) m.job = null - if (!m.job || m.jobT <= 0) pickHerderJob(m, g, wh) + if (!m.job || m.job === 'idle' || m.jobT <= 0) pickHerderJob(m, g, wh) if (m.job === 'feed' || m.job === 'collect') { const t = m.jobTarget const d = Math.hypot(t.x - m.x, t.y - m.y) - if (d > 40) { g.moveToward(m, t.x, t.y, speed, dt); return } + if (d > 36) { g.moveToward(m, t.x, t.y, speed, dt); return } if (m.job === 'feed') doFeed(m, g, t, wh) else doCollect(m, g, t, wh) m.job = null m.jobT = 0 + pickHerderJob(m, g, wh) return } - wanderAround(m, dt, g, anchor.x, anchor.y, 110, speed * 0.5) + wanderAround(m, dt, g, anchor.x, anchor.y, 110, speed * 0.55) } // ===================================================================== -// 厨师:从仓库抽 4 份食材 → cookPot → 写入食堂 +// 厨师:从大仓库 + 粮仓抽 4 份食材 → cookPot → 写入食堂 +// 每种菜最多备 30 份,够用就不再取粮 // ===================================================================== +const COOKED_DISHES = ['meatballs', 'meatystew', 'honeyham', 'honeynuggets', 'taffy', 'monsterlasagna', 'wetgoop', 'bread', 'cheese'] +const CANTEEN_PER_DISH = 30 + function nearestCanteen(g, ref) { let best = null let bd = Infinity @@ -641,48 +825,101 @@ function nearestCanteen(g, ref) { return best } -function listCookIngredients(g, wh) { - if (!wh) return [] +/** 厨师取料点:大仓库优先,其次粮仓 */ +function cookStores(g, ref) { + const list = [] + const wh = nearestWarehouse(g, ref) + if (wh) list.push({ kind: 'wh', e: wh }) + g.entities.forEach((e) => { + if (e.type === 'granary') list.push({ kind: 'granary', e }) + }) + return list +} + +function storeCount(g, store, code) { + if (!store) return 0 + if (store.kind === 'wh') return warehouseCount(store.e, code) + return (store.e.mx?.store && store.e.mx.store[code]) || 0 +} + +function storeWithdraw(g, store, code, n) { + if (!store || n <= 0) return 0 + if (store.kind === 'wh') return withdraw(g, store.e, code, n) + if (!store.e.mx) store.e.mx = {} + if (!store.e.mx.store) store.e.mx.store = {} + const have = store.e.mx.store[code] || 0 + const take = Math.min(have, n) + if (take <= 0) return 0 + store.e.mx.store[code] = have - take + if (store.e.mx.store[code] <= 0) delete store.e.mx.store[code] + g.markDirty(store.e) + return take +} + +function storeDeposit(g, store, code, n) { + if (!store || n <= 0) return + if (store.kind === 'wh') { + deposit(g, store.e, code, n) + return + } + if (!store.e.mx) store.e.mx = {} + if (!store.e.mx.store) store.e.mx.store = {} + store.e.mx.store[code] = (store.e.mx.store[code] || 0) + n + g.markDirty(store.e) +} + +function totalFoodCount(g, stores, code) { + return stores.reduce((n, s) => n + storeCount(g, s, code), 0) +} + +function canteenMenu(canteen) { + if (!canteen.mx) canteen.mx = { menu: {} } + if (!canteen.mx.menu) canteen.mx.menu = {} + return canteen.mx.menu +} + +function dishStock(canteen, dish) { + return canteenMenu(canteen)[dish] || 0 +} + +function listCookIngredients(g, stores) { + const counts = {} + stores.forEach((store) => { + if (store.kind === 'wh') { + boxesOf(store.e).forEach((box) => { + Object.entries(box.items || {}).forEach(([code, n]) => { + if (n > 0) counts[code] = (counts[code] || 0) + n + }) + }) + } else { + Object.entries(store.e.mx?.store || {}).forEach(([code, n]) => { + if (n > 0) counts[code] = (counts[code] || 0) + n + }) + } + }) const out = [] - boxesOf(wh).forEach((box) => { - Object.entries(box.items || {}).forEach(([code, n]) => { - if (n <= 0) return - const it = g.items[code] - if (!it?.food || code === 'rot') return - // 成品菜不再二次入锅 - if (['meatballs', 'meatystew', 'honeyham', 'honeynuggets', 'taffy', 'monsterlasagna', 'wetgoop', 'bread', 'cheese'].includes(code)) return - for (let i = 0; i < n; i++) out.push(code) - }) + Object.entries(counts).forEach(([code, n]) => { + const it = g.items[code] + if (!it?.food || code === 'rot') return + if (COOKED_DISHES.includes(code)) return + for (let i = 0; i < n; i++) out.push(code) }) return out } -function takeIngredients(g, wh, codes) { - const taken = [] - for (const code of codes) { - if (warehouseCount(wh, code) <= 0) break - withdraw(g, wh, code, 1) - taken.push(code) - } - return taken -} - -function doCook(m, g, wh, canteen) { - const pool = listCookIngredients(g, wh) - if (pool.length < 4) { - if (m.owner && !toldNoCanteen) { - // 缺料时安静游荡,不刷屏 - } - return false - } - // 尽量凑肉/甜以出正经菜:优先取肉度/甜度高的 +function chooseIngredients(g, stores) { + const pool = listCookIngredients(g, stores) + if (pool.length < 4) return null const scored = [...new Set(pool)].map((code) => { const it = g.items[code] || {} - return { code, s: (it.meatv || 0) * 2 + (it.sweet || 0) * 2 + (it.veg || 0) } + return { + code, + s: (it.grain || 0) * 3 + (it.meatv || 0) * 2 + (it.sweet || 0) * 2 + (it.veg || 0), + } }).sort((a, b) => b.s - a.s) const pick = [] for (const { code } of scored) { - while (pick.length < 4 && warehouseCount(wh, code) > 0) { + while (pick.length < 4 && totalFoodCount(g, stores, code) > pick.filter((c) => c === code).length) { pick.push(code) if (pick.length >= 4) break } @@ -690,86 +927,362 @@ function doCook(m, g, wh, canteen) { } while (pick.length < 4 && pool.length) { const c = pool[Math.floor(Math.random() * pool.length)] - if (warehouseCount(wh, c) > 0) pick.push(c) + if (totalFoodCount(g, stores, c) > pick.filter((x) => x === c).length) pick.push(c) else break } - if (pick.length < 4) return false - const used = takeIngredients(g, wh, pick.slice(0, 4)) - if (used.length < 4) { - used.forEach((c) => deposit(g, wh, c, 1)) + return pick.length >= 4 ? pick.slice(0, 4) : null +} + +function predictDish(g, stores) { + const pick = chooseIngredients(g, stores) + if (!pick) return null + return typeof g.cookPot === 'function' ? g.cookPot(pick) : 'wetgoop' +} + +/** 当前能做出的菜若已满 30,则不再取粮 */ +function canteenNeedsSupply(g, stores, canteen) { + const dish = predictDish(g, stores) + if (!dish) return false + return dishStock(canteen, dish) < CANTEEN_PER_DISH +} + +function takeIngredients(g, stores, codes) { + const taken = [] + for (const code of codes) { + let got = 0 + for (const s of stores) { + if (got) break + got = storeWithdraw(g, s, code, 1) + } + if (!got) break + taken.push(code) + } + return taken +} + +function doCook(m, g, stores, canteen) { + const pick = chooseIngredients(g, stores) + if (!pick) { + if (m.owner && !toldNoChefIng) { + toldNoChefIng = true + g.ptip(m.owner, '厨师:大仓库或粮仓里至少放 4 份小麦/面粉等食材才会做饭') + } return false } - const dish = typeof g.cookPot === 'function' ? g.cookPot(used) : 'wetgoop' - if (!canteen.mx) canteen.mx = { menu: {} } - if (!canteen.mx.menu) canteen.mx.menu = {} - canteen.mx.menu[dish] = (canteen.mx.menu[dish] || 0) + 1 + toldNoChefIng = false + const dish = typeof g.cookPot === 'function' ? g.cookPot(pick) : 'wetgoop' + if (dishStock(canteen, dish) >= CANTEEN_PER_DISH) return false + const used = takeIngredients(g, stores, pick) + if (used.length < 4) { + used.forEach((c) => { + if (stores[0]) storeDeposit(g, stores[0], c, 1) + }) + return false + } + const menu = canteenMenu(canteen) + menu[dish] = (menu[dish] || 0) + 1 g.markDirty(canteen) - if (m.owner) g.ptip(m.owner, `厨师做好了${g.items[dish]?.name || dish}`) + if (m.owner) g.ptip(m.owner, `厨师做好了${g.items[dish]?.name || dish}(${menu[dish]}/${CANTEEN_PER_DISH})`) return true } function updateChef(m, dt, g) { if (m.hp < 300) m.hp = 300 if (m.strike) { m.moving = false; return } + if (tryGoHomeNight(m, dt, g)) return const anchor = anchorOf(g, m) - const speed = g.mon[m.kind]?.speed || 118 + const speed = g.mon[m.kind]?.speed || 160 const canteen = nearestCanteen(g, m) if (!canteen) { if (!toldNoCanteen) { toldNoCanteen = true if (m.owner) g.ptip(m.owner, '厨师:需要建造食堂才能上菜') } - wanderAround(m, dt, g, anchor.x, anchor.y, 110, speed * 0.5) + wanderAround(m, dt, g, anchor.x, anchor.y, 110, speed * 0.55) return } - const wh = nearestWarehouse(g, m) + const stores = cookStores(g, m) + // 库存够用:在食堂附近闲逛,偶尔再检查,不持续取粮 + if (!canteenNeedsSupply(g, stores, canteen)) { + m.gotIng = false + m.job = null + m.jobT = Math.max(m.jobT || 0, 8) + wanderAround(m, dt, g, canteen.x, canteen.y, 90, speed * 0.4) + return + } + // 去最近且有可烹饪食材的仓库/粮仓“取料” + let pantry = null + let pantryD = Infinity + stores.forEach((s) => { + if (listCookIngredients(g, [s]).length <= 0) return + const d = Math.hypot(s.e.x - m.x, s.e.y - m.y) + if (d < pantryD) { pantryD = d; pantry = s.e } + }) + if (!pantry && stores[0]) pantry = stores[0].e m.jobT = (m.jobT || 0) - dt if (!m.job || m.jobT <= 0) { m.job = 'cook' - m.jobT = 10 + m.jobT = 5 m.jobTarget = canteen } if (m.job === 'cook') { - // 先去仓库取料,再到食堂 - if (wh && Math.hypot(wh.x - m.x, wh.y - m.y) > 50 && !m.gotIng) { - g.moveToward(m, wh.x, wh.y, speed, dt) + if (pantry && Math.hypot(pantry.x - m.x, pantry.y - m.y) > 44 && !m.gotIng) { + g.moveToward(m, pantry.x, pantry.y, speed, dt) return } - if (wh && !m.gotIng) { + if (pantry && !m.gotIng) { m.gotIng = true m.cookAt = canteen } const t = m.cookAt || canteen const d = Math.hypot(t.x - m.x, t.y - m.y) - if (d > 42) { + if (d > 38) { g.moveToward(m, t.x, t.y, speed, dt) return } - doCook(m, g, wh, t) + doCook(m, g, stores, t) m.gotIng = false m.job = null - m.jobT = 2 + m.jobT = 0.6 return } wanderAround(m, dt, g, anchor.x, anchor.y, 90, speed * 0.45) } +// ===================================================================== +// 伐木工:全图寻树砍伐 → 木头入库;树桩可刷新 +// ===================================================================== +function nearestTree(g, m) { + let best = null + let bd = Infinity + g.entities.forEach((e) => { + if (e.type !== 'tree' || e.stub) return + const d = Math.hypot(e.x - m.x, e.y - m.y) + if (d < bd) { bd = d; best = e } + }) + return best +} + +/** 全图游荡寻源:随机落点,尽量落在陆地上 */ +function exploreWorld(m, dt, g, speed) { + m.wanderT = (m.wanderT || 0) - dt + if (m.wanderT <= 0 || m.tx === undefined) { + m.wanderT = 5 + Math.random() * 7 + let tx = g.rand(120, Math.max(200, g.worldW - 120)) + let ty = g.rand(120, Math.max(200, g.worldH - 120)) + if (typeof g.findLand === 'function') { + const land = g.findLand(tx, ty, 0, 220) + if (land) { tx = land.x; ty = land.y } + } + m.tx = tx + m.ty = ty + } + const d = Math.hypot(m.tx - m.x, m.ty - m.y) + if (d > 14) g.moveToward(m, m.tx, m.ty, speed, dt) + else m.wanderT = 0 +} + +function doChopTree(m, g, tree, wh) { + const n = (tree.drops && tree.drops.log) || 2 + const x = tree.x + const y = tree.y + g.removeEntity(tree) + if (typeof g.addEntity === 'function') { + const stump = g.addEntity('stump', x, y) + if (stump) { + stump.respawnAs = 'tree' + stump.respawnT = 420 * (0.85 + Math.random() * 0.35) + } + } + let left = wh ? deposit(g, wh, 'log', n) : n + if (left > 0) { + const bx = wh ? wh.x : x + const by = wh ? wh.y : y + g.dropLoot(bx + g.rand(-20, 20), by + g.rand(14, 28), 'log', left) + } + if (m.owner) g.ptip(m.owner, `${m.workerName || '伐木工'}砍到木头 ×${n}`) +} + +function updateLumber(m, dt, g) { + if (m.hp < 300) m.hp = 300 + if (m.strike) { m.moving = false; return } + if (tryGoHomeNight(m, dt, g)) return + const speed = g.mon[m.kind]?.speed || 160 + const wh = nearestWarehouse(g, m) + m.jobT = (m.jobT || 0) - dt + if (m.job === 'chop' && (!m.jobTarget || !g.entities.includes(m.jobTarget) || m.jobTarget.stub)) { + m.job = null + m.jobTarget = null + } + // 探索途中也持续扫树 + if (!m.job || m.job === 'idle' || m.job === 'explore' || m.jobT <= 0) { + const tree = nearestTree(g, m) + if (tree) { + m.job = 'chop' + m.jobTarget = tree + m.jobT = 12 + } else if (m.job !== 'explore') { + m.job = 'explore' + m.jobTarget = null + m.jobT = 4 + m.wanderT = 0 + } + } + if (m.job === 'chop') { + const t = m.jobTarget + const d = Math.hypot(t.x - m.x, t.y - m.y) + if (d > 40) { + g.moveToward(m, t.x, t.y, speed, dt) + return + } + m.chopProg = (m.chopProg || 0) + dt + if (m.chopProg < 0.55) return + m.chopProg = 0 + doChopTree(m, g, t, wh) + m.job = null + m.jobT = 0.35 + return + } + exploreWorld(m, dt, g, speed * 0.85) +} + +// ===================================================================== +// 矿工:全图寻矿 → 入库;碎石可刷新 +// ===================================================================== +function nearestOre(g, m) { + let best = null + let bd = Infinity + g.entities.forEach((e) => { + if ((e.type !== 'rock' && e.type !== 'goldrock') || e.stub) return + const d = Math.hypot(e.x - m.x, e.y - m.y) + if (d < bd) { bd = d; best = e } + }) + return best +} + +function doMineOre(m, g, ore, wh) { + const drops = ore.drops || (ore.type === 'goldrock' ? { rock: 2, gold: 2 } : { rock: 2, flint: 1 }) + const x = ore.x + const y = ore.y + const typ = ore.type + g.removeEntity(ore) + if (typeof g.addEntity === 'function') { + const rubble = g.addEntity('rubble', x, y) + if (rubble) { + rubble.respawnAs = typ + rubble.respawnT = 520 * (0.85 + Math.random() * 0.4) + } + } + Object.entries(drops).forEach(([code, n]) => { + const cnt = code === 'gold' ? Math.max(1, Math.floor(n * (0.5 + Math.random()))) : n + let left = wh ? deposit(g, wh, code, cnt) : cnt + if (left > 0) g.dropLoot(x + g.rand(-16, 16), y + g.rand(10, 24), code, left) + if (m.owner) g.ptip(m.owner, `${m.workerName || '矿工'}采到${g.items[code]?.name || code} ×${cnt}`) + }) + if (typ === 'goldrock' && m.owner) { /* tip already */ } +} + +function updateMiner(m, dt, g) { + if (m.hp < 300) m.hp = 300 + if (m.strike) { m.moving = false; return } + if (tryGoHomeNight(m, dt, g)) return + const speed = g.mon[m.kind]?.speed || 155 + const wh = nearestWarehouse(g, m) + m.jobT = (m.jobT || 0) - dt + if (m.job === 'mine' && (!m.jobTarget || !g.entities.includes(m.jobTarget) || m.jobTarget.stub)) { + m.job = null + m.jobTarget = null + } + if (!m.job || m.job === 'idle' || m.job === 'explore' || m.jobT <= 0) { + const ore = nearestOre(g, m) + if (ore) { + m.job = 'mine' + m.jobTarget = ore + m.jobT = 12 + } else if (m.job !== 'explore') { + m.job = 'explore' + m.jobTarget = null + m.jobT = 4 + m.wanderT = 0 + } + } + if (m.job === 'mine') { + const t = m.jobTarget + const d = Math.hypot(t.x - m.x, t.y - m.y) + if (d > 40) { + g.moveToward(m, t.x, t.y, speed, dt) + return + } + m.mineProg = (m.mineProg || 0) + dt + if (m.mineProg < 0.7) return + m.mineProg = 0 + doMineOre(m, g, t, wh) + m.job = null + m.jobT = 0.4 + return + } + exploreWorld(m, dt, g, speed * 0.85) +} + +function updateVillager(m, dt, g) { + if (m.hp < 200) m.hp = 200 + const speed = g.mon[m.kind]?.speed || 120 + const c = getCampCenter(g) + // 夜间靠近房屋门口闲逛 + if (g.phase === 'night') { + const house = (g.entities || []).find((e) => e.type === 'camphouse') + const hx = house ? house.x : c.x + const hy = house ? house.y + 40 : c.y + if (Math.hypot(hx - m.x, hy - m.y) > 40) g.moveToward(m, hx, hy, speed * 0.7, dt) + else m.moving = false + return + } + wanderAround(m, dt, g, c.x, c.y, Math.min(220, 80 + getCampLevel() * 20), speed * 0.55) +} + +function maybeSpawnVillagers(g) { + const lv = getCampLevel() + if (lv < 2) return + const natives = g.monsters.filter((m) => m.kind === 'campvillager').length + const max = Math.min(MAX_VILLAGERS, 1 + Math.floor(lv / 2)) + if (natives >= max) return + // 每天最多来 1 个,概率随等级升 + if (Math.random() > 0.35 + lv * 0.04) return + const c = getCampCenter(g) + const a = Math.random() * TAU + const r = 80 + Math.random() * 140 + const spot = g.findLand(c.x + Math.cos(a) * r, c.y + Math.sin(a) * r, 20, 80) + if (!spot || !isInCamp(g, spot.x, spot.y)) return + const workerName = pickUniqueName(g) + const outfit = pickOutfit(g) + g.spawnMob('campvillager', spot.x, spot.y, { + workerName, + outfit, + source: 'native', + }) + const p = g.me() + if (p) g.ptip(p, `村民${workerName}来到了营地(点击分配职业)`) +} + // ===================================================================== // 面板布局(onDrawUI 与 onClick 共用同一套矩形,保证命中一致) // ===================================================================== function storeLayout(W, H) { const pw = Math.min(560, W * 0.86) - const headH = 54 - const rowH = 72 - const footH = 78 - const nRows = 3 - const ph = headH + nRows * rowH + footH + const headH = 44 + const footH = 70 + const nRows = 6 + const closeH = 34 + // 按视口压缩行高,保证关闭按钮始终在屏内 + const maxPh = Math.max(220, H - 12) + const rowH = Math.max(36, Math.min(50, Math.floor((maxPh - headH - footH) / nRows))) + const ph = Math.min(maxPh, headH + nRows * rowH + footH) const px = (W - pw) / 2 - const py = Math.max(14, (H - ph) / 2) + const py = Math.max(6, Math.min((H - ph) / 2, H - ph - 6)) const bw = Math.min(88, pw * 0.22) - const bh = 38 + const bh = Math.min(28, rowH - 8) const btns = [] - const roles = ['farmer', 'herder', 'chef'] + const roles = ['farmer', 'herder', 'builder', 'chef', 'lumber', 'miner'] for (let i = 0; i < nRows; i++) { const rowY = py + headH + i * rowH const by = rowY + (rowH - bh) / 2 @@ -777,32 +1290,122 @@ function storeLayout(W, H) { btns.push({ id: roles[i] + ':fire', x: px + pw - bw - 16, y: by, w: bw, h: bh }) } const closeW = 104 - const closeH = 40 - btns.push({ id: 'close', x: px + (pw - closeW) / 2, y: py + ph - closeH - 14, w: closeW, h: closeH }) + btns.push({ id: 'close', x: px + (pw - closeW) / 2, y: py + ph - closeH - 10, w: closeW, h: closeH }) return { px, py, pw, ph, headH, rowH, footH, bw, bh, btns } } -function whLayout(W, H) { - const pw = Math.min(560, W * 0.86) - const headH = 46 - const footH = 46 - const rowH = Math.max(16, Math.min(30, (H - headH - footH - 28) / BOX_COUNT)) - const ph = headH + BOX_COUNT * rowH + footH - const px = (W - pw) / 2 - const py = Math.max(10, (H - ph) / 2) - const bw = Math.min(62, pw * 0.16) - const bh = Math.max(14, Math.min(20, rowH - 4)) - const btns = [] - for (let i = 0; i < BOX_COUNT; i++) { - const rowY = py + headH + i * rowH - const by = rowY + (rowH - bh) / 2 - btns.push({ id: 'dep:' + i, x: px + pw - bw * 2 - 20, y: by, w: bw, h: bh }) - btns.push({ id: 'wd:' + i, x: px + pw - bw - 10, y: by, w: bw, h: bh }) +function whLayout(W, H, p) { + const pw = Math.min(860, W * 0.96) + const headH = 40 + const tabH = 28 + const invLabelH = 16 + const bulkH = 34 + const footH = 40 + const gap = 4 + const maxPh = Math.max(260, H - 10) + const slotPad = 14 + const slotGap = 10 + // 旧格约 106×52;大格约三倍边长(约 150+),3×2 排布 + const slotWMax = Math.floor((pw - slotPad * 2 - slotGap * (BOX_SLOT_COLS - 1)) / BOX_SLOT_COLS) + let slotSize = Math.min(slotWMax, 168) + let slotH = slotSize + let slotW = slotSize + + let cell = 36 + const main = p?.slots || [] + const pack = p?.pack || [] + const fitInv = (c, sH) => { + const cols = Math.max(8, Math.floor((pw - 28) / (c + gap))) + const mainRows = Math.max(1, Math.ceil(main.length / cols) || 1) + const packRows = pack.length ? Math.ceil(pack.length / cols) : 0 + const invH = mainRows * (c + gap) + (packRows ? 6 + packRows * (c + gap) : 0) + const boxAreaH = BOX_SLOT_ROWS * sH + (BOX_SLOT_ROWS - 1) * slotGap + const ph = headH + tabH + 8 + boxAreaH + invLabelH + 4 + invH + bulkH + footH + return { cols, mainRows, packRows, invH, boxAreaH, ph } } + let layout = fitInv(cell, slotH) + // 屏高不够时优先保大格,再压缩背包格 + while (layout.ph > maxPh && slotSize > 110) { + slotSize -= 8 + slotH = slotSize + slotW = slotSize + layout = fitInv(cell, slotH) + } + while (layout.ph > maxPh && cell > 28) { + cell -= 2 + layout = fitInv(cell, slotH) + } + const ph = Math.min(maxPh, layout.ph) + const px = (W - pw) / 2 + const py = Math.max(4, Math.min((H - ph) / 2, H - ph - 4)) + const useCols = layout.cols + const btns = [] + const tabW = Math.floor((pw - 24) / BOX_COUNT) + for (let i = 0; i < BOX_COUNT; i++) { + btns.push({ + id: 'box:' + i, + x: px + 12 + i * tabW, + y: py + headH + 3, + w: Math.max(18, tabW - 2), + h: tabH - 6, + }) + } + const slotY = py + headH + tabH + 8 + const gridW = BOX_SLOT_COLS * slotW + (BOX_SLOT_COLS - 1) * slotGap + const slotX0 = px + (pw - gridW) / 2 + for (let i = 0; i < BOX_KINDS; i++) { + const col = i % BOX_SLOT_COLS + const row = Math.floor(i / BOX_SLOT_COLS) + btns.push({ + id: 'wslot:' + i, + x: slotX0 + col * (slotW + slotGap), + y: slotY + row * (slotH + slotGap), + w: slotW, + h: slotH, + }) + } + const boxAreaH = BOX_SLOT_ROWS * slotH + (BOX_SLOT_ROWS - 1) * slotGap + const invY0 = slotY + boxAreaH + invLabelH + 4 + const invBottom = py + ph - footH - bulkH + main.forEach((_, i) => { + const col = i % useCols + const row = Math.floor(i / useCols) + const y = invY0 + row * (cell + gap) + if (y + cell > invBottom) return + btns.push({ + id: 'inv:main:' + i, + x: px + 14 + col * (cell + gap), + y, + w: cell, + h: cell, + }) + }) + const packY0 = invY0 + layout.mainRows * (cell + gap) + (layout.packRows ? 6 : 0) + pack.forEach((_, i) => { + const col = i % useCols + const row = Math.floor(i / useCols) + const y = packY0 + row * (cell + gap) + if (y + cell > invBottom) return + btns.push({ + id: 'inv:pack:' + i, + x: px + 14 + col * (cell + gap), + y, + w: cell, + h: cell, + }) + }) + const bulkY = py + ph - footH - bulkH + 4 + const bw = 100 + const bh = 28 + btns.push({ id: 'bulkdep', x: px + pw / 2 - bw - 8, y: bulkY, w: bw, h: bh }) + btns.push({ id: 'bulkwd', x: px + pw / 2 + 8, y: bulkY, w: bw, h: bh }) const closeW = 100 const closeH = 30 - btns.push({ id: 'close', x: px + (pw - closeW) / 2, y: py + headH + BOX_COUNT * rowH + 6, w: closeW, h: closeH }) - return { px, py, pw, ph, headH, rowH, footH, btns } + btns.push({ id: 'close', x: px + (pw - closeW) / 2, y: py + ph - closeH - 8, w: closeW, h: closeH }) + return { + px, py, pw, ph, headH, tabH, slotY, slotW, slotH, slotPad, slotGap, + invY0, cell, btns, bigSlot: true, + } } function clipText(ctx, text, maxW) { @@ -824,6 +1427,61 @@ function drawBtn(ctx, b, label, color, fs) { ctx.fillText(label, b.x + b.w / 2, b.y + b.h / 2 + 1) } +function fmtStack(n) { + const v = Math.max(0, Math.floor(Number(n) || 0)) + if (v >= 10000000) return `${(v / 10000000).toFixed(v % 10000000 === 0 ? 0 : 1)}千万` + if (v >= 10000) return `${(v / 10000).toFixed(v % 10000 === 0 ? 0 : 1)}万` + return String(v) +} + +function drawItemChip(ctx, g, b, code, n, emptyHint) { + const big = b.h >= 90 + ctx.fillStyle = code ? '#4a3a28' : '#2a2218' + ctx.strokeStyle = code ? '#c9a06a' : '#5a4a38' + ctx.lineWidth = big ? 2.4 : 2 + ctx.beginPath(); ctx.roundRect(b.x, b.y, b.w, b.h, big ? 10 : 6); ctx.fill(); ctx.stroke() + if (!code) { + ctx.fillStyle = '#6a5a48' + ctx.font = `${big ? 14 : 11}px "PingFang SC", "Microsoft YaHei", sans-serif` + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.fillText(emptyHint || '空', b.x + b.w / 2, b.y + b.h / 2) + return + } + const name = g.items[code]?.name || code + const icon = g.icons?.[code] + const cx = b.x + b.w / 2 + if (big && icon) { + const iconSz = Math.min(b.w, b.h) * 0.48 + ctx.drawImage(icon, cx - iconSz / 2, b.y + b.h * 0.12, iconSz, iconSz) + ctx.fillStyle = '#f2e8d0' + ctx.font = 'bold 13px "PingFang SC", "Microsoft YaHei", sans-serif' + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.fillText(clipText(ctx, name, b.w - 12), cx, b.y + b.h * 0.72) + ctx.fillStyle = '#a8d878' + ctx.font = 'bold 14px sans-serif' + ctx.fillText(`×${fmtStack(n)}`, cx, b.y + b.h * 0.88) + return + } + // 小格(背包):有图标则画图标,数量角标 + if (icon) { + const iconSz = Math.min(b.w, b.h) * 0.72 + ctx.drawImage(icon, cx - iconSz / 2, b.y + (b.h - iconSz) / 2 - 2, iconSz, iconSz) + } else { + ctx.fillStyle = '#f2e8d0' + ctx.font = 'bold 11px "PingFang SC", "Microsoft YaHei", sans-serif' + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.fillText(clipText(ctx, name, b.w - 6), cx, b.y + b.h / 2 - 6) + } + ctx.fillStyle = '#a8d878' + ctx.font = 'bold 11px sans-serif' + ctx.textAlign = 'right' + ctx.textBaseline = 'bottom' + ctx.fillText(fmtStack(n), b.x + b.w - 4, b.y + b.h - 3) +} + function drawStore(g, ctx, office) { const L = storeLayout(uiW, uiH) ctx.save() @@ -851,20 +1509,29 @@ function drawStore(g, ctx, office) { const roles = [ { role: 'farmer', label: '农民', fee: FARMER_FEE }, { role: 'herder', label: '牧工', fee: HERDER_FEE }, + { role: 'builder', label: '建筑工', fee: BUILDER_FEE }, { role: 'chef', label: '厨师', fee: CHEF_FEE }, + { role: 'lumber', label: '伐木工', fee: LUMBER_FEE }, + { role: 'miner', label: '矿工', fee: MINER_FEE }, ] ctx.textAlign = 'left' roles.forEach((r, idx) => { const rowY = L.py + L.headH + idx * L.rowH const info = roleInfo(g, office, r.role) + const mid = rowY + L.rowH / 2 ctx.fillStyle = '#e8d9b8' - ctx.font = 'bold 16px "PingFang SC", "Microsoft YaHei", sans-serif' - ctx.fillText(r.label, L.px + 18, rowY + 22) + ctx.font = 'bold 13px "PingFang SC", "Microsoft YaHei", sans-serif' + ctx.fillText(r.label, L.px + 18, mid - (L.rowH >= 44 ? 10 : 0)) ctx.fillStyle = '#c9b48c' - ctx.font = '12px "PingFang SC", "Microsoft YaHei", sans-serif' - ctx.fillText(`招聘费 ${r.fee} 份粮食 · 每日工资 ${WAGE} 份`, L.px + 18, rowY + 42) - ctx.fillStyle = info.striking ? '#e08a6a' : '#a9c98a' - ctx.fillText(`已有 ${info.n}/${info.max} · ${info.striking ? '罢工中' : '在岗'}`, L.px + 18, rowY + 60) + ctx.font = '11px "PingFang SC", "Microsoft YaHei", sans-serif' + if (L.rowH >= 44) { + ctx.fillText(`招聘费 ${r.fee} · 周薪 ${HIRED_WAGE}(村民 ${NATIVE_WAGE})`, L.px + 18, mid + 4) + ctx.fillStyle = info.striking ? '#e08a6a' : '#a9c98a' + ctx.fillText(`已有 ${info.n}/${info.max} · ${info.striking ? '罢工中' : '在岗'}`, L.px + 18, mid + 18) + } else { + ctx.fillStyle = info.striking ? '#e08a6a' : '#a9c98a' + ctx.fillText(`${info.n}/${info.max} · 费${r.fee}`, L.px + 70, mid) + } }) // 岗位按钮 L.btns.forEach((b) => { @@ -884,9 +1551,15 @@ function drawStore(g, ctx, office) { } function drawWarehouse(g, ctx, wh) { - const L = whLayout(uiW, uiH) + const p = g.me() + const L = whLayout(uiW, uiH, p) + if (whBoxSel < 0 || whBoxSel >= BOX_COUNT) whBoxSel = 0 + const boxes = boxesOf(wh) + const box = boxes[whBoxSel] || { items: {} } + const entries = Object.entries(box.items).filter(([, n]) => n > 0) + ctx.save() - ctx.fillStyle = 'rgba(0,0,0,0.42)' + ctx.fillStyle = 'rgba(0,0,0,0.45)' ctx.fillRect(0, 0, uiW, uiH) ctx.fillStyle = '#3a2a1a' ctx.strokeStyle = '#1d1409' @@ -895,41 +1568,63 @@ function drawWarehouse(g, ctx, wh) { ctx.strokeStyle = '#6b4a2e' ctx.lineWidth = 1.5 ctx.beginPath(); ctx.roundRect(L.px + 6, L.py + 6, L.pw - 12, L.ph - 12, 7); ctx.stroke() + // 标题 ctx.fillStyle = '#f0d79a' ctx.textAlign = 'center' ctx.textBaseline = 'middle' - ctx.font = 'bold 20px "PingFang SC", "Microsoft YaHei", sans-serif' - ctx.fillText('大 仓 库', L.px + L.pw / 2, L.py + L.headH / 2 + 2) - // 12 行箱子 - const boxes = boxesOf(wh) + ctx.font = 'bold 18px "PingFang SC", "Microsoft YaHei", sans-serif' + ctx.fillText('大 仓 库', L.px + L.pw / 2, L.py + 18) + ctx.fillStyle = '#c9b48c' + ctx.font = '11px "PingFang SC", "Microsoft YaHei", sans-serif' + ctx.fillText(`大格预览 · 点击取出 · 下方背包点击存入 · 单种上限 ${BOX_STACK}`, L.px + L.pw / 2, L.py + 34) + + // 页签 + L.btns.filter((b) => b.id.startsWith('box:')).forEach((b) => { + const i = +b.id.slice(4) + const sel = i === whBoxSel + const filled = Object.keys(boxes[i]?.items || {}).length + ctx.fillStyle = sel ? '#6a4a28' : '#2e2418' + ctx.strokeStyle = sel ? '#e0b060' : '#5a4a38' + ctx.lineWidth = sel ? 2.2 : 1.4 + ctx.beginPath(); ctx.roundRect(b.x, b.y, b.w, b.h, 4); ctx.fill(); ctx.stroke() + ctx.fillStyle = filled ? '#f0d79a' : '#8a7a5c' + ctx.font = `bold ${b.w < 28 ? 10 : 12}px sans-serif` + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.fillText(String(i + 1), b.x + b.w / 2, b.y + b.h / 2) + }) + + // 当前箱 6 格 + for (let i = 0; i < BOX_KINDS; i++) { + const b = L.btns.find((x) => x.id === 'wslot:' + i) + if (!b) continue + const ent = entries[i] + drawItemChip(ctx, g, b, ent?.[0], ent?.[1], '空') + } + + // 背包区标题 + ctx.fillStyle = '#c9b48c' + ctx.font = '12px "PingFang SC", "Microsoft YaHei", sans-serif' ctx.textAlign = 'left' ctx.textBaseline = 'middle' - for (let i = 0; i < BOX_COUNT; i++) { - const b = boxes[i] || { items: {} } - const rowY = L.py + L.headH + i * L.rowH - const cy = rowY + L.rowH / 2 - ctx.fillStyle = i % 2 ? 'rgba(0,0,0,0.16)' : 'rgba(255,255,255,0.05)' - ctx.beginPath(); ctx.roundRect(L.px + 10, rowY + 1, L.pw - 20, L.rowH - 2, 4); ctx.fill() - const entries = Object.entries(b.items).filter(([, n]) => n > 0) - ctx.fillStyle = '#e8d9b8' - ctx.font = '13px "PingFang SC", "Microsoft YaHei", sans-serif' - ctx.fillText(`箱子 ${i + 1}`, L.px + 18, cy) - ctx.fillStyle = '#c9b48c' - ctx.font = '12px "PingFang SC", "Microsoft YaHei", sans-serif' - ctx.fillText(`${entries.length}/${BOX_KINDS} 种`, L.px + 82, cy) - let sum = entries.slice(0, 3).map(([c, n]) => `${g.items[c]?.name || c}×${n}`).join('、') - if (entries.length > 3) sum += ' 等' - if (!sum) sum = '(空)' - ctx.fillStyle = entries.length ? '#d8c9a6' : '#8a7a5c' - ctx.font = '12px "PingFang SC", "Microsoft YaHei", sans-serif' - ctx.fillText(clipText(ctx, sum, L.pw - 320), L.px + 150, cy) + ctx.fillText('背包(点击存入仓库)', L.px + 14, L.invY0 - 12) + + const drawInv = (area, arr) => { + arr.forEach((s, i) => { + const b = L.btns.find((x) => x.id === `inv:${area}:${i}`) + if (!b) return + drawItemChip(ctx, g, b, s?.code, s?.n, '') + }) } - // 按钮 + drawInv('main', p?.slots || []) + drawInv('pack', p?.pack || []) + + // 整箱 / 关闭 L.btns.forEach((b) => { - if (b.id === 'close') { drawBtn(ctx, b, '关 闭', '#5a4630', 13); return } - const isDep = b.id.startsWith('dep:') - drawBtn(ctx, b, isDep ? '↓存入' : '↑取出', isDep ? '#4a6a8a' : '#5d7a3a', 11) + if (b.id === 'bulkdep') drawBtn(ctx, b, '整箱存入', '#4a6a8a', 12) + else if (b.id === 'bulkwd') drawBtn(ctx, b, '整箱取出', '#5d7a3a', 12) + else if (b.id === 'close') drawBtn(ctx, b, '关 闭', '#5a4630', 13) }) ctx.restore() } @@ -947,7 +1642,7 @@ function drawLaborer(ctx, m, time, role) { const hop = Math.sin(time * 8 + (m.bob || 0)) * (m.moving ? 1.8 : 0.45) const leg = m.moving ? Math.sin(time * 10) * 3.6 : 0 const dir = m.dir || 1 - const label = m.workerName || (role === 'farmer' ? '农民' : role === 'herder' ? '牧工' : '厨师') + const label = m.workerName || (role === 'farmer' ? '农民' : role === 'herder' ? '牧工' : role === 'builder' ? '建筑工' : role === 'lumber' ? '伐木工' : role === 'miner' ? '矿工' : role === 'villager' ? '村民' : '厨师') // 名牌(不随朝向翻转) ctx.save() ctx.fillStyle = 'rgba(20,16,10,0.72)' @@ -958,7 +1653,11 @@ function drawLaborer(ctx, m, time, role) { ctx.textAlign = 'center' ctx.textBaseline = 'middle' ctx.fillText(label, 0, -55 + hop * 0.3) - if (role === 'farmer' && m.boundZoneId != null) { + if (m.source === 'native' && role !== 'villager') { + ctx.fillStyle = '#a8d878' + ctx.font = '9px sans-serif' + ctx.fillText('村民', 0, -68 + hop * 0.3) + } else if (role === 'farmer' && m.boundZoneId != null) { ctx.fillStyle = '#a8d878' ctx.font = '9px sans-serif' ctx.fillText('专管', 0, -68 + hop * 0.3) @@ -1012,6 +1711,23 @@ function drawLaborer(ctx, m, time, role) { ctx.fillStyle = pal.hat ctx.beginPath(); ctx.ellipse(0, -39 + hop * 0.4, 15.5, 4.8, 0, 0, TAU); ctx.fill(); ctx.stroke() ctx.beginPath(); ctx.arc(0, -41 + hop * 0.4, 6.5, Math.PI, TAU); ctx.closePath(); ctx.fill(); ctx.stroke() + } else if (role === 'builder') { + ctx.fillStyle = '#5a6a7a' + ctx.beginPath(); ctx.roundRect(-8, -44 + hop * 0.4, 16, 8, 2); ctx.fill(); ctx.stroke() + ctx.fillStyle = '#e0a040' + ctx.beginPath(); ctx.roundRect(-9, -40 + hop * 0.4, 18, 3, 1); ctx.fill() + } else if (role === 'lumber') { + ctx.fillStyle = '#6a5030' + ctx.beginPath(); ctx.ellipse(0, -40 + hop * 0.4, 12.5, 4.2, 0, 0, TAU); ctx.fill(); ctx.stroke() + ctx.beginPath(); ctx.arc(0, -42 + hop * 0.4, 6.5, Math.PI, TAU); ctx.closePath(); ctx.fill(); ctx.stroke() + } else if (role === 'miner') { + ctx.fillStyle = '#5a5a68' + ctx.beginPath(); ctx.roundRect(-8, -44 + hop * 0.4, 16, 8, 2); ctx.fill(); ctx.stroke() + ctx.fillStyle = '#c0c0c8' + ctx.beginPath(); ctx.roundRect(-9, -40 + hop * 0.4, 18, 3, 1); ctx.fill() + } else if (role === 'villager') { + ctx.fillStyle = '#c8a070' + ctx.beginPath(); ctx.ellipse(0, -40 + hop * 0.4, 11, 3.8, 0, 0, TAU); ctx.fill(); ctx.stroke() } else { ctx.fillStyle = pal.hat ctx.beginPath(); ctx.ellipse(0, -40 + hop * 0.4, 12.5, 4.2, 0, 0, TAU); ctx.fill(); ctx.stroke() @@ -1042,6 +1758,45 @@ function drawLaborer(ctx, m, time, role) { ;[-3, 0, 3].forEach((o) => { ctx.beginPath(); ctx.moveTo(13, -32 + hop * 0.3); ctx.lineTo(13 + o, -42 + hop * 0.3); ctx.stroke() }) + } else if (role === 'builder') { + // 锤子 + ctx.save() + ctx.translate(8, -22 + hop * 0.3) + ctx.rotate(0.4) + ctx.strokeStyle = '#7a5b33' + ctx.lineWidth = 2.6 + ctx.beginPath(); ctx.moveTo(0, 8); ctx.lineTo(0, -10); ctx.stroke() + ctx.fillStyle = '#8d9097' + ctx.strokeStyle = '#1d1409' + ctx.lineWidth = 1.4 + ctx.beginPath(); ctx.roundRect(-7, -14, 14, 7, 1); ctx.fill(); ctx.stroke() + ctx.restore() + } else if (role === 'lumber') { + // 斧头 + ctx.save() + ctx.translate(7, -20 + hop * 0.3) + ctx.rotate(0.55) + ctx.strokeStyle = '#7a5b33' + ctx.lineWidth = 2.6 + ctx.beginPath(); ctx.moveTo(0, 10); ctx.lineTo(0, -8); ctx.stroke() + ctx.fillStyle = '#9aa0a8' + ctx.strokeStyle = '#1d1409' + ctx.lineWidth = 1.4 + ctx.beginPath(); ctx.moveTo(-2, -10); ctx.lineTo(10, -4); ctx.lineTo(10, -12); ctx.lineTo(-1, -14); ctx.closePath(); ctx.fill(); ctx.stroke() + ctx.restore() + } else if (role === 'miner') { + // 镐 + ctx.save() + ctx.translate(8, -22 + hop * 0.3) + ctx.rotate(-0.5) + ctx.strokeStyle = '#7a5b33' + ctx.lineWidth = 2.6 + ctx.beginPath(); ctx.moveTo(0, 10); ctx.lineTo(0, -10); ctx.stroke() + ctx.fillStyle = '#8d9097' + ctx.strokeStyle = '#1d1409' + ctx.lineWidth = 1.4 + ctx.beginPath(); ctx.moveTo(-8, -12); ctx.lineTo(8, -12); ctx.lineTo(0, -6); ctx.closePath(); ctx.fill(); ctx.stroke() + ctx.restore() } ctx.restore() } @@ -1049,13 +1804,13 @@ function drawLaborer(ctx, m, time, role) { export default { id: 'labor', name: '劳务市场', - desc: '用粮食招聘农民、牧工与厨师:耕作、放牧、食堂做饭', + desc: '招聘劳工与安置村民:耕作、放牧、盖房、做饭、砍树、采矿', factor: 1.05, tag: 'content', tabs: [{ code: 'labor', name: '劳务', icon: 'laboroffice' }], recipes: [ - { code: 'laboroffice', name: '招聘所', tab: 'labor', tech: 1, cost: { log: 8, rock: 4, rope: 2 }, build: true, desc: '招聘农民、牧工与厨师' }, - { code: 'laborwarehouse', name: '大仓库', tab: 'labor', tech: 1, cost: { log: 12, rock: 6 }, build: true, desc: '12 个箱子 · 农民自动入库' }, + { code: 'laboroffice', name: '招聘所', tab: 'labor', tech: 1, cost: { log: 8, rock: 4, rope: 2 }, build: true, desc: '招聘劳工;自然村民可分配职业(工资更低)' }, + { code: 'laborwarehouse', name: '大仓库', tab: 'labor', tech: 1, cost: { log: 12, rock: 6 }, build: true, desc: '12 箱×6 格 · 单种堆叠至 99999999 · 劳工自动入库' }, ], icons: { laboroffice: (c) => { @@ -1123,7 +1878,8 @@ export default { name: '招聘所', conf: { size: 44, struct: true }, init(e) { - if (!e.mx) e.mx = { hired: 0, farmer: 0, herder: 0, chef: 0, strike: 0 } + if (!e.mx) e.mx = { hired: 0, farmer: 0, herder: 0, builder: 0, chef: 0, lumber: 0, miner: 0, strike: 0 } + mxOf(e) }, interact(p, e, g) { mxOf(e) @@ -1133,6 +1889,7 @@ export default { g.ptip(p, '劳务市场:点击面板按钮招聘 / 解雇') }, draw(ctx, e) { + drawNameplate(ctx, e.mx?.name || '招聘所', -58) // 投影 ctx.fillStyle = 'rgba(0,0,0,0.25)' ctx.beginPath(); ctx.ellipse(0, 6, 26, 8, 0, 0, TAU); ctx.fill() @@ -1190,11 +1947,17 @@ export default { interact(p, e, g) { boxesOf(e) storeOpen = e.id + whBoxSel = 0 officeOpen = null g.invalidate() - g.ptip(p, '大仓库:12 个箱子 · 共 72 格物品容量') + g.ptip(p, '大仓库:点箱内物品取出,点下方背包格存入') }, draw(ctx, e) { + let stored = 0 + boxesOf(e).forEach((b) => { + Object.values(b.items || {}).forEach((k) => { stored += k || 0 }) + }) + drawNameplate(ctx, stored > 0 ? `大仓库 · ${stored}件` : '大仓库', -66) // 投影 ctx.fillStyle = 'rgba(0,0,0,0.25)' ctx.beginPath(); ctx.ellipse(0, 8, 32, 9, 0, 0, TAU); ctx.fill() @@ -1255,7 +2018,7 @@ export default { laborfarmer: { name: '农民', friendly: true, - conf: { hp: 300, speed: 118, dmg: 5, score: 0, friendly: true, name: '农民' }, + conf: { hp: 300, speed: 165, dmg: 5, score: 0, friendly: true, name: '农民' }, drops: [], update(m, dt, g) { updateFarmer(m, dt, g) @@ -1274,7 +2037,7 @@ export default { laborherder: { name: '牧工', friendly: true, - conf: { hp: 300, speed: 118, dmg: 5, score: 0, friendly: true, name: '牧工' }, + conf: { hp: 300, speed: 165, dmg: 5, score: 0, friendly: true, name: '牧工' }, drops: [], update(m, dt, g) { updateHerder(m, dt, g) @@ -1288,10 +2051,27 @@ export default { drawLaborer(ctx, m, time, 'herder') }, }, + laborbuilder: { + name: '建筑工', + friendly: true, + conf: { hp: 300, speed: 155, dmg: 5, score: 0, friendly: true, name: '建筑工' }, + drops: [], + update(m, dt, g) { + updateBuilder(m, dt, g) + }, + interact(p, m, g) { + g.ptip(p, m.owner + ? `${m.workerName || '建筑工'}会按宅基计划盖房(木头×12 石块×8)` + : '一位流落的建筑工') + }, + draw(ctx, m, time) { + drawLaborer(ctx, m, time, 'builder') + }, + }, laborchef: { name: '厨师', friendly: true, - conf: { hp: 300, speed: 118, dmg: 5, score: 0, friendly: true, name: '厨师' }, + conf: { hp: 300, speed: 160, dmg: 5, score: 0, friendly: true, name: '厨师' }, drops: [], update(m, dt, g) { updateChef(m, dt, g) @@ -1305,8 +2085,65 @@ export default { drawLaborer(ctx, m, time, 'chef') }, }, + laborlumber: { + name: '伐木工', + friendly: true, + conf: { hp: 300, speed: 160, dmg: 5, score: 0, friendly: true, name: '伐木工' }, + drops: [], + update(m, dt, g) { + updateLumber(m, dt, g) + }, + interact(p, m, g) { + g.ptip(p, m.owner + ? `${m.workerName || '伐木工'}正在附近砍树入库(可在招聘所解雇)` + : '一位流落的伐木工') + }, + draw(ctx, m, time) { + drawLaborer(ctx, m, time, 'lumber') + }, + }, + laborminer: { + name: '矿工', + friendly: true, + conf: { hp: 300, speed: 155, dmg: 5, score: 0, friendly: true, name: '矿工' }, + drops: [], + update(m, dt, g) { + updateMiner(m, dt, g) + }, + interact(p, m, g) { + g.ptip(p, m.owner + ? `${m.workerName || '矿工'}正在采矿入库(可在招聘所解雇)` + : '一位流落的矿工') + }, + draw(ctx, m, time) { + drawLaborer(ctx, m, time, 'miner') + }, + }, + campvillager: { + name: '村民', + friendly: true, + conf: { hp: 200, speed: 120, dmg: 0, score: 0, friendly: true, name: '村民' }, + drops: [], + update(m, dt, g) { + updateVillager(m, dt, g) + }, + interact(p, m, g) { + openVillagerAssign(g, p, m) + g.invalidate?.() + }, + draw(ctx, m, time) { + drawLaborer(ctx, m, time, 'villager') + }, + }, }, hooks: { + onTick(g) { + // Esc 关闭面板,避免关闭键被挤出屏外时卡住 + if ((officeOpen != null || storeOpen != null) && g.keyDown?.('Escape')) { + officeOpen = null + storeOpen = null + } + }, onDrawUI(g, ctx, env) { uiCamX = env.camX uiCamY = env.camY @@ -1321,8 +2158,10 @@ export default { if (!wh) storeOpen = null else drawWarehouse(g, ctx, wh) } + drawZoneMenu(ctx, env) }, onClick(g, wx, wy) { + if (handleZoneMenuClick(wx, wy)) return true if (officeOpen == null && storeOpen == null) return undefined const p = g.me() // 世界坐标 → 屏幕坐标(面板画在屏幕系) @@ -1335,61 +2174,87 @@ export default { const hit = hitBtn(L.btns, sx, sy) if (hit) { if (hit.id === 'close') officeOpen = null - else if (hit.id === 'farmer:hire') hire(g, p, office, 'farmer') - else if (hit.id === 'farmer:fire') fire(g, p, office, 'farmer') - else if (hit.id === 'herder:hire') hire(g, p, office, 'herder') - else if (hit.id === 'herder:fire') fire(g, p, office, 'herder') - else if (hit.id === 'chef:hire') hire(g, p, office, 'chef') - else if (hit.id === 'chef:fire') fire(g, p, office, 'chef') + else if (hit.id.endsWith(':hire') || hit.id.endsWith(':fire')) { + const [role, act] = hit.id.split(':') + if (act === 'hire') hire(g, p, office, role) + else fire(g, p, office, role) + } return true } - if (!inRect(L, sx, sy)) officeOpen = null + // 点遮罩(面板外)关闭 + officeOpen = null return true } const wh = entById(g, storeOpen) if (!wh) { storeOpen = null; return true } - const L = whLayout(uiW, uiH) + const L = whLayout(uiW, uiH, p) const hit = hitBtn(L.btns, sx, sy) if (hit) { if (hit.id === 'close') storeOpen = null - else if (hit.id.startsWith('dep:')) boxDeposit(g, p, wh, +hit.id.slice(4)) - else if (hit.id.startsWith('wd:')) boxWithdraw(g, p, wh, +hit.id.slice(3)) + else if (hit.id.startsWith('box:')) whBoxSel = +hit.id.slice(4) + else if (hit.id.startsWith('wslot:')) { + const si = +hit.id.slice(6) + const entries = Object.entries(boxesOf(wh)[whBoxSel]?.items || {}).filter(([, n]) => n > 0) + const ent = entries[si] + if (ent) withdrawItem(g, p, wh, whBoxSel, ent[0]) + else g.ptip(p, '空格子') + } + else if (hit.id.startsWith('inv:')) { + const parts = hit.id.split(':') // inv:main:0 + const area = parts[1] + const idx = +parts[2] + depositInvSlot(g, p, wh, area, idx) + } + else if (hit.id === 'bulkdep') boxDeposit(g, p, wh, whBoxSel) + else if (hit.id === 'bulkwd') boxWithdraw(g, p, wh, whBoxSel) return true } - if (!inRect(L, sx, sy)) storeOpen = null + // 点遮罩关闭 + storeOpen = null return true }, onNewDay(g) { + maybeSpawnVillagers(g) + // 每周发薪(第 1/8/15… 天);非发薪日只同步人数、解除误标罢工 + const payday = ((g.day - 1) % WAGE_EVERY) === 0 let anyStrike = false g.entities.filter((e) => e.type === 'laboroffice').forEach((office) => { const mx = mxOf(office) - const mine = g.monsters.filter((m) => (m.kind === 'laborfarmer' || m.kind === 'laborherder' || m.kind === 'laborchef') && m.office === office.id) + const mine = g.monsters.filter((m) => isLaborKind(m.kind) && m.office === office.id) const wh = nearestWarehouse(g, office) - const total = mine.length * WAGE + const total = mine.reduce((s, m) => s + wageOf(m), 0) const p = g.me() - if (total > 0 && availableFood(g, p, wh) < total) { - mine.forEach((m) => { m.strike = true }) - mx.strike = 1 - anyStrike = true - } else if (total > 0) { - takeFood(g, p, wh, total, true) - mine.forEach((m) => { m.strike = false }) - mx.strike = 0 + if (payday && total > 0) { + if (availableFood(g, p, wh) < total) { + mine.forEach((m) => { m.strike = true }) + mx.strike = 1 + anyStrike = true + } else { + takeFood(g, p, wh, total, true) + mine.forEach((m) => { m.strike = false }) + mx.strike = 0 + g.tip(`发了本周工资:${total} 份粮食(招聘 ${HIRED_WAGE}/人 · 村民 ${NATIVE_WAGE}/人)`) + } } // 同步计数 mx.farmer = g.monsters.filter((m) => m.kind === 'laborfarmer' && m.office === office.id).length mx.herder = g.monsters.filter((m) => m.kind === 'laborherder' && m.office === office.id).length + mx.builder = g.monsters.filter((m) => m.kind === 'laborbuilder' && m.office === office.id).length mx.chef = g.monsters.filter((m) => m.kind === 'laborchef' && m.office === office.id).length + mx.lumber = g.monsters.filter((m) => m.kind === 'laborlumber' && m.office === office.id).length + mx.miner = g.monsters.filter((m) => m.kind === 'laborminer' && m.office === office.id).length syncHired(mx) g.markDirty(office) }) - if (anyStrike) g.tip('劳工罢工了:仓库/背包粮食不够发工资') + // 劳工补分配住房 + g.monsters.filter((m) => isLaborKind(m.kind)).forEach((m) => ensureWorkerHome(g, m)) + if (anyStrike) g.tip('劳工罢工了:仓库/背包粮食不够发本周工资') }, // 劳工不进世界快照,这里单独持久化 save(g) { const offices = g.entities.filter((e) => e.type === 'laboroffice').map((e) => ({ id: e.id, mx: e.mx || null })) const workers = g.monsters - .filter((m) => m.kind === 'laborfarmer' || m.kind === 'laborherder' || m.kind === 'laborchef') + .filter((m) => isLaborKind(m.kind) || m.kind === 'campvillager') .map((m) => ({ kind: m.kind, x: Math.round(m.x), @@ -1400,6 +2265,8 @@ export default { workerName: m.workerName || '', outfit: m.outfit | 0, boundZoneId: m.boundZoneId != null ? m.boundZoneId : null, + source: m.source || 'hired', + homeHouseId: m.homeHouseId != null ? m.homeHouseId : null, })) if (!offices.length && !workers.length) return null return { offices, workers } @@ -1413,23 +2280,32 @@ export default { }) ;(data.workers || []).forEach((w) => { const office = w.office != null ? g.entities.find((e) => e.type === 'laboroffice' && e.id === w.office) : null - if (w.office != null && !office) return - const kind = w.kind === 'laborherder' ? 'laborherder' : w.kind === 'laborchef' ? 'laborchef' : 'laborfarmer' + if (w.office != null && !office && w.kind !== 'campvillager') return + const kind = (LABOR_KINDS.includes(w.kind) || w.kind === 'campvillager') ? w.kind : 'laborfarmer' const spot = g.findLand(w.x, w.y, 0, 24) const m = g.spawnMob(kind, spot.x, spot.y, {}) m.office = office ? office.id : null - m.role = kind === 'laborfarmer' ? 'farmer' : kind === 'laborherder' ? 'herder' : 'chef' + m.role = kind === 'laborfarmer' ? 'farmer' + : kind === 'laborherder' ? 'herder' + : kind === 'laborbuilder' ? 'builder' + : kind === 'laborlumber' ? 'lumber' + : kind === 'laborminer' ? 'miner' + : kind === 'campvillager' ? 'villager' : 'chef' m.strike = !!w.strike m.workerName = w.workerName || pickUniqueName(g) m.outfit = w.outfit != null ? w.outfit : pickOutfit(g) m.boundZoneId = w.boundZoneId != null ? w.boundZoneId : null + m.source = w.source || (kind === 'campvillager' ? 'native' : 'hired') + m.homeHouseId = w.homeHouseId != null ? w.homeHouseId : null m.hp = g.mon[kind]?.hp || 300 if (w.seat != null) { const owner = g.players.find((q) => q.seat === w.seat) if (owner) m.owner = owner } - if (!m.owner) m.owner = g.me() || null + if (!m.owner && kind !== 'campvillager') m.owner = g.me() || null + if (isLaborKind(kind)) ensureWorkerHome(g, m) }) + syncHouseLooks(g) }, }, } diff --git a/src/games/starve/mods/zoneUtil.js b/src/games/starve/mods/zoneUtil.js index 3ece7b0..6a7155c 100644 --- a/src/games/starve/mods/zoneUtil.js +++ b/src/games/starve/mods/zoneUtil.js @@ -1,5 +1,6 @@ // 分区框选 / 围栏贴边共用工具 -const FENCE_STEP = 48 +// 栅栏段视觉宽约 32,步长略小让横杆搭接;竖边用竖向朝向填缝 +const FENCE_STEP = 30 export function normalizeRect(x0, y0, x1, y1) { return { @@ -37,8 +38,121 @@ const ZONE_KEEP = new Set([ 'well', 'granary', 'mill', 'trough', 'coop', 'sciencemachine', 'alchemyengine', 'crockpot', 'woodchest', 'pighouse', 'trap', 'canteen', 'resttent', 'camphq', + 'camphouse', 'campstore', 'houseplan', 'housezone', + 'laborwarehouse', ]) +/** 产出倍率:每级 +12% */ +export function zoneYieldMul(level) { + return 1 + 0.12 * Math.max(0, (level || 1) - 1) +} + +/** 视觉层数:1 级 1 层,之后隔一级加一层(3→2层、5→3层…) */ +export function zoneFloors(level) { + return 1 + Math.floor(Math.max(0, (level || 1) - 1) / 2) +} + +/** 升级费用:随等级上涨 */ +export function zoneUpgradeCost(level) { + const lv = Math.max(1, level || 1) + return { log: 4 * lv, rock: 2 * lv, gold: Math.max(0, lv - 1) } +} + +/** 可参与升级扣费的仓储实体 */ +const STOCK_STORE_TYPES = new Set(['laborwarehouse', 'campstore']) + +function warehouseStockOf(g, code) { + let n = 0 + ;(g?.entities || []).forEach((e) => { + if (!STOCK_STORE_TYPES.has(e.type)) return + const boxes = e.mx?.boxes + if (!Array.isArray(boxes)) return + boxes.forEach((b) => { n += b?.items?.[code] || 0 }) + }) + return n +} + +/** 背包 + 仓库合计持有量 */ +export function stockCount(g, code) { + const p = g?.me?.() + const bag = (p && typeof g.countItem === 'function' ? g.countItem(p, code) : 0) || 0 + return bag + warehouseStockOf(g, code) +} + +/** 从所有仓库扣物品,返回实际扣到的数量 */ +export function withdrawFromWarehouses(g, code, amount) { + let need = Math.max(0, amount | 0) + if (need <= 0) return 0 + let got = 0 + ;(g?.entities || []).forEach((e) => { + if (need <= 0 || !STOCK_STORE_TYPES.has(e.type)) return + const boxes = e.mx?.boxes + if (!Array.isArray(boxes)) return + for (const b of boxes) { + if (need <= 0) break + if (!b.items) b.items = {} + const have = b.items[code] || 0 + if (have <= 0) continue + const take = Math.min(have, need) + b.items[code] = have - take + if (b.items[code] <= 0) delete b.items[code] + need -= take + got += take + g.markDirty?.(e) + } + }) + return got +} + +export function hasStockCost(g, cost) { + if (!cost) return false + return Object.entries(cost).every(([code, n]) => n <= 0 || stockCount(g, code) >= n) +} + +/** 优先扣仓库,不足再扣背包 */ +export function payStockCost(g, cost) { + const p = g?.me?.() + Object.entries(cost || {}).forEach(([code, n]) => { + if (n <= 0) return + const fromWh = withdrawFromWarehouses(g, code, n) + const left = n - fromWh + if (left > 0 && p && typeof g.payCost === 'function') g.payCost(p, { [code]: left }) + }) +} + +/** + * 生成升级材料明细(背包+仓库合计),供模态框展示 + * @returns {{ name: string, need: number, have: number, miss: number, ok: boolean, text: string }[]} + */ +export function buildCostDetails(g, cost) { + if (!cost) return [] + return Object.entries(cost) + .filter(([, n]) => n > 0) + .map(([code, need]) => { + const bag = (typeof g.countItem === 'function' && g.me?.() ? g.countItem(g.me(), code) : 0) || 0 + const wh = warehouseStockOf(g, code) + const have = bag + wh + const name = g.items?.[code]?.name || code + const miss = Math.max(0, need - have) + const where = wh > 0 ? `包${bag}+仓${wh}` : `有 ${have}` + return { + code, + name, + need, + have, + miss, + ok: have >= need, + text: miss > 0 + ? `${name} ×${need}(${where},缺 ${miss})` + : `${name} ×${need}(${where} ✓)`, + } + }) +} + +export function costDetailsAllOk(details) { + return !!details?.length && details.every((d) => d.ok) +} + function shouldKeepInZone(e) { if (!e) return true if (ZONE_KEEP.has(e.type)) return true @@ -110,36 +224,76 @@ export function clearZoneClutter(g, z) { return n } -/** 沿 AABB 外轮廓采样点;底边仅一个点作为门位(避免大门缺口过大) */ +/** + * 沿 AABB 外轮廓均匀采样;南北边横向(h)、东西边纵向(v);底边中点作门 + * @returns {{x,y,side,orient,gate}[]} + */ export function perimeterPoints(z, step = FENCE_STEP, gateGap = true) { const pts = [] const { x0, y0, x1, y1 } = z const w = x1 - x0 const h = y1 - y0 if (w < step * 2 || h < step * 2) return pts - const midX = (x0 + x1) / 2 - // 顶边 - for (let x = x0; x <= x1 + 0.1; x += step) pts.push({ x, y: y0, gate: false }) - // 右边 - for (let y = y0 + step; y <= y1 - step + 0.1; y += step) pts.push({ x: x1, y, gate: false }) - // 底边 - const bottom = [] - for (let x = x1; x >= x0 - 0.1; x -= step) bottom.push({ x, y: y1, gate: false }) - if (gateGap && bottom.length) { - let best = 0 + + function edge(ax, ay, bx, by, side, includeStart, includeEnd) { + const len = Math.hypot(bx - ax, by - ay) + const n = Math.max(1, Math.round(len / step)) + const orient = side === 'e' || side === 'w' ? 'v' : 'h' + for (let i = 0; i <= n; i++) { + if (i === 0 && !includeStart) continue + if (i === n && !includeEnd) continue + const t = i / n + pts.push({ + x: ax + (bx - ax) * t, + y: ay + (by - ay) * t, + side, + orient, + gate: false, + }) + } + } + // 四角只由南北边给出,东西边不含端点,避免重复 + edge(x0, y0, x1, y0, 'n', true, true) + edge(x1, y0, x1, y1, 'e', false, false) + edge(x1, y1, x0, y1, 's', true, true) + edge(x0, y1, x0, y0, 'w', false, false) + + if (gateGap) { + const midX = (x0 + x1) / 2 + let best = -1 let bd = Infinity - bottom.forEach((p, i) => { + pts.forEach((p, i) => { + if (p.side !== 's') return const d = Math.abs(p.x - midX) if (d < bd) { bd = d; best = i } }) - bottom[best].gate = true + if (best >= 0) { + pts[best].gate = true + // 门两侧各空出一段,避免邻接栅栏堵住门口 + ;[best - 1, best + 1].forEach((i) => { + if (i >= 0 && i < pts.length && pts[i].side === 's') pts[i].skip = true + }) + } } - pts.push(...bottom) - // 左边 - for (let y = y1 - step; y >= y0 + step - 0.1; y -= step) pts.push({ x: x0, y, gate: false }) return pts } +/** + * 清除矩形内围栏/门(不重建) + */ +export function clearFencePerimeter(g, z, opt = {}) { + if (!z || !g?.entities) return 0 + const fenceType = opt.fenceType || 'fence' + const gateType = opt.gateType || 'gate' + const pad = opt.pad ?? 8 + const doomed = g.entities.filter((e) => { + if (e.type !== fenceType && e.type !== gateType) return false + return e.x >= z.x0 - pad && e.x <= z.x1 + pad && e.y >= z.y0 - pad && e.y <= z.y1 + pad + }) + doomed.forEach((e) => g.removeEntity(e)) + return doomed.length +} + /** * 清除矩形内旧围栏/门,按外轮廓重建 * @param {object} g 游戏上下文 @@ -150,23 +304,18 @@ export function rebuildFencePerimeter(g, z, opt = {}) { const fenceType = opt.fenceType || 'fence' const gateType = opt.gateType || 'gate' const step = opt.step || FENCE_STEP - const pad = 8 - // 清除内部及边缘旧栏 - const doomed = g.entities.filter((e) => { - if (e.type !== fenceType && e.type !== gateType) return false - return e.x >= z.x0 - pad && e.x <= z.x1 + pad && e.y >= z.y0 - pad && e.y <= z.y1 + pad - }) - doomed.forEach((e) => g.removeEntity(e)) + clearFencePerimeter(g, z, { fenceType, gateType, pad: 8 }) const pts = perimeterPoints(z, step, true) pts.forEach((p) => { + if (p.skip) return if (!g.isLand(p.x, p.y)) return if (p.gate) { - g.addEntity(gateType, p.x, p.y, { mx: { open: false, openT: 0 } }) + g.addEntity(gateType, p.x, p.y, { mx: { open: false, openT: 0, orient: p.orient || 'h' } }) return } - const near = g.entities.some((e) => (e.type === fenceType || e.type === gateType) && Math.hypot(e.x - p.x, e.y - p.y) < step * 0.45) + const near = g.entities.some((e) => (e.type === fenceType || e.type === gateType) && Math.hypot(e.x - p.x, e.y - p.y) < step * 0.35) if (near) return - g.addEntity(fenceType, p.x, p.y) + g.addEntity(fenceType, p.x, p.y, { mx: { orient: p.orient || 'h' } }) }) } @@ -185,4 +334,206 @@ export function findDarkSpots(g, cx, cy, radius, count, minDist = 90) { return out } +/** 建筑/实体头顶名牌(世界坐标已由调用方 translate 到实体中心) */ +export function drawNameplate(ctx, text, y = -56) { + if (!text) return + const label = String(text) + ctx.save() + ctx.fillStyle = 'rgba(20,16,10,0.78)' + const tw = Math.max(40, label.length * 9 + 14) + ctx.beginPath() + if (ctx.roundRect) ctx.roundRect(-tw / 2, y - 8, tw, 16, 4) + else ctx.rect(-tw / 2, y - 8, tw, 16) + ctx.fill() + ctx.fillStyle = '#f4ead2' + ctx.font = 'bold 11px "PingFang SC","Microsoft YaHei",sans-serif' + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.fillText(label, 0, y) + ctx.restore() +} + +// ===================================================================== +// 区域右键菜单(升级 / 改名 / 删除),替代 window.prompt +// ===================================================================== +let zoneMenu = null // { title, subtitle, details, detailTitle, actions, onPick, ... } +let zoneMenuDrawnAt = null +let lastUiEnv = { camX: 0, camY: 0, W: 800, H: 600 } + +/** + * @param {{ + * title: string, + * subtitle?: string, + * detailTitle?: string, + * details?: { text: string, ok?: boolean }[], + * actions: { id: string, label: string, color?: string, disabled?: boolean }[], + * onPick: (id: string) => void + * }} opts + */ +export function openZoneMenu(opts) { + zoneMenu = { + title: opts.title || '区域', + subtitle: opts.subtitle || '', + detailTitle: opts.detailTitle || '', + details: Array.isArray(opts.details) ? opts.details.slice() : [], + actions: (opts.actions || []).slice(), + onPick: typeof opts.onPick === 'function' ? opts.onPick : () => {}, + camX: lastUiEnv.camX, + camY: lastUiEnv.camY, + W: lastUiEnv.W, + H: lastUiEnv.H, + btns: [], + panel: null, + } + layoutZoneMenu(zoneMenu.W, zoneMenu.H) + zoneMenuDrawnAt = null +} + +export function closeZoneMenu() { + zoneMenu = null + zoneMenuDrawnAt = null +} + +export function isZoneMenuOpen() { + return !!zoneMenu +} + +function layoutZoneMenu(W, H) { + if (!zoneMenu) return + const n = Math.max(1, zoneMenu.actions.length) + const details = zoneMenu.details || [] + const bw = 200 + const gap = 8 + const headH = zoneMenu.subtitle ? 52 : 40 + const detailHead = details.length ? 20 : 0 + const detailBody = details.length ? details.length * 18 + 8 : 0 + let detailBlock = detailHead + detailBody + const pw = Math.max(260, details.length ? 280 : 220) + const maxPh = Math.max(160, H - 12) + // 压缩按钮高度,确保整块面板含「取消/关闭」落在屏内 + let bh = 34 + let ph = headH + detailBlock + n * (bh + gap) + 14 + if (ph > maxPh) { + detailBlock = Math.min(detailBlock, Math.max(0, maxPh - headH - n * 28 - 14)) + bh = Math.max(28, Math.floor((maxPh - headH - detailBlock - 14) / n) - gap) + ph = Math.min(maxPh, headH + detailBlock + n * (bh + gap) + 14) + } + const px = (W - pw) / 2 + const py = Math.max(6, Math.min((H - ph) / 2, H - ph - 6)) + zoneMenu.panel = { x: px, y: py, w: pw, h: ph } + const btnTop = py + headH + detailBlock + zoneMenu.btns = zoneMenu.actions.map((a, i) => ({ + id: a.id, + label: a.label, + color: a.disabled ? '#4a4038' : (a.color || '#5a4630'), + disabled: !!a.disabled, + x: px + (pw - bw) / 2, + y: btnTop + i * (bh + gap), + w: bw, + h: bh, + })) + zoneMenu._detailTop = py + headH +} + +/** 在 onDrawUI 中调用;同一帧多次调用只会画一次 */ +export function drawZoneMenu(ctx, env) { + lastUiEnv = { camX: env.camX, camY: env.camY, W: env.W, H: env.H } + if (!zoneMenu) return + const fid = env.time + if (zoneMenuDrawnAt === fid) return + zoneMenuDrawnAt = fid + zoneMenu.camX = env.camX + zoneMenu.camY = env.camY + zoneMenu.W = env.W + zoneMenu.H = env.H + layoutZoneMenu(env.W, env.H) + const p = zoneMenu.panel + const details = zoneMenu.details || [] + ctx.save() + ctx.fillStyle = 'rgba(0,0,0,0.48)' + ctx.fillRect(0, 0, env.W, env.H) + ctx.fillStyle = '#3a2a1a' + ctx.strokeStyle = '#1d1409' + ctx.lineWidth = 3 + ctx.beginPath() + if (ctx.roundRect) ctx.roundRect(p.x, p.y, p.w, p.h, 10) + else ctx.rect(p.x, p.y, p.w, p.h) + ctx.fill() + ctx.stroke() + ctx.fillStyle = '#f0d79a' + ctx.font = 'bold 16px "PingFang SC","Microsoft YaHei",sans-serif' + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.fillText(zoneMenu.title, p.x + p.w / 2, p.y + (zoneMenu.subtitle ? 20 : 24)) + if (zoneMenu.subtitle) { + ctx.fillStyle = '#c9b48c' + ctx.font = '12px "PingFang SC","Microsoft YaHei",sans-serif' + ctx.fillText(zoneMenu.subtitle, p.x + p.w / 2, p.y + 40) + } + if (details.length) { + const boxY = zoneMenu._detailTop + const boxH = 22 + details.length * 20 + 8 + ctx.fillStyle = 'rgba(0,0,0,0.28)' + ctx.beginPath() + if (ctx.roundRect) ctx.roundRect(p.x + 14, boxY, p.w - 28, boxH, 6) + else ctx.rect(p.x + 14, boxY, p.w - 28, boxH) + ctx.fill() + ctx.fillStyle = '#e0c080' + ctx.font = 'bold 12px "PingFang SC","Microsoft YaHei",sans-serif' + ctx.textAlign = 'left' + ctx.fillText(zoneMenu.detailTitle || '升级所需材料', p.x + 24, boxY + 14) + details.forEach((d, i) => { + const y = boxY + 34 + i * 20 + ctx.fillStyle = d.ok ? '#8dce7a' : '#e08a6a' + ctx.font = '12px "PingFang SC","Microsoft YaHei",sans-serif' + ctx.fillText(d.text || '', p.x + 24, y) + }) + } + zoneMenu.btns.forEach((b) => { + ctx.fillStyle = b.color + ctx.strokeStyle = '#1d1409' + ctx.lineWidth = 2 + ctx.beginPath() + if (ctx.roundRect) ctx.roundRect(b.x, b.y, b.w, b.h, 6) + else ctx.rect(b.x, b.y, b.w, b.h) + ctx.fill() + ctx.stroke() + ctx.fillStyle = b.disabled ? '#a09080' : '#f2e8d0' + ctx.font = 'bold 14px "PingFang SC","Microsoft YaHei",sans-serif' + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.fillText(b.label, b.x + b.w / 2, b.y + b.h / 2 + 1) + }) + ctx.restore() +} + +/** + * 在 onClick 开头调用(世界坐标)。菜单打开时拦截点击。 + * @returns {boolean} + */ +export function handleZoneMenuClick(wx, wy) { + if (!zoneMenu) return false + if (!zoneMenu.panel) layoutZoneMenu(zoneMenu.W || 800, zoneMenu.H || 600) + const sx = wx - zoneMenu.camX + const sy = wy - zoneMenu.camY + const hit = zoneMenu.btns.find((b) => sx >= b.x && sx <= b.x + b.w && sy >= b.y && sy <= b.y + b.h) + if (hit) { + if (hit.disabled) return true + const pick = zoneMenu.onPick + const id = hit.id + if (id === 'cancel') { + closeZoneMenu() + return true + } + // 先关再回调,避免回调里再 open 时被清掉 + closeZoneMenu() + pick(id) + return true + } + const p = zoneMenu.panel + if (p && sx >= p.x && sx <= p.x + p.w && sy >= p.y && sy <= p.y + p.h) return true + closeZoneMenu() + return true +} + export { FENCE_STEP } diff --git a/src/games/starve/paint.js b/src/games/starve/paint.js index 1a02fd6..f7ad9c4 100644 --- a/src/games/starve/paint.js +++ b/src/games/starve/paint.js @@ -682,6 +682,19 @@ export function drawEnt(ctx, ent, env) { } function drawWoodchest(ctx, ent) { + const n = (ent.mx?.slots || []).reduce((a, s) => a + (s?.n || 0), 0) + const label = n > 0 ? `木箱 · ${n}` : '木箱' + ctx.fillStyle = 'rgba(20,16,10,0.78)' + const tw = Math.max(40, label.length * 9 + 14) + ctx.beginPath() + if (ctx.roundRect) ctx.roundRect(-tw / 2, -42, tw, 16, 4) + else ctx.rect(-tw / 2, -42, tw, 16) + ctx.fill() + ctx.fillStyle = '#f4ead2' + ctx.font = 'bold 11px "PingFang SC","Microsoft YaHei",sans-serif' + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.fillText(label, 0, -34) shadow(ctx, 0, 6, 18, 5) const open = !!ent.mx?.open ctx.fillStyle = '#7a5230' diff --git a/src/games/starve/world.js b/src/games/starve/world.js index c934472..c205835 100644 --- a/src/games/starve/world.js +++ b/src/games/starve/world.js @@ -59,6 +59,21 @@ export function findLandNear(tiles, x, y, rMin, rMax, tries = 24) { return { x, y } } +/** 建造落点:优先精确点击处,否则螺旋找最近陆地(稳定、不抖) */ +export function snapToLand(tiles, x, y, maxR = 56) { + if (isLand(tiles, x, y)) return { x, y } + for (let r = 4; r <= maxR; r += 4) { + const n = Math.max(8, Math.round((Math.PI * 2 * r) / 6)) + for (let i = 0; i < n; i++) { + const a = (i / n) * Math.PI * 2 + const nx = x + Math.cos(a) * r + const ny = y + Math.sin(a) * r + if (isLand(tiles, nx, ny)) return { x: nx, y: ny } + } + } + return null +} + // ===================================================================== // 岛屿 + 群系 + 道路生成 // =====================================================================