更新饥荒

This commit is contained in:
2026-09-24 10:24:51 +08:00
parent bc73abbd64
commit e0a6cc293c
16 changed files with 4924 additions and 26 deletions

202
_campsmoke.mjs Normal file
View File

@@ -0,0 +1,202 @@
// 临时冒烟:验证 camp.js / wideworld.js 运行时行为(不加入正式测试)
import camp from './src/games/starve/mods/camp.js'
import wide from './src/games/starve/mods/wideworld.js'
let fails = 0
const ok = (c, m) => { if (c) console.log(' PASS', m); else { fails++; console.error(' FAIL', m) } }
const makeCtx = () => new Proxy({}, { get: (t, k) => (k in t ? t[k] : () => {}), set: (t, k, v) => { t[k] = v; return true } })
const items = {
log: { name: '木料', stack: 20 },
rock: { name: '石头', stack: 20 },
spear: { name: '长矛', stack: 1, equip: 'hand' },
}
function makeG(opts = {}) {
const world = { entities: opts.entities || [], monsters: opts.monsters || [], fires: [] }
const p = opts.player || { x: 1200, y: 1000, slots: [], pack: null, hp: 100, san: 100, dir: 1, dead: false }
const count = (code) => (p.slots || []).reduce((n, s) => n + (s && s.code === code ? s.n : 0), 0)
+ (p.pack || []).reduce((n, s) => n + (s && s.code === code ? s.n : 0), 0)
const g = {
get players() { return [p] },
get monsters() { return world.monsters },
get entities() { return world.entities },
get fires() { return world.fires },
mon: {
guard: { hp: 9999, speed: 150, dmg: 60, score: 0, friendly: true, name: '守卫' },
hound: { hp: 150, speed: 195, dmg: 20, score: 20, name: '猎犬', hostile: true },
},
items, tune: { stackMax: 20 },
worldW: 2400, worldH: 2000, day: 1, season: 'autumn', phase: 'day', time: 5,
isLand: () => true,
litAt: () => true,
findLand: (x, y) => ({ x, y }),
countItem: (pp, c) => count(c),
hasCost: (pp, cost) => Object.entries(cost).every(([c, n]) => count(c) >= n),
payCost: (pp, cost) => {
if (!Object.entries(cost).every(([c, n]) => count(c) >= n)) return false
Object.entries(cost).forEach(([c, need]) => {
let n = need
;[p.slots, p.pack].filter(Boolean).forEach((area) => {
for (let i = 0; i < area.length && n > 0; i++) {
const s = area[i]
if (s && s.code === c) { const t = Math.min(n, s.n); s.n -= t; n -= t; if (s.n <= 0) area[i] = null }
}
})
})
return true
},
addItem: (pp, c, n) => {
for (let i = 0; i < p.slots.length; i++) if (!p.slots[i]) { p.slots[i] = { code: c, n }; return }
p.slots.push({ code: c, n })
},
consumeAt: () => {},
me: () => p,
isHost: () => true,
rand: (a, b) => a,
addEntity: (type, x, y, extra) => { const e = { id: world.entities.length + 1, type, x, y, ...(extra || {}) }; world.entities.push(e); return e },
removeEntity: () => {},
markDirty: () => {},
spawnMob: (kind, x, y) => { const m = { id: world.monsters.length + 1, kind, x, y, hp: 100, cd: 0 }; world.monsters.push(m); return m },
removeMob: () => {},
killMob: () => {},
damageMob: (m, d) => { m.hp -= d },
moveToward: (m, x, y, sp, dt) => { const dd = Math.hypot(x - m.x, y - m.y) || 1; m.x += (x - m.x) / dd * sp * dt; m.y += (y - m.y) / dd * sp * dt },
smashStructure: () => {},
addFire: (x, y, o) => { const f = { fid: world.fires.length + 1, x, y, ...(o || {}) }; world.fires.push(f); return f },
addScore: () => {}, ptip: () => {}, swing: () => {}, tip: () => {}, shake: () => {}, invalidate: () => {},
...(opts.extra || {}),
}
g._player = p
g._world = world
return g
}
console.log('== wideworld ==')
ok(wide.id === 'wideworld' && wide.flags.worldScale === 1.5, `worldScale=${wide.flags && wide.flags.worldScale}`)
ok(wide.factor === 0.9 && wide.tag === 'rule', 'factor/tag 正确')
console.log('== camp 结构 ==')
ok(camp.recipes.every((r) => camp.icons[r.code]), '每个配方都有图标')
ok(camp.recipes.filter((r) => r.build).every((r) => camp.ents[r.code]), '每个 build 配方都有同名实体')
ok(camp.tabs[0].code === 'camp' && !!camp.icons[camp.tabs[0].icon], '页签图标存在')
const uniq = (arr) => new Set(arr).size === arr.length
ok(
uniq(camp.recipes.map((r) => r.code)) && uniq(Object.keys(camp.ents)) && uniq(Object.keys(camp.mobs)) && uniq(Object.keys(camp.icons)),
'各自命名空间内 code 唯一'
)
console.log('== onWorldGen ==')
{
const g = makeG()
camp.hooks.onWorldGen(g)
const house = g._world.entities.find((e) => e.type === 'camphouse')
const guard = g._world.monsters.find((m) => m.kind === 'guard')
ok(!!house && !!guard && g._world.fires.some((f) => f.eternal), '房屋/守卫/长明篝火就位')
const pd = Math.hypot(house.x - g._player.x, house.y - g._player.y)
ok(pd > 30, `房屋与出生点错开 (d=${pd.toFixed(0)})`)
}
console.log('== 房屋休息 ==')
{
const g = makeG()
const house = { id: 1, type: 'camphouse', mx: { restDay: 0 } }
camp.ents.camphouse.init(house)
const p = { san: 100, hp: 100 }
camp.ents.camphouse.interact(p, house, g)
ok(p.san === 130 && p.hp === 120 && house.mx.restDay === g.day, `休息 san+30 hp+20 (${p.san}/${p.hp})`)
camp.ents.camphouse.interact(p, house, g)
ok(p.san === 130 && p.hp === 120, '同日重复休息不再回复')
}
console.log('== 营地仓储 ==')
{
const p = { x: 1300, y: 1000, slots: [{ code: 'log', n: 5 }, { code: 'spear', n: 1 }, null], pack: null }
const g = makeG({ player: p })
const store = g.addEntity('campstore', 1300, 1000)
camp.ents.campstore.init(store)
camp.ents.campstore.interact(p, store, g)
const ctx = makeCtx()
const env = { camX: 0, camY: 0, W: 900, H: 620, time: 1 }
camp.hooks.onDrawUI(g, ctx, env)
// 点击某行“存入”按钮(复刻面板坐标:dep.x=572, sy≈198.5+30i)
let deposited = false
for (let i = 0; i < 8 && !deposited; i++) {
// 行内按钮 x 位置:右缘附近的“存入”钮
const sx = 900 / 2 + 470 / 2 - 10 - 4 - 60 - 6 - 33 // 近似“存入”按钮中心
const sy = Math.round((620 - (40 + 8 * 30 + 42 + 10)) / 2) + 40 + i * 30 + 14
const handled = camp.hooks.onClick(g, sx, sy)
if (handled === true) {
const box0 = store.mx.boxes[i]
if (Object.keys(box0.items).length) deposited = true
}
}
const anyBox = store.mx.boxes.find((b) => Object.keys(b.items).length)
ok(!!anyBox && anyBox.items.log === 5, `存入 log×5 (${JSON.stringify(store.mx.boxes.map((b) => b.items))})`)
ok(p.slots.find((s) => s && s.code === 'spear'), '工具(长矛)不被存入')
// 取出全部
const p2 = { x: 1300, y: 1000, slots: [], pack: null }
const g2 = makeG({ player: p2 })
g2._world.entities.push(store)
camp.hooks.onDrawUI(g2, ctx, env)
// 找出有内容的箱子行按钮
const idx = store.mx.boxes.findIndex((b) => Object.keys(b.items).length)
const sx = 900 / 2 + 470 / 2 - 10 - 4 - 30 // “取出”按钮中心
const sy = Math.round((620 - (40 + 8 * 30 + 42 + 10)) / 2) + 40 + idx * 30 + 14
const h = camp.hooks.onClick(g2, sx, sy)
ok(h === true && (p2.slots.some((s) => s && s.code === 'log')), '取出物资到背包')
// 面板外点击 → 关闭面板并吃掉
const outside = camp.hooks.onClick(g2, -9999, -9999)
ok(outside === true, '面板外点击返回 true(关闭)')
const noPanel = camp.hooks.onClick(g2, -9999, -9999)
ok(noPanel === undefined, '无面板时返回 undefined(不干扰)')
}
console.log('== 领地:敌对生物被推离 ==')
{
const g = makeG()
camp.hooks.onWorldGen(g)
const house = g._world.entities.find((e) => e.type === 'camphouse')
const hound = g.spawnMob('hound', house.x + 40, house.y + 20) // 营地内
camp.hooks.onTick(g)
const d = Math.hypot(hound.x - house.x, hound.y - house.y)
ok(d >= 420, `营地内猎犬被推到边界外 (d=${d.toFixed(0)})`)
}
console.log('== 守卫拦截 ==')
{
const g = makeG()
camp.hooks.onWorldGen(g)
const house = g._world.entities.find((e) => e.type === 'camphouse')
const guard = g._world.monsters.find((m) => m.kind === 'guard')
const hound = g.spawnMob('hound', house.x + 200, house.y + 60)
const before = Math.hypot(hound.x - guard.x, hound.y - guard.y)
for (let i = 0; i < 40; i++) { hound.x = house.x + 200; camp.mobs.guard.update(guard, 0.05, g) }
const after = Math.hypot(hound.x - guard.x, hound.y - guard.y)
ok(guard.hp === 1e9, '守卫每帧回满血(无限血)')
ok(after < before, `守卫向敌人靠拢 (${before.toFixed(0)}→${after.toFixed(0)})`)
}
console.log('== 绘制不抛异常 ==')
{
const g = makeG()
camp.hooks.onWorldGen(g)
const house = g._world.entities.find((e) => e.type === 'camphouse')
const store = g.addEntity('campstore', house.x + 40, house.y)
const ctx = makeCtx()
let err = null
try {
camp.ents.camphouse.draw(ctx, house, { time: 3 })
camp.ents.campstore.draw(ctx, store, { time: 3 })
camp.mobs.guard.draw(ctx, { dir: 1, moving: true, bob: 0 }, 3)
camp.hooks.onDraw(g, ctx, { camX: 100, camY: 60, W: 800, H: 600, time: 3 })
camp.ents.campstore.interact(g._player, store, g)
camp.hooks.onDrawUI(g, ctx, { camX: 100, camY: 60, W: 800, H: 600, time: 3 })
camp.icons.camphouse(ctx)
camp.icons.campstore(ctx)
} catch (e) { err = e.message }
ok(err === null, `绘制/UI 无异常 (${err || 'ok'})`)
}
console.log(`\n${fails} 项失败`)
process.exit(fails ? 1 : 0)

77
_campveto.mjs Normal file
View File

@@ -0,0 +1,77 @@
// 临时:验证 onMobVictim 否决钩子语义(跑完删除)
import camp from './src/games/starve/mods/camp.js'
let fails = 0
const ok = (c, m) => { if (c) console.log(' PASS', m); else { fails++; console.error(' FAIL', m) } }
const HOOK = camp.hooks.onMobVictim
function makeG() {
const g = {
mon: { hound: { hostile: true }, icehound: { hostile: true }, pig: { friendly: true } },
players: [],
monsters: [],
entities: [{ type: 'camphouse', x: 0, y: 0 }],
worldW: 0,
worldH: 0,
}
// 模拟核心 g.allows:任一 mod 返回 false 即 false
g.allows = (name, mm, vv) => HOOK(g, mm, vv) !== false
return g
}
console.log('== onMobVictim 否决语义 ==')
const g = makeG()
const hound = { kind: 'hound', x: 800, y: 800 }
const pig = { kind: 'pig', x: 800, y: 800 }
const inCamp = { x: 100, y: 100 } // 距房(0,0) ~141 < 420
const outside = { x: 800, y: 800 } // 距房 ~1131 > 420
ok(HOOK(g, hound, inCamp) === false, '营地内玩家 → 返回 false(怪不选他)')
ok(HOOK(g, hound, outside) !== false, '营地外玩家 → 不返回 false')
ok(HOOK(g, pig, inCamp) !== false, '非敌对生物 → 不返回 false(保留打守卫/猪人)')
ok(g.allows('onMobVictim', hound, inCamp) === false, 'g.allows 透传 false 正确')
ok(g.allows('onMobVictim', hound, outside) === true, 'g.allows 透传 true 正确')
console.log('== 端点边界 ==')
ok(HOOK(g, hound, { x: 419, y: 0 }) === false, '半径内 419 → false')
ok(HOOK(g, hound, { x: 421, y: 0 }) !== false, '半径外 421 → 允许')
console.log('== 无房屋时退回世界中心 ==')
const g2 = makeG()
g2.entities = []
g2.worldW = 1000
g2.worldH = 1000
ok(HOOK(g2, hound, { x: 600, y: 500 }) === false, '无房屋→以世界中心(500,500)为圆心,100 距离内 → false')
ok(HOOK(g2, hound, { x: 1200, y: 500 }) !== false, '无房屋→距世界中心 700 → 允许')
ok(HOOK(g2, hound, { x: 500, y: 500 }) === false, '世界中心本身 → false')
console.log('== 模拟核心 nearestVictim:营地内玩家被排除 ==')
{
// 复刻 mobs.js nearestVictim 的玩家循环(含 g.allows 否决)
const pick = (gg, m) => {
let best = null
let bd = Infinity
gg.players.forEach((p) => {
if (p.dead) return
if (gg.allows && gg.allows('onMobVictim', m, p) === false) return
const d = Math.hypot(p.x - m.x, p.y - m.y)
if (d < bd) { bd = d; best = p }
})
return best
}
const gg = makeG()
const m = { kind: 'hound', x: 300, y: 300 }
const pIn = { dead: false, x: 100, y: 100 } // 营地内,离怪更近 (~283)
const pOut = { dead: false, x: 900, y: 900 } // 营地外,离怪更远 (~849)
gg.players = [pIn, pOut]
ok(pick(gg, m) === pOut, '营地内玩家被否决,目标落到营地外玩家')
// 若营地内玩家是唯一玩家 → 无目标(怪待命)
const gg2 = makeG()
gg2.players = [pIn]
ok(pick(gg2, { kind: 'hound', x: 300, y: 300 }) === null, '仅营地内玩家 → 无目标(怪待命,符合 updHound if(!near)return)')
}
console.log(`\n${fails} 项失败`)
process.exit(fails ? 1 : 0)

27
_qa_debug.mjs Normal file
View File

@@ -0,0 +1,27 @@
import { activateMods } from './src/games/starve/mods/registry.js'
const laborRt2 = activateMods(['labor'])
console.log('berry item =', JSON.stringify(laborRt2.items.berry))
console.log('wheat item =', !!laborRt2.items.wheat)
const office = { type: 'laboroffice', id: 5 }
laborRt2.entKinds.laboroffice.init(office); office.mx.farmer = 1
const wh = { type: 'laborwarehouse', x: 0, y: 0, id: 6 }
laborRt2.entKinds.laborwarehouse.init(wh)
wh.mx.boxes[0].items.berry = 10
console.log('boxes[0] =', JSON.stringify(wh.mx.boxes[0].items))
const players = [{ x: 0, y: 0, dead: false, seat: 0, slots: [], pack: null }]
const g = {
entities: [office, wh],
monsters: [{ kind: 'laborfarmer', x: 0, y: 0, id: 901, office: 5 }],
players,
mon: laborRt2.mon,
items: laborRt2.items,
me: () => players[0],
markDirty() {}, tip() {}, ptip() {},
}
const hooks = (laborRt2.hooks.onNewDay || [])
console.log('onNewDay hooks =', hooks.map((h) => h.id))
hooks.forEach((h) => h.fn(g))
console.log('after onNewDay strike =', g.monsters[0].strike, 'berry =', wh.mx.boxes.reduce((n, b) => n + (b.items.berry || 0), 0))

View File

@@ -6,7 +6,7 @@
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { createLoop, Keys, randInt } from '../engine'
import { skinByCode, drawPlayerFig } from '../starveSkins'
import { CAP, DAY_LEN, CRAFT_TABS, cookPot, DECOR } from '../starve/data'
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,
@@ -27,6 +27,7 @@ let RES = modRt.res
let NAMES = modRt.names
let ITEM_CODES = modRt.itemCodes
let ITEM_IDX = modRt.itemIdx
let CRAFT_TABS = modRt.tabs
function stackMax(code) {
return ITEMS[code]?.stack || TUNE.stackMax
}
@@ -84,6 +85,8 @@ const ICONS = buildIcons() // {url, cv}
// 切换 mod 运行时:重定向遮蔽表 + 补齐 mod 物品图标
function applyModRuntime(rt) {
modRt = rt
// 应用 mod 声明的世界缩放:必须在任何瓦片数组分配(genWorldAll / 客机 tiles 分配)之前
configureWorld(rt.flags?.worldScale || 1)
TUNE = rt.tune
ITEMS = rt.items
RECIPES = rt.recipes
@@ -93,6 +96,11 @@ function applyModRuntime(rt) {
NAMES = rt.names
ITEM_CODES = rt.itemCodes
ITEM_IDX = rt.itemIdx
CRAFT_TABS = rt.tabs
// 页签兜底:当前所选页签若被卸载的 mod 移除,则回退到第一个页签
if (!CRAFT_TABS.some((t) => t.code === craftTab.value)) {
craftTab.value = CRAFT_TABS[0]?.code || 'tools'
}
rt.itemCodes.forEach((code) => {
if (ICONS.cv[code]) return
const cv = document.createElement('canvas')
@@ -660,6 +668,8 @@ function onCanvasClick(ev) {
canvas.value?.blur?.()
if (!playing.value || !player || player.dead || waitingWorld.value) return
const { x: wxp, y: wyp } = eventToWorld(ev)
// mod 点击钩子:任一 mod 处理并返回 true 则吃掉本次点击(不选中怪物/火/实体,也不点地移动)
if (callHandled(modRt, 'onClick', gCtx, wxp, wyp)) return
let mon = null
let md = Infinity
monsters.forEach((m) => {
@@ -1327,7 +1337,8 @@ const gCtx = {
get mon() { return MON },
get items() { return ITEMS },
get tune() { return TUNE },
worldW: WORLD.w, worldH: WORLD.h,
get worldW() { return WORLD.w },
get worldH() { return WORLD.h },
isLand: (x, y) => !tiles || isLand(tiles, x, y),
litAt: (x, y) => litAt(x, y),
damagePlayer: (p, dmg, label) => damagePlayer(p, dmg, label),
@@ -1365,6 +1376,15 @@ const gCtx = {
},
swing: (p, act) => swingAct(p, act),
heatAt: (p) => heatAt(p),
// 新增火焰(供 mod 使用):eternal 火焰永久燃烧、免疫消耗与雨天加速
addFire: (x, y, o = {}) => {
const f = { fid: fid++, x, y, ttl: o.ttl ?? 50, max: o.max ?? 90, pit: !!o.pit, eternal: !!o.eternal }
if (f.eternal) f.ttl = f.max
fires.push(f)
return f
},
// 否决型钩子调用器(供 mod 使用):任一 mod 返回 false 即返回 false
allows: (name, ...args) => callAllows(modRt, name, ...args),
me: () => player,
isHost: () => isHostRole(),
invalidate: () => { invDirty = true },
@@ -1382,8 +1402,12 @@ function updateDayNight(dt) {
if (tickWeather(wx, dt)) tip('雨停了')
// 火焰燃烧(雨天更快)
const burn = dt * (wx.type === 'rain' ? 1.6 : 1)
fires.forEach((f) => { if (f.ttl > 0) f.ttl -= burn })
fires.forEach((f) => {
if (f.eternal) { f.ttl = f.max; return } // 永久火焰:免疫消耗与雨天加速
if (f.ttl > 0) f.ttl -= burn
})
fires = fires.filter((f) => {
if (f.eternal) return true // 永久火焰永不熄灭
if (f.pit) { f.ttl = Math.max(0, f.ttl); return true }
if (f.ttl <= 0) {
dropLoot(f.x, f.y, 'ash', 1)
@@ -1981,7 +2005,10 @@ function clientTick(dt) {
}
})
entities.forEach((e) => { if (e.shake > 0) e.shake -= dt })
fires.forEach((f) => { if (f.ttl > 0) f.ttl -= dt })
fires.forEach((f) => {
if (f.eternal) { f.ttl = f.max; return } // 客机本地:永久火焰同样不衰减
if (f.ttl > 0) f.ttl -= dt
})
charlieFx(dt)
updateExplore(dt)
updateCamera()
@@ -2077,7 +2104,7 @@ function sendSnapshot() {
wx: [wx.type, Math.round(wx.t)],
players: players.map(serializePlayerRow),
mons: monsters.map((m) => [m.id, m.kind, Math.round(m.x), Math.round(m.y), m.dir, m.state || '', m.shake > 0 ? 1 : 0, Math.round((m.hp / MON[m.kind].hp) * 100), m.loyalT > 0 ? 1 : 0, m.moving ? 1 : 0]),
fires: fires.map((f) => [f.fid, Math.round(f.x), Math.round(f.y), Math.round(f.ttl), f.max, f.pit ? 1 : 0]),
fires: fires.map((f) => [f.fid, Math.round(f.x), Math.round(f.y), Math.round(f.ttl), f.max, f.pit ? 1 : 0, f.eternal ? 1 : 0]),
}
if (entsAdded.size) {
snap.ea = entities.filter((e) => entsAdded.has(e.id)).map(serializeEntFull)
@@ -2108,7 +2135,7 @@ function sendFullWorld() {
wx: [wx.type, Math.round(wx.t)],
players: players.map(serializePlayerRow),
mons: monsters.map((m) => [m.id, m.kind, Math.round(m.x), Math.round(m.y), m.dir, m.state || '', 0, 100, 0, 0]),
fires: fires.map((f) => [f.fid, Math.round(f.x), Math.round(f.y), Math.round(f.ttl), f.max, f.pit ? 1 : 0]),
fires: fires.map((f) => [f.fid, Math.round(f.x), Math.round(f.y), Math.round(f.ttl), f.max, f.pit ? 1 : 0, f.eternal ? 1 : 0]),
})
}
function applySnapshot(snap) {
@@ -2190,7 +2217,7 @@ function applySnapshot(snap) {
})
monsters = monsters.filter((m) => seen.has(m.id))
// 火焰
fires = snap.fires.map(([fid2, x, y, ttl, max, pit]) => ({ fid: fid2, x, y, ttl, max, pit: !!pit }))
fires = snap.fires.map(([fid2, x, y, ttl, max, pit, eternal]) => ({ fid: fid2, x, y, ttl, max, pit: !!pit, eternal: !!eternal }))
// 实体增量
snap.ea?.forEach((row) => {
if (!entities.some((e) => e.id === row[0])) entities.push(restoreEntFull(row))
@@ -2307,7 +2334,7 @@ function serialize() {
road: Array.from(road).join(''),
explored: Array.from(explored).join(''),
ents: entities.map(serializeEntFull),
fires: fires.map((f) => [f.fid, Math.round(f.x), Math.round(f.y), Math.round(f.ttl), f.max, f.pit ? 1 : 0]),
fires: fires.map((f) => [f.fid, Math.round(f.x), Math.round(f.y), Math.round(f.ttl), f.max, f.pit ? 1 : 0, f.eternal ? 1 : 0]),
}
}
function restore(v) {
@@ -2316,15 +2343,25 @@ function restore(v) {
seasonNow = seasonOf(day.value)
player.score = v.score || 0
if (v.v >= 4) {
tiles = Uint8Array.from(v.tiles.split('').map(Number))
road = Uint8Array.from(v.road.split('').map(Number))
explored = v.explored ? Uint8Array.from(v.explored.split('').map(Number)) : new Uint8Array(GW * GH)
decoSpots = genDecor(tiles)
entities = (v.ents || []).map(restoreEntFull)
fires = (v.fires || []).map(([fid2, x, y, ttl, max, pit]) => ({ fid: fid2, x, y, ttl, max: max || 90, pit: !!pit }))
fid = fires.reduce((n, f) => Math.max(n, f.fid + 1), 1)
player.x = v.px
player.y = v.py
// 尺寸护栏:存档瓦片长度与当前 GW*GH 不符(worldScale 变化)时,丢弃世界数据并重新生成地形(保留玩家进度)
const savedTiles = v.tiles ? Uint8Array.from(v.tiles.split('').map(Number)) : new Uint8Array(0)
if (savedTiles.length !== GW * GH) {
genWorldAll()
fid = 1
player.x = WORLD.w / 2
player.y = WORLD.h / 2 + 40
tip('世界尺寸已变化,已重新生成地形')
} else {
tiles = savedTiles
road = Uint8Array.from(v.road.split('').map(Number))
explored = v.explored ? Uint8Array.from(v.explored.split('').map(Number)) : new Uint8Array(GW * GH)
decoSpots = genDecor(tiles)
entities = (v.ents || []).map(restoreEntFull)
fires = (v.fires || []).map(([fid2, x, y, ttl, max, pit, eternal]) => ({ fid: fid2, x, y, ttl, max: max || 90, pit: !!pit, eternal: !!eternal }))
fid = fires.reduce((n, f) => Math.max(n, f.fid + 1), 1)
player.x = v.px
player.y = v.py
}
player.hp = v.hp
player.hunger = v.hunger
player.san = v.san
@@ -2500,6 +2537,9 @@ function draw() {
}
}
ctx.restore()
// mod UI 钩子:全部全屏覆盖层(天气/夜晚/查理红闪/理智暗角/寒气)绘制完毕后调用,
// 此时 ctx 已恢复为屏幕坐标系(无相机平移/缩放),mod 面板不会被压暗
callHook(modRt, 'onDrawUI', gCtx, ctx, { camX, camY, W, H, time })
drawClockV2()
if (showFullMap.value) drawFullMap()
}

View File

@@ -32,6 +32,8 @@ function nearestVictim(m, g, playersOnly = false) {
let bd = Infinity
g.players.forEach((p) => {
if (p.dead) return
// mod 否决:任一 mod 的 onMobVictim 返回 false 时,这只怪不把该玩家视为可选目标
if (g.allows && g.allows('onMobVictim', m, p) === false) return
const d = Math.hypot(p.x - m.x, p.y - m.y)
if (d < bd) { bd = d; best = { kind: 'p', ref: p } }
})

View File

@@ -0,0 +1,589 @@
// 农业文明:犁锄开垦良田 + 水井灌溉加速 + 粮仓储粮 + 磨坊磨面,把荒野变粮仓
// - 犁锄(1 级科技,耐久 40):点击在面前开垦一块良田
// - 良田:200 秒三阶段生长(比 farm.js 快),冬季暂停;140px 内有水井则生长 ×1.6
// - 水井:打水回理智(60 秒冷却),同时是良田的灌溉水源
// - 粮仓:存入/取出 wheat/pumpkin/berry/carrot 类作物
// - 磨坊:wheat×3 → flour×2
// - 采草 12% 掉小麦种子、采浆果丛 12% 掉南瓜种子
const TAU = Math.PI * 2
const GROW_TOTAL = 200 // 生长总时长(秒):比 farm.js 的 320 秒更快
// 种子 → 成熟作物 code
const SEED_CROP = { wheatseeds: 'wheat', pumpkinseeds: 'pumpkin', berryseeds: 'berry' }
// 粮仓可收纳的作物
const CROPS = ['wheat', 'pumpkin', 'berry', 'carrot']
function stageOf(t) {
if (t >= GROW_TOTAL) return 'ready'
if (t >= 133) return 'grow2'
if (t >= 66) return 'grow1'
return 'grow0'
}
function plant(p, plot, seedCode, g) {
plot.mx = { seed: SEED_CROP[seedCode], t: 0 }
plot.state = 'grow0'
g.markDirty(plot)
g.swing(p, 'dig')
g.ptip(p, `种下了${g.items[SEED_CROP[seedCode]]?.name || '作物'}种子`)
}
export default {
id: 'agri',
name: '农业文明',
desc: '良田、水井与粮仓:把荒野变成粮仓',
factor: 1.05,
tag: 'content',
tabs: [{ code: 'agri', name: '农业', icon: 'agrihoe' }],
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 },
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 },
fertilizer: { name: '肥料', stack: 20 },
},
recipes: [
{ code: 'agrihoe', name: '犁锄', tab: 'agri', tech: 1, cost: { twig: 2, flint: 2, rope: 1 }, desc: '开垦良田 · 耐久 40 次' },
{ code: 'well', name: '水井', tab: 'agri', tech: 1, cost: { rock: 12, rope: 2 }, build: true, desc: '灌溉周边良田,生长加速' },
{ code: 'granary', name: '粮仓', tab: 'agri', tech: 1, cost: { log: 8, rope: 2 }, build: true, desc: '存储作物,防潮保鲜' },
{ code: 'mill', name: '磨坊', tab: 'agri', tech: 2, cost: { log: 4, rock: 6, gold: 2 }, build: true, desc: '小麦磨面粉' },
{ code: 'fertilizer', name: '肥料', tab: 'agri', tech: 1, cost: { rot: 2, ash: 1 }, desc: '施于良田,生长 +50%' },
],
icons: {
agrihoe: (c) => {
c.strokeStyle = '#7a5b33'
c.lineWidth = 3
c.beginPath(); c.moveTo(-9, 12); c.lineTo(5, -9); c.stroke()
c.fillStyle = '#8d9097'
c.strokeStyle = '#1d1409'
c.lineWidth = 1.6
c.beginPath(); c.moveTo(2, -6); c.lineTo(13, -3); c.lineTo(13, -10); c.lineTo(4, -12); c.closePath()
c.fill(); c.stroke()
c.strokeStyle = '#6f7278'
c.lineWidth = 1.2
c.beginPath(); c.moveTo(6, -11); c.lineTo(9, -4); c.stroke()
},
wheatseeds: (c) => {
c.fillStyle = '#d8b06a'
c.strokeStyle = '#1d1409'
c.lineWidth = 1.4
;[[-5, 2], [3, -3], [6, 6], [-3, 8], [0, -7]].forEach(([x, y]) => {
c.beginPath(); c.ellipse(x, y, 3, 4.2, 0.4, 0, TAU); c.fill(); c.stroke()
})
c.fillStyle = '#e6c34a'
c.beginPath(); c.ellipse(0, -1, 2, 2.6, 0, 0, TAU); c.fill()
},
wheat: (c) => {
c.strokeStyle = '#5d8a45'
c.lineWidth = 2.4
c.beginPath(); c.moveTo(0, 13); c.lineTo(0, -6); c.stroke()
c.fillStyle = '#e6c34a'
c.strokeStyle = '#1d1409'
c.lineWidth = 1.2
;[-7, -2, 3, 8].forEach((y) => {
;[-1, 1].forEach((sgn) => {
c.beginPath(); c.ellipse(sgn * 4, y, 2.4, 4, sgn * 0.5, 0, TAU); c.fill(); c.stroke()
})
})
c.beginPath(); c.ellipse(0, -11, 2.4, 4.5, 0, 0, TAU); c.fill(); c.stroke()
},
flour: (c) => {
c.fillStyle = '#e8e2d0'
c.strokeStyle = '#1d1409'
c.lineWidth = 1.8
c.beginPath()
c.moveTo(-9, 12); c.lineTo(-7, -3); c.quadraticCurveTo(0, -12, 7, -3); c.lineTo(9, 12)
c.closePath()
c.fill(); c.stroke()
c.strokeStyle = '#b0a888'
c.lineWidth = 1.4
c.beginPath(); c.moveTo(-6, 2); c.lineTo(6, 2); c.stroke()
c.fillStyle = '#f5f1e6'
c.beginPath(); c.arc(0, -5, 2.4, 0, TAU); c.fill()
},
bread: (c) => {
c.fillStyle = '#c98a44'
c.strokeStyle = '#1d1409'
c.lineWidth = 1.8
c.beginPath(); c.roundRect(-12, -4, 24, 14, 5); c.fill(); c.stroke()
c.strokeStyle = '#8a5a28'
c.lineWidth = 1.6
;[-6, 0, 6].forEach((x) => {
c.beginPath(); c.moveTo(x, -1); c.lineTo(x - 2, 6); c.stroke()
})
c.fillStyle = '#e0a85a'
c.beginPath(); c.ellipse(-4, -2, 5, 2.2, 0, 0, TAU); c.fill()
},
pumpkin: (c) => {
c.fillStyle = '#e08a3c'
c.strokeStyle = '#1d1409'
c.lineWidth = 1.8
c.beginPath(); c.ellipse(0, 2, 12, 10, 0, 0, TAU); c.fill(); c.stroke()
c.strokeStyle = '#b86a28'
c.lineWidth = 1.4
;[-5, 0, 5].forEach((x) => {
c.beginPath(); c.moveTo(x, -7); c.quadraticCurveTo(x * 1.5, 2, x, 11); c.stroke()
})
c.strokeStyle = '#5d8a45'
c.lineWidth = 2.6
c.beginPath(); c.moveTo(0, -8); c.lineTo(0, -13); c.stroke()
},
pumpkinseeds: (c) => {
c.fillStyle = '#e6d7a8'
c.strokeStyle = '#1d1409'
c.lineWidth = 1.4
;[[-5, 2], [3, -3], [6, 6], [-3, 8], [0, -7]].forEach(([x, y]) => {
c.beginPath(); c.ellipse(x, y, 3, 4.2, 0.4, 0, TAU); c.fill(); c.stroke()
})
c.fillStyle = '#e08a3c'
c.beginPath(); c.ellipse(0, -1, 2, 2.6, 0, 0, TAU); c.fill()
},
fertilizer: (c) => {
c.fillStyle = '#6b4a2e'
c.strokeStyle = '#1d1409'
c.lineWidth = 1.8
c.beginPath()
c.moveTo(-11, 10); c.quadraticCurveTo(-8, -6, 0, -8); c.quadraticCurveTo(8, -6, 11, 10)
c.closePath()
c.fill(); c.stroke()
c.fillStyle = '#8f9a3e'
c.strokeStyle = '#1d1409'
c.lineWidth = 1
;[[-4, 1], [3, 4], [0, -3], [5, -2]].forEach(([x, y]) => {
c.beginPath(); c.ellipse(x, y, 2, 1.4, 0.3, 0, TAU); c.fill(); c.stroke()
})
},
well: (c) => {
c.fillStyle = '#7a5230'
c.strokeStyle = '#1d1409'
c.lineWidth = 1.6
;[-9, 9].forEach((x) => {
c.beginPath(); c.roundRect(x - 2, -10, 4, 18, 1); c.fill(); c.stroke()
})
c.beginPath(); c.roundRect(-11, -13, 22, 4, 1.5); c.fill(); c.stroke()
c.fillStyle = '#8d9097'
c.beginPath(); c.ellipse(0, 8, 12, 5, 0, 0, TAU); c.fill(); c.stroke()
c.fillStyle = '#12100c'
c.beginPath(); c.ellipse(0, 7, 6.5, 2.6, 0, 0, TAU); c.fill()
c.strokeStyle = '#c9a36b'
c.lineWidth = 1.2
c.beginPath(); c.moveTo(0, -9); c.lineTo(0, 1); c.stroke()
},
granary: (c) => {
c.fillStyle = '#8d9097'
c.strokeStyle = '#1d1409'
c.lineWidth = 1.6
c.beginPath(); c.roundRect(-11, 6, 22, 5, 1.5); c.fill(); c.stroke()
c.fillStyle = '#a97b47'
c.beginPath(); c.roundRect(-9, -8, 18, 15, 2); c.fill(); c.stroke()
c.fillStyle = '#c8a24a'
c.beginPath(); c.moveTo(-13, -8); c.lineTo(13, -8); c.lineTo(0, -19); c.closePath()
c.fill(); c.stroke()
},
mill: (c) => {
c.fillStyle = '#8d9097'
c.strokeStyle = '#1d1409'
c.lineWidth = 1.6
c.beginPath(); c.ellipse(0, 8, 11, 6, 0, 0, TAU); c.fill(); c.stroke()
c.fillStyle = '#7a5230'
c.beginPath(); c.roundRect(-3, -8, 6, 17, 1.5); c.fill(); c.stroke()
c.save()
c.translate(0, -10)
c.rotate(-0.5)
c.fillStyle = '#c9a36b'
c.strokeStyle = '#1d1409'
c.lineWidth = 1.2
for (let k = 0; k < 4; k++) {
c.rotate(Math.PI / 2)
c.beginPath(); c.moveTo(0, 0); c.lineTo(3, -10); c.lineTo(7, -10); c.lineTo(4, 0); c.closePath()
c.fill(); c.stroke()
}
c.restore()
c.fillStyle = '#1d1409'
c.beginPath(); c.arc(0, -10, 2, 0, TAU); c.fill()
},
},
ents: {
agrifarm: {
name: '良田',
conf: { size: 26 },
init(e) {
if (!e.state) e.state = 'empty'
},
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 st = stageOf(e.mx.t)
if (st !== e.state) {
e.state = st
g.markDirty(e)
if (st === 'ready') g.tip('良田里的作物成熟了!')
}
},
interact(p, e, g) {
if (e.state === 'ready') {
const crop = e.mx?.seed || 'wheat'
const n = g.rand(3, 4)
g.addItem(p, crop, n)
g.addScore(12, p)
g.swing(p, 'chop')
g.ptip(p, `收获${g.items[crop]?.name || '作物'} ×${n}`)
e.state = 'empty'
e.mx = null
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 })
plant(p, e, seed, g)
} else {
g.ptip(p, '空良田:需要小麦/南瓜/浆果种子')
}
return
}
const pct = Math.min(99, Math.floor(((e.mx?.t || 0) / GROW_TOTAL) * 100))
g.ptip(p, `作物生长中…… ${pct}%(靠近水井更快)`)
},
draw(ctx, e) {
// 垄起的土地
ctx.fillStyle = '#6b4a2e'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 2
ctx.beginPath()
ctx.ellipse(0, 0, 24, 14, 0, 0, TAU)
ctx.fill()
ctx.stroke()
// 两条田垄线(良田更规整)
ctx.strokeStyle = '#553a24'
ctx.lineWidth = 2
;[-5, 5].forEach((y) => {
ctx.beginPath()
ctx.moveTo(-18, y)
ctx.lineTo(18, y)
ctx.stroke()
})
const st = e.state
if (st === 'empty' || !st) return
const crop = e.mx?.seed
if (st === 'grow0') {
ctx.strokeStyle = '#7fb069'
ctx.lineWidth = 2
ctx.beginPath(); ctx.moveTo(0, -2); ctx.lineTo(0, -9); ctx.stroke()
ctx.fillStyle = '#8f9a3e'
ctx.beginPath(); ctx.ellipse(2, -9, 3, 1.8, -0.4, 0, TAU); ctx.fill()
} else if (st === 'grow1') {
ctx.strokeStyle = '#6f9a52'
ctx.lineWidth = 2.4
;[-6, 0, 6].forEach((x) => {
ctx.beginPath(); ctx.moveTo(x, 2); ctx.lineTo(x * 1.3, -12); ctx.stroke()
})
} else if (st === 'grow2') {
ctx.strokeStyle = '#5d8a45'
ctx.lineWidth = 2.8
;[-8, 0, 8].forEach((x) => {
ctx.beginPath(); ctx.moveTo(x, 2); ctx.quadraticCurveTo(x * 1.4, -10, x * 1.1, -19); ctx.stroke()
})
ctx.fillStyle = crop === 'pumpkin' ? '#7a9a52' : '#8f9a3e'
ctx.beginPath(); ctx.ellipse(0, -17, 9, 5, 0, 0, TAU); ctx.fill()
} else {
// ready:结出作物
ctx.strokeStyle = '#5d8a45'
ctx.lineWidth = 2.8
;[-9, 0, 9].forEach((x) => {
ctx.beginPath(); ctx.moveTo(x, 2); ctx.quadraticCurveTo(x * 1.4, -12, x * 1.1, -21); ctx.stroke()
})
if (crop === 'pumpkin') {
ctx.fillStyle = '#e08a3c'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 1.6
ctx.beginPath(); ctx.ellipse(0, -6, 8, 6.5, 0, 0, TAU); ctx.fill(); ctx.stroke()
ctx.strokeStyle = '#5d8a45'
ctx.lineWidth = 2
ctx.beginPath(); ctx.moveTo(0, -12); ctx.lineTo(0, -6); ctx.stroke()
} else if (crop === 'berry') {
ctx.fillStyle = '#c0455a'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 1.4
;[[-8, -15], [0, -20], [8, -14]].forEach(([x, y]) => {
ctx.beginPath(); ctx.arc(x, y, 3.6, 0, TAU); ctx.fill(); ctx.stroke()
})
} else {
// 小麦:金黄色麦穗
ctx.fillStyle = '#e6c34a'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 1.4
;[-9, 0, 9].forEach((x, k) => {
const top = k === 1 ? -26 : -23
;[0, 5, 10].forEach((dy, j) => {
const ex = x + (j % 2 ? 2.4 : -2.4)
ctx.beginPath()
ctx.ellipse(ex, top + dy, 2.6, 4, j % 2 ? 0.4 : -0.4, 0, TAU)
ctx.fill(); ctx.stroke()
})
})
}
}
},
},
well: {
name: '水井',
conf: { size: 30, struct: true },
init(e) {
if (e.cd == null) e.cd = 0
},
update(e, dt, g) {
if (e.cd > 0) e.cd = Math.max(0, e.cd - dt)
},
interact(p, e, g) {
if (e.cd > 0) {
g.ptip(p, `井水还没续满……(${Math.ceil(e.cd)} 秒)`)
return
}
p.san += 5
e.cd = 60
g.markDirty(e)
g.swing(p, 'eat')
g.ptip(p, '打了一瓢清水,理智 +5')
},
draw(ctx, e, env) {
// 投影
ctx.fillStyle = 'rgba(0,0,0,0.25)'
ctx.beginPath(); ctx.ellipse(0, 4, 18, 6, 0, 0, TAU); ctx.fill()
// 井沿(石砌圆环)
ctx.fillStyle = '#8d9097'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 2
ctx.beginPath(); ctx.ellipse(0, -4, 16, 8, 0, 0, TAU); ctx.fill(); ctx.stroke()
// 井内黑洞
ctx.fillStyle = '#12100c'
ctx.beginPath(); ctx.ellipse(0, -5, 10, 4.5, 0, 0, TAU); ctx.fill()
// 石缝
ctx.strokeStyle = '#6f7278'
ctx.lineWidth = 1.4
;[-11, -4, 4, 11].forEach((x) => {
ctx.beginPath(); ctx.moveTo(x, -11); ctx.lineTo(x + 2, 2); ctx.stroke()
})
// 木架:两根立柱 + 顶梁
ctx.fillStyle = '#7a5230'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 2
;[-12, 12].forEach((x) => {
ctx.beginPath(); ctx.roundRect(x - 2, -34, 4, 26, 1.5); ctx.fill(); ctx.stroke()
})
ctx.beginPath(); ctx.roundRect(-14, -38, 28, 5, 2); ctx.fill(); ctx.stroke()
// 辘轳轴
ctx.beginPath(); ctx.arc(0, -35, 3, 0, TAU); ctx.fill(); ctx.stroke()
// 悬挂的水桶(轻微摆动)
const sway = Math.sin((env?.time || 0) * 1.6 + e.id) * 1.2
ctx.strokeStyle = '#c9a36b'
ctx.lineWidth = 1.4
ctx.beginPath(); ctx.moveTo(0, -33); ctx.lineTo(sway, -20); ctx.stroke()
ctx.fillStyle = '#7a5230'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 1.6
ctx.beginPath()
ctx.moveTo(sway - 4, -20); ctx.lineTo(sway + 4, -20); ctx.lineTo(sway + 3, -12); ctx.lineTo(sway - 3, -12)
ctx.closePath(); ctx.fill(); ctx.stroke()
// 水面
ctx.fillStyle = '#5aa7c4'
ctx.beginPath(); ctx.ellipse(sway, -19, 3.4, 1.4, 0, 0, TAU); ctx.fill()
},
},
granary: {
name: '粮仓',
conf: { size: 36, struct: true },
init(e) {
// 持久化数据只能放进 e.mx(核心 extraOf 只序列化 e.mx / e.state)
if (!e.mx) e.mx = {}
if (!e.mx.store) e.mx.store = {}
},
interact(p, e, g) {
// 容忍 e.mx 缺失(旧存档 / 直接构造的实体)
if (!e.mx) e.mx = {}
if (!e.mx.store) e.mx.store = {}
const store = e.mx.store
// 先尝试存入身上全部作物
let stored = 0
CROPS.forEach((code) => {
if (!g.items[code]) return
const n = g.countItem(p, code)
if (n > 0 && g.payCost(p, { [code]: n })) {
store[code] = (store[code] || 0) + n
stored += n
}
})
if (stored > 0) {
g.markDirty(e)
g.ptip(p, `存入粮仓 ×${stored}`)
return
}
// 身上没有作物:取出仓里全部
const total = Object.values(store).reduce((a, b) => a + b, 0)
if (total > 0) {
const got = []
Object.entries(store).forEach(([code, n]) => {
if (n > 0) {
g.addItem(p, code, n)
got.push(`${g.items[code]?.name || code} ×${n}`)
}
})
e.mx.store = {}
g.markDirty(e)
g.ptip(p, `取出:${got.join('、')}`)
return
}
g.ptip(p, '粮仓空着(点击存入身上的作物)')
},
draw(ctx, e, env) {
// 投影
ctx.fillStyle = 'rgba(0,0,0,0.25)'
ctx.beginPath(); ctx.ellipse(0, 4, 20, 7, 0, 0, TAU); ctx.fill()
// 石基座
ctx.fillStyle = '#8d9097'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 2
ctx.beginPath(); ctx.roundRect(-17, -2, 34, 9, 2); ctx.fill(); ctx.stroke()
// 木圆仓
ctx.fillStyle = '#a97b47'
ctx.beginPath(); ctx.roundRect(-14, -26, 28, 26, 3); ctx.fill(); ctx.stroke()
// 木纹
ctx.strokeStyle = '#7a5230'
ctx.lineWidth = 1.4
;[-8, -1, 6].forEach((y) => {
ctx.beginPath(); ctx.moveTo(-14, y); ctx.lineTo(14, y); ctx.stroke()
})
// 锥形茅草顶
ctx.fillStyle = '#c8a24a'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 2
ctx.beginPath(); ctx.moveTo(-20, -26); ctx.lineTo(20, -26); ctx.lineTo(0, -48); ctx.closePath()
ctx.fill(); ctx.stroke()
ctx.strokeStyle = '#a8813a'
ctx.lineWidth = 1.2
;[-12, -4, 4, 12].forEach((x) => {
ctx.beginPath(); ctx.moveTo(x, -26); ctx.lineTo(x * 0.4, -44); ctx.stroke()
})
// 谷袋
ctx.fillStyle = '#c9a36b'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 1.6
ctx.beginPath(); ctx.ellipse(12, -6, 5, 6, 0.2, 0, TAU); ctx.fill(); ctx.stroke()
ctx.strokeStyle = '#8a5a28'
ctx.lineWidth = 1.2
ctx.beginPath(); ctx.moveTo(9, -10); ctx.lineTo(15, -10); ctx.stroke()
},
},
mill: {
name: '磨坊',
conf: { size: 32, struct: true },
interact(p, e, g) {
if (!g.hasCost(p, { wheat: 3 })) {
g.ptip(p, '磨坊需要 3 个小麦才能磨面')
return
}
g.payCost(p, { wheat: 3 })
g.addItem(p, 'flour', 2)
g.ptip(p, '磨出面粉 ×2')
},
draw(ctx, e, env) {
// 投影
ctx.fillStyle = 'rgba(0,0,0,0.25)'
ctx.beginPath(); ctx.ellipse(0, 4, 18, 6, 0, 0, TAU); ctx.fill()
// 石磨盘
ctx.fillStyle = '#8d9097'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 2
ctx.beginPath(); ctx.ellipse(0, -2, 15, 9, 0, 0, TAU); ctx.fill(); ctx.stroke()
ctx.fillStyle = '#6f7278'
ctx.beginPath(); ctx.ellipse(0, -3, 6, 3.5, 0, 0, TAU); ctx.fill()
ctx.beginPath(); ctx.arc(0, -3, 1.6, 0, TAU); ctx.fill()
// 木架立柱
ctx.fillStyle = '#7a5230'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 2
ctx.beginPath(); ctx.roundRect(-4, -30, 8, 24, 2); ctx.fill(); ctx.stroke()
// 风车叶片(env.time 驱动旋转)
ctx.save()
ctx.translate(0, -32)
ctx.rotate((env?.time || 0) * 1.5 + e.id)
ctx.fillStyle = '#c9a36b'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 1.6
for (let k = 0; k < 4; k++) {
ctx.rotate(Math.PI / 2)
ctx.beginPath()
ctx.moveTo(0, 0); ctx.lineTo(4, -14); ctx.lineTo(9, -14); ctx.lineTo(6, 0)
ctx.closePath()
ctx.fill(); ctx.stroke()
}
ctx.restore()
ctx.fillStyle = '#1d1409'
ctx.beginPath(); ctx.arc(0, -32, 3, 0, TAU); ctx.fill()
},
},
},
hooks: {
onUseItem(g, p, s, area, i) {
if (s.code === 'agrihoe') {
const spot = g.findLand(p.x + p.dir * 46, p.y + 8, 0, 24)
const crowded = g.entities.some((e) => !e.deco && !e.loot && Math.hypot(e.x - spot.x, e.y - spot.y) < 34)
if (crowded) {
g.ptip(p, '这里太挤了,换块空地开垦')
return true
}
g.addEntity('agrifarm', spot.x, spot.y)
g.swing(p, 'dig')
s.dur = (s.dur ?? g.items.agrihoe.uses) - 1
if (s.dur <= 0) {
g.consumeAt(p, area, i)
g.ptip(p, '开垦出良田 · 犁锄用坏了')
} else {
g.ptip(p, '开垦出一块良田(种子点击播种)')
}
g.invalidate()
return true
}
if (s.code === 'wheatseeds' || s.code === 'pumpkinseeds') {
const plot = g.entities.find((e) => e.type === 'agrifarm' && e.state === 'empty' && Math.hypot(e.x - p.x, e.y - p.y) < 80)
if (!plot) {
g.ptip(p, '需要站在空良田旁播种(先用犁锄开垦)')
return true
}
const code = s.code
g.consumeAt(p, area, i)
plant(p, plot, code, g)
return true
}
if (s.code === 'fertilizer') {
const plot = g.entities.find((e) => e.type === 'agrifarm' && e.mx && e.state !== 'ready' && Math.hypot(e.x - p.x, e.y - p.y) < 80)
if (!plot) {
g.ptip(p, '附近没有生长中的良田可施肥')
return true
}
plot.mx.t += 100 // 生长进度 +100 秒(总时长 200 秒 ≈ +50%)
g.markDirty(plot)
g.consumeAt(p, area, i)
g.ptip(p, '撒下肥料,作物猛地蹿高了一截')
return true
}
},
onGather(g, p, e) {
if (e.type === 'grass' && Math.random() < 0.12) {
g.addItem(p, 'wheatseeds', 1)
g.ptip(p, '草丛里翻出了小麦种子!')
} else if (e.type === 'berry' && Math.random() < 0.12) {
g.addItem(p, 'pumpkinseeds', 1)
g.ptip(p, '摘到了一把南瓜种子')
}
},
},
}

View File

@@ -0,0 +1,605 @@
// 营地:出生点自带房屋、长明篝火与无敌守卫,可建造营地仓储
// - 房屋 camphouse:点击休息,回复理智/生命(每天一次)
// - 守卫 guard:无限血,营地半径内巡逻并拦截、击杀敌对生物
// - 营地仓储 campstore:8 个箱子,每箱 6 种、每种最多 999(共 48 种容量)
// - 领地:以房屋为圆心 CAMP_R 范围,通过核心 onMobVictim 否决钩子让敌对生物不再锁定营地内的玩家;守卫自动拦截入侵者
// 时间/数值基准:三围 150/150/200,一天 160 秒
const TAU = Math.PI * 2
const CAMP_R = 420 // 营地安全半径(以 camphouse 为圆心)
const BOXES = 8 // 箱子数
const BOX_KINDS = 6 // 每箱最多物品种类
const BOX_CAP = 999 // 每种最多数量
// ---- 模块级面板状态(不入存档;面板只是一次交互会话) ----
let storeOpen = null // 当前打开的 campstore 实体 id
let uiCamX = 0 // onDrawUI 记录的相机位置,供 onClick 做世界→屏幕换算
let uiCamY = 0
let uiPanel = null // 面板矩形(屏幕坐标)
let uiClose = null // 关闭按钮矩形
let uiRows = [] // 每行箱子的按钮矩形 [{ i, dep, wit }]
// 相机无关的“点是否在矩形内”
function inside(x, y, r) {
return !!r && x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h
}
// 可堆叠物品判定(装备/工具/护甲不算)
function isStackable(g, code) {
const def = g.items[code]
if (!def) return false
if (def.equip || def.tool || def.armor) return false
const max = def.stack || (g.tune && g.tune.stackMax) || 20
return max > 1
}
// 规范化营地的 8 个箱子(容忍旧存档 / 缺字段)
function boxesOf(e) {
if (!e.mx) e.mx = { boxes: [] }
if (!Array.isArray(e.mx.boxes) || e.mx.boxes.length !== BOXES) {
e.mx.boxes = Array.from({ length: BOXES }, () => ({ items: {} }))
}
// 逐个兜底:容忍旧存档 / 缺字段的箱子
for (let i = 0; i < BOXES; i++) {
if (!e.mx.boxes[i] || typeof e.mx.boxes[i] !== 'object') e.mx.boxes[i] = { items: {} }
if (!e.mx.boxes[i].items) e.mx.boxes[i].items = {}
}
return e.mx.boxes
}
function usedKinds(box) {
return Object.keys(box.items || {}).filter((k) => (box.items[k] || 0) > 0).length
}
// 把玩家背包里所有可堆叠物品存入指定箱子(尊重箱容量限制),返回实际存入件数
function depositBox(g, p, box) {
if (!box.items) box.items = {}
const codes = new Set()
;[p.slots, p.pack].filter(Boolean).forEach((area) => {
area.forEach((s) => {
if (s && isStackable(g, s.code)) codes.add(s.code)
})
})
let moved = 0
codes.forEach((code) => {
const have = g.countItem(p, code)
if (have <= 0) return
const cur = box.items[code] || 0
const kinds = usedKinds(box)
if (cur <= 0 && kinds >= BOX_KINDS) return // 新物种种类已满
const room = BOX_CAP - cur
if (room <= 0) return
const add = Math.min(have, room)
if (add <= 0) return
if (!g.payCost(p, { [code]: add })) return
box.items[code] = cur + add
moved += add
})
return moved
}
// 把指定箱子全部取出到玩家背包,返回取回件数
function withdrawBox(g, p, box) {
if (!box.items) {
box.items = {}
return 0
}
let moved = 0
Object.entries(box.items).forEach(([code, n]) => {
if (n > 0) {
g.addItem(p, code, n)
moved += n
}
})
box.items = {}
return moved
}
// 营地圆心(优先取第一座房屋,缺则退回世界中心)
function campCenter(g) {
const house = g.entities.find((e) => e.type === 'camphouse')
if (house) return { x: house.x, y: house.y }
return { x: g.worldW / 2, y: g.worldH / 2 }
}
export default {
id: 'camp',
name: '营地',
desc: '出生点自带房屋、长明篝火与无敌守卫,可建造营地仓储',
factor: 0.75, // 大幅降低难度
tag: 'easy',
tabs: [{ code: 'camp', name: '营地', icon: 'camphouse' }],
recipes: [
{ code: 'campstore', name: '营地仓储', tab: 'camp', tech: 1, cost: { log: 10, rock: 4 }, build: true, desc: '8 个箱子,可建造多个' },
{ code: 'camphouse', name: '营地房屋', tab: 'camp', tech: 1, cost: { log: 12, rock: 8 }, build: true, desc: '休息回复理智与生命' },
],
icons: {
camphouse: (c) => {
// 石砌地基
c.fillStyle = '#8d9097'
c.strokeStyle = '#1d1409'
c.lineWidth = 1.6
c.beginPath(); c.roundRect(-14, 6, 28, 6, 1.5); c.fill(); c.stroke()
// 木板墙
c.fillStyle = '#b07f47'
c.beginPath(); c.roundRect(-11, -8, 22, 15, 1.5); c.fill(); c.stroke()
c.strokeStyle = '#8a5f30'
c.lineWidth = 1
;[-4, 0, 4].forEach((y) => { c.beginPath(); c.moveTo(-11, y); c.lineTo(11, y); c.stroke() })
// 门窗
c.fillStyle = '#3a2a16'
c.beginPath(); c.roundRect(-6, 0, 6, 7, 1); c.fill()
c.fillStyle = '#ffd98a'
c.beginPath(); c.roundRect(3, -4, 5, 5, 1); c.fill()
// 斜屋顶
c.fillStyle = '#7a5230'
c.strokeStyle = '#1d1409'
c.lineWidth = 1.8
c.beginPath(); c.moveTo(-15, -8); c.lineTo(15, -8); c.lineTo(0, -19); c.closePath(); c.fill(); c.stroke()
c.strokeStyle = '#5d3f24'
c.lineWidth = 1
;[-9, -4, 4, 9].forEach((x) => { c.beginPath(); c.moveTo(x, -8); c.lineTo(x * 0.42, -18); c.stroke() })
// 烟囱
c.fillStyle = '#9a6a3c'
c.beginPath(); c.roundRect(6, -18, 4, 7, 1); c.fill(); c.stroke()
},
campstore: (c) => {
// 仓房
c.fillStyle = '#a9773c'
c.strokeStyle = '#1d1409'
c.lineWidth = 1.8
c.beginPath(); c.roundRect(-13, -12, 26, 20, 2); c.fill(); c.stroke()
c.fillStyle = '#7a5230'
c.beginPath(); c.moveTo(-15, -12); c.lineTo(0, -22); c.lineTo(15, -12); c.closePath(); c.fill(); c.stroke()
c.fillStyle = '#2a1c10'
c.beginPath(); c.roundRect(-6, -2, 12, 10, 1); c.fill(); c.stroke()
// 门口木箱
c.fillStyle = '#c9a36b'
c.strokeStyle = '#1d1409'
c.lineWidth = 1.4
c.beginPath(); c.roundRect(-14, 6, 12, 10, 1.5); c.fill(); c.stroke()
c.beginPath(); c.roundRect(1, 8, 12, 8, 1.5); c.fill(); c.stroke()
c.strokeStyle = '#8a5f30'
c.lineWidth = 1
c.beginPath(); c.moveTo(-14, 11); c.lineTo(-2, 11); c.moveTo(1, 12); c.lineTo(13, 12); c.stroke()
},
},
ents: {
camphouse: {
name: '房屋',
conf: { size: 52, struct: true },
init(e) {
if (!e.mx) e.mx = { restDay: 0 }
if (e.mx.restDay == null) e.mx.restDay = 0
},
interact(p, e, g) {
if (!e.mx) e.mx = { restDay: 0 }
if (e.mx.restDay === g.day) {
g.ptip(p, '今天已经休息过了')
return
}
p.san = Math.min(200, p.san + 30)
p.hp = Math.min(150, p.hp + 20)
e.mx.restDay = g.day
g.markDirty(e)
g.ptip(p, '在屋里歇了一会儿:理智 +30、生命 +20')
g.swing(p, 'eat')
},
draw(ctx, e, env) {
// 底部投影
ctx.fillStyle = 'rgba(0,0,0,0.25)'
ctx.beginPath(); ctx.ellipse(0, 8, 34, 9, 0, 0, TAU); ctx.fill()
// 石砌地基
ctx.fillStyle = '#8d9097'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 2
ctx.beginPath(); ctx.roundRect(-30, -4, 60, 12, 2); ctx.fill(); ctx.stroke()
ctx.strokeStyle = '#6f7278'
ctx.lineWidth = 1
;[-20, -10, 0, 10, 20].forEach((x) => { ctx.beginPath(); ctx.moveTo(x, -4); ctx.lineTo(x, 8); ctx.stroke() })
// 木板墙
ctx.fillStyle = '#b07f47'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 2
ctx.beginPath(); ctx.roundRect(-26, -46, 52, 44, 3); ctx.fill(); ctx.stroke()
// 横向木纹
ctx.strokeStyle = '#8a5f30'
ctx.lineWidth = 1.2
;[-38, -30, -22, -14, -6].forEach((y) => { ctx.beginPath(); ctx.moveTo(-26, y); ctx.lineTo(26, y); ctx.stroke() })
// 竖向立柱
;[-18, 0, 18].forEach((x) => { ctx.beginPath(); ctx.moveTo(x, -46); ctx.lineTo(x, -2); ctx.stroke() })
// 门(深色木门 + 门把手)
ctx.fillStyle = '#3a2a16'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 2
ctx.beginPath(); ctx.roundRect(-11, -26, 20, 24, [3, 3, 0, 0]); ctx.fill(); ctx.stroke()
ctx.fillStyle = '#e0c060'
ctx.beginPath(); ctx.arc(4, -14, 1.6, 0, TAU); ctx.fill()
// 亮着暖光的窗
ctx.fillStyle = '#ffd98a'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 2
ctx.beginPath(); ctx.roundRect(7, -40, 15, 13, 1.5); ctx.fill(); ctx.stroke()
ctx.strokeStyle = '#8a6a34'
ctx.lineWidth = 1.2
ctx.beginPath(); ctx.moveTo(7, -33.5); ctx.lineTo(22, -33.5); ctx.moveTo(14.5, -40); ctx.lineTo(14.5, -27); ctx.stroke()
// 斜屋顶(瓦片纹)
ctx.fillStyle = '#7a5230'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 2
ctx.beginPath(); ctx.moveTo(-34, -46); ctx.lineTo(34, -46); ctx.lineTo(0, -76); ctx.closePath(); ctx.fill(); ctx.stroke()
ctx.strokeStyle = '#5d3f24'
ctx.lineWidth = 1.2
;[-26, -16, -6, 6, 16, 26].forEach((x) => { ctx.beginPath(); ctx.moveTo(x, -46); ctx.lineTo(x * 0.4, -72); ctx.stroke() })
// 烟囱
ctx.fillStyle = '#9a6a3c'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 1.8
ctx.beginPath(); ctx.roundRect(12, -72, 11, 20, 2); ctx.fill(); ctx.stroke()
// 炊烟(缓慢飘动)
const t = env?.time || 0
ctx.fillStyle = 'rgba(220,220,220,0.4)'
for (let i = 0; i < 3; i++) {
const ph = (t * 0.5 + i * 0.5 + e.id * 0.1) % 1.5
ctx.beginPath()
ctx.arc(17 + Math.sin(t + i) * 3, -80 - ph * 22, 3 + ph * 3, 0, TAU)
ctx.fill()
}
// 门口小台阶
ctx.fillStyle = '#9a6a3c'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 1.6
ctx.beginPath(); ctx.roundRect(-13, -2, 24, 5, 1.5); ctx.fill(); ctx.stroke()
},
},
campstore: {
name: '营地仓储',
conf: { size: 40, struct: true },
init(e) {
if (!e.mx) e.mx = { boxes: Array.from({ length: BOXES }, () => ({ items: {} })) }
boxesOf(e)
},
interact(p, e, g) {
boxesOf(e)
storeOpen = e.id
g.ptip(p, '打开营地仓储(背包可堆叠物资可存入箱子)')
},
draw(ctx, e, env) {
// 底部投影
ctx.fillStyle = 'rgba(0,0,0,0.25)'
ctx.beginPath(); ctx.ellipse(0, 8, 28, 8, 0, 0, TAU); ctx.fill()
// 木石仓房
ctx.fillStyle = '#a9773c'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 2
ctx.beginPath(); ctx.roundRect(-24, -40, 48, 46, 3); ctx.fill(); ctx.stroke()
// 竖向木板纹
ctx.strokeStyle = '#8a5f30'
ctx.lineWidth = 1.2
;[-14, -4, 6, 16].forEach((x) => { ctx.beginPath(); ctx.moveTo(x, -40); ctx.lineTo(x, 6); ctx.stroke() })
// 大门
ctx.fillStyle = '#3a2a16'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 2
ctx.beginPath(); ctx.roundRect(-12, -22, 24, 28, [3, 3, 0, 0]); ctx.fill(); ctx.stroke()
ctx.strokeStyle = '#5d3f24'
ctx.lineWidth = 1.2
ctx.beginPath(); ctx.moveTo(0, -22); ctx.lineTo(0, 6); ctx.stroke()
// 斜屋顶
ctx.fillStyle = '#7a5230'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 2
ctx.beginPath(); ctx.moveTo(-30, -40); ctx.lineTo(30, -40); ctx.lineTo(0, -64); ctx.closePath(); ctx.fill(); ctx.stroke()
ctx.strokeStyle = '#5d3f24'
ctx.lineWidth = 1
;[-22, -12, 0, 12, 22].forEach((x) => { ctx.beginPath(); ctx.moveTo(x, -40); ctx.lineTo(x * 0.4, -61); ctx.stroke() })
// 门口码放的木箱(3~4 个叠放,体现"很多箱子")
ctx.fillStyle = 'rgba(0,0,0,0.18)'
ctx.beginPath(); ctx.ellipse(24, 8, 20, 6, 0, 0, TAU); ctx.fill()
const crate = (x, y, w, h) => {
ctx.fillStyle = '#c9a36b'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 2
ctx.beginPath(); ctx.roundRect(x, y, w, h, 1.5); ctx.fill(); ctx.stroke()
ctx.strokeStyle = '#8a5f30'
ctx.lineWidth = 1.1
ctx.beginPath()
ctx.moveTo(x, y); ctx.lineTo(x + w, y + h)
ctx.moveTo(x + w, y); ctx.lineTo(x, y + h)
ctx.stroke()
}
crate(-30, -8, 16, 16) // 左前
crate(-14, -4, 16, 16) // 中间偏前
crate(-30, -24, 16, 16) // 左后上叠
crate(14, -10, 18, 18) // 右侧一只
},
},
},
mobs: {
guard: {
name: '守卫',
friendly: true,
conf: { hp: 9999, speed: 150, dmg: 60, score: 0, friendly: true, name: '守卫' },
drops: [],
update(m, dt, g) {
const conf = g.mon[m.kind] || this.conf
// 无限血:核心所有伤害路径都是 m.hp -= dmg 后判 <=0,每帧回满即可免疫
m.hp = 1e9
const c = campCenter(g)
// 找营地半径内的敌对生物
let target = null
let bd = CAMP_R
g.monsters.forEach((q) => {
if (q === m) return
if (!g.mon[q.kind]?.hostile) return
const d = Math.hypot(q.x - c.x, q.y - c.y)
if (d < bd) { bd = d; target = q }
})
if (target) {
const d = Math.hypot(target.x - m.x, target.y - m.y)
if (d > 34) {
g.moveToward(m, target.x, target.y, conf.speed, dt)
} else if ((m.cd || 0) <= 0) {
m.cd = 0.9
g.damageMob(target, conf.dmg, null)
}
return
}
// 无敌人:在房屋周围小范围巡逻待命
m.wanderT = (m.wanderT || 0) - dt
if (m.wanderT <= 0) {
m.wanderT = 2 + Math.random() * 3
const a = Math.random() * TAU
const r = 60 + Math.random() * 110
m.tx = c.x + Math.cos(a) * r
m.ty = c.y + Math.sin(a) * r
}
if (m.tx !== undefined && Math.hypot(m.tx - m.x, m.ty - m.y) > 8) {
g.moveToward(m, m.tx, m.ty, conf.speed * 0.4, dt)
} else {
m.moving = false
}
},
interact(p, m, g) {
g.ptip(p, '守卫挺直身板:营地范围里很安全')
},
draw(ctx, m, time) {
ctx.save()
ctx.scale(m.dir || 1, 1)
const hop = Math.sin(time * 7 + (m.bob || 0)) * (m.moving ? 1.8 : 0.5)
// 底部投影
ctx.fillStyle = 'rgba(0,0,0,0.25)'
ctx.beginPath(); ctx.ellipse(0, 5, 15, 6, 0, 0, TAU); ctx.fill()
// 腿(皮靴)
ctx.strokeStyle = '#5b3a1e'
ctx.lineWidth = 4.6
const leg = m.moving ? Math.sin(time * 9) * 2.6 : 0
ctx.beginPath()
ctx.moveTo(-5, -10); ctx.lineTo(-6 + leg, 3)
ctx.moveTo(5, -10); ctx.lineTo(6 - leg, 3)
ctx.stroke()
// 身体:皮甲(棕褐)
ctx.fillStyle = '#8a5f34'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 2
ctx.beginPath(); ctx.roundRect(-11, -32 + hop * 0.3, 22, 24, 5); ctx.fill(); ctx.stroke()
// 皮甲护胸横条
ctx.strokeStyle = '#6b4a2a'
ctx.lineWidth = 1.4
;[-27, -22, -17].forEach((y) => { ctx.beginPath(); ctx.moveTo(-9, y); ctx.lineTo(9, y); ctx.stroke() })
// 肩甲
ctx.fillStyle = '#a97b47'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 1.6
ctx.beginPath(); ctx.arc(-11, -30 + hop * 0.3, 5, 0, TAU); ctx.fill(); ctx.stroke()
ctx.beginPath(); ctx.arc(11, -30 + hop * 0.3, 5, 0, TAU); ctx.fill(); ctx.stroke()
// 长矛(斜握)
ctx.strokeStyle = '#7a5230'
ctx.lineWidth = 3
ctx.beginPath(); ctx.moveTo(8, 2); ctx.lineTo(24, -40); ctx.stroke()
ctx.fillStyle = '#c6ccd4'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 1.4
ctx.beginPath(); ctx.moveTo(22, -38); ctx.lineTo(27, -47); ctx.lineTo(30, -40); ctx.closePath(); ctx.fill(); ctx.stroke()
// 头
ctx.fillStyle = '#e8c9a0'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 2
ctx.beginPath(); ctx.arc(0, -40 + hop * 0.5, 7, 0, TAU); ctx.fill(); ctx.stroke()
// 金属头盔
ctx.fillStyle = '#9aa0a8'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 1.8
ctx.beginPath(); ctx.arc(0, -42 + hop * 0.5, 8, Math.PI, TAU); ctx.fill(); ctx.stroke()
ctx.beginPath(); ctx.roundRect(-8, -43 + hop * 0.5, 16, 3, 1); ctx.fill(); ctx.stroke()
// 头盔红缨
ctx.fillStyle = '#cf3b34'
ctx.beginPath()
ctx.moveTo(0, -50 + hop * 0.5); ctx.quadraticCurveTo(4, -56 + hop * 0.5, 1, -60 + hop * 0.5)
ctx.quadraticCurveTo(-2, -56 + hop * 0.5, -2, -50 + hop * 0.5)
ctx.closePath(); ctx.fill(); ctx.stroke()
// 目光
ctx.fillStyle = '#1d1409'
ctx.beginPath(); ctx.arc(3, -40 + hop * 0.5, 1.2, 0, TAU); ctx.fill()
ctx.restore()
},
},
},
hooks: {
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, {})
// 长明篝火:永不熄灭,营地夜间照明
g.addFire(cx + 90, cy + 70, { pit: true, eternal: true, ttl: 150, max: 150 })
g.tip('营地已就绪:房屋、长明篝火与守卫')
},
onMobVictim(g, m, victim) {
// 核心 nearestVictim() 会调此钩子:返回 false = 这只怪不把该玩家算作可选目标。
// 只对敌对生物生效(保留「怪可以去打守卫/猪人」的能力)。
if (!g.mon[m.kind]?.hostile) return
// 判断依据是「受害者在不在营地内」,怪本身不做任何位移(无跳变)。
const center = g.entities.find((e) => e.type === 'camphouse') || { x: g.worldW / 2, y: g.worldH / 2 }
if (Math.hypot(victim.x - center.x, victim.y - center.y) < CAMP_R) return false
},
onDraw(g, ctx, env) {
// 虚线画出营地领地边界(世界坐标位置,画布为屏幕坐标系,故减去相机)
const c = campCenter(g)
const sx = c.x - env.camX
const sy = c.y - env.camY
ctx.save()
ctx.strokeStyle = 'rgba(255,220,140,0.35)'
ctx.lineWidth = 2
ctx.setLineDash([10, 8])
ctx.beginPath()
ctx.ellipse(sx, sy, CAMP_R, CAMP_R * 0.62, 0, 0, TAU)
ctx.stroke()
ctx.restore()
},
onDrawUI(g, ctx, env) {
// 记录相机位置,供 onClick 做世界→屏幕换算
uiCamX = env.camX
uiCamY = env.camY
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 W = env.W
const H = env.H
const rowH = 30
const headerH = 40
const footerH = 42
const pad = 10
const panelW = Math.min(470, W - 24)
const panelH = headerH + BOXES * rowH + footerH + pad
const px = Math.round((W - panelW) / 2)
const py = Math.round((H - panelH) / 2)
uiPanel = { x: px, y: py, w: panelW, h: panelH }
// 深色木质面板
ctx.save()
ctx.fillStyle = 'rgba(0,0,0,0.45)'
ctx.fillRect(0, 0, W, H)
ctx.fillStyle = '#3a2a18'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 3
ctx.beginPath(); ctx.roundRect(px, py, panelW, panelH, 10); ctx.fill(); ctx.stroke()
// 标题栏
ctx.fillStyle = '#5a4022'
ctx.beginPath(); ctx.roundRect(px + 3, py + 3, panelW - 6, headerH - 6, [8, 8, 0, 0]); ctx.fill()
ctx.fillStyle = '#f4ead2'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.font = 'bold 17px system-ui, sans-serif'
ctx.fillText('营地仓储', px + panelW / 2, py + headerH / 2 - 2)
ctx.font = '11px system-ui, sans-serif'
ctx.fillStyle = 'rgba(244,234,210,0.7)'
ctx.fillText(`共 ${BOXES} 箱 · 每箱 ${BOX_KINDS} 种 · 每种上限 ${BOX_CAP}`, px + panelW / 2, py + headerH - 6)
// 行
uiRows = []
const rightEdge = px + panelW - pad - 4
const btnH = 20
const depW = 66
const witW = 60
const gap = 6
const fontSize = 13
for (let i = 0; i < BOXES; i++) {
const rowY = py + headerH + i * rowH
const box = boxes[i]
const used = usedKinds(box)
// 交替底色
ctx.fillStyle = i % 2 ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.14)'
ctx.beginPath(); ctx.roundRect(px + pad, rowY + 2, panelW - pad * 2, rowH - 5, 4); ctx.fill()
// 箱子 N + 用量
ctx.textAlign = 'left'
ctx.textBaseline = 'middle'
ctx.fillStyle = used > 0 ? '#f4ead2' : 'rgba(244,234,210,0.5)'
ctx.font = `bold ${fontSize}px system-ui, sans-serif`
ctx.fillText(`箱子 ${i + 1}`, px + pad + 8, rowY + rowH / 2 - 1)
ctx.fillStyle = 'rgba(255,220,140,0.85)'
ctx.font = `11px system-ui, sans-serif`
ctx.fillText(`${used}/${BOX_KINDS} 种`, px + pad + 62, rowY + rowH / 2 - 1)
// 内容摘要(最多 3 种)
const entries = Object.entries(box.items || {}).filter(([, n]) => n > 0)
const shown = entries.slice(0, 3).map(([code, n]) => `${g.items[code]?.name || code}×${n}`)
const more = entries.length > 3 ? ` 等${entries.length}种` : ''
ctx.fillStyle = 'rgba(230,220,200,0.8)'
ctx.font = `12px system-ui, sans-serif`
ctx.fillText(entries.length ? shown.join(' ') + more : '(空)', px + pad + 108, rowY + rowH / 2 - 1)
// 按钮
const by = rowY + (rowH - 3 - btnH) / 2 + 1
const dep = { x: rightEdge - witW - gap - depW, y: by, w: depW, h: btnH }
const wit = { x: rightEdge - witW, y: by, w: witW, h: btnH }
const drawBtn = (r, label, bg) => {
ctx.fillStyle = bg
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 1.4
ctx.beginPath(); ctx.roundRect(r.x, r.y, r.w, r.h, 5); ctx.fill(); ctx.stroke()
ctx.fillStyle = '#f4ead2'
ctx.textAlign = 'center'
ctx.font = `bold 12px system-ui, sans-serif`
ctx.fillText(label, r.x + r.w / 2, r.y + r.h / 2 + 0.5)
}
drawBtn(dep, '↓ 存入', '#3f6b34')
drawBtn(wit, '↑ 取出', '#8a6a34')
uiRows.push({ i, dep, wit })
}
// 关闭按钮
const cw = 120
const ch = 26
const cx2 = px + panelW / 2 - cw / 2
const cy2 = py + panelH - footerH + 8
uiClose = { x: cx2, y: cy2, w: cw, h: ch }
ctx.fillStyle = '#7a2f24'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 2
ctx.beginPath(); ctx.roundRect(cx2, cy2, cw, ch, 6); ctx.fill(); ctx.stroke()
ctx.fillStyle = '#f4ead2'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.font = 'bold 14px system-ui, sans-serif'
ctx.fillText('关闭', cx2 + cw / 2, cy2 + ch / 2 + 0.5)
ctx.restore()
},
onClick(g, wx, wy) {
if (storeOpen == null) return undefined // 没有面板:不干扰游戏点击
const sx = wx - uiCamX
const sy = wy - uiCamY
const e = g.entities.find((x) => x.id === storeOpen)
const p = g.me()
if (!e || !p) {
storeOpen = null
return true
}
// 关闭按钮
if (inside(sx, sy, uiClose)) {
storeOpen = null
return true
}
// 每行的存入/取出
for (const r of uiRows) {
if (inside(sx, sy, r.dep)) {
const box = boxesOf(e)[r.i]
const moved = depositBox(g, p, box)
g.markDirty(e)
g.ptip(p, moved > 0 ? `存入 ${moved} 件物资` : '背包里没有可堆入的物资')
return true
}
if (inside(sx, sy, r.wit)) {
const box = boxesOf(e)[r.i]
const moved = withdrawBox(g, p, box)
g.markDirty(e)
g.ptip(p, moved > 0 ? `取回 ${moved} 件物资` : '这个箱子是空的')
return true
}
}
// 面板内空白:吃掉点击
if (inside(sx, sy, uiPanel)) return true
// 面板外:关闭
storeOpen = null
return true
},
},
}

View File

@@ -0,0 +1,485 @@
// 牧业文明:栅栏圈养、饲料槽投喂 —— 羊毛与鸡蛋的牧场经济
// - 剪刀(科学机器合成,耐久 30):点击绵羊剪毛,每只羊每天一次(给羊毛 ×2)
// - 饲料槽:投喂浆果/作物,让附近鸡舍每日囤更多蛋;点击鸡舍收取鸡蛋
// - 羊毛可做便宜的保暖帽(保暖 60),鸡蛋/牛奶/奶酪补充食物
// 数值基准:三围 150/150/200,一天 160 秒,官方食物折算
const TAU = Math.PI * 2
// 绵羊觅食:无害短距游荡(照抄 loyaldog 流浪分支,速度乘 0.5)
function wander(m, dt, g, speed) {
m.wanderT = (m.wanderT || 0) - dt
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
}
if (m.tx !== undefined && Math.hypot(m.tx - m.x, m.ty - m.y) > 8) {
g.moveToward(m, m.tx, m.ty, speed, dt)
}
}
export default {
id: 'husbandry',
name: '牧业文明',
desc: '栅栏圈养、饲料槽投喂:羊毛与鸡蛋的牧场经济',
factor: 1.05,
tag: 'content',
tabs: [{ code: 'husbandry', name: '牧业', icon: 'husbshears' }],
items: {
husbshears: { name: '剪刀', stack: 1, equip: 'hand', dmg: 10, uses: 30 },
wool: { name: '羊毛', stack: 40 },
woolhat: { name: '羊毛帽', stack: 1, equip: 'head', insul: 60 },
egg: { name: '鸡蛋', food: { hunger: 12.5, hp: 1, san: 0 }, cook: 'cookedegg', perish: 20 },
cookedegg: { name: '煎蛋', food: { hunger: 18.75, hp: 3, san: 0 }, perish: 10 },
milk: { name: '牛奶', food: { hunger: 12.5, hp: 3, san: 0 }, perish: 10 },
cheese: { name: '奶酪', food: { hunger: 25, hp: 8, san: 5 }, perish: 20 },
},
recipes: [
{ code: 'husbshears', name: '剪刀', tab: 'husbandry', tech: 1, cost: { gold: 2, rope: 1 }, desc: '给绵羊剪毛 · 耐久 30 次' },
{ code: 'fence', name: '栅栏', tab: 'husbandry', tech: 1, cost: { twig: 4, grass: 2 }, build: true, desc: '圈养动物的围栏' },
{ code: 'trough', name: '饲料槽', tab: 'husbandry', tech: 1, cost: { log: 4, rock: 2 }, build: true, desc: '投喂作物,提升牧场产出' },
{ code: 'coop', name: '鸡舍', tab: 'husbandry', tech: 2, cost: { log: 6, silk: 4, rope: 2 }, build: true, desc: '每日自动囤蛋' },
{ code: 'woolhat', name: '羊毛帽', tab: 'husbandry', tech: 2, cost: { wool: 6, rope: 1 }, desc: '保暖 60(弱于冬帽但便宜)' },
],
icons: {
husbshears: (c) => {
// 指环
c.strokeStyle = '#6f767d'
c.lineWidth = 3
c.beginPath(); c.arc(-8, 11, 4.5, 0, TAU); c.stroke()
c.beginPath(); c.arc(8, 11, 4.5, 0, TAU); c.stroke()
// 刀身
c.strokeStyle = '#b7bec7'
c.lineWidth = 3
c.beginPath(); c.moveTo(-6, -14); c.lineTo(6, 6); c.stroke()
c.beginPath(); c.moveTo(6, -14); c.lineTo(-6, 6); c.stroke()
// 内刃
c.strokeStyle = '#8d949c'
c.lineWidth = 1
c.beginPath(); c.moveTo(-5, -13); c.lineTo(5, 5); c.stroke()
// 轴钉
c.fillStyle = '#4a4f55'
c.beginPath(); c.arc(0, -4, 2.4, 0, TAU); c.fill()
},
wool: (c) => {
c.fillStyle = '#f2ede1'
c.strokeStyle = '#1d1409'
c.lineWidth = 1.5
const puffs = [[-6, 2, 6.5], [6, 2, 6.5], [0, -4, 7], [0, 5, 6]]
puffs.forEach(([x, y, r]) => { c.moveTo(x + r, y); c.arc(x, y, r, 0, TAU) })
c.fill()
puffs.forEach(([x, y, r]) => { c.beginPath(); c.arc(x, y, r, 0, TAU); c.stroke() })
},
woolhat: (c) => {
c.fillStyle = '#efe7d6'
c.strokeStyle = '#1d1409'
c.lineWidth = 1.8
c.beginPath()
c.moveTo(-12, 6); c.quadraticCurveTo(-13, -12, 0, -14); c.quadraticCurveTo(13, -12, 12, 6)
c.closePath(); c.fill(); c.stroke()
// 帽檐
c.fillStyle = '#d9cdb4'
c.beginPath(); c.roundRect(-13, 4, 26, 6, 2); c.fill(); c.stroke()
// 顶部绒球
c.fillStyle = '#f6f1e6'
c.beginPath(); c.arc(0, -15, 4, 0, TAU); c.fill(); c.stroke()
},
egg: (c) => {
c.fillStyle = '#f6efe0'
c.strokeStyle = '#1d1409'
c.lineWidth = 1.8
c.beginPath()
c.moveTo(0, -12); c.bezierCurveTo(8, -10, 10, 4, 0, 12); c.bezierCurveTo(-10, 4, -8, -10, 0, -12)
c.closePath(); c.fill(); c.stroke()
c.fillStyle = '#fffdf6'
c.beginPath(); c.ellipse(-3, -3, 2.6, 4, -0.3, 0, TAU); c.fill()
},
cookedegg: (c) => {
c.fillStyle = '#f7f2e6'
c.strokeStyle = '#1d1409'
c.lineWidth = 1.6
c.beginPath()
c.moveTo(-13, 2); c.quadraticCurveTo(-14, -8, -4, -9); c.quadraticCurveTo(2, -14, 9, -7)
c.quadraticCurveTo(15, -4, 12, 4); c.quadraticCurveTo(8, 12, -2, 10); c.quadraticCurveTo(-11, 9, -13, 2)
c.closePath(); c.fill(); c.stroke()
// 蛋黄
c.fillStyle = '#f2b53a'
c.beginPath(); c.arc(-1, -1, 5.5, 0, TAU); c.fill(); c.stroke()
c.fillStyle = '#f8d374'
c.beginPath(); c.arc(-2.5, -2.5, 2, 0, TAU); c.fill()
},
milk: (c) => {
// 瓶身
c.fillStyle = '#f4f1ea'
c.strokeStyle = '#1d1409'
c.lineWidth = 1.6
c.beginPath(); c.roundRect(-7, -2, 14, 16, 2); c.fill(); c.stroke()
// 瓶颈
c.beginPath(); c.roundRect(-4, -12, 8, 11, 1.5); c.fill(); c.stroke()
// 瓶盖
c.fillStyle = '#c0455a'
c.beginPath(); c.roundRect(-5, -15, 10, 4, 1.5); c.fill(); c.stroke()
// 奶面
c.fillStyle = '#ffffff'
c.beginPath(); c.roundRect(-5, 3, 10, 9, 1.5); c.fill()
},
cheese: (c) => {
// 侧面
c.fillStyle = '#d99f34'
c.strokeStyle = '#1d1409'
c.lineWidth = 1.8
c.beginPath(); c.moveTo(-12, 4); c.lineTo(12, 2); c.lineTo(12, 8); c.lineTo(-12, 8); c.closePath()
c.fill(); c.stroke()
// 顶面
c.fillStyle = '#f0c04a'
c.beginPath(); c.moveTo(-12, 4); c.lineTo(-12, -3); c.lineTo(10, -8); c.lineTo(12, 2); c.closePath()
c.fill(); c.stroke()
// 孔洞
c.fillStyle = '#c07f22'
;[[-4, 0, 2.2], [4, -3, 1.6], [7, 5, 1.4]].forEach(([x, y, r]) => {
c.beginPath(); c.arc(x, y, r, 0, TAU); c.fill()
})
},
fence: (c) => {
c.fillStyle = '#a9773c'
c.strokeStyle = '#1d1409'
c.lineWidth = 1.8
;[-10, 10].forEach((x) => {
c.beginPath(); c.roundRect(x - 3, -13, 6, 26, 1.5); c.fill(); c.stroke()
})
// 横木
c.beginPath(); c.roundRect(-14, -6, 28, 4, 1.5); c.fill(); c.stroke()
c.beginPath(); c.roundRect(-14, 3, 28, 4, 1.5); c.fill(); c.stroke()
// 木纹
c.strokeStyle = '#7a5230'
c.lineWidth = 1
c.beginPath(); c.moveTo(-10, -13); c.lineTo(-10, 13); c.moveTo(10, -13); c.lineTo(10, 13); c.stroke()
},
trough: (c) => {
c.fillStyle = '#8a5f38'
c.strokeStyle = '#1d1409'
c.lineWidth = 1.8
c.beginPath(); c.moveTo(-14, -6); c.lineTo(14, -6); c.lineTo(10, 10); c.lineTo(-10, 10); c.closePath()
c.fill(); c.stroke()
// 槽内
c.fillStyle = '#5d3f24'
c.beginPath(); c.roundRect(-11, -7, 22, 5, 1); c.fill()
// 谷粒
c.fillStyle = '#e0c060'
;[[-6, -5], [-1, -6], [4, -5], [-3, -3], [6, -3]].forEach(([x, y]) => {
c.beginPath(); c.arc(x, y, 1.6, 0, TAU); c.fill()
})
},
coop: (c) => {
// 屋体
c.fillStyle = '#a9773c'
c.strokeStyle = '#1d1409'
c.lineWidth = 1.8
c.beginPath(); c.roundRect(-11, -4, 22, 16, 2); c.fill(); c.stroke()
// 屋顶
c.fillStyle = '#7a5230'
c.beginPath(); c.moveTo(-14, -4); c.lineTo(0, -15); c.lineTo(14, -4); c.closePath(); c.fill(); c.stroke()
// 出入口
c.fillStyle = '#2a1c10'
c.beginPath(); c.roundRect(-4, 2, 8, 10, 3); c.fill(); c.stroke()
// 烟囱
c.fillStyle = '#8a5f38'
c.beginPath(); c.roundRect(5, -15, 4, 7, 1); c.fill(); c.stroke()
},
},
ents: {
fence: {
name: '栅栏',
conf: { size: 24, struct: true, solid: true },
draw(ctx) {
// 底部投影
ctx.fillStyle = 'rgba(0,0,0,0.25)'
ctx.beginPath(); ctx.ellipse(0, 4, 16, 5, 0, 0, TAU); ctx.fill()
// 两根竖木桩
ctx.fillStyle = '#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()
},
},
trough: {
name: '饲料槽',
conf: { size: 26, struct: true },
init(e) {
// 饲料数必须放进 e.mx 才能随存档/联机同步(e.feed 不会被序列化)
if (!e.mx) e.mx = {}
if (e.mx.feed == null) e.mx.feed = 0
},
interact(p, e, g) {
if (!e.mx) e.mx = {}
if (e.mx.feed == null) e.mx.feed = 0
// 只投喂实际存在于物品表里的作物/浆果
const feeds = ['berry', 'wheat', 'pumpkin', 'carrot'].filter((code) => g.items[code])
const code = feeds.find((c2) => g.countItem(p, c2) > 0)
if (!code) {
g.ptip(p, '需要投喂作物或浆果')
return
}
g.payCost(p, { [code]: 1 })
e.mx.feed = (e.mx.feed || 0) + 1
g.markDirty(e)
g.ptip(p, `投喂了一份饲料(现有 ${e.mx.feed} 份)`)
},
draw(ctx, e) {
// 底部投影
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'
ctx.lineWidth = 2
ctx.beginPath()
ctx.moveTo(-18, -14); ctx.lineTo(18, -14); ctx.lineTo(13, 6); ctx.lineTo(-13, 6); ctx.closePath()
ctx.fill(); ctx.stroke()
// 槽内暗面
ctx.fillStyle = '#5d3f24'
ctx.beginPath(); ctx.roundRect(-15, -15, 30, 7, 2); ctx.fill()
// 谷粒:有饲料时更满更亮
const bright = feed > 0
const full = Math.min(1, feed / 4)
ctx.fillStyle = bright ? '#ecd06a' : '#8a7a4a'
const grains = bright ? 10 : 4
for (let i = 0; i < grains; i++) {
const gx = -13 + i * 2.9
const gy = -13 + Math.sin(i * 1.7) * 1.6
ctx.beginPath(); ctx.arc(gx, gy, 2 + full, 0, TAU); ctx.fill()
}
// 高光边
ctx.strokeStyle = '#b08a54'
ctx.lineWidth = 1.2
ctx.beginPath(); ctx.moveTo(-16, -12); ctx.lineTo(16, -12); ctx.stroke()
},
},
coop: {
name: '鸡舍',
conf: { size: 34, struct: true },
init(e) {
// 囤蛋数必须放进 e.mx 才能随存档/联机同步(e.stored 不会被序列化)
if (!e.mx) e.mx = {}
if (e.mx.stored == null) e.mx.stored = 0
},
interact(p, e, g) {
if (!e.mx) e.mx = {}
const stored = e.mx.stored || 0
if (stored > 0) {
g.addItem(p, 'egg', stored)
e.mx.stored = 0
g.markDirty(e)
g.ptip(p, `收取鸡蛋 ×${stored}`)
} else {
g.ptip(p, '鸡舍里暂时没有蛋')
}
},
draw(ctx, e) {
// 底部投影
ctx.fillStyle = 'rgba(0,0,0,0.25)'
ctx.beginPath(); ctx.ellipse(0, 5, 22, 7, 0, 0, TAU); ctx.fill()
// 屋体
ctx.fillStyle = '#a9773c'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 2
ctx.beginPath(); ctx.roundRect(-18, -22, 36, 28, 3); ctx.fill(); ctx.stroke()
// 木板纹
ctx.strokeStyle = '#8a5f38'
ctx.lineWidth = 1.2
ctx.beginPath(); ctx.moveTo(-6, -22); ctx.lineTo(-6, 6); ctx.moveTo(6, -22); ctx.lineTo(6, 6); ctx.stroke()
// 斜屋顶
ctx.fillStyle = '#7a5230'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 2
ctx.beginPath(); ctx.moveTo(-23, -22); ctx.lineTo(0, -40); ctx.lineTo(23, -22); ctx.closePath(); ctx.fill(); ctx.stroke()
// 屋檐压线
ctx.strokeStyle = '#5d3f24'
ctx.lineWidth = 1.4
ctx.beginPath(); ctx.moveTo(-20, -25); ctx.lineTo(20, -25); ctx.stroke()
// 圆形出入口
ctx.fillStyle = '#2a1c10'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 2
ctx.beginPath(); ctx.roundRect(-8, -10, 16, 16, [8, 8, 2, 2]); ctx.fill(); ctx.stroke()
// 屋顶小烟囱
ctx.fillStyle = '#8a5f38'
ctx.beginPath(); ctx.roundRect(10, -44, 7, 10, 2); ctx.fill(); ctx.stroke()
// 囤蛋提示
if ((e.mx?.stored || 0) > 0) {
ctx.fillStyle = '#f6efe0'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 1.4
ctx.beginPath(); ctx.ellipse(-2, 12, 3.4, 4, 0, 0, TAU); ctx.fill(); ctx.stroke()
}
},
},
},
mobs: {
sheep: {
name: '绵羊',
conf: { hp: 250, speed: 70, dmg: 8, score: 0, friendly: true, name: '绵羊' },
drops: [[1, 'meat', 1], [1, 'wool', 1]],
update(m, dt, g) {
const conf = g.mon[m.kind] || this.conf
if (m.hp <= 0) return
wander(m, dt, g, conf.speed * 0.5)
},
interact(p, m, g) {
if (g.countItem(p, 'husbshears') <= 0) {
g.ptip(p, '需要剪刀才能剪羊毛')
return
}
if (m.shearDay === g.day) {
g.ptip(p, '羊毛还没长回来(需等到明天)')
return
}
g.addItem(p, 'wool', 2)
m.shearDay = g.day
g.ptip(p, '给绵羊剪下一团羊毛')
g.swing(p, 'chop')
},
draw(ctx, m, time) {
ctx.save()
ctx.scale(m.dir || 1, 1)
const hop = Math.sin(time * 6 + (m.bob || 0)) * (m.moving ? 1.6 : 0.4)
// 底部投影
ctx.fillStyle = 'rgba(0,0,0,0.25)'
ctx.beginPath(); ctx.ellipse(0, 4, 18, 6, 0, 0, TAU); ctx.fill()
// 黑色细腿
ctx.strokeStyle = '#3a3a3a'
ctx.lineWidth = 3.4
const leg = m.moving ? Math.sin(time * 9) * 2.4 : 0
ctx.beginPath()
ctx.moveTo(-7, -8); ctx.lineTo(-8 + leg, 3)
ctx.moveTo(6, -8); ctx.lineTo(7 - leg, 3)
ctx.stroke()
// 白色蓬松羊毛(多团叠加)
ctx.fillStyle = '#f4f0e6'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 2
const puffs = [[-9, -13, 9], [0, -16, 11], [9, -13, 9], [-4, -8, 9], [5, -8, 9]]
puffs.forEach(([x, y, r]) => { ctx.moveTo(x + r, y); ctx.arc(x, y, r, 0, TAU) })
ctx.fill()
puffs.forEach(([x, y, r]) => { ctx.beginPath(); ctx.arc(x, y, r, 0, TAU); ctx.stroke() })
// 深灰头部
ctx.fillStyle = '#5b5b5b'
ctx.beginPath(); ctx.ellipse(15, -12 + hop * 0.3, 6.5, 6, 0, 0, TAU); ctx.fill(); ctx.stroke()
// 耳朵
ctx.fillStyle = '#4a4a4a'
ctx.beginPath(); ctx.moveTo(11, -17); ctx.quadraticCurveTo(8, -22, 13, -20); ctx.closePath(); ctx.fill(); ctx.stroke()
// 眼
ctx.fillStyle = '#f4ead2'
ctx.beginPath(); ctx.arc(17, -13, 1.6, 0, TAU); ctx.fill()
ctx.fillStyle = '#1d1409'
ctx.beginPath(); ctx.arc(18.4, -12.4, 1.1, 0, TAU); ctx.fill()
ctx.restore()
},
},
chicken: {
name: '鸡',
conf: { hp: 50, speed: 90, dmg: 5, score: 0, friendly: true, name: '鸡' },
drops: [[1, 'morsel', 1]],
update(m, dt, g) {
const conf = g.mon[m.kind] || this.conf
if (m.hp <= 0) return
wander(m, dt, g, conf.speed * 0.5)
},
interact(p, m, g) {
g.ptip(p, '咕咕咕……')
},
draw(ctx, m, time) {
ctx.save()
ctx.scale(m.dir || 1, 1)
const hop = Math.sin(time * 10 + (m.bob || 0)) * (m.moving ? 1.4 : 0.5)
// 底部投影
ctx.fillStyle = 'rgba(0,0,0,0.25)'
ctx.beginPath(); ctx.ellipse(0, 3, 11, 4, 0, 0, TAU); ctx.fill()
// 橙色腿
ctx.strokeStyle = '#e08a3c'
ctx.lineWidth = 2.4
const leg = m.moving ? Math.sin(time * 14) * 2 : 0
ctx.beginPath()
ctx.moveTo(-2, -6); ctx.lineTo(-3 + leg, 2)
ctx.moveTo(3, -6); ctx.lineTo(4 - leg, 2)
ctx.stroke()
// 米黄身体
ctx.fillStyle = '#f3ead6'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 2
ctx.beginPath(); ctx.ellipse(-1, -12 + hop * 0.3, 10, 8, 0, 0, TAU); ctx.fill(); ctx.stroke()
// 翅膀
ctx.fillStyle = '#e4d6ba'
ctx.beginPath(); ctx.ellipse(-2, -12, 5, 4, 0.2, 0, TAU); ctx.fill(); ctx.stroke()
// 尾羽
ctx.beginPath()
ctx.moveTo(-10, -14); ctx.quadraticCurveTo(-16, -18, -15, -10); ctx.quadraticCurveTo(-13, -12, -10, -11)
ctx.closePath(); ctx.fill(); ctx.stroke()
// 头
ctx.fillStyle = '#f6efdd'
ctx.beginPath(); ctx.arc(8, -20 + hop * 0.4, 5, 0, TAU); ctx.fill(); ctx.stroke()
// 红鸡冠
ctx.fillStyle = '#d8483c'
ctx.beginPath()
ctx.moveTo(6 + 2.4, -25); ctx.arc(6, -25, 2.4, 0, TAU)
ctx.moveTo(9 + 2.6, -26); ctx.arc(9, -26, 2.6, 0, TAU)
ctx.moveTo(11.5 + 2.2, -24); ctx.arc(11.5, -24, 2.2, 0, TAU)
ctx.fill(); ctx.stroke()
// 橙喙
ctx.fillStyle = '#e8a33c'
ctx.beginPath(); ctx.moveTo(12.5, -20); ctx.lineTo(17, -19); ctx.lineTo(12.5, -17.6); ctx.closePath(); ctx.fill(); ctx.stroke()
// 红肉垂
ctx.fillStyle = '#d8483c'
ctx.beginPath(); ctx.ellipse(11, -15.5, 2, 3, 0, 0, TAU); ctx.fill(); ctx.stroke()
// 眼
ctx.fillStyle = '#1d1409'
ctx.beginPath(); ctx.arc(9.4, -21, 1.1, 0, TAU); ctx.fill()
ctx.restore()
},
},
},
hooks: {
onWorldGen(g) {
// 草原上散落绵羊与鸡(寻找邻近陆地投放)
const sheepN = g.rand(4, 6)
const chickN = g.rand(5, 8)
for (let i = 0; i < sheepN; i++) {
const spot = g.findLand(80 + Math.random() * (g.worldW - 160), 80 + Math.random() * (g.worldH - 160), 0, 20)
g.spawnMob('sheep', spot.x, spot.y, {})
}
for (let i = 0; i < chickN; i++) {
const spot = g.findLand(80 + Math.random() * (g.worldW - 160), 80 + Math.random() * (g.worldH - 160), 0, 20)
g.spawnMob('chicken', spot.x, spot.y, {})
}
g.tip('草原上传来羊叫与鸡鸣……')
},
onNewDay(g) {
// 结算每座鸡舍的囤蛋:附近有鸡或喂了饲料槽则翻倍,每日上限 4
g.entities.filter((e) => e.type === 'coop').forEach((coop) => {
if (!coop.mx) coop.mx = {}
if (coop.mx.stored == null) coop.mx.stored = 0
const chickenNear = g.monsters.some((m) => m.kind === 'chicken' && Math.hypot(m.x - coop.x, m.y - coop.y) < 180)
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
if (trough) {
trough.mx.feed -= 1
g.markDirty(trough)
}
coop.mx.stored = Math.min(4, (coop.mx.stored || 0) + inc)
g.markDirty(coop)
})
},
},
}

View File

@@ -11,5 +11,12 @@ import chill from './chill.js'
import traveler from './traveler.js'
import lucky from './lucky.js'
import loyaldog from './loyaldog.js'
import startchest from './startchest.js'
import agri from './agri.js'
import husbandry from './husbandry.js'
import industry from './industry.js'
import wideworld from './wideworld.js'
import camp from './camp.js'
import labor from './labor.js'
export const MODS = [qol, farm, chest, chester, revive, peaceful, nightmare, magic, chill, traveler, lucky, loyaldog]
export const MODS = [qol, farm, chest, chester, revive, peaceful, nightmare, magic, chill, traveler, lucky, loyaldog, startchest, agri, husbandry, industry, wideworld, camp, labor]

View File

@@ -0,0 +1,465 @@
// 工业文明:蒸汽时代 —— 熔炉炼锭、矿机自动开采、工厂流水线
// 产业进阶链(无循环依赖):
// 熔炉(石+木) → 金属锭 → 蒸汽机(锭) → 供能 → 工厂(锭) → 齿轮 → 矿机(锭+齿轮)
// - 熔炉:投入 石头×3 + 木头×1 产出 1 金属锭
// - 蒸汽机:投入木头当燃料,为 300px 内的工厂/矿机供能
// - 工厂:有蒸汽时投入 金属锭×2 产出齿轮;矿机:每日自动开采,蒸汽下翻倍
// 数值基准:三围 150/150/200,一天 160 秒
const TAU = Math.PI * 2
const STEAM_RANGE = 300 // 蒸汽机供能半径
export default {
id: 'industry',
name: '工业文明',
desc: '蒸汽时代:熔炉炼锭、矿机自动开采、工厂流水线',
factor: 1.1,
tag: 'content',
tabs: [{ code: 'industry', name: '工业', icon: 'indpickaxe' }],
items: {
indpickaxe: { name: '钢镐', stack: 1, equip: 'hand', tool: 'pick', dmg: 34, uses: 60 },
indaxe: { name: '机械斧', stack: 1, equip: 'hand', tool: 'axe', dmg: 34, uses: 80 },
ingot: { name: '金属锭', stack: 40 },
gear: { name: '齿轮', stack: 40 },
steelarmor: { name: '钢甲', stack: 1, equip: 'body', absorb: 0.85, armor: 525 },
steelhelmet: { name: '钢盔', stack: 1, equip: 'head', absorb: 0.85, armor: 525 },
},
recipes: [
{ code: 'smelter', name: '熔炉', tab: 'industry', tech: 1, cost: { rock: 12, log: 6 }, build: true, desc: '石头+木头 → 金属锭' },
{ code: 'steamengine', name: '蒸汽机', tab: 'industry', tech: 2, cost: { gold: 8, rock: 10, ingot: 4 }, build: true, desc: '范围供能 · 需投木为燃料' },
{ code: 'factory', name: '工厂', tab: 'industry', tech: 2, cost: { ingot: 6, log: 8, rock: 6 }, build: true, desc: '消耗金属锭产出齿轮' },
{ code: 'miner', name: '自动矿机', tab: 'industry', tech: 2, cost: { ingot: 4, gear: 2, log: 6 }, build: true, desc: '每日自动开采,蒸汽下翻倍' },
{ code: 'indpickaxe', name: '钢镐', tab: 'industry', tech: 2, cost: { ingot: 2, log: 2 }, desc: '采矿 · 耐久 60 次' },
{ code: 'indaxe', name: '机械斧', tab: 'industry', tech: 2, cost: { ingot: 2, log: 2 }, desc: '砍树 · 耐久 80 次' },
{ code: 'steelarmor', name: '钢甲', tab: 'industry', tech: 2, cost: { ingot: 6, rope: 2 }, desc: '减伤 85% · 耐久 525' },
{ code: 'steelhelmet', name: '钢盔', tab: 'industry', tech: 2, cost: { ingot: 4, rope: 1 }, desc: '减伤 85% · 耐久 525' },
],
icons: {
indpickaxe: (c) => {
// 弧形钢头
c.fillStyle = '#8d949c'
c.strokeStyle = '#1d1409'
c.lineWidth = 1.6
c.beginPath()
c.moveTo(-14, -6); c.quadraticCurveTo(0, -16, 14, -6); c.quadraticCurveTo(0, -9, -14, -6)
c.closePath(); c.fill(); c.stroke()
// 木柄
c.strokeStyle = '#7a5b33'
c.lineWidth = 3.4
c.beginPath(); c.moveTo(0, -8); c.lineTo(0, 15); c.stroke()
c.strokeStyle = '#5f4626'
c.lineWidth = 1
c.beginPath(); c.moveTo(-2, 15); c.lineTo(2, 15); c.stroke()
},
indaxe: (c) => {
// 木柄
c.strokeStyle = '#7a5b33'
c.lineWidth = 3.2
c.beginPath(); c.moveTo(-8, 14); c.lineTo(6, -10); c.stroke()
// 钢刃
c.fillStyle = '#b7bec7'
c.strokeStyle = '#1d1409'
c.lineWidth = 1.8
c.beginPath()
c.moveTo(4, -16); c.quadraticCurveTo(15, -12, 12, 0); c.quadraticCurveTo(9, -4, 3, -8)
c.closePath(); c.fill(); c.stroke()
c.fillStyle = '#8d949c'
c.beginPath(); c.moveTo(4, -16); c.lineTo(8, -12); c.lineTo(5, -8); c.closePath(); c.fill()
// 齿轮轴钉
c.fillStyle = '#c9a34a'
c.beginPath(); c.arc(0, -4, 2.2, 0, TAU); c.fill()
},
ingot: (c) => {
c.fillStyle = '#b7bec7'
c.strokeStyle = '#1d1409'
c.lineWidth = 1.8
c.beginPath()
c.moveTo(-13, 6); c.lineTo(-9, -6); c.lineTo(9, -6); c.lineTo(13, 6); c.closePath()
c.fill(); c.stroke()
// 顶面
c.fillStyle = '#d7dde4'
c.beginPath(); c.moveTo(-9, -6); c.lineTo(9, -6); c.lineTo(7, -2); c.lineTo(-7, -2); c.closePath(); c.fill()
// 高光
c.strokeStyle = '#eef2f6'
c.lineWidth = 1.4
c.beginPath(); c.moveTo(-6, -4); c.lineTo(4, -4); c.stroke()
},
gear: (c) => {
c.fillStyle = '#c9a34a'
c.strokeStyle = '#1d1409'
c.lineWidth = 1.4
for (let i = 0; i < 8; i++) {
c.save()
c.rotate((i / 8) * TAU)
c.beginPath(); c.roundRect(-2.6, -14, 5.2, 6, 1.4); c.fill(); c.stroke()
c.restore()
}
c.lineWidth = 1.6
c.beginPath(); c.arc(0, 0, 10, 0, TAU); c.fill(); c.stroke()
c.fillStyle = '#8a6a24'
c.beginPath(); c.arc(0, 0, 3.6, 0, TAU); c.fill(); c.stroke()
c.fillStyle = '#f0d488'
c.beginPath(); c.arc(-3, -3, 2.2, 0, TAU); c.fill()
},
steelarmor: (c) => {
c.fillStyle = '#aab2ba'
c.strokeStyle = '#1d1409'
c.lineWidth = 1.8
c.beginPath()
c.moveTo(-11, -10); c.lineTo(-4, -13); c.quadraticCurveTo(0, -11, 4, -13); c.lineTo(11, -10)
c.lineTo(9, 4); c.quadraticCurveTo(0, 10, -9, 4); c.closePath()
c.fill(); c.stroke()
// 中央脊线
c.strokeStyle = '#7f878f'
c.lineWidth = 1.4
c.beginPath(); c.moveTo(0, -11); c.lineTo(0, 7); c.stroke()
// 铆钉
c.fillStyle = '#5f666d'
;[[-6, -6], [6, -6], [0, 6]].forEach(([x, y]) => {
c.beginPath(); c.arc(x, y, 1.6, 0, TAU); c.fill()
})
},
steelhelmet: (c) => {
c.fillStyle = '#aab2ba'
c.strokeStyle = '#1d1409'
c.lineWidth = 1.8
c.beginPath(); c.arc(0, 0, 12, Math.PI, 0); c.closePath(); c.fill(); c.stroke()
// 下沿
c.beginPath(); c.roundRect(-13, 0, 26, 5, 2); c.fill(); c.stroke()
// 面甲缝
c.fillStyle = '#2a2f34'
c.beginPath(); c.roundRect(-8, -7, 16, 4, 1.5); c.fill()
// 高光
c.fillStyle = '#cfd6dd'
c.beginPath(); c.ellipse(-5, -6, 2.4, 3.4, -0.3, 0, TAU); c.fill()
},
smelter: (c) => {
// 石砌炉体
c.fillStyle = '#8d9097'
c.strokeStyle = '#1d1409'
c.lineWidth = 1.8
c.beginPath(); c.moveTo(-12, 12); c.lineTo(-9, -6); c.lineTo(9, -6); c.lineTo(12, 12); c.closePath()
c.fill(); c.stroke()
// 烟囱
c.fillStyle = '#6f767d'
c.beginPath(); c.roundRect(-3, -15, 6, 9, 1.5); c.fill(); c.stroke()
// 炉口火光
c.fillStyle = '#e0662e'
c.beginPath(); c.roundRect(-5, 0, 10, 8, 2); c.fill(); c.stroke()
c.fillStyle = '#f0c04a'
c.beginPath(); c.arc(0, 5, 2.4, 0, TAU); c.fill()
// 砖缝
c.strokeStyle = '#6f767d'
c.lineWidth = 1
c.beginPath(); c.moveTo(-10, 4); c.lineTo(10, 4); c.stroke()
},
steamengine: (c) => {
// 锅炉
c.fillStyle = '#c9a34a'
c.strokeStyle = '#1d1409'
c.lineWidth = 1.8
c.beginPath(); c.roundRect(-12, -6, 24, 16, 3); c.fill(); c.stroke()
// 压力表
c.fillStyle = '#e8e3d6'
c.beginPath(); c.arc(0, 0, 5, 0, TAU); c.fill(); c.stroke()
c.strokeStyle = '#c0455a'
c.lineWidth = 1.4
c.beginPath(); c.moveTo(0, 0); c.lineTo(3, -3); c.stroke()
// 活塞
c.fillStyle = '#8d949c'
c.strokeStyle = '#1d1409'
c.lineWidth = 1.6
c.beginPath(); c.roundRect(-5, -15, 10, 9, 2); c.fill(); c.stroke()
c.beginPath(); c.roundRect(-2, -19, 4, 5, 1); c.fill(); c.stroke()
// 蒸汽
c.fillStyle = 'rgba(240,244,248,0.9)'
;[[-5, -22, 2.4], [2, -24, 3], [7, -20, 2]].forEach(([x, y, r]) => {
c.beginPath(); c.arc(x, y, r, 0, TAU); c.fill()
})
},
factory: (c) => {
// 厂房
c.fillStyle = '#b06a4a'
c.strokeStyle = '#1d1409'
c.lineWidth = 1.8
c.beginPath(); c.roundRect(-13, -8, 26, 20, 2); c.fill(); c.stroke()
// 屋顶横梁
c.fillStyle = '#8a5040'
c.beginPath(); c.roundRect(-14, -12, 28, 6, 2); c.fill(); c.stroke()
// 烟囱
c.fillStyle = '#6f5040'
c.beginPath(); c.roundRect(6, -19, 5, 8, 1.5); c.fill(); c.stroke()
// 齿轮窗
c.fillStyle = '#c9a34a'
c.strokeStyle = '#1d1409'
c.lineWidth = 1.2
for (let i = 0; i < 6; i++) {
c.save()
c.rotate((i / 6) * TAU)
c.beginPath(); c.roundRect(-1.4, -8, 2.8, 3, 1); c.fill()
c.restore()
}
c.lineWidth = 1.6
c.beginPath(); c.arc(0, 2, 5.2, 0, TAU); c.fill(); c.stroke()
c.fillStyle = '#8a6a24'
c.beginPath(); c.arc(0, 2, 1.8, 0, TAU); c.fill()
},
miner: (c) => {
// 机身
c.fillStyle = '#8d949c'
c.strokeStyle = '#1d1409'
c.lineWidth = 1.8
c.beginPath(); c.roundRect(-12, -8, 24, 14, 2); c.fill(); c.stroke()
// 钻头
c.fillStyle = '#b7bec7'
c.beginPath(); c.moveTo(-4, -8); c.lineTo(4, -8); c.lineTo(0, -18); c.closePath(); c.fill(); c.stroke()
// 履带
c.fillStyle = '#4a4f55'
c.beginPath(); c.roundRect(-13, 6, 26, 6, 3); c.fill(); c.stroke()
c.fillStyle = '#6f767d'
;[-9, -3, 3, 9].forEach((x) => {
c.beginPath(); c.arc(x, 9, 1.6, 0, TAU); c.fill()
})
// 指示灯
c.fillStyle = '#f0c04a'
c.beginPath(); c.arc(0, 0, 2.4, 0, TAU); c.fill()
},
},
ents: {
smelter: {
name: '熔炉',
conf: { size: 36, struct: true },
interact(p, e, g) {
if (g.hasCost(p, { rock: 3, log: 1 })) {
g.payCost(p, { rock: 3, log: 1 })
g.addItem(p, 'ingot', 1)
g.swing(p, 'chop')
g.shake(1)
g.ptip(p, '熔炉产出 1 块金属锭')
} else {
g.ptip(p, '需要 石头×3、木头×1')
}
},
draw(ctx, e, env) {
// 底部投影
ctx.fillStyle = 'rgba(0,0,0,0.25)'
ctx.beginPath(); ctx.ellipse(0, 5, 22, 7, 0, 0, TAU); ctx.fill()
// 石砌炉体
ctx.fillStyle = '#8d9097'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 2
ctx.beginPath(); ctx.moveTo(-20, 6); ctx.lineTo(-15, -22); ctx.lineTo(15, -22); ctx.lineTo(20, 6); ctx.closePath()
ctx.fill(); ctx.stroke()
// 砖缝
ctx.strokeStyle = '#6f767d'
ctx.lineWidth = 1.2
;[-14, -4].forEach((y) => {
const half = 15 + ((y + 22) / 28) * 5
ctx.beginPath(); ctx.moveTo(-half, y); ctx.lineTo(half, y); ctx.stroke()
})
ctx.beginPath(); ctx.moveTo(0, -22); ctx.lineTo(0, 6); ctx.stroke()
// 顶部烟囱
ctx.fillStyle = '#6f767d'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 2
ctx.beginPath(); ctx.roundRect(-5, -34, 10, 14, 2); ctx.fill(); ctx.stroke()
// 炉口(呼吸火光)
const k = 0.6 + Math.sin((env?.time || 0) * 3 + (e.id || 0)) * 0.4
ctx.fillStyle = `rgba(224,102,46,${0.7 + k * 0.3})`
ctx.beginPath(); ctx.roundRect(-7, -6, 14, 12, 3); ctx.fill()
ctx.fillStyle = `rgba(240,192,74,${0.5 + k * 0.5})`
ctx.beginPath(); ctx.arc(0, 2, 3 + k * 2, 0, TAU); ctx.fill()
// 顶烟
ctx.fillStyle = `rgba(200,200,200,${0.14 + k * 0.06})`
ctx.beginPath(); ctx.arc(0, -40, 5, 0, TAU); ctx.fill()
},
},
steamengine: {
name: '蒸汽机',
conf: { size: 38, struct: true },
init(e) {
if (e.fuel == null) e.fuel = 0
if (e.on == null) e.on = false
},
update(e, dt, g) {
e.on = (e.fuel || 0) > 0
},
interact(p, e, g) {
if (g.hasCost(p, { log: 1 })) {
g.payCost(p, { log: 1 })
e.fuel = (e.fuel || 0) + 3
g.markDirty(e)
g.ptip(p, `投入木头,蒸汽机燃料剩余 ${e.fuel}`)
} else {
g.ptip(p, '蒸汽机需要木头作燃料')
}
},
draw(ctx, e, env) {
const t = env?.time || 0
const on = !!e.on
// 底部投影
ctx.fillStyle = 'rgba(0,0,0,0.25)'
ctx.beginPath(); ctx.ellipse(0, 6, 22, 7, 0, 0, TAU); ctx.fill()
// 底座
ctx.fillStyle = '#5f4626'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 2
ctx.beginPath(); ctx.roundRect(-20, 2, 40, 8, 2); ctx.fill(); ctx.stroke()
// 黄铜锅炉
ctx.fillStyle = on ? '#c9a34a' : '#8a7a4a'
ctx.beginPath(); ctx.roundRect(-18, -16, 36, 20, 5); ctx.fill(); ctx.stroke()
// 压力表
ctx.fillStyle = '#e8e3d6'
ctx.beginPath(); ctx.arc(-6, -6, 6, 0, TAU); ctx.fill(); ctx.stroke()
ctx.strokeStyle = '#c0455a'
ctx.lineWidth = 1.6
ctx.beginPath(); ctx.moveTo(-6, -6); ctx.lineTo(-6 + (on ? 4 : 2), -10); ctx.stroke()
// 铆钉
ctx.fillStyle = on ? '#8a6a24' : '#5f5432'
;[[8, -12], [14, -12], [8, 0], [14, 0]].forEach(([x, y]) => {
ctx.beginPath(); ctx.arc(x, y, 1.6, 0, TAU); ctx.fill()
})
// 活塞汽缸
ctx.fillStyle = '#8d949c'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 2
ctx.beginPath(); ctx.roundRect(-7, -28, 14, 12, 3); ctx.fill(); ctx.stroke()
const piston = on ? Math.sin(t * 6 + (e.id || 0)) * 4 : 0
ctx.beginPath(); ctx.roundRect(-2.5, -36 + piston, 5, 12, 2); ctx.fill(); ctx.stroke()
// 蒸汽白烟(供能时冒烟)
if (on) {
ctx.fillStyle = '#f0f4f8'
for (let i = 0; i < 3; i++) {
const p = (t * 1.4 + i * 0.33 + (e.id || 0) * 0.1) % 1
ctx.globalAlpha = 1 - p
ctx.beginPath(); ctx.arc(0, -40 - p * 18, 3 + p * 5, 0, TAU); ctx.fill()
}
ctx.globalAlpha = 1
}
},
},
factory: {
name: '工厂',
conf: { size: 40, struct: true },
interact(p, e, g) {
const powered = g.entities.some((s) => s.type === 'steamengine' && s.on && Math.hypot(s.x - e.x, s.y - e.y) < STEAM_RANGE)
if (!powered) {
g.ptip(p, '需要通蒸汽动力(在附近建蒸汽机并加燃料)')
return
}
if (g.hasCost(p, { ingot: 2 })) {
g.payCost(p, { ingot: 2 })
g.addItem(p, 'gear', 1)
g.ptip(p, '齿轮 ×1')
} else {
g.ptip(p, '需要 金属锭×2')
}
},
draw(ctx, e, env) {
const t = env?.time || 0
// 底部投影
ctx.fillStyle = 'rgba(0,0,0,0.25)'
ctx.beginPath(); ctx.ellipse(0, 8, 24, 8, 0, 0, TAU); ctx.fill()
// 砖砌厂房
ctx.fillStyle = '#b06a4a'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 2
ctx.beginPath(); ctx.roundRect(-22, -26, 44, 32, 3); ctx.fill(); ctx.stroke()
// 砖纹
ctx.strokeStyle = '#8f4f34'
ctx.lineWidth = 1
;[-20, -14, -8, -2, 4].forEach((y) => {
ctx.beginPath(); ctx.moveTo(-22, y); ctx.lineTo(22, y); ctx.stroke()
})
// 屋顶横梁
ctx.fillStyle = '#8a5040'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 2
ctx.beginPath(); ctx.roundRect(-24, -30, 48, 6, 2); ctx.fill(); ctx.stroke()
// 烟囱
ctx.fillStyle = '#6f5040'
ctx.beginPath(); ctx.roundRect(12, -40, 7, 12, 2); ctx.fill(); ctx.stroke()
// 齿轮窗(旋转)
ctx.save()
ctx.translate(0, -8)
ctx.rotate(t * 1.2 + (e.id || 0))
ctx.fillStyle = '#c9a34a'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 1.4
for (let i = 0; i < 8; i++) {
ctx.save()
ctx.rotate((i / 8) * TAU)
ctx.beginPath(); ctx.roundRect(-2, -10, 4, 4.5, 1.2); ctx.fill(); ctx.stroke()
ctx.restore()
}
ctx.lineWidth = 1.8
ctx.beginPath(); ctx.arc(0, 0, 7, 0, TAU); ctx.fill(); ctx.stroke()
ctx.fillStyle = '#8a6a24'
ctx.beginPath(); ctx.arc(0, 0, 2.6, 0, TAU); ctx.fill()
ctx.restore()
},
},
miner: {
name: '自动矿机',
conf: { size: 34, struct: true },
interact(p, e, g) {
const powered = g.entities.some((s) => s.type === 'steamengine' && s.on && Math.hypot(s.x - e.x, s.y - e.y) < STEAM_RANGE)
g.ptip(p, powered ? '矿机正在满速运转(每台每天自动开采)' : '缺少蒸汽动力,效率减半')
},
draw(ctx, e, env) {
const t = env?.time || 0
// 底部投影
ctx.fillStyle = 'rgba(0,0,0,0.25)'
ctx.beginPath(); ctx.ellipse(0, 6, 20, 7, 0, 0, TAU); ctx.fill()
// 履带底盘
ctx.fillStyle = '#4a4f55'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 2
ctx.beginPath(); ctx.roundRect(-18, -2, 36, 8, 4); ctx.fill(); ctx.stroke()
ctx.fillStyle = '#6f767d'
;[-12, -4, 4, 12].forEach((x) => {
ctx.beginPath(); ctx.arc(x, 2, 2, 0, TAU); ctx.fill()
})
// 金属机身
ctx.fillStyle = '#8d949c'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 2
ctx.beginPath(); ctx.roundRect(-14, -22, 28, 20, 3); ctx.fill(); ctx.stroke()
// 铆钉
ctx.fillStyle = '#5f666d'
;[[-10, -18], [10, -18], [-10, -6], [10, -6]].forEach(([x, y]) => {
ctx.beginPath(); ctx.arc(x, y, 1.6, 0, TAU); ctx.fill()
})
// 指示灯
ctx.fillStyle = '#f0c04a'
ctx.beginPath(); ctx.arc(0, -14, 2.4, 0, TAU); ctx.fill()
// 钻头(上下活塞)
const d = Math.sin(t * 7 + (e.id || 0)) * 5
ctx.fillStyle = '#b7bec7'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 2
ctx.beginPath(); ctx.moveTo(-6, -22); ctx.lineTo(6, -22); ctx.lineTo(0, -34 - d); ctx.closePath()
ctx.fill(); ctx.stroke()
// 钻纹
ctx.strokeStyle = '#7f878f'
ctx.lineWidth = 1.2
ctx.beginPath(); ctx.moveTo(-4, -26 - d * 0.4); ctx.lineTo(4, -26 - d * 0.4); ctx.stroke()
},
},
},
hooks: {
onNewDay(g) {
// 自动矿机开采:附近有供能的蒸汽机则翻倍
g.entities.filter((e) => e.type === 'miner').forEach((m) => {
const powered = g.entities.some((s) => s.type === 'steamengine' && s.on && Math.hypot(s.x - m.x, s.y - m.y) < STEAM_RANGE)
g.dropLoot(m.x + g.rand(-24, 24), m.y + g.rand(-24, 24), 'rock', powered ? 4 : 2)
g.dropLoot(m.x + g.rand(-24, 24), m.y + g.rand(-24, 24), 'flint', powered ? 2 : 1)
})
// 蒸汽机每日消耗 1 份燃料
g.entities.filter((e) => e.type === 'steamengine').forEach((s) => {
s.fuel = Math.max(0, (s.fuel || 0) - 1)
s.on = s.fuel > 0
g.markDirty(s)
})
},
},
}

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@
// - activateMods() 把选中的 mod 合并成运行时注册表(不改动 data.js 基表)
// - 核心只依赖本文件与 index.js;mod 只依赖传入的 g(游戏受控上下文),互不感知实现
import {
ITEMS, RECIPES, TUNE, MON, MOB_DROPS, RES, NAMES,
ITEMS, RECIPES, TUNE, MON, MOB_DROPS, RES, NAMES, CRAFT_TABS,
} from '../data.js'
import { MODS } from './index.js'
@@ -20,6 +20,7 @@ export function activateMods(ids = []) {
metas: act.map(({ id, name, desc, factor }) => ({ id, name, desc, factor: factor || 1 })),
items: { ...ITEMS },
recipes: [...RECIPES],
tabs: [...CRAFT_TABS], // 合成页签(基表 + mod 新增,按 code 去重)
tune: { ...TUNE },
mon: { ...MON },
mobDrops: { ...MOB_DROPS },
@@ -39,6 +40,10 @@ export function activateMods(ids = []) {
rt.names[code] = def.name
})
;(mod.recipes || []).forEach((r) => rt.recipes.push(r))
// 合并 mod 新增的合成页签(按 code 去重,不覆盖已有页签)
;(mod.tabs || []).forEach((t) => {
if (!rt.tabs.some((x) => x.code === t.code)) rt.tabs.push(t)
})
Object.assign(rt.tune, mod.tune || {})
Object.assign(rt.flags, mod.flags || {})
Object.entries(mod.ents || {}).forEach(([type, def]) => {

View File

@@ -0,0 +1,131 @@
// 文明火种:出生点旁放一只扎红绸木箱,首次开启一次性发齐开局物资(工具/种子/建材)
// - 与 chest.js 的散落宝箱区分:出生点固定 1 只、系红绸带、只发固定清单不做随机
// - 防御式发放:只给"当前物品表里确实存在"的物品,保证独立开启或任意 mod 组合都不报错
const TAU = Math.PI * 2
// 固定发放(vanilla 核心物资,必定存在)
const GIVE_ALWAYS = [
['axe', 1], ['pickaxe', 1], ['torch', 2], ['grass', 10], ['twig', 10],
['log', 8], ['rock', 8], ['rope', 2], ['berry', 6], ['healingsalve', 2],
]
// 条件发放(依赖其它 mod:农业/农场等;不存在则跳过)
const GIVE_IF = [
['carrotseeds', 3], ['berryseeds', 3], ['wheatseeds', 3],
['pumpkinseeds', 3], ['reeds', 2], ['flint', 6],
]
function openBox(p, e, g) {
e.state = 'open'
g.markDirty(e)
g.swing(p, 'chop')
const got = []
const give = (code, n) => {
g.addItem(p, code, n)
got.push(`${g.items[code]?.name || code} ×${n}`)
}
GIVE_ALWAYS.forEach(([code, n]) => {
if (g.items[code]) give(code, n)
})
GIVE_IF.forEach(([code, n]) => {
if (g.items[code]) give(code, n)
})
g.addScore(15, p)
g.shake(1.5)
g.ptip(p, `打开木箱:${got.join('、')}`)
}
export default {
id: 'startchest',
name: '文明火种',
desc: '出生点旁的起始宝箱:工具、种子与建材一次到位',
factor: 0.9,
tag: 'easy',
ents: {
startchest: {
name: '文明火种木箱',
conf: { size: 30 },
init(e) {
if (!e.state) e.state = 'closed'
},
interact(p, e, g) {
if (e.state === 'closed') {
openBox(p, e, g)
return
}
g.ptip(p, '箱子已经空了')
},
draw(ctx, e, env) {
const open = e.state === 'open'
// 底部投影
ctx.fillStyle = 'rgba(0,0,0,0.25)'
ctx.beginPath()
ctx.ellipse(0, 4, 18, 6, 0, 0, TAU)
ctx.fill()
// 箱体(红木)
ctx.fillStyle = '#8a4b32'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 2
ctx.beginPath()
ctx.roundRect(-17, -12, 34, 18, 3)
ctx.fill()
ctx.stroke()
// 盖子:闭合盖在上方 / 打开向后仰并露出内部
ctx.fillStyle = open ? '#5d3320' : '#a05a3c'
ctx.beginPath()
if (open) {
ctx.roundRect(-17, -26, 34, 9, 3)
ctx.fill()
ctx.stroke()
ctx.fillStyle = '#2a160e'
ctx.fillRect(-14, -12, 28, 6)
} else {
ctx.roundRect(-18, -18, 36, 9, 3)
ctx.fill()
ctx.stroke()
}
// 金色包边 + 锁扣
ctx.strokeStyle = '#d8b04a'
ctx.lineWidth = 2
ctx.beginPath()
ctx.moveTo(-11, open ? -12 : -18)
ctx.lineTo(-11, 6)
ctx.moveTo(11, open ? -12 : -18)
ctx.lineTo(11, 6)
ctx.stroke()
// 红绸带(与普通宝箱区分)
ctx.strokeStyle = '#c8323f'
ctx.lineWidth = 3
ctx.beginPath()
ctx.moveTo(0, open ? -26 : -18)
ctx.lineTo(0, 6)
ctx.stroke()
ctx.beginPath()
ctx.moveTo(-18, -4)
ctx.lineTo(18, -4)
ctx.stroke()
if (!open) {
ctx.fillStyle = '#d8b04a'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 1.4
ctx.beginPath()
ctx.roundRect(-3, -12, 6, 7, 1.5)
ctx.fill()
ctx.stroke()
// 呼吸微光
const tw = 0.5 + Math.sin((env?.time || 0) * 3 + e.id) * 0.5
ctx.fillStyle = `rgba(255,230,150,${0.22 + tw * 0.3})`
ctx.beginPath()
ctx.arc(7, -15, 1.6 + tw, 0, TAU)
ctx.fill()
}
},
},
},
hooks: {
onWorldGen(g) {
const spot = g.findLand(g.worldW / 2 + 70, g.worldH / 2 + 40, 0, 30)
g.addEntity('startchest', spot.x, spot.y)
g.tip('脚边放着一只扎红绸的木箱……')
},
},
}

View File

@@ -0,0 +1,10 @@
// 辽阔世界:把世界放大 1.5 倍,多出近一倍土地可供开垦
// 本 mod 不含任何物品/实体/配方/钩子,唯一作用是通过 flags.worldScale 让核心重建更大的世界
export default {
id: 'wideworld',
name: '辽阔世界',
desc: '把世界放大 1.5 倍,多出近一倍土地可供开垦',
factor: 0.9, // 更容易 → 得分打折
tag: 'rule',
flags: { worldScale: 1.5 },
}

View File

@@ -6,9 +6,18 @@ function randInt(min, max) {
}
export const TILE = 80
export const GW = 64
export const GH = 48
export let GW = 64
export let GH = 48
export const WORLD = { w: GW * TILE, h: GH * TILE } // 3200 x 2400
// 世界尺寸可配置:GW/GH 为模块级 let,genIsland/planEntities/genDecor/drawTiles/tileIdx
// 等函数闭包引用它们(ES module 实时绑定),改尺寸后自动跟随;WORLD 保持同一对象只改属性。
export function configureWorld(scale = 1) {
const s = Math.max(0.5, Math.min(2, Number(scale) || 1))
GW = Math.round(64 * s)
GH = Math.round(48 * s)
WORLD.w = GW * TILE
WORLD.h = GH * TILE
}
export const BIOMES = {
1: { code: 'meadow', name: '出生草原', color: '#4a5d33', map: '#5d7a3d' },

File diff suppressed because it is too large Load Diff