diff --git a/_campsmoke.mjs b/_campsmoke.mjs new file mode 100644 index 0000000..001eca0 --- /dev/null +++ b/_campsmoke.mjs @@ -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) diff --git a/_campveto.mjs b/_campveto.mjs new file mode 100644 index 0000000..af4f2ca --- /dev/null +++ b/_campveto.mjs @@ -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) diff --git a/_qa_debug.mjs b/_qa_debug.mjs new file mode 100644 index 0000000..f017707 --- /dev/null +++ b/_qa_debug.mjs @@ -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)) diff --git a/src/games/components/StarveGame.vue b/src/games/components/StarveGame.vue index 81c1596..56727f7 100644 --- a/src/games/components/StarveGame.vue +++ b/src/games/components/StarveGame.vue @@ -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() } diff --git a/src/games/starve/mobs.js b/src/games/starve/mobs.js index 6d70ed0..10087d0 100644 --- a/src/games/starve/mobs.js +++ b/src/games/starve/mobs.js @@ -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 } } }) diff --git a/src/games/starve/mods/agri.js b/src/games/starve/mods/agri.js new file mode 100644 index 0000000..e0e808b --- /dev/null +++ b/src/games/starve/mods/agri.js @@ -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, '摘到了一把南瓜种子') + } + }, + }, +} diff --git a/src/games/starve/mods/camp.js b/src/games/starve/mods/camp.js new file mode 100644 index 0000000..280b07a --- /dev/null +++ b/src/games/starve/mods/camp.js @@ -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 + }, + }, +} diff --git a/src/games/starve/mods/husbandry.js b/src/games/starve/mods/husbandry.js new file mode 100644 index 0000000..0545b78 --- /dev/null +++ b/src/games/starve/mods/husbandry.js @@ -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) + }) + }, + }, +} diff --git a/src/games/starve/mods/index.js b/src/games/starve/mods/index.js index 4b7d241..54826e4 100644 --- a/src/games/starve/mods/index.js +++ b/src/games/starve/mods/index.js @@ -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] diff --git a/src/games/starve/mods/industry.js b/src/games/starve/mods/industry.js new file mode 100644 index 0000000..92acdec --- /dev/null +++ b/src/games/starve/mods/industry.js @@ -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) + }) + }, + }, +} diff --git a/src/games/starve/mods/labor.js b/src/games/starve/mods/labor.js new file mode 100644 index 0000000..3e1f032 --- /dev/null +++ b/src/games/starve/mods/labor.js @@ -0,0 +1,1178 @@ +// 劳务市场:用粮食招聘农民与牧工,替你自动开垦、播种、收割、入库与投喂 +// - 招聘所 laboroffice:打开面板招/解雇岗位(农民上限 4、牧工上限 4) +// - 大仓库 laborwarehouse:12 个箱子 × 6 种 × 999,共 72 格容量,农民收获自动入库 +// - 农民 laborfarmer:收割 ready 良田 → 播种空良田 → 开垦新田(需农业 mod)→ 游荡 +// - 牧工 laborherder:投喂饲料槽 → 收鸡蛋入仓(需牧业 mod)→ 游荡 +// - 每日工资:每亩人手 2 份粮食,优先扣仓库,付不出则全体罢工;补上即复工 +// - 货币「粮食」= 任何带 food 属性的物品(背包优先,不够再扣大仓库) +// 数值基准:三围 150/150/200,一天 160 秒 +const TAU = Math.PI * 2 + +// 种子 → 成熟作物 code(agri.js 的 SEED_CROP 未导出,此处自备一份映射) +const SEED_CROP = { wheatseeds: 'wheat', pumpkinseeds: 'pumpkin', berryseeds: 'berry', carrotseeds: 'carrot' } +const SEEDS = Object.keys(SEED_CROP) +// 牧工可投喂的作物 +const FEEDS = ['wheat', 'pumpkin', 'carrot', 'berry'] +// 岗位与经济 +const MAX_WORKERS = 4 +const FARMER_FEE = 8 +const HERDER_FEE = 6 +const WAGE = 2 +// 大仓库结构:12 箱 × 每箱 6 种 × 每种 999 = 72 格容量 +const BOX_COUNT = 12 +const BOX_KINDS = 6 +const BOX_STACK = 999 + +// 当前打开的面板(存实体 id):招聘所 / 大仓库 +let officeOpen = null +let storeOpen = null +// onDrawUI 保存的屏幕参数,供 onClick(世界坐标)换算命中 +let uiCamX = 0 +let uiCamY = 0 +let uiW = 800 +let uiH = 600 +// 「缺少前置 mod」提示只出一次的开关 +let toldNoAgri = false +let toldNoHusbandry = false +let toldNoSeed = false + +// ===================================================================== +// 通用工具 +// ===================================================================== +function entById(g, id) { + return g.entities.find((e) => e.id === id) || null +} + +function mxOf(office) { + if (!office.mx) office.mx = { hired: 0, farmer: 0, herder: 0, strike: 0 } + const mx = office.mx + if (mx.farmer == null) mx.farmer = 0 + if (mx.herder == null) mx.herder = 0 + if (mx.strike == null) mx.strike = 0 + mx.hired = (mx.farmer || 0) + (mx.herder || 0) + return mx +} + +// 取(并补齐)大仓库存放结构 +function boxesOf(wh) { + if (!wh.mx) wh.mx = {} + let bx = wh.mx.boxes + if (!Array.isArray(bx)) bx = wh.mx.boxes = [] + while (bx.length < BOX_COUNT) bx.push({ items: {} }) + for (let i = 0; i < bx.length; i++) { + if (!bx[i] || typeof bx[i] !== 'object') bx[i] = { items: {} } + if (!bx[i].items || typeof bx[i].items !== 'object') bx[i].items = {} + } + return bx +} + +// 最近的仓库(作为劳作锚点) +function nearestWarehouse(g, ref) { + let best = null + let bd = Infinity + g.entities.forEach((e) => { + if (e.type !== 'laborwarehouse') return + const d = Math.hypot(e.x - ref.x, e.y - ref.y) + if (d < bd) { bd = d; best = e } + }) + return best +} + +// 劳作锚点:优先仓库,其次主人,最后世界中心 +function anchorOf(g, m) { + const wh = nearestWarehouse(g, m) + if (wh) return { x: wh.x, y: wh.y } + if (m.owner) return { x: m.owner.x, y: m.owner.y } + return { x: g.worldW / 2, y: g.worldH / 2 } +} + +// 最近的符合 type/谓词的实体 +function nearestEnt(g, m, type, pred) { + let best = null + let bd = Infinity + g.entities.forEach((e) => { + if (e.type !== type) return + if (pred && !pred(e)) return + const d = Math.hypot(e.x - m.x, e.y - m.y) + if (d < bd) { bd = d; best = e } + }) + return best +} + +// 附近小范围游荡(照抄 loyaldog 流浪分支) +function wanderAround(m, dt, g, cx, cy, radius, speed) { + m.wanderT = (m.wanderT || 0) - dt + if (m.wanderT <= 0 || m.tx === undefined) { + m.wanderT = 2 + Math.random() * 3 + const a = Math.random() * TAU + m.tx = cx + Math.cos(a) * radius + m.ty = cy + Math.sin(a) * radius + } + const d = Math.hypot(m.tx - m.x, m.ty - m.y) + if (d > 10) g.moveToward(m, m.tx, m.ty, speed, dt) +} + +// ===================================================================== +// 粮食货币:判定 / 统计 / 结算 +// ===================================================================== +function playerFood(g, p) { + let n = 0 + if (!p) return 0 + const areas = [p.slots, p.pack] + for (const arr of areas) { + if (!arr) continue + for (const s of arr) if (s && g.items[s.code]?.food) n += s.n + } + return n +} + +function warehouseFood(g, wh) { + let n = 0 + if (!wh) return 0 + boxesOf(wh).forEach((b) => { + Object.entries(b.items).forEach(([code, k]) => { if (g.items[code]?.food) n += k }) + }) + return n +} + +function availableFood(g, p, wh) { + return playerFood(g, p) + warehouseFood(g, wh) +} + +// 从玩家背包扣粮食,返回实际扣除数 +function takeFromPlayer(g, p, need) { + if (!p) return 0 + let left = need + const areas = [p.slots, p.pack] + for (const arr of areas) { + if (!arr) continue + for (let i = 0; i < arr.length && left > 0; i++) { + const s = arr[i] + if (!s || !g.items[s.code]?.food) continue + const take = Math.min(s.n, left) + s.n -= take + left -= take + if (s.n <= 0) arr[i] = null + } + } + return need - left +} + +// 从大仓库扣粮食,返回实际扣除数 +function takeFromStore(g, wh, need) { + if (!wh || need <= 0) return 0 + let left = need + const boxes = boxesOf(wh) + for (const b of boxes) { + if (left <= 0) break + for (const code of Object.keys(b.items)) { + if (left <= 0) break + if (!g.items[code]?.food) continue + const have = b.items[code] + const take = Math.min(have, left) + b.items[code] -= take + if (b.items[code] <= 0) delete b.items[code] + left -= take + } + } + const got = need - left + if (got > 0) g.markDirty(wh) + return got +} + +// 结算一笔粮食:storeFirst=true 仓库优先(工资),false 背包优先(招聘) +function takeFood(g, p, wh, need, storeFirst) { + if (need <= 0) return true + let left = need + if (storeFirst) left -= takeFromStore(g, wh, left) + if (left > 0) left -= takeFromPlayer(g, p, left) + if (left > 0) left -= takeFromStore(g, wh, left) + return left <= 0 +} + +function payerOf(g) { + return g.players.find((p) => !p.dead) || g.me() || null +} + +// ===================================================================== +// 大仓库存取 +// ===================================================================== +// 存入,返回放不下的余量 +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 + } + } + if (left !== n) g.markDirty(wh) + return left +} + +// 取出,返回实际取出数 +function withdraw(g, wh, code, n) { + if (!wh) return 0 + const boxes = boxesOf(wh) + let need = n + for (const b of boxes) { + if (need <= 0) break + const have = b.items[code] || 0 + if (have > 0) { + const take = Math.min(have, need) + b.items[code] -= take + if (b.items[code] <= 0) delete b.items[code] + need -= take + } + } + const got = n - need + if (got > 0) g.markDirty(wh) + return got +} + +function warehouseCount(wh, code) { + let n = 0 + boxesOf(wh).forEach((b) => { n += b.items[code] || 0 }) + return n +} + +// 把玩家身上的东西尽量塞进指定箱子(受该箱容量限制) +function boxDeposit(g, p, wh, bi) { + const box = boxesOf(wh)[bi] + if (!box) return + 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 (take <= 0) continue + box.items[s.code] = have + take + s.n -= take + moved += take + if (s.n <= 0) arr[i] = null + } + } + if (moved > 0) g.markDirty(wh) + g.ptip(p, moved > 0 ? `存入箱子 ${bi + 1}:×${moved}` : '这个箱子放不下了') +} + +// 把指定箱子内容全部取回背包 +function boxWithdraw(g, p, wh, bi) { + const box = boxesOf(wh)[bi] + if (!box) return + const entries = Object.entries(box.items) + if (!entries.length) { + g.ptip(p, '这个箱子是空的') + return + } + let moved = 0 + entries.forEach(([code, n]) => { + if (n > 0) { g.addItem(p, code, n); moved += n } + }) + box.items = {} + g.markDirty(wh) + g.ptip(p, `取出箱子 ${bi + 1}:×${moved}`) +} + +// ===================================================================== +// 招聘 / 解雇 +// ===================================================================== +function hire(g, p, office, role) { + const mx = mxOf(office) + const isFarmer = role === 'farmer' + const roleKey = isFarmer ? 'farmer' : 'herder' + const kind = isFarmer ? 'laborfarmer' : 'laborherder' + const fee = isFarmer ? FARMER_FEE : HERDER_FEE + if ((mx[roleKey] || 0) >= MAX_WORKERS) { + g.ptip(p, `该岗位最多雇 ${MAX_WORKERS} 人`) + return + } + const wh = nearestWarehouse(g, office) + if (availableFood(g, p, wh) < fee) { + g.ptip(p, `粮食不足:招聘需要 ${fee} 份粮食`) + return + } + takeFood(g, p, wh, fee, false) + const spot = g.findLand(office.x, office.y + 50, 22, 70) + g.spawnMob(kind, spot.x, spot.y, { owner: p, office: office.id, role: roleKey, hiredAt: g.day }) + mx[roleKey] = (mx[roleKey] || 0) + 1 + mx.hired = (mx.farmer || 0) + (mx.herder || 0) + g.markDirty(office) + g.ptip(p, isFarmer ? '雇到了一位农民' : '雇到了一位牧工') +} + +function fire(g, p, office, role) { + const mx = mxOf(office) + const isFarmer = role === 'farmer' + const roleKey = isFarmer ? 'farmer' : 'herder' + const kind = isFarmer ? 'laborfarmer' : 'laborherder' + const mine = g.monsters.filter((m) => m.kind === kind && m.office === office.id) + if (!mine.length) { + mx[roleKey] = 0 + mx.hired = (mx.farmer || 0) + (mx.herder || 0) + g.markDirty(office) + g.ptip(p, '没有可以解雇的人手') + return + } + g.removeMob(mine[mine.length - 1]) + mx[roleKey] = Math.max(0, (mx[roleKey] || 0) - 1) + mx.hired = (mx.farmer || 0) + (mx.herder || 0) + g.markDirty(office) + g.ptip(p, isFarmer ? '解雇了一位农民' : '解雇了一位牧工') +} + +function roleInfo(g, office, role) { + const mx = mxOf(office) + const isFarmer = role === 'farmer' + const kind = isFarmer ? 'laborfarmer' : 'laborherder' + const key = isFarmer ? 'farmer' : 'herder' + const n = mx[key] || 0 + const striking = g.monsters.some((m) => m.kind === kind && m.office === office.id && m.strike) + return { n, striking } +} + +// ===================================================================== +// 农民劳作 +// ===================================================================== +function pickFarmerJob(m, g, anchor, agriOn) { + m.jobT = 8 + // 1) 收割:最近的 ready 良田 + const ready = nearestEnt(g, m, 'agrifarm', (e) => e.state === 'ready') + if (ready) { m.job = 'harvest'; m.jobTarget = ready; return } + // 2) 播种:空的良田 + const empty = nearestEnt(g, m, 'agrifarm', (e) => e.state === 'empty') + if (empty) { m.job = 'sow'; m.jobTarget = empty; return } + // 3) 开垦:空闲良田不足 3 块,则在锚点 320px 内找空地 + const emptyPlots = g.entities.filter((e) => e.type === 'agrifarm' && e.state === 'empty').length + if (agriOn && emptyPlots < 3) { + const spot = g.findLand(anchor.x, anchor.y, 30, 320) + const crowded = g.entities.some((e) => !e.deco && !e.loot && Math.hypot(e.x - spot.x, e.y - spot.y) < 34) + if (!crowded) { m.job = 'till'; m.jobTarget = { x: spot.x, y: spot.y }; return } + } + // 4) 无事可做:游荡 + m.job = 'idle' + m.jobTarget = null +} + +function doHarvest(m, g, plot, wh) { + const crop = plot.mx?.seed || 'wheat' + const n = g.rand(3, 4) + 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) + } + plot.state = 'empty' + plot.mx = null + g.markDirty(plot) + if (m.owner) g.ptip(m.owner, `农民收获${g.items[crop]?.name || '作物'} ×${n}`) +} + +function doSow(m, g, plot, wh) { + let crop = 'wheat' + let used = false + if (wh) { + const seed = SEEDS.find((s) => SEED_CROP[s] && warehouseCount(wh, s) > 0) + if (seed) { + withdraw(g, wh, seed, 1) + crop = SEED_CROP[seed] + used = true + } + } + if (!used && !toldNoSeed) { + toldNoSeed = true + if (m.owner) g.ptip(m.owner, '农民:仓库没有种子,先留种播种') + } + plot.mx = { seed: crop, t: 0 } + plot.state = 'grow0' + g.markDirty(plot) +} + +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') + const anchor = anchorOf(g, m) + const speed = g.mon[m.kind]?.speed || 118 + if (!agriOn) { + if (!toldNoAgri) { + toldNoAgri = true + if (m.owner) g.ptip(m.owner, '农民:需要农业文明 mod 才能耕作') + } + wanderAround(m, dt, g, anchor.x, anchor.y, 110, speed * 0.5) + 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) + + 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 (m.job === 'harvest') doHarvest(m, g, t, wh) + else doSow(m, g, t, wh) + m.job = null + m.jobT = 0 + 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) +} + +// ===================================================================== +// 牧工劳作 +// ===================================================================== +function pickHerderJob(m, g, wh) { + m.jobT = 8 + // 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)) { + m.job = 'feed' + m.jobTarget = trough + return + } + // 2) 收蛋:鸡舍 stored>0 + const coop = nearestEnt(g, m, 'coop', (e) => ((e.mx?.stored) || 0) > 0) + if (coop) { m.job = 'collect'; m.jobTarget = coop; return } + // 3) 游荡 + m.job = 'idle' + m.jobTarget = null +} + +function doFeed(m, g, trough, wh) { + if (!wh) return + const code = FEEDS.find((c) => g.items[c] && warehouseCount(wh, c) > 0) + if (!code) return + withdraw(g, wh, code, 1) + const feed = ((trough.mx?.feed) || 0) + 1 + trough.mx = { ...(trough.mx || {}), feed } + g.markDirty(trough) + if (m.owner) g.ptip(m.owner, `牧工投喂了一份${g.items[code]?.name || '饲料'}`) +} + +function doCollect(m, g, coop, wh) { + const n = (coop.mx?.stored) || 0 + if (n <= 0) return + let left = wh ? deposit(g, wh, 'egg', n) : n + if (left > 0) { + const bx = wh ? wh.x : coop.x + const by = wh ? wh.y : coop.y + g.dropLoot(bx + g.rand(-20, 20), by + g.rand(16, 32), 'egg', left) + } + coop.mx = { ...(coop.mx || {}), stored: 0 } + g.markDirty(coop) +} + +function updateHerder(m, dt, g) { + if (m.hp < 300) m.hp = 300 + if (m.strike) { m.moving = false; 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 + 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) + 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 === '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 (m.job === 'feed') doFeed(m, g, t, wh) + else doCollect(m, g, t, wh) + m.job = null + m.jobT = 0 + return + } + wanderAround(m, dt, g, anchor.x, anchor.y, 110, speed * 0.5) +} + +// ===================================================================== +// 面板布局(onDrawUI 与 onClick 共用同一套矩形,保证命中一致) +// ===================================================================== +function storeLayout(W, H) { + const pw = Math.min(560, W * 0.86) + const headH = 54 + const rowH = 78 + const footH = 78 + const nRows = 2 + const ph = headH + nRows * rowH + footH + const px = (W - pw) / 2 + const py = Math.max(14, (H - ph) / 2) + const bw = Math.min(88, pw * 0.22) + const bh = 42 + const btns = [] + const roles = ['farmer', 'herder'] + for (let i = 0; i < nRows; i++) { + const rowY = py + headH + i * rowH + const by = rowY + (rowH - bh) / 2 + btns.push({ id: roles[i] + ':hire', x: px + pw - bw * 2 - 30, y: by, w: bw, h: bh }) + 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 }) + 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 }) + } + 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 } +} + +function clipText(ctx, text, maxW) { + if (ctx.measureText(text).width <= maxW) return text + let t = text + while (t.length > 1 && ctx.measureText(t + '…').width > maxW) t = t.slice(0, -1) + return t + '…' +} + +function drawBtn(ctx, b, label, color, fs) { + ctx.fillStyle = color || '#5a4630' + ctx.strokeStyle = '#1d1409' + ctx.lineWidth = 2 + ctx.beginPath(); ctx.roundRect(b.x, b.y, b.w, b.h, 5); ctx.fill(); ctx.stroke() + ctx.fillStyle = '#f2e8d0' + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.font = `bold ${fs || 15}px "PingFang SC", "Microsoft YaHei", sans-serif` + ctx.fillText(label, b.x + b.w / 2, b.y + b.h / 2 + 1) +} + +function drawStore(g, ctx, office) { + const L = storeLayout(uiW, uiH) + ctx.save() + // 全屏遮罩 + ctx.fillStyle = 'rgba(0,0,0,0.42)' + ctx.fillRect(0, 0, uiW, uiH) + // 木质面板 + ctx.fillStyle = '#3a2a1a' + ctx.strokeStyle = '#1d1409' + ctx.lineWidth = 3 + ctx.beginPath(); ctx.roundRect(L.px, L.py, L.pw, L.ph, 10); ctx.fill(); ctx.stroke() + 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 22px "PingFang SC", "Microsoft YaHei", sans-serif' + ctx.fillText('劳 务 市 场', L.px + L.pw / 2, L.py + L.headH / 2 + 2) + ctx.strokeStyle = '#6b4a2e' + ctx.lineWidth = 1.2 + ctx.beginPath(); ctx.moveTo(L.px + 16, L.py + L.headH); ctx.lineTo(L.px + L.pw - 16, L.py + L.headH); ctx.stroke() + + const roles = [ + { role: 'farmer', label: '农民', fee: FARMER_FEE }, + { role: 'herder', label: '牧工', fee: HERDER_FEE }, + ] + ctx.textAlign = 'left' + roles.forEach((r, idx) => { + const rowY = L.py + L.headH + idx * L.rowH + const info = roleInfo(g, office, r.role) + ctx.fillStyle = '#e8d9b8' + ctx.font = 'bold 16px "PingFang SC", "Microsoft YaHei", sans-serif' + ctx.fillText(r.label, L.px + 18, rowY + 24) + ctx.fillStyle = '#c9b48c' + ctx.font = '12px "PingFang SC", "Microsoft YaHei", sans-serif' + ctx.fillText(`招聘费 ${r.fee} 份粮食 · 每日工资 ${WAGE} 份`, L.px + 18, rowY + 46) + ctx.fillStyle = info.striking ? '#e08a6a' : '#a9c98a' + ctx.fillText(`已有 ${info.n}/${MAX_WORKERS} · ${info.striking ? '罢工中' : '在岗'}`, L.px + 18, rowY + 64) + }) + // 岗位按钮 + L.btns.forEach((b) => { + if (b.id === 'close') return + drawBtn(ctx, b, b.id.endsWith('hire') ? '招 聘' : '解 雇', b.id.endsWith('hire') ? '#5d7a3a' : '#7a4a3a') + }) + // 底部提示 + const p = g.me() + const wh = nearestWarehouse(g, office) + ctx.fillStyle = '#b8a684' + ctx.font = '12px "PingFang SC", "Microsoft YaHei", sans-serif' + ctx.textAlign = 'center' + ctx.fillText(`粮食:背包 ${playerFood(g, p)} 份 / 仓库 ${warehouseFood(g, wh)} 份`, L.px + L.pw / 2, L.py + L.headH + roles.length * L.rowH + 18) + const cb = L.btns.find((b) => b.id === 'close') + if (cb) drawBtn(ctx, cb, '关 闭', '#5a4630') + ctx.restore() +} + +function drawWarehouse(g, ctx, wh) { + const L = whLayout(uiW, uiH) + ctx.save() + ctx.fillStyle = 'rgba(0,0,0,0.42)' + ctx.fillRect(0, 0, uiW, uiH) + ctx.fillStyle = '#3a2a1a' + ctx.strokeStyle = '#1d1409' + ctx.lineWidth = 3 + ctx.beginPath(); ctx.roundRect(L.px, L.py, L.pw, L.ph, 10); ctx.fill(); ctx.stroke() + 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.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) + } + // 按钮 + 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) + }) + ctx.restore() +} + +function hitBtn(btns, sx, sy) { + return btns.find((b) => sx >= b.x && sx <= b.x + b.w && sy >= b.y && sy <= b.y + b.h) || null +} + +function inRect(r, sx, sy) { + return sx >= r.px && sx <= r.px + r.pw && sy >= r.py && sy <= r.py + r.ph +} + +export default { + id: 'labor', + name: '劳务市场', + 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 个箱子 · 农民自动入库' }, + ], + icons: { + laboroffice: (c) => { + // 石基 + c.fillStyle = '#8d9097' + c.strokeStyle = '#1d1409' + c.lineWidth = 1.6 + c.beginPath(); c.roundRect(-13, 6, 26, 5, 1.5); c.fill(); c.stroke() + // 木墙 + c.fillStyle = '#a97b47' + c.beginPath(); c.roundRect(-11, -8, 22, 15, 2); c.fill(); c.stroke() + // 门 + c.fillStyle = '#5d3f24' + c.beginPath(); c.roundRect(-4, -2, 8, 9, [3, 3, 0, 0]); c.fill(); c.stroke() + // 斜屋顶 + c.fillStyle = '#7a5230' + c.beginPath(); c.moveTo(-14, -8); c.lineTo(14, -8); c.lineTo(0, -18); c.closePath(); c.fill(); c.stroke() + // 门口木牌 + c.fillStyle = '#c9a36b' + c.strokeStyle = '#1d1409' + c.lineWidth = 1.2 + c.beginPath(); c.roundRect(-18, -13, 8, 12, 1.5); c.fill(); c.stroke() + // 牌上麦穗标记 + c.strokeStyle = '#d8b06a' + c.lineWidth = 1 + c.beginPath(); c.moveTo(-14, -3); c.lineTo(-14, -12); c.stroke() + c.fillStyle = '#e6c34a' + ;[-10, -6.5].forEach((y) => { + c.beginPath(); c.ellipse(-16, y, 1.6, 2.4, -0.5, 0, TAU); c.fill() + c.beginPath(); c.ellipse(-12, y, 1.6, 2.4, 0.5, 0, TAU); c.fill() + }) + }, + laborwarehouse: (c) => { + // 石基 + c.fillStyle = '#8d9097' + c.strokeStyle = '#1d1409' + c.lineWidth = 1.6 + c.beginPath(); c.roundRect(-16, 6, 32, 5, 1.5); c.fill(); c.stroke() + // 木板墙 + c.fillStyle = '#a9773c' + c.beginPath(); c.roundRect(-14, -10, 28, 17, 2); c.fill(); c.stroke() + // 木纹 + c.strokeStyle = '#8a5f38' + c.lineWidth = 1 + c.beginPath(); c.moveTo(-14, -4); c.lineTo(14, -4); c.moveTo(-14, 1); c.lineTo(14, 1); c.stroke() + // 双开大门 + c.fillStyle = '#7a5230' + c.strokeStyle = '#1d1409' + c.lineWidth = 1.4 + c.beginPath(); c.roundRect(-7, -4, 14, 11, 1.5); c.fill(); c.stroke() + c.beginPath(); c.moveTo(0, -4); c.lineTo(0, 7); c.stroke() + // 大屋顶 + c.fillStyle = '#7a5230' + c.beginPath(); c.moveTo(-17, -10); c.lineTo(17, -10); c.lineTo(0, -20); c.closePath(); c.fill(); c.stroke() + // 门口木箱 + c.fillStyle = '#c9a36b' + c.strokeStyle = '#1d1409' + c.lineWidth = 1.2 + c.beginPath(); c.roundRect(-18, -1, 6, 7, 1); c.fill(); c.stroke() + c.beginPath(); c.roundRect(12, -1, 6, 7, 1); c.fill(); c.stroke() + }, + }, + ents: { + laboroffice: { + name: '招聘所', + conf: { size: 44, struct: true }, + init(e) { + if (!e.mx) e.mx = { hired: 0, farmer: 0, herder: 0, strike: 0 } + }, + interact(p, e, g) { + mxOf(e) + officeOpen = e.id + storeOpen = null + g.invalidate() + g.ptip(p, '劳务市场:点击面板按钮招聘 / 解雇') + }, + draw(ctx, e) { + // 投影 + ctx.fillStyle = 'rgba(0,0,0,0.25)' + ctx.beginPath(); ctx.ellipse(0, 6, 26, 8, 0, 0, TAU); ctx.fill() + // 石基 + ctx.fillStyle = '#8d9097' + ctx.strokeStyle = '#1d1409' + ctx.lineWidth = 2 + ctx.beginPath(); ctx.roundRect(-20, -2, 40, 10, 2); ctx.fill(); ctx.stroke() + // 木墙 + ctx.fillStyle = '#a97b47' + ctx.beginPath(); ctx.roundRect(-16, -30, 32, 28, 3); ctx.fill(); ctx.stroke() + // 木板纹 + ctx.strokeStyle = '#7a5230' + ctx.lineWidth = 1.2 + ;[-24, -16].forEach((y) => { + ctx.beginPath(); ctx.moveTo(-16, y); ctx.lineTo(16, y); ctx.stroke() + }) + // 门 + ctx.fillStyle = '#5d3f24' + ctx.strokeStyle = '#1d1409' + ctx.lineWidth = 2 + ctx.beginPath(); ctx.roundRect(-6, -20, 12, 18, [6, 6, 0, 0]); ctx.fill(); ctx.stroke() + // 屋顶 + ctx.fillStyle = '#7a5230' + ctx.beginPath(); ctx.moveTo(-22, -30); ctx.lineTo(22, -30); ctx.lineTo(0, -48); ctx.closePath(); ctx.fill(); ctx.stroke() + // 门口木牌 + ctx.fillStyle = '#c9a36b' + ctx.strokeStyle = '#1d1409' + ctx.lineWidth = 1.6 + ctx.beginPath(); ctx.roundRect(-28, -24, 13, 17, 2); ctx.fill(); ctx.stroke() + // 牌上麦穗标记 + ctx.strokeStyle = '#d8b06a' + ctx.lineWidth = 1.4 + ctx.beginPath(); ctx.moveTo(-21.5, -11); ctx.lineTo(-21.5, -21); ctx.stroke() + ctx.fillStyle = '#e6c34a' + ;[-18, -14.5].forEach((y) => { + ctx.beginPath(); ctx.ellipse(-24, y, 2, 3, -0.5, 0, TAU); ctx.fill() + ctx.beginPath(); ctx.ellipse(-19, y, 2, 3, 0.5, 0, TAU); ctx.fill() + }) + // 门口桌子 + ctx.fillStyle = '#8a5f38' + ctx.strokeStyle = '#1d1409' + ctx.lineWidth = 1.8 + ctx.beginPath(); ctx.roundRect(10, -12, 18, 5, 1.5); ctx.fill(); ctx.stroke() + ctx.beginPath(); ctx.roundRect(12, -7, 3, 10, 1); ctx.fill(); ctx.stroke() + ctx.beginPath(); ctx.roundRect(23, -7, 3, 10, 1); ctx.fill(); ctx.stroke() + }, + }, + laborwarehouse: { + name: '大仓库', + conf: { size: 46, struct: true }, + init(e) { + if (!e.mx) e.mx = { boxes: Array.from({ length: BOX_COUNT }, () => ({ items: {} })) } + }, + interact(p, e, g) { + boxesOf(e) + storeOpen = e.id + officeOpen = null + g.invalidate() + g.ptip(p, '大仓库:12 个箱子 · 共 72 格物品容量') + }, + draw(ctx, e) { + // 投影 + ctx.fillStyle = 'rgba(0,0,0,0.25)' + ctx.beginPath(); ctx.ellipse(0, 8, 32, 9, 0, 0, TAU); ctx.fill() + // 石基 + ctx.fillStyle = '#8d9097' + ctx.strokeStyle = '#1d1409' + ctx.lineWidth = 2 + ctx.beginPath(); ctx.roundRect(-26, 0, 52, 10, 2); ctx.fill(); ctx.stroke() + ctx.strokeStyle = '#6f7278' + ctx.lineWidth = 1.2 + ;[-16, -6, 6, 16].forEach((x) => { + ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x + 2, 10); ctx.stroke() + }) + // 木板墙 + ctx.fillStyle = '#a9773c' + ctx.strokeStyle = '#1d1409' + ctx.lineWidth = 2 + ctx.beginPath(); ctx.roundRect(-24, -34, 48, 36, 3); ctx.fill(); ctx.stroke() + ctx.strokeStyle = '#8a5f38' + ctx.lineWidth = 1.2 + ;[-28, -22, -16, -10].forEach((y) => { + ctx.beginPath(); ctx.moveTo(-24, y); ctx.lineTo(24, y); ctx.stroke() + }) + // 双开大门 + ctx.fillStyle = '#7a5230' + ctx.strokeStyle = '#1d1409' + ctx.lineWidth = 2 + ctx.beginPath(); ctx.roundRect(-12, -22, 24, 24, 2); ctx.fill(); ctx.stroke() + ctx.beginPath(); ctx.moveTo(0, -22); ctx.lineTo(0, 2); ctx.stroke() + ctx.fillStyle = '#4a3018' + ctx.beginPath(); ctx.arc(-3, -8, 1.6, 0, TAU); ctx.fill() + ctx.beginPath(); ctx.arc(3, -8, 1.6, 0, TAU); ctx.fill() + // 大屋顶 + ctx.fillStyle = '#7a5230' + ctx.strokeStyle = '#1d1409' + ctx.lineWidth = 2 + ctx.beginPath(); ctx.moveTo(-30, -34); ctx.lineTo(30, -34); ctx.lineTo(0, -56); ctx.closePath(); ctx.fill(); ctx.stroke() + ctx.strokeStyle = '#5d3f24' + ctx.lineWidth = 1.4 + ;[-18, -6, 6, 18].forEach((x) => { + ctx.beginPath(); ctx.moveTo(x, -34); ctx.lineTo(x * 0.4, -52); ctx.stroke() + }) + // 门口码放的木箱 + const crates = [[-30, 4], [-18, 6], [18, 4], [30, 6]] + crates.forEach(([x, y]) => { + ctx.fillStyle = '#c9a36b' + ctx.strokeStyle = '#1d1409' + ctx.lineWidth = 1.6 + ctx.beginPath(); ctx.roundRect(x - 6, y - 8, 12, 12, 2); ctx.fill(); ctx.stroke() + ctx.strokeStyle = '#a9773c' + ctx.lineWidth = 1 + ctx.beginPath(); ctx.moveTo(x - 6, y - 2); ctx.lineTo(x + 6, y - 2); ctx.stroke() + }) + }, + }, + }, + mobs: { + laborfarmer: { + name: '农民', + friendly: true, + conf: { hp: 300, speed: 118, dmg: 5, score: 0, friendly: true, name: '农民' }, + drops: [], + update(m, dt, g) { + updateFarmer(m, dt, g) + }, + interact(p, m, g) { + g.ptip(p, m.owner ? '这位农民正在替你干活(可在招聘所解雇)' : '一位流落的农民,正等着被雇佣') + }, + draw(ctx, m, time) { + ctx.save() + ctx.scale(m.dir || 1, 1) + const hop = Math.sin(time * 8 + (m.bob || 0)) * (m.moving ? 1.6 : 0.4) + // 投影 + ctx.fillStyle = 'rgba(0,0,0,0.25)' + ctx.beginPath(); ctx.ellipse(0, 3, 12, 4.5, 0, 0, TAU); ctx.fill() + // 腿(走路摆动) + ctx.strokeStyle = '#3f2f1e' + ctx.lineWidth = 4 + const leg = m.moving ? Math.sin(time * 10) * 3.2 : 0 + ctx.beginPath() + ctx.moveTo(-4, -10); ctx.lineTo(-5 + leg, 2) + ctx.moveTo(4, -10); ctx.lineTo(5 - leg, 2) + ctx.stroke() + // 布衣身体 + ctx.fillStyle = '#7d9a6a' + ctx.strokeStyle = '#1d1409' + ctx.lineWidth = 2 + ctx.beginPath(); ctx.roundRect(-8, -26 + hop * 0.3, 16, 18, 4); ctx.fill(); ctx.stroke() + // 腰带 + ctx.strokeStyle = '#5a4630' + ctx.lineWidth = 2.4 + ctx.beginPath(); ctx.moveTo(-8, -13 + hop * 0.3); ctx.lineTo(8, -13 + hop * 0.3); ctx.stroke() + // 头 + ctx.fillStyle = '#f0c89a' + ctx.strokeStyle = '#1d1409' + ctx.lineWidth = 2 + ctx.beginPath(); ctx.arc(0, -33 + hop * 0.4, 7, 0, TAU); ctx.fill(); ctx.stroke() + // 草帽 + ctx.fillStyle = '#d8b06a' + ctx.beginPath(); ctx.ellipse(0, -38 + hop * 0.4, 12, 4, 0, 0, TAU); ctx.fill(); ctx.stroke() + ctx.beginPath(); ctx.arc(0, -40 + hop * 0.4, 6, Math.PI, TAU); ctx.closePath(); ctx.fill(); ctx.stroke() + // 眼 + ctx.fillStyle = '#1d1409' + ctx.beginPath(); ctx.arc(3.2, -33 + hop * 0.4, 1.1, 0, TAU); ctx.fill() + // 肩上扛着的锄头 + ctx.save() + ctx.translate(-2, -30 + hop * 0.3) + ctx.rotate(-0.6) + ctx.strokeStyle = '#7a5b33' + ctx.lineWidth = 2.6 + ctx.beginPath(); ctx.moveTo(-16, 6); ctx.lineTo(14, -6); ctx.stroke() + ctx.fillStyle = '#8d9097' + ctx.strokeStyle = '#1d1409' + ctx.lineWidth = 1.4 + ctx.beginPath(); ctx.moveTo(10, -8); ctx.lineTo(18, -4); ctx.lineTo(18, -10); ctx.lineTo(11, -12); ctx.closePath(); ctx.fill(); ctx.stroke() + ctx.restore() + // 罢工标记(灰暗感叹号) + if (m.strike) { + ctx.fillStyle = '#8d9097' + ctx.strokeStyle = '#1d1409' + ctx.lineWidth = 1.4 + ctx.beginPath(); ctx.roundRect(-11, -60 + hop * 0.4, 9, 16, 3); ctx.fill(); ctx.stroke() + ctx.beginPath(); ctx.arc(-6.5, -41 + hop * 0.4, 1.8, 0, TAU); ctx.fill() + } + ctx.restore() + }, + }, + laborherder: { + name: '牧工', + friendly: true, + conf: { hp: 300, speed: 118, dmg: 5, score: 0, friendly: true, name: '牧工' }, + drops: [], + update(m, dt, g) { + updateHerder(m, dt, g) + }, + interact(p, m, g) { + g.ptip(p, m.owner ? '这位牧工正在替你干活(可在招聘所解雇)' : '一位流落的牧工,正等着被雇佣') + }, + draw(ctx, m, time) { + ctx.save() + ctx.scale(m.dir || 1, 1) + const hop = Math.sin(time * 8 + (m.bob || 0)) * (m.moving ? 1.6 : 0.4) + // 投影 + ctx.fillStyle = 'rgba(0,0,0,0.25)' + ctx.beginPath(); ctx.ellipse(0, 3, 12, 4.5, 0, 0, TAU); ctx.fill() + // 腿 + ctx.strokeStyle = '#3f2f1e' + ctx.lineWidth = 4 + const leg = m.moving ? Math.sin(time * 10) * 3.2 : 0 + ctx.beginPath() + ctx.moveTo(-4, -10); ctx.lineTo(-5 + leg, 2) + ctx.moveTo(4, -10); ctx.lineTo(5 - leg, 2) + ctx.stroke() + // 布衣身体 + ctx.fillStyle = '#8a7d5a' + ctx.strokeStyle = '#1d1409' + ctx.lineWidth = 2 + ctx.beginPath(); ctx.roundRect(-8, -26 + hop * 0.3, 16, 18, 4); ctx.fill(); ctx.stroke() + // 腰带 + ctx.strokeStyle = '#5a4630' + ctx.lineWidth = 2.4 + ctx.beginPath(); ctx.moveTo(-8, -13 + hop * 0.3); ctx.lineTo(8, -13 + hop * 0.3); ctx.stroke() + // 头 + ctx.fillStyle = '#e8b98a' + ctx.strokeStyle = '#1d1409' + ctx.lineWidth = 2 + ctx.beginPath(); ctx.arc(0, -33 + hop * 0.4, 7, 0, TAU); ctx.fill(); ctx.stroke() + // 宽檐帽 + ctx.fillStyle = '#b98f52' + ctx.beginPath(); ctx.ellipse(0, -37 + hop * 0.4, 15, 4.5, 0, 0, TAU); ctx.fill(); ctx.stroke() + ctx.beginPath(); ctx.arc(0, -39 + hop * 0.4, 6, Math.PI, TAU); ctx.closePath(); ctx.fill(); ctx.stroke() + // 眼 + ctx.fillStyle = '#1d1409' + ctx.beginPath(); ctx.arc(3.2, -33 + hop * 0.4, 1.1, 0, TAU); ctx.fill() + // 手里拿的干草叉 + ctx.strokeStyle = '#8a6a3a' + ctx.lineWidth = 2.4 + ctx.beginPath(); ctx.moveTo(9, 6); ctx.lineTo(13, -30 + hop * 0.3); ctx.stroke() + ctx.strokeStyle = '#b7bec7' + ctx.lineWidth = 1.6 + ;[-3, 0, 3].forEach((o) => { + ctx.beginPath(); ctx.moveTo(13, -30 + hop * 0.3); ctx.lineTo(13 + o, -40 + hop * 0.3); ctx.stroke() + }) + // 罢工标记(灰暗感叹号) + if (m.strike) { + ctx.fillStyle = '#8d9097' + ctx.strokeStyle = '#1d1409' + ctx.lineWidth = 1.4 + ctx.beginPath(); ctx.roundRect(-11, -60 + hop * 0.4, 9, 16, 3); ctx.fill(); ctx.stroke() + ctx.beginPath(); ctx.arc(-6.5, -41 + hop * 0.4, 1.8, 0, TAU); ctx.fill() + } + ctx.restore() + }, + }, + }, + hooks: { + onDrawUI(g, ctx, env) { + uiCamX = env.camX + uiCamY = env.camY + uiW = env.W + uiH = env.H + if (officeOpen != null) { + const office = entById(g, officeOpen) + if (!office) officeOpen = null + else drawStore(g, ctx, office) + } else if (storeOpen != null) { + const wh = entById(g, storeOpen) + if (!wh) storeOpen = null + else drawWarehouse(g, ctx, wh) + } + }, + onClick(g, wx, wy) { + if (officeOpen == null && storeOpen == null) return undefined + const p = g.me() + // 世界坐标 → 屏幕坐标(面板画在屏幕系) + const sx = wx - uiCamX + const sy = wy - uiCamY + if (officeOpen != null) { + const office = entById(g, officeOpen) + if (!office) { officeOpen = null; return true } + const L = storeLayout(uiW, uiH) + 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') + return true + } + if (!inRect(L, sx, sy)) officeOpen = null + return true + } + const wh = entById(g, storeOpen) + if (!wh) { storeOpen = null; return true } + const L = whLayout(uiW, uiH) + 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)) + return true + } + if (!inRect(L, sx, sy)) storeOpen = null + return true + }, + onNewDay(g) { + 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.office === office.id) + if (!mine.length) { mx.strike = 0; return } + const total = mine.length * WAGE + const wh = nearestWarehouse(g, office) + const p = payerOf(g) + if (availableFood(g, p, wh) >= total) { + takeFood(g, p, wh, total, true) // 工资优先扣仓库,再退回背包 + mine.forEach((m) => { m.strike = false }) + mx.strike = 0 + g.markDirty(office) + } else { + mine.forEach((m) => { m.strike = true }) + mx.strike = mine.length + g.markDirty(office) + anyStrike = true + } + }) + 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') + .map((m) => ({ + kind: m.kind, + x: Math.round(m.x), + y: Math.round(m.y), + strike: m.strike ? 1 : 0, + office: m.office != null ? m.office : null, + seat: m.owner && m.owner.seat !== undefined ? m.owner.seat : null, + })) + if (!offices.length && !workers.length) return null + return { offices, workers } + }, + load(g, data) { + officeOpen = null + storeOpen = null + // 恢复各所状态(核心已随实体恢复 mx,这里做补充与兼容) + ;(data.offices || []).forEach((rec) => { + const office = g.entities.find((e) => e.type === 'laboroffice' && e.id === rec.id) + if (office && rec.mx) office.mx = rec.mx + }) + // 复原工人;找不到对应招聘所的记录直接丢弃 + ;(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' : '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' : 'herder' + m.strike = !!w.strike + 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 + }) + }, + }, +} diff --git a/src/games/starve/mods/registry.js b/src/games/starve/mods/registry.js index 9b7c65c..83387a3 100644 --- a/src/games/starve/mods/registry.js +++ b/src/games/starve/mods/registry.js @@ -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]) => { diff --git a/src/games/starve/mods/startchest.js b/src/games/starve/mods/startchest.js new file mode 100644 index 0000000..986616c --- /dev/null +++ b/src/games/starve/mods/startchest.js @@ -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('脚边放着一只扎红绸的木箱……') + }, + }, +} diff --git a/src/games/starve/mods/wideworld.js b/src/games/starve/mods/wideworld.js new file mode 100644 index 0000000..003071a --- /dev/null +++ b/src/games/starve/mods/wideworld.js @@ -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 }, +} diff --git a/src/games/starve/world.js b/src/games/starve/world.js index c4dd76a..c934472 100644 --- a/src/games/starve/world.js +++ b/src/games/starve/world.js @@ -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' }, diff --git a/test-starve.mjs b/test-starve.mjs index 387dcb6..e2c56ce 100644 --- a/test-starve.mjs +++ b/test-starve.mjs @@ -1,5 +1,5 @@ // 临时冒烟测试:纯逻辑模块(不触 DOM)——世界生成 / 季节 / 烹饪锅 / 数据表一致性 / mod 注册表 -import { genIsland, planEntities, isLand, biomeAt, WORLD, GW, GH, findLandNear } from './src/games/starve/world.js' +import { genIsland, planEntities, isLand, biomeAt, WORLD, GW, GH, TILE, configureWorld, findLandNear } from './src/games/starve/world.js' import { seasonOf, daySplit, phaseOf, ambientTemp, rollWeather } from './src/games/starve/climate.js' import { cookPot, RECIPES, ITEMS, MON, MOB_DROPS, RES, SEG_SPLIT, SEASONS } from './src/games/starve/data.js' import { activateMods, modMeta, callAllows, callHandled } from './src/games/starve/mods/registry.js' @@ -91,7 +91,7 @@ Object.entries(RES).forEach(([type, conf]) => { ok(SEASONS.reduce((n, s) => n + s.days, 0) === 70, '一年 70 天') console.log('== Mod 注册表 ==') -ok(MODS.length === 8, `内置 8 个 mod (${MODS.length})`) +ok(MODS.length === 19, `内置 19 个 mod (${MODS.length})`) ok(modMeta().every((m) => m.id && m.name && m.desc), '元数据齐全') // 空激活 = 基表原样 const rt0 = activateMods([]) @@ -101,9 +101,9 @@ ok(rt0.recipes.length === RECIPES.length, '无 mod 配方表与基表一致') // 全量激活 const allIds = MODS.map((m) => m.id) const rt = activateMods(allIds) -ok(rt.ids.length === 8, '全量激活 8 个') -// 系数:0.7(便利包) * 0.7(和平) * 1.3(噩梦) = 0.637 → 0.64;夹在 0.5~1.5 -ok(rt.scoreFactor === 0.64, `全开系数 0.64 (${rt.scoreFactor})`) +ok(rt.ids.length === 19, '全量激活 19 个') +// 系数:0.7(便利包)*0.7(和平)*1.3(噩梦)*1.1(寒潮)*1.1(旅人)*1.15(幸运)*0.9(文明火种)*1.05(农业)*1.05(牧业)*1.1(工业)*0.9(辽阔世界)*0.75(营地)*1.05(劳务市场) ≈ 0.686 → 0.69 +ok(rt.scoreFactor === 0.69, `全开系数 0.69 (${rt.scoreFactor})`) ok(activateMods(['qol']).scoreFactor === 0.7, '便利包 0.7') ok(activateMods(['nightmare']).scoreFactor === 1.3, '噩梦 1.3') ok(activateMods(['farm', 'chest', 'chester', 'revive', 'magic']).scoreFactor === 1, '内容 mod 不影响系数') @@ -177,5 +177,1071 @@ ok(callAllows(activateMods(['farm']), 'onSpawnWave', {}, 'hound') === true, '无 ok(callHandled(rt, 'onPlayerDeath', g, p) === false, '没有雕像不再拦截') } +// ===================================================================== +// 本次交付扩展:startchest / agri / husbandry / industry 四个新 mod +// ===================================================================== +const NEW_IDS = ['startchest', 'agri', 'husbandry', 'industry'] +const rt4 = activateMods(NEW_IDS) // 只激活 4 个新 mod(验证不依赖其它 mod 独有物品) + +// ---- 通用测试替身 ---- +function makeG(opts = {}) { + const inv = {} + const received = [] + if (opts.grant) opts.grant.forEach(([c, n]) => { inv[c] = (inv[c] || 0) + n }) + return { + items: opts.items || rt.items, + entities: opts.entities || [], + monsters: opts.monsters || [], + day: opts.day ?? 1, + season: opts.season || 'autumn', + worldW: 2400, worldH: 2000, + inv, received, + rand: (a) => a, + addItem: (p, c, n) => { inv[c] = (inv[c] || 0) + n; received.push([c, n]) }, + countItem: (p, c) => inv[c] || 0, + hasCost: (p, cost) => Object.entries(cost).every(([c, n]) => (inv[c] || 0) >= n), + payCost: (p, cost) => { + if (!Object.entries(cost).every(([c, n]) => (inv[c] || 0) >= n)) return false + Object.entries(cost).forEach(([c, n]) => { inv[c] -= n }) + return true + }, + markDirty() {}, swing() {}, shake() {}, addScore() {}, ptip() {}, tip() {}, + invalidate() {}, consumeAt() {}, spawnMob() {}, dropLoot() {}, + findLand: (x, y) => ({ x, y }), + ...(opts.extra || {}), + } +} +const hookFn = (r, name, id) => (r.hooks[name] || []).find((h) => h.id === id)?.fn +// 假 40x40 canvas 2D 上下文:任意方法可调用、样式属性可读写 +const makeCtx = () => new Proxy({}, { + get: (t, k) => (k in t ? t[k] : () => {}), + set: (t, k, v) => { t[k] = v; return true }, +}) + +console.log('== 新 mod 注册与合并 ==') +ok(MODS.length === 19, `内置 19 个 mod (${MODS.length})`) +const metas = modMeta() +const metaOf = (id) => metas.find((m) => m.id === id) +;[['startchest', 0.9], ['agri', 1.05], ['husbandry', 1.05], ['industry', 1.1]].forEach(([id, f]) => { + const m = metaOf(id) + ok(!!(m && m.id === id && m.name && m.desc), `新 mod ${id} 元数据齐全`) + ok(!!(m && Math.abs(m.factor - f) < 1e-9), `新 mod ${id} 系数 ${f} (${m && m.factor})`) +}) +const itemSets = { + agri: ['agrihoe', 'wheatseeds', 'wheat', 'flour', 'bread', 'pumpkin', 'pumpkinseeds', 'fertilizer'], + husbandry: ['husbshears', 'wool', 'woolhat', 'egg', 'cookedegg', 'milk', 'cheese'], + industry: ['indpickaxe', 'indaxe', 'ingot', 'gear', 'steelarmor', 'steelhelmet'], +} +Object.entries(itemSets).forEach(([id, codes]) => { + ok(codes.every((c) => !!rt.items[c]), `${id} 全部 ${codes.length} 个物品已注册`) +}) +const entSets = { + startchest: ['startchest'], + agri: ['agrifarm', 'well', 'granary', 'mill'], + husbandry: ['fence', 'trough', 'coop'], + industry: ['smelter', 'steamengine', 'factory', 'miner'], +} +Object.entries(entSets).forEach(([id, types]) => { + ok(types.every((t) => !!rt.entKinds[t]), `${id} 全部 ${types.length} 个实体已注册`) +}) +ok(!!rt.mobKinds.sheep && !!rt.mobKinds.chicken, 'sheep/chicken 生物已注册') +ok(rt.mon.sheep?.name === '绵羊' && rt.mon.chicken?.name === '鸡', `绵羊/鸡命名正确 (${rt.mon.sheep?.name})`) +ok((rt.mobDrops.sheep || []).length > 0, 'sheep 掉落表非空') +// 页签合并 +ok(activateMods([]).tabs.length === 7, `无 mod 时基表页签 7 (${activateMods([]).tabs.length})`) +{ + const rA = activateMods(['agri']) + const codes = rA.tabs.map((t) => t.code) + ok(codes.includes('agri'), '单独激活 agri 含农业页签') + ok(['tools', 'light', 'surv', 'fight', 'food', 'science', 'dress'].every((c) => codes.includes(c)), '基表 7 页签仍在') + ok(new Set(codes).size === codes.length, `无重复 code (${codes.join(',')})`) +} +{ + const r3 = activateMods(['agri', 'husbandry', 'industry']) + const codes = r3.tabs.map((t) => t.code) + ok(['agri', 'husbandry', 'industry'].every((c) => codes.includes(c)), '三新页签都在') + ok(r3.tabs.length === 10, `三新页签后共 10 (${r3.tabs.length})`) +} +{ + // 去重:借道临时 mod(含与基表冲突/自身重复的 code) + const dup = { + id: '__dup', name: 'x', desc: 'x', factor: 1, + tabs: [ + { code: 'tools', name: '重复基表', icon: 'axe' }, + { code: 'agri', name: '农业', icon: 'agrihoe' }, + { code: 'agri', name: '农业2', icon: 'agrihoe' }, + ], + } + MODS.push(dup) + const rd = activateMods(['__dup']) + MODS.pop() + ok(rd.tabs.filter((t) => t.code === 'tools').length === 1, '去重:不覆盖/不重复基表 tools') + ok(rd.tabs.filter((t) => t.code === 'agri').length === 1, '去重:同 mod 内重复 code 只留一个') + ok(rd.tabs.length === 8, `去重后页签 8 (${rd.tabs.length})`) +} +ok(rt.itemCodes.length === Object.keys(rt.items).length, 'itemCodes 数量与物品表一致') +ok(rt.itemCodes.every((c, i) => rt.itemIdx[c] === i), 'itemIdx 与 itemCodes 一一对应') + +console.log('== 新 mod 数据一致性(静态) ==') +const newRecipes = rt4.recipes.filter((r) => ['agri', 'husbandry', 'industry'].includes(r.tab)) +ok(newRecipes.length === 18, `新配方 18 条 (${newRecipes.length})`) +newRecipes.forEach((r) => { + Object.keys(r.cost).forEach((c) => ok(!!rt4.items[c], `新配方 ${r.code} 材料 ${c} 可解析(仅 4 新 mod)`)) + ok(typeof rt4.iconPainters[r.code] === 'function', `新配方 ${r.code} 有图标画法`) +}) +newRecipes.filter((r) => r.build).forEach((r) => { + ok(!!rt4.entKinds[r.code], `build 配方 ${r.code} 有同名实体`) +}) +{ + const ctx = makeCtx() + let painterErr = null + Object.keys(rt4.iconPainters).forEach((code) => { + try { rt4.iconPainters[code](ctx) } catch (err) { painterErr = `${code}: ${err.message}` } + }) + ok(painterErr === null, `全部图标画法可执行 (${painterErr || 'ok'})`) +} +{ + const byCode = Object.fromEntries(rt4.recipes.map((r) => [r.code, r])) + ok(!('ingot' in byCode.smelter.cost) && !('gear' in byCode.smelter.cost), '熔炉不依赖 ingot/gear') + ok('ingot' in byCode.steamengine.cost, '蒸汽机依赖 ingot(链条上游)') + ok('ingot' in byCode.factory.cost, '工厂依赖 ingot') + ok('gear' in byCode.miner.cost && 'ingot' in byCode.miner.cost, '矿机依赖 gear+ingot(链条末端)') + ok(Object.keys(byCode.miner.cost).every((c) => !!rt4.items[c]), '矿机材料均可解析') +} + +console.log('== 新 mod 行为级验证 ==') +// --- 开局宝箱 --- +{ + const g = makeG() + const box = { state: 'closed', id: 1 } + rt.entKinds.startchest.interact({}, box, g) + const always = { axe: 1, pickaxe: 1, torch: 2, grass: 10, twig: 10, log: 8, rock: 8, rope: 2, berry: 6, healingsalve: 2 } + ok(box.state === 'open', '开箱后 state=open') + ok(Object.entries(always).every(([c, n]) => g.inv[c] === n), '固定物资一次发齐') + ok(g.received.every(([c]) => !!g.items[c]), '发放物品都在物品表内') + const snap = JSON.stringify(g.inv) + g.received.length = 0 + rt.entKinds.startchest.interact({}, box, g) + ok(g.received.length === 0 && JSON.stringify(g.inv) === snap, '二次交互不再发放') +} +// 边界:仅激活 startchest(无种子物品)开箱不崩、不发不存在的物品 +{ + const rtStart = activateMods(['startchest']) + const g = makeG({ items: rtStart.items }) + let err = null + try { rtStart.entKinds.startchest.interact({}, { state: 'closed', id: 2 }, g) } catch (e) { err = e.message } + ok(err === null, `仅激活 startchest、缺种子时开箱不抛异常 (${err || 'ok'})`) + ok(!rtStart.items.wheatseeds && !(g.inv.wheatseeds > 0), '不发不存在的种子(wheatseeds)') + ok(g.inv.axe === 1 && g.inv.log === 8, '固定物资照发') + ok(g.received.every(([c]) => !!rtStart.items[c]), '只发存在的物品') +} +// 物品表为空时的防御分支 +{ + const g = makeG({ items: {} }) + let err = null + try { rt.entKinds.startchest.interact({}, { state: 'closed', id: 3 }, g) } catch (e) { err = e.message } + ok(err === null, '物品表为空时开箱不崩') + ok(g.received.length === 0, '物品表为空时不发任何物品') +} +// --- 农业良田生长(拨表)--- +{ + const farm = rt.entKinds.agrifarm + const p = { state: 'grow0', mx: { seed: 'wheat', t: 0 }, x: 0, y: 0, id: 1 } + const g = makeG() + farm.update(p, 66, g); ok(p.state === 'grow1', `良田 66s→grow1 (${p.state})`) + farm.update(p, 67, g); ok(p.state === 'grow2', `良田 133s→grow2 (${p.state})`) + farm.update(p, 67, g); ok(p.state === 'ready', `良田 200s→ready (${p.state})`) + const pw = { state: 'grow0', mx: { seed: 'wheat', t: 0 }, x: 0, y: 0, id: 2 } + farm.update(pw, 120, makeG({ season: 'winter' })) + ok(pw.state === 'grow0' && pw.mx.t === 0, '冬季生长暂停') + const well = { type: 'well', x: 0, y: 0, id: 9 } + const near = { state: 'grow0', mx: { seed: 'wheat', t: 0 }, x: 0, y: 0, id: 3 } + const far = { state: 'grow0', mx: { seed: 'wheat', t: 0 }, x: 500, y: 0, id: 4 } + const gw = makeG({ entities: [well] }) + farm.update(near, 10, gw); farm.update(far, 10, gw) + ok(Math.abs(near.mx.t - 16) < 1e-6, `近水井 ×1.6 (t=${near.mx.t})`) + ok(far.mx.t === 10 && near.mx.t > far.mx.t, `远水井正常生长 (t=${far.mx.t})`) +} +// --- 农业磨坊 --- +{ + const g = makeG({ grant: [['wheat', 3]] }) + rt.entKinds.mill.interact({}, { id: 1 }, g) + ok(g.inv.wheat === 0 && g.inv.flour === 2, `磨面 3 小麦→2 面粉 (wheat=${g.inv.wheat}, flour=${g.inv.flour})`) + const g2 = makeG({ grant: [['wheat', 2]] }) + rt.entKinds.mill.interact({}, { id: 2 }, g2) + ok(g2.inv.wheat === 2 && !g2.inv.flour, '材料不足不产出不消耗') +} +// --- 农业水井 --- +{ + const g = makeG() + const p = { san: 50 } + const well = { type: 'well', cd: 0, id: 1 } + rt.entKinds.well.interact(p, well, g) + ok(p.san === 55 && well.cd === 60, `打水 san+5 冷却 60s (${p.san})`) + rt.entKinds.well.interact(p, well, g) + ok(p.san === 55, '冷却中不再加理智') +} +// --- 农业粮仓(持久化数据在 e.mx.store)--- +{ + const g = makeG({ grant: [['wheat', 4]] }) + const gr = { type: 'granary', mx: { store: {} }, id: 1 } + rt.entKinds.granary.interact({}, gr, g) + ok(gr.mx.store.wheat === 4 && (g.inv.wheat || 0) === 0, `粮仓存入作物 (store=${JSON.stringify(gr.mx.store)})`) + rt.entKinds.granary.interact({}, gr, g) + ok(g.inv.wheat === 4 && Object.keys(gr.mx.store).length === 0, '粮仓取出作物') +} +// --- 农业肥料(onUseItem)--- +{ + const fn = hookFn(rt, 'onUseItem', 'agri') + const plot = { type: 'agrifarm', state: 'grow0', mx: { seed: 'wheat', t: 20 }, x: 0, y: 0, id: 1 } + const ret = fn(makeG({ entities: [plot] }), { x: 0, y: 0, dir: 1 }, { code: 'fertilizer' }, null, 0) + ok(plot.mx.t === 120 && ret === true, `肥料 +100s (t=${plot.mx.t})`) + const plot2 = { type: 'agrifarm', state: 'grow0', mx: { seed: 'wheat', t: 80 }, x: 0, y: 0, id: 2 } + fn(makeG({ entities: [plot2] }), { x: 0, y: 0, dir: 1 }, { code: 'fertilizer' }, null, 0) + ok(plot2.mx.t === 180, `肥料可跨阶段 +100s (t=${plot2.mx.t})`) + const empty = { type: 'agrifarm', state: 'empty', mx: null, x: 0, y: 0, id: 3 } + let err = null + try { fn(makeG({ entities: [empty] }), { x: 0, y: 0, dir: 1 }, { code: 'fertilizer' }, null, 0) } catch (e) { err = e.message } + ok(err === null, `对空良田施肥不崩 (${err || 'ok'})`) +} +// --- 牧业剪毛 --- +{ + const g = makeG({ grant: [['husbshears', 1]], day: 3 }) + const m = { kind: 'sheep', id: 1 } + rt.mobKinds.sheep.interact({}, m, g) + ok(g.inv.wool === 2 && m.shearDay === 3, `剪毛得羊毛2 (wool=${g.inv.wool})`) + g.received.length = 0 + rt.mobKinds.sheep.interact({}, m, g) + ok(g.inv.wool === 2 && g.received.length === 0, '同日重复剪毛不再产出') + const g2 = makeG({ day: 3 }) + rt.mobKinds.sheep.interact({}, { kind: 'sheep', id: 2 }, g2) + ok(!g2.inv.wool, '没剪刀不产出') +} +// --- 牧业鸡舍(持久化数据在 e.mx.stored)--- +{ + const coop = { type: 'coop', mx: { stored: 0 }, x: 0, y: 0, id: 1 } + const g = makeG({ entities: [coop], monsters: [] }) + hookFn(rt, 'onNewDay', 'husbandry')(g) + ok(coop.mx.stored >= 1, `鸡舍每日囤蛋 (stored=${coop.mx.stored})`) + const n = coop.mx.stored + rt.entKinds.coop.interact({}, coop, g) + ok(g.inv.egg === n && coop.mx.stored === 0, `收取鸡蛋×${n} 且清空 (egg=${g.inv.egg})`) +} +// --- 牧业饲料槽(持久化数据在 e.mx.feed)--- +{ + const g = makeG({ grant: [['berry', 3]] }) + const tr = { type: 'trough', mx: { feed: 0 }, id: 1 } + rt.entKinds.trough.interact({}, tr, g) + ok(tr.mx.feed === 1 && g.inv.berry === 2, `投喂加饲料 (feed=${tr.mx.feed})`) + rt.entKinds.trough.interact({}, tr, g) + rt.entKinds.trough.interact({}, tr, g) + ok(tr.mx.feed === 3 && (g.inv.berry || 0) === 0, `连续投喂累加 (feed=${tr.mx.feed})`) + rt.entKinds.trough.interact({}, tr, g) + ok(tr.mx.feed === 3, '没作物后停止投喂') +} +// --- 工业熔炉 --- +{ + const g = makeG({ grant: [['rock', 3], ['log', 1]] }) + rt.entKinds.smelter.interact({}, { id: 1 }, g) + ok(g.inv.ingot === 1 && g.inv.rock === 0 && g.inv.log === 0, '熔炉 rock×3+log×1→ingot×1') + const g2 = makeG({ grant: [['rock', 2], ['log', 1]] }) + rt.entKinds.smelter.interact({}, { id: 2 }, g2) + ok(!g2.inv.ingot, '熔炉材料不足不出货') +} +// --- 工业工厂 --- +{ + const g = makeG({ grant: [['ingot', 2]] }) + rt.entKinds.factory.interact({}, { x: 0, y: 0, id: 1 }, g) + ok(!g.inv.gear && g.inv.ingot === 2, '无供能蒸汽机不出齿轮') + const eng = { type: 'steamengine', on: true, x: 0, y: 0, id: 9 } + const g2 = makeG({ entities: [eng], grant: [['ingot', 2]] }) + rt.entKinds.factory.interact({}, { x: 0, y: 0, id: 2 }, g2) + ok(g2.inv.gear === 1 && g2.inv.ingot === 0, '有蒸汽且 ingot 够→齿轮×1') + const engOff = { type: 'steamengine', on: false, x: 0, y: 0, id: 10 } + const g3 = makeG({ entities: [engOff], grant: [['ingot', 2]] }) + rt.entKinds.factory.interact({}, { x: 0, y: 0, id: 3 }, g3) + ok(!g3.inv.gear, '蒸汽机停转不出齿轮') + const farEng = { type: 'steamengine', on: true, x: 999, y: 0, id: 11 } + const g4 = makeG({ entities: [farEng], grant: [['ingot', 2]] }) + rt.entKinds.factory.interact({}, { x: 0, y: 0, id: 4 }, g4) + ok(!g4.inv.gear, '蒸汽机超出 300px 不出齿轮') +} +// --- 工业矿机(onNewDay)--- +{ + const sum = (arr, code) => arr.filter((c) => c[0] === code).reduce((a, c) => a + c[1], 0) + const cap1 = [] + hookFn(rt, 'onNewDay', 'industry')(makeG({ + entities: [{ type: 'miner', x: 0, y: 0, id: 1 }], + extra: { dropLoot: (x, y, c, n) => cap1.push([c, n]) }, + })) + ok(sum(cap1, 'rock') === 2 && sum(cap1, 'flint') === 1, `无蒸汽矿机 rock×2 flint×1 (${sum(cap1, 'rock')}/${sum(cap1, 'flint')})`) + const cap2 = [] + const eng = { type: 'steamengine', on: true, fuel: 5, x: 0, y: 0, id: 9 } + hookFn(rt, 'onNewDay', 'industry')(makeG({ + entities: [{ type: 'miner', x: 0, y: 0, id: 2 }, eng], + extra: { dropLoot: (x, y, c, n) => cap2.push([c, n]) }, + })) + ok(sum(cap2, 'rock') === 4 && sum(cap2, 'flint') === 2, `蒸汽矿机翻倍 rock×4 flint×2 (${sum(cap2, 'rock')}/${sum(cap2, 'flint')})`) +} +// --- 工业蒸汽机 --- +{ + const g = makeG({ grant: [['log', 1]] }) + const eng = { type: 'steamengine', fuel: 0, on: false, id: 1 } + rt.entKinds.steamengine.interact({}, eng, g) + ok(eng.fuel === 3 && g.inv.log === 0, `投 log×1 燃料+3 (fuel=${eng.fuel})`) + const gNo = makeG() + rt.entKinds.steamengine.interact({}, { type: 'steamengine', fuel: 0, id: 2 }, gNo) + ok(gNo.inv.log === undefined, '无燃料不消耗') + hookFn(rt, 'onNewDay', 'industry')(makeG({ entities: [eng] })) + ok(eng.fuel === 2, `蒸汽机每日耗 1 (fuel=${eng.fuel})`) + const eng2 = { type: 'steamengine', fuel: 1, on: true, id: 3 } + hookFn(rt, 'onNewDay', 'industry')(makeG({ entities: [eng2] })) + ok(eng2.fuel === 0 && eng2.on === false, `燃料归零停转 (fuel=${eng2.fuel}, on=${eng2.on})`) + eng.fuel = 2; eng.on = false + rt.entKinds.steamengine.update(eng, 0.1, makeG()) + ok(eng.on === true, 'fuel>0 时 update 置 on=true') +} + +console.log('== 跨 mod 独立性 ==') +{ + const rtInd = activateMods(['industry']) + ok(['smelter', 'steamengine', 'factory', 'miner'].every((t) => !!rtInd.entKinds[t]), '单激活 industry:实体齐全') + const g = makeG({ items: rtInd.items, grant: [['rock', 3], ['log', 1]] }) + let err = null + try { rtInd.entKinds.smelter.interact({}, { id: 1 }, g) } catch (e) { err = e.message } + ok(err === null && g.inv.ingot === 1, `单激活 industry 熔炉可用 (${err || 'ok'})`) + let err2 = null + try { + hookFn(rtInd, 'onNewDay', 'industry')(makeG({ + items: rtInd.items, + entities: [{ type: 'miner', x: 0, y: 0, id: 1 }, { type: 'steamengine', on: true, fuel: 2, x: 0, y: 0, id: 2 }], + })) + } catch (e) { err2 = e.message } + ok(err2 === null, `单激活 industry onNewDay 不崩 (${err2 || 'ok'})`) + const rtAgri = activateMods(['agri']) + const ga = makeG({ items: rtAgri.items, grant: [['wheat', 3]] }) + let err3 = null + try { rtAgri.entKinds.mill.interact({}, { id: 1 }, ga) } catch (e) { err3 = e.message } + ok(err3 === null && ga.inv.flour === 2, `单激活 agri 磨坊可用 (${err3 || 'ok'})`) +} + +// ===================================================================== +// 本轮交付(第二轮):世界缩放 + 辽阔世界 / 营地 / 劳务市场 +// ===================================================================== + +// ---- 通用测试替身 ---- +function mkPG(items = []) { + const slots = items.map(([code, n]) => ({ code, n })) + return { x: 0, y: 0, hp: 100, san: 100, hunger: 100, temp: 20, dead: false, seat: 0, slots, pack: null } +} +function countP(p, code) { + let n = 0 + for (const arr of [p.slots, p.pack]) { + if (!arr) continue + for (const s of arr) if (s && s.code === code) n += s.n + } + return n +} +function payP(p, cost) { + for (const [c, need] of Object.entries(cost)) if (countP(p, c) < need) return false + for (const [c, need0] of Object.entries(cost)) { + let left = need0 + for (const arr of [p.slots, p.pack]) { + if (!arr) continue + for (let i = 0; i < arr.length && left > 0; i++) { + const s = arr[i] + if (!s || s.code !== c) continue + const t = Math.min(s.n, left) + s.n -= t + left -= t + if (s.n <= 0) arr[i] = null + } + } + } + return true +} +function addP(p, code, n = 1) { + if (!p.slots) p.slots = [] + const s = p.slots.find((x) => x && x.code === code) + if (s) s.n += n + else p.slots.push({ code, n }) +} +function mkCG(o = {}) { + const entities = o.entities || [] + const monsters = o.monsters || [] + const players = o.players || [] + const calls = { addEntity: [], spawnMob: [], addFire: [], damageMob: [], removeMob: [], drops: [], tips: [], ptips: [] } + const g = { + entities, monsters, players, + mon: o.mon || rt.mon, + items: o.items || rt.items, + tune: { stackMax: 20 }, + day: o.day ?? 1, + season: o.season || 'autumn', + worldW: o.worldW ?? 2400, + worldH: o.worldH ?? 2000, + calls, + me: () => o.me || players[0] || null, + rand: (a) => a, + findLand: (x, y) => (o.findLand ? o.findLand(x, y) : { x, y }), + moveToward: (m, x, y) => { m.x = x; m.y = y; m.moving = true }, + damageMob: (m, dmg) => { calls.damageMob.push([m, dmg]); m.hp -= dmg }, + addEntity: (type, x, y, extra) => { const e = { type, x, y, id: 100 + entities.length, mx: null, ...(extra || {}) }; entities.push(e); calls.addEntity.push([type, x, y]); return e }, + removeEntity: (e) => { const i = entities.indexOf(e); if (i >= 0) entities.splice(i, 1) }, + spawnMob: (kind, x, y, extra) => { const m = { kind, x, y, id: 500 + monsters.length, hp: (g.mon[kind] && g.mon[kind].hp) || 100, ...(extra || {}) }; monsters.push(m); calls.spawnMob.push([kind, x, y, extra]); return m }, + removeMob: (m) => { const i = monsters.indexOf(m); if (i >= 0) monsters.splice(i, 1); calls.removeMob.push(m) }, + addItem: (p, code, n = 1) => addP(p, code, n), + countItem: (p, code) => countP(p, code), + hasCost: (p, cost) => Object.entries(cost).every(([c, n]) => countP(p, c) >= n), + payCost: (p, cost) => payP(p, cost), + dropLoot: (x, y, code, n) => calls.drops.push([code, n]), + markDirty: () => {}, ptip: (p, t) => calls.ptips.push(t), tip: (t) => calls.tips.push(t), + invalidate: () => {}, consumeAt: () => {}, swing: () => {}, addScore: () => {}, shake: () => {}, + addFire: (x, y, opt) => { const f = { x, y, ...(opt || {}) }; calls.addFire.push(f); return f }, + } + return g +} +// 假 2D 上下文:任意方法可调,measureText 返回带 width 的对象(供 labor clipText 用) +const makeCtx2 = () => new Proxy({}, { + get: (t, k) => { + if (k in t) return t[k] + if (k === 'measureText') return () => ({ width: 12 }) + return () => {} + }, + set: (t, k, v) => { t[k] = v; return true }, +}) + +console.log('== A. 世界缩放(configureWorld)==') +{ + ok(TILE === 80, `TILE 基准 80 (${TILE})`) + configureWorld(1.5) + ok(GW === 96 && GH === 72, `1.5 倍尺寸 96/72 (${GW}/${GH})`) + ok(WORLD.w === 7680 && WORLD.h === 5760, `1.5 倍世界像素 7680x5760 (${WORLD.w}x${WORLD.h})`) + const isle = genIsland() + ok(isle.tiles.length === GW * GH, `1.5 倍 tiles 长度 = GW*GH (${isle.tiles.length})`) + const landN = isle.tiles.reduce((n, v) => n + (v > 0 ? 1 : 0), 0) + ok(landN > 1000 && landN < GW * GH, `1.5 倍陆地占比合理 (${landN}/${GW * GH})`) + ok(isLand(isle.tiles, WORLD.w / 2, WORLD.h / 2), '1.5 倍出生点在陆地') + let planErr = null + let plan = null + try { plan = planEntities(isle.tiles, isle.road) } catch (e) { planErr = e.message } + ok(planErr === null, `1.5 倍 planEntities 不抛异常 (${planErr || 'ok'})`) + ok(plan && plan.placed.length > 0, `1.5 倍放置实体非空 (${plan && plan.placed.length})`) + const offLand = plan.placed.filter((p) => !isLand(isle.tiles, p.x, p.y)).length + ok(offLand === 0, `1.5 倍无实体落海 (${offLand})`) + configureWorld(2) + ok(GW === 128 && GH === 96, `2 倍尺寸 128/96 (${GW}/${GH})`) + configureWorld(0.1) + ok(GW === 32 && GH === 24, `0.1 夹到下限 0.5 → 32/24 (${GW}/${GH})`) + configureWorld(3) + ok(GW === 128 && GH === 96, `3 夹到上限 2 → 128/96 (${GW}/${GH})`) + let e0 = null + try { configureWorld(0) } catch (e) { e0 = e.message } + ok(e0 === null && GW === 64 && GH === 48, `configureWorld(0) 不崩且回落默认 (${GW}/${GH})`) + let e1 = null + try { configureWorld('abc') } catch (e) { e1 = e.message } + ok(e1 === null && GW === 64 && GH === 48, `configureWorld('abc') 不崩且回落默认 (${GW}/${GH})`) + let e2 = null + try { configureWorld() } catch (e) { e2 = e.message } + ok(e2 === null && GW === 64 && GH === 48, `configureWorld() 默认 1 (${GW}/${GH})`) + configureWorld(1) + ok(GW === 64 && GH === 48 && WORLD.w === 5120 && WORLD.h === 3840, `恢复默认 64/48/5120/3840 (${GW}/${GH}/${WORLD.w}/${WORLD.h})`) +} + +console.log('== B. 三个新 mod 注册与数据一致性 ==') +ok(MODS.length === 19, `内置 19 个 mod (${MODS.length})`) +const META2 = modMeta() +const m2 = (id) => META2.find((x) => x.id === id) +;[['wideworld', '辽阔世界', 0.9], ['camp', '营地', 0.75], ['labor', '劳务市场', 1.05]].forEach(([id, name, f]) => { + const m = m2(id) + ok(!!m && m.name === name && !!m.desc, `新 mod ${id} 名称/描述齐全 (${m && m.name})`) + ok(!!m && Math.abs(m.factor - f) < 1e-9, `新 mod ${id} 系数 ${f} (${m && m.factor})`) +}) +{ + const rw = activateMods(['wideworld']) + ok(rw.flags.worldScale === 1.5, `wideworld flag worldScale=1.5 (${rw.flags.worldScale})`) + ok(Object.keys(rw.entKinds).length === 0 && rw.recipes.length === RECIPES.length, 'wideworld 不含实体/配方/钩子') + ok(Object.keys(rw.hooks).length === 0, 'wideworld 无钩子') +} +const rtAll = activateMods(MODS.map((m) => m.id)) +ok(rtAll.flags.worldScale === 1.5, '全量激活 worldScale=1.5') +;['camphouse', 'campstore'].forEach((t) => ok(!!rtAll.entKinds[t], `camp 实体 ${t} 已注册`)) +;['laboroffice', 'laborwarehouse'].forEach((t) => ok(!!rtAll.entKinds[t], `labor 实体 ${t} 已注册`)) +ok(!!rtAll.mobKinds.guard && rtAll.mon.guard.friendly === true, 'guard 生物已注册且 friendly') +ok(!!rtAll.mobKinds.laborfarmer && !!rtAll.mobKinds.laborherder, 'laborfarmer/laborherder 已注册') +{ + const byCodeAll = Object.fromEntries(rtAll.recipes.map((r) => [r.code, r])) + ;['campstore', 'camphouse', 'laboroffice', 'laborwarehouse'].forEach((code) => { + const r = byCodeAll[code] + ok(!!r, `新配方 ${code} 存在`) + Object.keys(r.cost).forEach((c) => ok(!!rtAll.items[c], `新配方 ${code} 材料 ${c} 可解析`)) + ok(typeof rtAll.iconPainters[code] === 'function', `新配方 ${code} 有图标画法`) + ok(r.build === true && !!rtAll.entKinds[code], `新配方 ${code} build 且有同名实体`) + }) + const ctx = makeCtx2() + let err = null + ;['camphouse', 'campstore', 'laboroffice', 'laborwarehouse'].forEach((code) => { + try { rtAll.iconPainters[code](ctx) } catch (e) { err = `${code}: ${e.message}` } + }) + ok(err === null, `新图标画法可执行 (${err || 'ok'})`) +} +{ + const tc = activateMods(['camp']).tabs.map((t) => t.code) + ok(tc.includes('camp'), '单激活 camp 含营地页签') + ok(['tools', 'light', 'surv', 'fight', 'food', 'science', 'dress'].every((c) => tc.includes(c)), '基表 7 页签仍在(camp)') + const tl = activateMods(['labor']).tabs.map((t) => t.code) + ok(tl.includes('labor'), '单激活 labor 含劳务页签') + ok(['tools', 'light', 'surv', 'fight', 'food', 'science', 'dress'].every((c) => tl.includes(c)), '基表 7 页签仍在(labor)') +} + +// camp 面板布局(与 camp.js onDrawUI 保持一致,用于换算命中点;相机置 0 时屏幕=世界) +function campLayout(W, H) { + const rowH = 30, headerH = 40, footerH = 42, pad = 10 + const panelW = Math.min(470, W - 24) + const panelH = headerH + 8 * rowH + footerH + pad + const px = Math.round((W - panelW) / 2) + const py = Math.round((H - panelH) / 2) + const rightEdge = px + panelW - pad - 4 + const btnH = 20, depW = 66, witW = 60, gap = 6 + const rows = [] + for (let i = 0; i < 8; i++) { + const rowY = py + headerH + i * rowH + const by = rowY + (rowH - 3 - btnH) / 2 + 1 + rows.push({ dep: { x: rightEdge - witW - gap - depW, y: by, w: depW, h: btnH }, wit: { x: rightEdge - witW, y: by, w: witW, h: btnH } }) + } + const close = { x: px + panelW / 2 - 60, y: py + panelH - footerH + 8, w: 120, h: 26 } + return { px, py, panelW, panelH, rows, close } +} +const C1 = (r) => ({ x: r.x + r.w / 2, y: r.y + r.h / 2 }) + +console.log('== C. 营地 mod 行为级验证 ==') +const campHooks = (name) => hookFn(rt, name, 'camp') +{ + const g = mkCG() + campHooks('onWorldGen')(g) + const ents = g.calls.addEntity.map((c) => c[0]) + ok(ents.includes('camphouse'), 'onWorldGen 放出 camphouse') + ok(g.calls.spawnMob.some((s) => s[0] === 'guard'), 'onWorldGen 生成守卫') + ok(g.calls.addFire.length === 1 && g.calls.addFire[0].eternal === true, 'onWorldGen 放出 eternal 火焰') + const f = g.calls.addFire[0] + ok(f.pit === true && f.ttl === 150 && f.max === 150, `长明篝火 pit/ttl/max 正确 (${f && f.pit}/${f && f.ttl}/${f && f.max})`) +} +{ + const g = mkCG({ day: 5 }) + const house = { type: 'camphouse', x: 0, y: 0, id: 1 } + rt.entKinds.camphouse.init(house) + const p = { san: 100, hp: 100 } + rt.entKinds.camphouse.interact(p, house, g) + ok(p.san === 130 && p.hp === 120 && house.mx.restDay === 5, `首次休息 san+30/hp+20 (san=${p.san},hp=${p.hp},day=${house.mx.restDay})`) + const s = p.san, h = p.hp + rt.entKinds.camphouse.interact(p, house, g) + ok(p.san === s && p.hp === h, '同日二次休息无效') + rt.entKinds.camphouse.interact(p, house, mkCG({ day: 6 })) + ok(p.san === s + 30 && house.mx.restDay === 6, '次日可再休息') + const pFull = { san: 190, hp: 145 } + const hf = { type: 'camphouse', id: 2, mx: { restDay: 0 } } + rt.entKinds.camphouse.interact(pFull, hf, mkCG({ day: 6 })) + ok(pFull.san === 200 && pFull.hp === 150, `回复封顶 200/150 (${pFull.san}/${pFull.hp})`) + const pNoMx = { san: 10, hp: 10 } + const hNoMx = { type: 'camphouse', id: 3 } + let err = null + try { rt.entKinds.camphouse.interact(pNoMx, hNoMx, mkCG()) } catch (e) { err = e.message } + ok(err === null && hNoMx.mx && hNoMx.mx.restDay === 1, `无 mx 房屋交互自动补建 (${err || 'ok'})`) +} +{ + const W = 800, H = 600 + const g = mkCG() + const store = { type: 'campstore', x: 0, y: 0, id: 7 } + rt.entKinds.campstore.init(store) + ok(store.mx.boxes.length === 8, `营地仓储 8 个箱子 (${store.mx.boxes.length})`) + const p = mkPG([['log', 5], ['rock', 3]]) + g.players = [p]; g.me = () => p; g.entities.push(store) + rt.entKinds.campstore.interact(p, store, g) + campHooks('onDrawUI')(g, makeCtx2(), { camX: 0, camY: 0, W, H, time: 0 }) + const L = campLayout(W, H) + ok(campHooks('onClick')(g, C1(L.rows[0].dep).x, C1(L.rows[0].dep).y) === true, '面板内存入按钮返回 true') + ok(store.mx.boxes[0].items.log === 5 && store.mx.boxes[0].items.rock === 3, `存入箱0 log5/rock3 (${JSON.stringify(store.mx.boxes[0].items)})`) + ok(countP(p, 'log') === 0 && countP(p, 'rock') === 0, '存入后背包物资被扣') + campHooks('onClick')(g, C1(L.rows[0].wit).x, C1(L.rows[0].wit).y) + ok(countP(p, 'log') === 5 && countP(p, 'rock') === 3, '取出归还背包') + ok(Object.keys(store.mx.boxes[0].items).length === 0, '取出后箱清空') + ok(campHooks('onClick')(g, C1(L.close).x, C1(L.close).y) === true, '关闭按钮返回 true') + ok(campHooks('onClick')(g, 10, 10) === undefined, '面板关闭后 onClick 返回 undefined') +} +{ + const W = 800, H = 600 + const g = mkCG() + const store = { type: 'campstore', x: 0, y: 0, id: 8 } + rt.entKinds.campstore.init(store) + const codes = ['log', 'rock', 'flint', 'grass', 'twig', 'berry', 'gold'] + const p = mkPG(codes.map((c) => [c, 2])) + g.players = [p]; g.me = () => p; g.entities.push(store) + rt.entKinds.campstore.interact(p, store, g) + campHooks('onDrawUI')(g, makeCtx2(), { camX: 0, camY: 0, W, H, time: 0 }) + const L = campLayout(W, H) + campHooks('onClick')(g, C1(L.rows[0].dep).x, C1(L.rows[0].dep).y) + ok(Object.keys(store.mx.boxes[0].items).length === 6, `单箱最多 6 种(实际 ${Object.keys(store.mx.boxes[0].items).length})`) + ok(Object.values(store.mx.boxes[0].items).every((n) => n === 2), '6 种各存入 2 个') + campHooks('onClick')(g, C1(L.close).x, C1(L.close).y) +} +{ + const W = 800, H = 600 + const g = mkCG() + const store = { type: 'campstore', x: 0, y: 0, id: 9 } + rt.entKinds.campstore.init(store) + store.mx.boxes[0].items.log = 990 + const p = mkPG([['log', 100]]) + g.players = [p]; g.me = () => p; g.entities.push(store) + rt.entKinds.campstore.interact(p, store, g) + campHooks('onDrawUI')(g, makeCtx2(), { camX: 0, camY: 0, W, H, time: 0 }) + const L = campLayout(W, H) + campHooks('onClick')(g, C1(L.rows[0].dep).x, C1(L.rows[0].dep).y) + ok(store.mx.boxes[0].items.log === 999, `单种封顶 999(实际 ${store.mx.boxes[0].items.log})`) + ok(countP(p, 'log') === 91, `超出部分留在背包(实际 ${countP(p, 'log')})`) + campHooks('onClick')(g, C1(L.close).x, C1(L.close).y) +} +{ + const W = 800, H = 600 + const g = mkCG() + const store = { type: 'campstore', x: 0, y: 0, id: 10 } + rt.entKinds.campstore.init(store) + const p = mkPG() + g.players = [p]; g.me = () => p; g.entities.push(store) + rt.entKinds.campstore.interact(p, store, g) + campHooks('onDrawUI')(g, makeCtx2(), { camX: 0, camY: 0, W, H, time: 0 }) + const L = campLayout(W, H) + let err = null + try { campHooks('onClick')(g, C1(L.rows[2].wit).x, C1(L.rows[2].wit).y) } catch (e) { err = e.message } + ok(err === null, `空箱取出不崩 (${err || 'ok'})`) + campHooks('onClick')(g, C1(L.close).x, C1(L.close).y) +} +{ + const hook = campHooks('onMobVictim') + const gIn = mkCG({ entities: [{ type: 'camphouse', x: 0, y: 0, id: 1 }] }) + ok(hook(gIn, { kind: 'spider' }, { x: 100, y: 0 }) === false, '敌对怪对营地内玩家返回 false') + ok(hook(gIn, { kind: 'spider' }, { x: 419, y: 0 }) === false, '边界 419 内 → false') + ok(hook(gIn, { kind: 'spider' }, { x: 421, y: 0 }) !== false, '边界 421 外 → 不否决') + ok(hook(gIn, { kind: 'beefalo' }, { x: 10, y: 0 }) !== false, '非敌对生物不处理') + const gNo = mkCG({ worldW: 1000, worldH: 800 }) + ok(hook(gNo, { kind: 'spider' }, { x: 500, y: 400 }) === false, '无房屋时以世界中心判定 → false') + ok(hook(gNo, { kind: 'spider' }, { x: 920, y: 400 }) !== false, '无房屋时中心外 → 不否决') +} +{ + const g = mkCG({ entities: [{ type: 'camphouse', x: 0, y: 0, id: 1 }], monsters: [] }) + const guard = { kind: 'guard', x: 0, y: 0, id: 50, hp: 500, cd: 0 } + const spider = { kind: 'spider', x: 10, y: 0, id: 51, hp: 100 } + g.monsters.push(guard, spider) + rt.mobKinds.guard.update(guard, 0.1, g) + ok(guard.hp >= 1e8, `守卫无限血(hp=${guard.hp})`) + ok(g.calls.damageMob.length >= 1 && g.calls.damageMob[0][0] === spider, '守卫攻击营地内敌对生物') + const g2 = mkCG({ entities: [{ type: 'camphouse', x: 0, y: 0, id: 1 }], monsters: [] }) + const guard2 = { kind: 'guard', x: 0, y: 0, id: 1, hp: 1 } + g2.monsters.push(guard2) + let err = null + try { rt.mobKinds.guard.update(guard2, 0.1, g2) } catch (e) { err = e.message } + ok(err === null && g2.calls.damageMob.length === 0, `守卫无敌人时巡逻不崩 (${err || 'ok'})`) +} +{ + const g = mkCG({ entities: [{ type: 'camphouse', x: 0, y: 0, id: 1 }] }) + let err = null + try { campHooks('onDraw')(g, makeCtx2(), { camX: 0, camY: 0, W: 800, H: 600, time: 0 }) } catch (e) { err = e.message } + ok(err === null, `camp onDraw 假 ctx 不崩 (${err || 'ok'})`) +} + +// labor 面板布局(与 labor.js storeLayout/whLayout 一致) +function laborStoreLayout(W, H) { + const pw = Math.min(560, W * 0.86) + const headH = 54, rowH = 78, footH = 78, nRows = 2 + const ph = headH + nRows * rowH + footH + const px = (W - pw) / 2 + const py = Math.max(14, (H - ph) / 2) + const bw = Math.min(88, pw * 0.22), bh = 42 + const btns = [] + const roles = ['farmer', 'herder'] + for (let i = 0; i < nRows; i++) { + const rowY = py + headH + i * rowH + const by = rowY + (rowH - bh) / 2 + btns.push({ id: roles[i] + ':hire', x: px + pw - bw * 2 - 30, y: by, w: bw, h: bh }) + btns.push({ id: roles[i] + ':fire', x: px + pw - bw - 16, y: by, w: bw, h: bh }) + } + const closeW = 104, closeH = 40 + btns.push({ id: 'close', x: px + (pw - closeW) / 2, y: py + ph - closeH - 14, w: closeW, h: closeH }) + return { px, py, pw, ph, btns } +} +function laborWhLayout(W, H) { + const pw = Math.min(560, W * 0.86) + const headH = 46, footH = 46 + const rowH = Math.max(16, Math.min(30, (H - headH - footH - 28) / 12)) + const ph = headH + 12 * rowH + footH + const px = (W - pw) / 2 + const py = Math.max(10, (H - ph) / 2) + const bw = Math.min(62, pw * 0.16), bh = Math.max(14, Math.min(20, rowH - 4)) + const btns = [] + for (let i = 0; i < 12; 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 }) + } + const closeW = 100, closeH = 30 + btns.push({ id: 'close', x: px + (pw - closeW) / 2, y: py + headH + 12 * rowH + 6, w: closeW, h: closeH }) + return { px, py, pw, ph, btns } +} +const btnC = (L, id) => { const b = L.btns.find((x) => x.id === id); return { x: b.x + b.w / 2, y: b.y + b.h / 2 } } + +console.log('== D. 劳务市场 mod 行为级验证 ==') +const laborRt2 = activateMods(['labor']) +const laborHooks = (name) => hookFn(rt, name, 'labor') +const farmerUpdate = laborRt2.mobKinds.laborfarmer.update +const herderUpdate = laborRt2.mobKinds.laborherder.update +const mkLabG = (o = {}) => mkCG({ items: laborRt2.items, mon: laborRt2.mon, ...o }) +ok(Object.keys(laborRt2.entKinds).join(',') === 'laboroffice,laborwarehouse', `labor 单激活实体齐全 (${Object.keys(laborRt2.entKinds).join(',')})`) +ok(!!laborRt2.mobKinds.laborfarmer && !!laborRt2.mobKinds.laborherder, 'labor 单激活生物齐全') +{ + const g = mkLabG() + const wh = { type: 'laborwarehouse', x: 0, y: 0, id: 1 } + laborRt2.entKinds.laborwarehouse.init(wh) + const plot = { type: 'agrifarm', x: 0, y: 0, id: 2, state: 'ready', mx: { seed: 'wheat', t: 999 } } + g.entities.push(wh, plot) + g.monsters.push({ kind: 'laborfarmer', x: 0, y: 0, id: 3, hp: 300, owner: null }) + farmerUpdate(g.monsters[0], 0.1, g) + ok(wh.mx.boxes.some((b) => (b.items.wheat || 0) >= 3), `农民收割入库 wheat (${JSON.stringify(wh.mx.boxes[0].items)})`) + ok(plot.state === 'empty' && plot.mx === null, '收割后良田重置 empty') +} +{ + const g = mkLabG() + const wh = { type: 'laborwarehouse', x: 0, y: 0, id: 1 } + laborRt2.entKinds.laborwarehouse.init(wh) + wh.mx.boxes[0].items.wheatseeds = 1 + const plot = { type: 'agrifarm', x: 0, y: 0, id: 2, state: 'empty', mx: null } + g.entities.push(wh, plot) + g.monsters.push({ kind: 'laborfarmer', x: 0, y: 0, id: 3, hp: 300 }) + farmerUpdate(g.monsters[0], 0.1, g) + ok(plot.state === 'grow0' && plot.mx && plot.mx.seed === 'wheat', `播种后 grow0 且 seed=作物 code (${plot.state}/${plot.mx && plot.mx.seed})`) + ok(plot.mx.seed !== 'wheatseeds' && !wh.mx.boxes.some((b) => b.items.wheatseeds), 'seed 是作物 code 而非种子 code,且种子被消耗') +} +{ + const g = mkLabG({ findLand: () => ({ x: 500, y: 500 }) }) + const wh = { type: 'laborwarehouse', x: 0, y: 0, id: 1 } + laborRt2.entKinds.laborwarehouse.init(wh) + const grow = { type: 'agrifarm', x: 1000, y: 1000, id: 2, state: 'grow0', mx: { seed: 'wheat', t: 0 } } + g.entities.push(wh, grow) + g.monsters.push({ kind: 'laborfarmer', x: 500, y: 500, id: 3, hp: 300 }) + farmerUpdate(g.monsters[0], 0.1, g) + ok(g.calls.addEntity.some((c) => c[0] === 'agrifarm'), '空闲田不足 3 时开垦新田 addEntity') +} +{ + const g = mkLabG() + const wh = { type: 'laborwarehouse', x: 0, y: 0, id: 1 } + laborRt2.entKinds.laborwarehouse.init(wh) + g.entities.push(wh) + g.monsters.push({ kind: 'laborfarmer', x: 0, y: 0, id: 3, hp: 300 }) + let err = null + try { farmerUpdate(g.monsters[0], 0.1, g) } catch (e) { err = e.message } + ok(err === null && g.calls.addEntity.length === 0, `无农业 mod 时农民原地待命不崩 (${err || 'ok'})`) +} +{ + const g = mkLabG() + const wh = { type: 'laborwarehouse', x: 0, y: 0, id: 1 } + laborRt2.entKinds.laborwarehouse.init(wh) + const plot = { type: 'agrifarm', x: 0, y: 0, id: 2, state: 'ready', mx: { seed: 'wheat', t: 9 } } + g.entities.push(wh, plot) + g.monsters.push({ kind: 'laborfarmer', x: 0, y: 0, id: 3, hp: 300, strike: true }) + farmerUpdate(g.monsters[0], 0.1, g) + ok(plot.state === 'ready' && !wh.mx.boxes.some((b) => b.items.wheat), '罢工农民无任何收割副作用') +} +{ + const g = mkLabG() + const wh = { type: 'laborwarehouse', x: 0, y: 0, id: 1 } + laborRt2.entKinds.laborwarehouse.init(wh) + wh.mx.boxes[0].items.berry = 1 + const trough = { type: 'trough', x: 0, y: 0, id: 2, mx: { feed: 2 } } + g.entities.push(wh, trough) + g.monsters.push({ kind: 'laborherder', x: 0, y: 0, id: 3, hp: 300 }) + herderUpdate(g.monsters[0], 0.1, g) + ok(trough.mx.feed === 3, `牧工投喂 feed 2→3 (${trough.mx.feed})`) + ok(!wh.mx.boxes.some((b) => (b.items.berry || 0) > 0), '投喂消耗仓库一份 berry') +} +{ + const g = mkLabG() + const wh = { type: 'laborwarehouse', x: 0, y: 0, id: 1 } + laborRt2.entKinds.laborwarehouse.init(wh) + const coop = { type: 'coop', x: 0, y: 0, id: 2, mx: { stored: 3 } } + g.entities.push(wh, coop) + g.monsters.push({ kind: 'laborherder', x: 0, y: 0, id: 3, hp: 300 }) + herderUpdate(g.monsters[0], 0.1, g) + ok(wh.mx.boxes.some((b) => (b.items.egg || 0) >= 3), '牧工收蛋入库') + ok(coop.mx.stored === 0, '收蛋后鸡舍清零') +} +{ + const g = mkLabG() + const wh = { type: 'laborwarehouse', x: 0, y: 0, id: 1 } + laborRt2.entKinds.laborwarehouse.init(wh) + g.entities.push(wh) + g.monsters.push({ kind: 'laborherder', x: 0, y: 0, id: 3, hp: 300 }) + let err = null + try { herderUpdate(g.monsters[0], 0.1, g) } catch (e) { err = e.message } + ok(err === null, `无饲料槽/鸡舍时牧工原地待命不崩 (${err || 'ok'})`) +} +// 面板:招聘所 +{ + const W = 800, H = 600 + const g = mkLabG() + const office = { type: 'laboroffice', x: 0, y: 0, id: 1 } + laborRt2.entKinds.laboroffice.init(office) + g.entities.push(office) + const p = mkPG([['berry', 20]]) + g.players = [p]; g.me = () => p + laborRt2.entKinds.laboroffice.interact(p, office, g) + laborHooks('onDrawUI')(g, makeCtx2(), { camX: 0, camY: 0, W, H, time: 0 }) + const L = laborStoreLayout(W, H) + ok(laborHooks('onClick')(g, btnC(L, 'farmer:hire').x, btnC(L, 'farmer:hire').y) === true, '招聘按钮返回 true') + ok(g.calls.spawnMob.some((s) => s[0] === 'laborfarmer'), '招聘生成 laborfarmer') + ok(office.mx.farmer === 1, `office.mx.farmer=1 (${office.mx.farmer})`) + ok(countP(p, 'berry') === 12, `招聘扣 8 粮食 (剩 ${countP(p, 'berry')})`) + ok(laborHooks('onClick')(g, btnC(L, 'farmer:fire').x, btnC(L, 'farmer:fire').y) === true, '解雇按钮返回 true') + ok(office.mx.farmer === 0 && g.monsters.length === 0, `解雇后人数归零、mob 移除 (${office.mx.farmer}/${g.monsters.length})`) + ok(laborHooks('onClick')(g, btnC(L, 'close').x, btnC(L, 'close').y) === true, '关闭按钮返回 true') + ok(laborHooks('onClick')(g, 5, 5) === undefined, '面板关闭后 onClick 返回 undefined') +} +// 面板:粮食不足 / 上限 / 点面板外 +{ + const W = 800, H = 600 + const g = mkLabG() + const office = { type: 'laboroffice', x: 0, y: 0, id: 2 } + laborRt2.entKinds.laboroffice.init(office) + g.entities.push(office) + const p = mkPG([['berry', 3]]) + g.players = [p]; g.me = () => p + laborRt2.entKinds.laboroffice.interact(p, office, g) + laborHooks('onDrawUI')(g, makeCtx2(), { camX: 0, camY: 0, W, H, time: 0 }) + const L = laborStoreLayout(W, H) + laborHooks('onClick')(g, btnC(L, 'farmer:hire').x, btnC(L, 'farmer:hire').y) + ok(g.calls.spawnMob.length === 0 && countP(p, 'berry') === 3, '粮食不足招不到人且不扣粮') + office.mx.farmer = 4 + const pRich = mkPG([['berry', 100]]) + g.players = [pRich]; g.me = () => pRich + laborHooks('onClick')(g, btnC(L, 'farmer:hire').x, btnC(L, 'farmer:hire').y) + ok(g.calls.spawnMob.length === 0 && countP(pRich, 'berry') === 100, '达上限 4 招不到人且不扣粮') + ok(laborHooks('onClick')(g, 2, 2) === true, '点面板外返回 true(吃掉点击)') + ok(laborHooks('onClick')(g, 2, 2) === undefined, '点面板外后面板关闭(再次返回 undefined)') +} +// 面板:大仓库存取 +{ + const W = 800, H = 600 + const g = mkLabG() + const wh = { type: 'laborwarehouse', id: 4 } + laborRt2.entKinds.laborwarehouse.init(wh) + g.entities.push(wh) + const p = mkPG([['log', 5]]) + g.players = [p]; g.me = () => p + laborRt2.entKinds.laborwarehouse.interact(p, wh, g) + laborHooks('onDrawUI')(g, makeCtx2(), { camX: 0, camY: 0, W, H, time: 0 }) + const L = laborWhLayout(W, H) + laborHooks('onClick')(g, btnC(L, 'dep:0').x, btnC(L, 'dep:0').y) + ok(wh.mx.boxes[0].items.log === 5 && countP(p, 'log') === 0, `大仓库存入箱0 (${JSON.stringify(wh.mx.boxes[0].items)})`) + laborHooks('onClick')(g, btnC(L, 'wd:0').x, btnC(L, 'wd:0').y) + ok(countP(p, 'log') === 5 && Object.keys(wh.mx.boxes[0].items).length === 0, '大仓库取出归还') + laborHooks('onClick')(g, btnC(L, 'close').x, btnC(L, 'close').y) + ok(laborHooks('onClick')(g, 5, 5) === undefined, '仓库面板关闭后 onClick 返回 undefined') +} +// 连续招聘到满员 +{ + const W = 800, H = 600 + const g = mkLabG() + const office = { type: 'laboroffice', x: 0, y: 0, id: 5 } + laborRt2.entKinds.laboroffice.init(office) + g.entities.push(office) + const p = mkPG([['berry', 100]]) + g.players = [p]; g.me = () => p + laborRt2.entKinds.laboroffice.interact(p, office, g) + laborHooks('onDrawUI')(g, makeCtx2(), { camX: 0, camY: 0, W, H, time: 0 }) + const L = laborStoreLayout(W, H) + for (let i = 0; i < 5; i++) laborHooks('onClick')(g, btnC(L, 'farmer:hire').x, btnC(L, 'farmer:hire').y) + ok(office.mx.farmer === 4, `连续招聘封顶 4 (${office.mx.farmer})`) + ok(g.monsters.length === 4, `工人数 4 (${g.monsters.length})`) + ok(countP(p, 'berry') === 68, `招聘扣费 4×8 (剩 ${countP(p, 'berry')})`) + laborHooks('onClick')(g, btnC(L, 'close').x, btnC(L, 'close').y) +} +// 工资结算 +{ + const g = mkLabG() + const office = { type: 'laboroffice', x: 0, y: 0, 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 + g.entities.push(office, wh) + const m1 = { kind: 'laborfarmer', x: 0, y: 0, id: 901, office: 5 } + g.monsters.push(m1) + const p = mkPG(); g.players = [p]; g.me = () => p + laborHooks('onNewDay')(g) + ok(!m1.strike, '粮食充足 → 不罢工') + ok(wh.mx.boxes.reduce((n, b) => n + (b.items.berry || 0), 0) === 8, `工资从仓库扣 2 (${wh.mx.boxes.reduce((n, b) => n + (b.items.berry || 0), 0)})`) + const g2 = mkLabG() + const office2 = { type: 'laboroffice', x: 0, y: 0, id: 5 } + laborRt2.entKinds.laboroffice.init(office2); office2.mx.farmer = 1 + const wh2 = { type: 'laborwarehouse', x: 0, y: 0, id: 6 } + laborRt2.entKinds.laborwarehouse.init(wh2) + g2.entities.push(office2, wh2) + const m2 = { kind: 'laborfarmer', x: 0, y: 0, id: 902, office: 5 } + g2.monsters.push(m2) + const p2 = mkPG(); g2.players = [p2]; g2.me = () => p2 + laborHooks('onNewDay')(g2) + ok(m2.strike === true, '粮食不足 → 罢工') +} +// 存档往返 +{ + const g = mkLabG() + const office = { type: 'laboroffice', x: 0, y: 0, id: 5 } + laborRt2.entKinds.laboroffice.init(office); office.mx.farmer = 2; office.mx.hired = 2 + g.entities.push(office) + g.monsters.push({ kind: 'laborfarmer', x: 100, y: 120, id: 901, office: 5, strike: true }) + g.monsters.push({ kind: 'laborherder', x: 130, y: 140, id: 902, office: 5 }) + const p = mkPG(); g.players = [p]; g.me = () => p + const data = laborHooks('save')(g) + let jsonOk = true + try { JSON.parse(JSON.stringify(data)) } catch (e) { jsonOk = false } + ok(jsonOk && data && data.offices.length === 1 && data.workers.length === 2, 'save 可 JSON 序列化且含 1 所 2 工') + const g2 = mkLabG() + const office2 = { type: 'laboroffice', x: 0, y: 0, id: 5 } + laborRt2.entKinds.laboroffice.init(office2) + g2.entities.push(office2) + const p2 = mkPG(); g2.players = [p2]; g2.me = () => p2 + laborHooks('load')(g2, data) + ok(office2.mx.farmer === 2, `load 复原 office.mx (${office2.mx.farmer})`) + ok(g2.monsters.length === 2, `load 复原 2 名工人 (${g2.monsters.length})`) + ok(g2.monsters.some((m) => m.strike === true), 'load 复原罢工状态') + const g3 = mkLabG() + const p3 = mkPG(); g3.players = [p3]; g3.me = () => p3 + let err = null + try { laborHooks('load')(g3, data) } catch (e) { err = e.message } + ok(err === null && g3.monsters.length === 0, `load 失配数据不抛异常 (${err || 'ok'})`) +} + +console.log('== E. 持久化迁移回归(e.mx.*)==') +const has = (o, k) => Object.prototype.hasOwnProperty.call(o, k) +{ + const g = mkCG() + const p = mkPG([['wheat', 4]]) + g.players = [p]; g.me = () => p + const gr = { type: 'granary', id: 1 } + rt.entKinds.granary.init(gr) + rt.entKinds.granary.interact(p, gr, g) + ok(gr.mx && gr.mx.store && gr.mx.store.wheat === 4, `粮仓存 e.mx.store (${JSON.stringify(gr.mx && gr.mx.store)})`) + ok(!has(gr, 'store') && !has(gr, 'feed') && !has(gr, 'stored'), '粮仓无残留 e.store/e.feed/e.stored') +} +{ + const g = mkCG() + const p = mkPG([['berry', 2]]) + g.players = [p]; g.me = () => p + const tr = { type: 'trough', id: 1 } + rt.entKinds.trough.init(tr) + rt.entKinds.trough.interact(p, tr, g) + ok(tr.mx && tr.mx.feed === 1, `饲料槽存 e.mx.feed (${tr.mx && tr.mx.feed})`) + ok(!has(tr, 'feed') && !has(tr, 'store') && !has(tr, 'stored'), '饲料槽无残留旧字段') +} +{ + const coop = { type: 'coop', x: 0, y: 0, id: 1 } + rt.entKinds.coop.init(coop) + const g = mkCG({ entities: [coop] }) + hookFn(rt, 'onNewDay', 'husbandry')(g) + ok(coop.mx && coop.mx.stored >= 1, `鸡舍存 e.mx.stored (${coop.mx && coop.mx.stored})`) + ok(!has(coop, 'stored') && !has(coop, 'feed') && !has(coop, 'store'), '鸡舍无残留旧字段') +} +{ + let err = null + ;['agri', 'husbandry', 'camp', 'labor'].forEach((id) => { + const r = activateMods([id]) + Object.entries(r.entKinds).forEach(([type, def]) => { + if (!def) return + const e = { type, id: 1 } + if (typeof def.init === 'function') { + try { def.init(e) } catch (ex) { err = `${id}/${type}.init: ${ex.message}` } + } + if (has(e, 'store')) err = `${id}/${type} 残留 e.store` + if (has(e, 'feed')) err = `${id}/${type} 残留 e.feed` + if (has(e, 'stored')) err = `${id}/${type} 残留 e.stored` + }) + }) + ok(err === null, `空实体 init 全部安全、无残留旧字段 (${err || 'ok'})`) +} + +console.log('== F. 边界与错误路径 ==') +{ + const rc = activateMods(['camp']) + const ch = (n) => hookFn(rc, n, 'camp') + const g = mkCG({ mon: rc.mon, items: rc.items }) + let err = null + try { + ch('onWorldGen')(g) + const t = ch('onTick'); if (t) t(g, 0.016) + ch('onDraw')(g, makeCtx2(), { camX: 0, camY: 0, W: 800, H: 600, time: 0 }) + ch('onMobVictim')(g, { kind: 'spider' }, { x: 0, y: 0 }) + ch('onDrawUI')(g, makeCtx2(), { camX: 0, camY: 0, W: 800, H: 600, time: 0 }) + } catch (e) { err = e.message } + ok(err === null, `仅激活 camp 各钩子不崩 (${err || 'ok'})`) +} +{ + const rw = activateMods(['wideworld']) + ok(rw.flags.worldScale === 1.5, '仅激活 wideworld worldScale=1.5') + ok(Object.keys(rw.entKinds).length === 0, '仅激活 wideworld 无实体') +} +{ + // 没种子 → 退回默认作物 + const g = mkLabG() + const wh = { type: 'laborwarehouse', x: 0, y: 0, id: 1 } + laborRt2.entKinds.laborwarehouse.init(wh) + const plot = { type: 'agrifarm', x: 0, y: 0, id: 2, state: 'empty', mx: null } + g.entities.push(wh, plot) + g.monsters.push({ kind: 'laborfarmer', x: 0, y: 0, id: 3, hp: 300 }) + farmerUpdate(g.monsters[0], 0.1, g) + ok(plot.state === 'grow0' && plot.mx && plot.mx.seed === 'wheat', `无种子退回默认作物 wheat (${plot.mx && plot.mx.seed})`) +} +{ + // 仓库满 → 收获溢出掉落 + const g = mkLabG() + const wh = { type: 'laborwarehouse', x: 0, y: 0, id: 1 } + laborRt2.entKinds.laborwarehouse.init(wh) + wh.mx.boxes.forEach((b) => { b.items = { wheat: 999, berry: 999, log: 999, rock: 999, flint: 999, grass: 999 } }) + const plot = { type: 'agrifarm', x: 0, y: 0, id: 2, state: 'ready', mx: { seed: 'wheat', t: 9 } } + g.entities.push(wh, plot) + g.monsters.push({ kind: 'laborfarmer', x: 0, y: 0, id: 3, hp: 300 }) + farmerUpdate(g.monsters[0], 0.1, g) + ok(g.calls.drops.some((d) => d[0] === 'wheat'), '仓库满 → 收获溢出掉落') + ok(plot.state === 'empty', '仓库满仍完成收割') +} +{ + // 缺 mx / 缺 boxes 状态下 draw 不崩 + const ctx = makeCtx2() + let err = null + try { + Object.entries(activateMods(['camp']).entKinds).forEach(([t, def]) => { + const e = { type: t, id: 1, x: 0, y: 0 } + if (def.draw) def.draw(ctx, e, { time: 0 }) + if (def.update) def.update(e, 0.1, mkCG()) + }) + Object.entries(activateMods(['labor']).entKinds).forEach(([t, def]) => { + const e = { type: t, id: 1, x: 0, y: 0 } + if (def.draw) def.draw(ctx, e) + if (def.update) def.update(e, 0.1, mkLabG()) + }) + laborRt2.entKinds.laborwarehouse.draw(ctx, { type: 'laborwarehouse', id: 1, mx: null }) + laborRt2.entKinds.laboroffice.draw(ctx, { type: 'laboroffice', id: 1, mx: null }) + } catch (e) { err = e.message } + ok(err === null, `缺 mx/boxes 状态下实体 draw/update 不崩 (${err || 'ok'})`) +} +{ + // camp/labor 工人 draw 假 ctx 不崩(含罢工标记分支) + const ctx = makeCtx2() + let err = null + try { + rt.mobKinds.guard.draw(ctx, { dir: 1, moving: true, bob: 0, id: 1 }, 1) + laborRt2.mobKinds.laborfarmer.draw(ctx, { dir: 1, moving: true, bob: 0, strike: true, id: 1 }, 1) + laborRt2.mobKinds.laborherder.draw(ctx, { dir: -1, moving: false, bob: 0, strike: true, id: 2 }, 1) + } catch (e) { err = e.message } + ok(err === null, `守卫/农民/牧工 draw 假 ctx 不崩 (${err || 'ok'})`) +} + console.log(fails ? `\n${fails} 项失败` : '\n全部通过') process.exit(fails ? 1 : 0)