更新饥荒
This commit is contained in:
202
_campsmoke.mjs
Normal file
202
_campsmoke.mjs
Normal file
@@ -0,0 +1,202 @@
|
||||
// 临时冒烟:验证 camp.js / wideworld.js 运行时行为(不加入正式测试)
|
||||
import camp from './src/games/starve/mods/camp.js'
|
||||
import wide from './src/games/starve/mods/wideworld.js'
|
||||
|
||||
let fails = 0
|
||||
const ok = (c, m) => { if (c) console.log(' PASS', m); else { fails++; console.error(' FAIL', m) } }
|
||||
|
||||
const makeCtx = () => new Proxy({}, { get: (t, k) => (k in t ? t[k] : () => {}), set: (t, k, v) => { t[k] = v; return true } })
|
||||
|
||||
const items = {
|
||||
log: { name: '木料', stack: 20 },
|
||||
rock: { name: '石头', stack: 20 },
|
||||
spear: { name: '长矛', stack: 1, equip: 'hand' },
|
||||
}
|
||||
function makeG(opts = {}) {
|
||||
const world = { entities: opts.entities || [], monsters: opts.monsters || [], fires: [] }
|
||||
const p = opts.player || { x: 1200, y: 1000, slots: [], pack: null, hp: 100, san: 100, dir: 1, dead: false }
|
||||
const count = (code) => (p.slots || []).reduce((n, s) => n + (s && s.code === code ? s.n : 0), 0)
|
||||
+ (p.pack || []).reduce((n, s) => n + (s && s.code === code ? s.n : 0), 0)
|
||||
const g = {
|
||||
get players() { return [p] },
|
||||
get monsters() { return world.monsters },
|
||||
get entities() { return world.entities },
|
||||
get fires() { return world.fires },
|
||||
mon: {
|
||||
guard: { hp: 9999, speed: 150, dmg: 60, score: 0, friendly: true, name: '守卫' },
|
||||
hound: { hp: 150, speed: 195, dmg: 20, score: 20, name: '猎犬', hostile: true },
|
||||
},
|
||||
items, tune: { stackMax: 20 },
|
||||
worldW: 2400, worldH: 2000, day: 1, season: 'autumn', phase: 'day', time: 5,
|
||||
isLand: () => true,
|
||||
litAt: () => true,
|
||||
findLand: (x, y) => ({ x, y }),
|
||||
countItem: (pp, c) => count(c),
|
||||
hasCost: (pp, cost) => Object.entries(cost).every(([c, n]) => count(c) >= n),
|
||||
payCost: (pp, cost) => {
|
||||
if (!Object.entries(cost).every(([c, n]) => count(c) >= n)) return false
|
||||
Object.entries(cost).forEach(([c, need]) => {
|
||||
let n = need
|
||||
;[p.slots, p.pack].filter(Boolean).forEach((area) => {
|
||||
for (let i = 0; i < area.length && n > 0; i++) {
|
||||
const s = area[i]
|
||||
if (s && s.code === c) { const t = Math.min(n, s.n); s.n -= t; n -= t; if (s.n <= 0) area[i] = null }
|
||||
}
|
||||
})
|
||||
})
|
||||
return true
|
||||
},
|
||||
addItem: (pp, c, n) => {
|
||||
for (let i = 0; i < p.slots.length; i++) if (!p.slots[i]) { p.slots[i] = { code: c, n }; return }
|
||||
p.slots.push({ code: c, n })
|
||||
},
|
||||
consumeAt: () => {},
|
||||
me: () => p,
|
||||
isHost: () => true,
|
||||
rand: (a, b) => a,
|
||||
addEntity: (type, x, y, extra) => { const e = { id: world.entities.length + 1, type, x, y, ...(extra || {}) }; world.entities.push(e); return e },
|
||||
removeEntity: () => {},
|
||||
markDirty: () => {},
|
||||
spawnMob: (kind, x, y) => { const m = { id: world.monsters.length + 1, kind, x, y, hp: 100, cd: 0 }; world.monsters.push(m); return m },
|
||||
removeMob: () => {},
|
||||
killMob: () => {},
|
||||
damageMob: (m, d) => { m.hp -= d },
|
||||
moveToward: (m, x, y, sp, dt) => { const dd = Math.hypot(x - m.x, y - m.y) || 1; m.x += (x - m.x) / dd * sp * dt; m.y += (y - m.y) / dd * sp * dt },
|
||||
smashStructure: () => {},
|
||||
addFire: (x, y, o) => { const f = { fid: world.fires.length + 1, x, y, ...(o || {}) }; world.fires.push(f); return f },
|
||||
addScore: () => {}, ptip: () => {}, swing: () => {}, tip: () => {}, shake: () => {}, invalidate: () => {},
|
||||
...(opts.extra || {}),
|
||||
}
|
||||
g._player = p
|
||||
g._world = world
|
||||
return g
|
||||
}
|
||||
|
||||
console.log('== wideworld ==')
|
||||
ok(wide.id === 'wideworld' && wide.flags.worldScale === 1.5, `worldScale=${wide.flags && wide.flags.worldScale}`)
|
||||
ok(wide.factor === 0.9 && wide.tag === 'rule', 'factor/tag 正确')
|
||||
|
||||
console.log('== camp 结构 ==')
|
||||
ok(camp.recipes.every((r) => camp.icons[r.code]), '每个配方都有图标')
|
||||
ok(camp.recipes.filter((r) => r.build).every((r) => camp.ents[r.code]), '每个 build 配方都有同名实体')
|
||||
ok(camp.tabs[0].code === 'camp' && !!camp.icons[camp.tabs[0].icon], '页签图标存在')
|
||||
const uniq = (arr) => new Set(arr).size === arr.length
|
||||
ok(
|
||||
uniq(camp.recipes.map((r) => r.code)) && uniq(Object.keys(camp.ents)) && uniq(Object.keys(camp.mobs)) && uniq(Object.keys(camp.icons)),
|
||||
'各自命名空间内 code 唯一'
|
||||
)
|
||||
|
||||
console.log('== onWorldGen ==')
|
||||
{
|
||||
const g = makeG()
|
||||
camp.hooks.onWorldGen(g)
|
||||
const house = g._world.entities.find((e) => e.type === 'camphouse')
|
||||
const guard = g._world.monsters.find((m) => m.kind === 'guard')
|
||||
ok(!!house && !!guard && g._world.fires.some((f) => f.eternal), '房屋/守卫/长明篝火就位')
|
||||
const pd = Math.hypot(house.x - g._player.x, house.y - g._player.y)
|
||||
ok(pd > 30, `房屋与出生点错开 (d=${pd.toFixed(0)})`)
|
||||
}
|
||||
|
||||
console.log('== 房屋休息 ==')
|
||||
{
|
||||
const g = makeG()
|
||||
const house = { id: 1, type: 'camphouse', mx: { restDay: 0 } }
|
||||
camp.ents.camphouse.init(house)
|
||||
const p = { san: 100, hp: 100 }
|
||||
camp.ents.camphouse.interact(p, house, g)
|
||||
ok(p.san === 130 && p.hp === 120 && house.mx.restDay === g.day, `休息 san+30 hp+20 (${p.san}/${p.hp})`)
|
||||
camp.ents.camphouse.interact(p, house, g)
|
||||
ok(p.san === 130 && p.hp === 120, '同日重复休息不再回复')
|
||||
}
|
||||
|
||||
console.log('== 营地仓储 ==')
|
||||
{
|
||||
const p = { x: 1300, y: 1000, slots: [{ code: 'log', n: 5 }, { code: 'spear', n: 1 }, null], pack: null }
|
||||
const g = makeG({ player: p })
|
||||
const store = g.addEntity('campstore', 1300, 1000)
|
||||
camp.ents.campstore.init(store)
|
||||
camp.ents.campstore.interact(p, store, g)
|
||||
const ctx = makeCtx()
|
||||
const env = { camX: 0, camY: 0, W: 900, H: 620, time: 1 }
|
||||
camp.hooks.onDrawUI(g, ctx, env)
|
||||
// 点击某行“存入”按钮(复刻面板坐标:dep.x=572, sy≈198.5+30i)
|
||||
let deposited = false
|
||||
for (let i = 0; i < 8 && !deposited; i++) {
|
||||
// 行内按钮 x 位置:右缘附近的“存入”钮
|
||||
const sx = 900 / 2 + 470 / 2 - 10 - 4 - 60 - 6 - 33 // 近似“存入”按钮中心
|
||||
const sy = Math.round((620 - (40 + 8 * 30 + 42 + 10)) / 2) + 40 + i * 30 + 14
|
||||
const handled = camp.hooks.onClick(g, sx, sy)
|
||||
if (handled === true) {
|
||||
const box0 = store.mx.boxes[i]
|
||||
if (Object.keys(box0.items).length) deposited = true
|
||||
}
|
||||
}
|
||||
const anyBox = store.mx.boxes.find((b) => Object.keys(b.items).length)
|
||||
ok(!!anyBox && anyBox.items.log === 5, `存入 log×5 (${JSON.stringify(store.mx.boxes.map((b) => b.items))})`)
|
||||
ok(p.slots.find((s) => s && s.code === 'spear'), '工具(长矛)不被存入')
|
||||
// 取出全部
|
||||
const p2 = { x: 1300, y: 1000, slots: [], pack: null }
|
||||
const g2 = makeG({ player: p2 })
|
||||
g2._world.entities.push(store)
|
||||
camp.hooks.onDrawUI(g2, ctx, env)
|
||||
// 找出有内容的箱子行按钮
|
||||
const idx = store.mx.boxes.findIndex((b) => Object.keys(b.items).length)
|
||||
const sx = 900 / 2 + 470 / 2 - 10 - 4 - 30 // “取出”按钮中心
|
||||
const sy = Math.round((620 - (40 + 8 * 30 + 42 + 10)) / 2) + 40 + idx * 30 + 14
|
||||
const h = camp.hooks.onClick(g2, sx, sy)
|
||||
ok(h === true && (p2.slots.some((s) => s && s.code === 'log')), '取出物资到背包')
|
||||
// 面板外点击 → 关闭面板并吃掉
|
||||
const outside = camp.hooks.onClick(g2, -9999, -9999)
|
||||
ok(outside === true, '面板外点击返回 true(关闭)')
|
||||
const noPanel = camp.hooks.onClick(g2, -9999, -9999)
|
||||
ok(noPanel === undefined, '无面板时返回 undefined(不干扰)')
|
||||
}
|
||||
|
||||
console.log('== 领地:敌对生物被推离 ==')
|
||||
{
|
||||
const g = makeG()
|
||||
camp.hooks.onWorldGen(g)
|
||||
const house = g._world.entities.find((e) => e.type === 'camphouse')
|
||||
const hound = g.spawnMob('hound', house.x + 40, house.y + 20) // 营地内
|
||||
camp.hooks.onTick(g)
|
||||
const d = Math.hypot(hound.x - house.x, hound.y - house.y)
|
||||
ok(d >= 420, `营地内猎犬被推到边界外 (d=${d.toFixed(0)})`)
|
||||
}
|
||||
|
||||
console.log('== 守卫拦截 ==')
|
||||
{
|
||||
const g = makeG()
|
||||
camp.hooks.onWorldGen(g)
|
||||
const house = g._world.entities.find((e) => e.type === 'camphouse')
|
||||
const guard = g._world.monsters.find((m) => m.kind === 'guard')
|
||||
const hound = g.spawnMob('hound', house.x + 200, house.y + 60)
|
||||
const before = Math.hypot(hound.x - guard.x, hound.y - guard.y)
|
||||
for (let i = 0; i < 40; i++) { hound.x = house.x + 200; camp.mobs.guard.update(guard, 0.05, g) }
|
||||
const after = Math.hypot(hound.x - guard.x, hound.y - guard.y)
|
||||
ok(guard.hp === 1e9, '守卫每帧回满血(无限血)')
|
||||
ok(after < before, `守卫向敌人靠拢 (${before.toFixed(0)}→${after.toFixed(0)})`)
|
||||
}
|
||||
|
||||
console.log('== 绘制不抛异常 ==')
|
||||
{
|
||||
const g = makeG()
|
||||
camp.hooks.onWorldGen(g)
|
||||
const house = g._world.entities.find((e) => e.type === 'camphouse')
|
||||
const store = g.addEntity('campstore', house.x + 40, house.y)
|
||||
const ctx = makeCtx()
|
||||
let err = null
|
||||
try {
|
||||
camp.ents.camphouse.draw(ctx, house, { time: 3 })
|
||||
camp.ents.campstore.draw(ctx, store, { time: 3 })
|
||||
camp.mobs.guard.draw(ctx, { dir: 1, moving: true, bob: 0 }, 3)
|
||||
camp.hooks.onDraw(g, ctx, { camX: 100, camY: 60, W: 800, H: 600, time: 3 })
|
||||
camp.ents.campstore.interact(g._player, store, g)
|
||||
camp.hooks.onDrawUI(g, ctx, { camX: 100, camY: 60, W: 800, H: 600, time: 3 })
|
||||
camp.icons.camphouse(ctx)
|
||||
camp.icons.campstore(ctx)
|
||||
} catch (e) { err = e.message }
|
||||
ok(err === null, `绘制/UI 无异常 (${err || 'ok'})`)
|
||||
}
|
||||
|
||||
console.log(`\n${fails} 项失败`)
|
||||
process.exit(fails ? 1 : 0)
|
||||
77
_campveto.mjs
Normal file
77
_campveto.mjs
Normal file
@@ -0,0 +1,77 @@
|
||||
// 临时:验证 onMobVictim 否决钩子语义(跑完删除)
|
||||
import camp from './src/games/starve/mods/camp.js'
|
||||
|
||||
let fails = 0
|
||||
const ok = (c, m) => { if (c) console.log(' PASS', m); else { fails++; console.error(' FAIL', m) } }
|
||||
|
||||
const HOOK = camp.hooks.onMobVictim
|
||||
function makeG() {
|
||||
const g = {
|
||||
mon: { hound: { hostile: true }, icehound: { hostile: true }, pig: { friendly: true } },
|
||||
players: [],
|
||||
monsters: [],
|
||||
entities: [{ type: 'camphouse', x: 0, y: 0 }],
|
||||
worldW: 0,
|
||||
worldH: 0,
|
||||
}
|
||||
// 模拟核心 g.allows:任一 mod 返回 false 即 false
|
||||
g.allows = (name, mm, vv) => HOOK(g, mm, vv) !== false
|
||||
return g
|
||||
}
|
||||
|
||||
console.log('== onMobVictim 否决语义 ==')
|
||||
const g = makeG()
|
||||
const hound = { kind: 'hound', x: 800, y: 800 }
|
||||
const pig = { kind: 'pig', x: 800, y: 800 }
|
||||
|
||||
const inCamp = { x: 100, y: 100 } // 距房(0,0) ~141 < 420
|
||||
const outside = { x: 800, y: 800 } // 距房 ~1131 > 420
|
||||
|
||||
ok(HOOK(g, hound, inCamp) === false, '营地内玩家 → 返回 false(怪不选他)')
|
||||
ok(HOOK(g, hound, outside) !== false, '营地外玩家 → 不返回 false')
|
||||
ok(HOOK(g, pig, inCamp) !== false, '非敌对生物 → 不返回 false(保留打守卫/猪人)')
|
||||
ok(g.allows('onMobVictim', hound, inCamp) === false, 'g.allows 透传 false 正确')
|
||||
ok(g.allows('onMobVictim', hound, outside) === true, 'g.allows 透传 true 正确')
|
||||
|
||||
console.log('== 端点边界 ==')
|
||||
ok(HOOK(g, hound, { x: 419, y: 0 }) === false, '半径内 419 → false')
|
||||
ok(HOOK(g, hound, { x: 421, y: 0 }) !== false, '半径外 421 → 允许')
|
||||
|
||||
console.log('== 无房屋时退回世界中心 ==')
|
||||
const g2 = makeG()
|
||||
g2.entities = []
|
||||
g2.worldW = 1000
|
||||
g2.worldH = 1000
|
||||
ok(HOOK(g2, hound, { x: 600, y: 500 }) === false, '无房屋→以世界中心(500,500)为圆心,100 距离内 → false')
|
||||
ok(HOOK(g2, hound, { x: 1200, y: 500 }) !== false, '无房屋→距世界中心 700 → 允许')
|
||||
ok(HOOK(g2, hound, { x: 500, y: 500 }) === false, '世界中心本身 → false')
|
||||
|
||||
console.log('== 模拟核心 nearestVictim:营地内玩家被排除 ==')
|
||||
{
|
||||
// 复刻 mobs.js nearestVictim 的玩家循环(含 g.allows 否决)
|
||||
const pick = (gg, m) => {
|
||||
let best = null
|
||||
let bd = Infinity
|
||||
gg.players.forEach((p) => {
|
||||
if (p.dead) return
|
||||
if (gg.allows && gg.allows('onMobVictim', m, p) === false) return
|
||||
const d = Math.hypot(p.x - m.x, p.y - m.y)
|
||||
if (d < bd) { bd = d; best = p }
|
||||
})
|
||||
return best
|
||||
}
|
||||
const gg = makeG()
|
||||
const m = { kind: 'hound', x: 300, y: 300 }
|
||||
const pIn = { dead: false, x: 100, y: 100 } // 营地内,离怪更近 (~283)
|
||||
const pOut = { dead: false, x: 900, y: 900 } // 营地外,离怪更远 (~849)
|
||||
gg.players = [pIn, pOut]
|
||||
ok(pick(gg, m) === pOut, '营地内玩家被否决,目标落到营地外玩家')
|
||||
|
||||
// 若营地内玩家是唯一玩家 → 无目标(怪待命)
|
||||
const gg2 = makeG()
|
||||
gg2.players = [pIn]
|
||||
ok(pick(gg2, { kind: 'hound', x: 300, y: 300 }) === null, '仅营地内玩家 → 无目标(怪待命,符合 updHound if(!near)return)')
|
||||
}
|
||||
|
||||
console.log(`\n${fails} 项失败`)
|
||||
process.exit(fails ? 1 : 0)
|
||||
27
_qa_debug.mjs
Normal file
27
_qa_debug.mjs
Normal file
@@ -0,0 +1,27 @@
|
||||
import { activateMods } from './src/games/starve/mods/registry.js'
|
||||
|
||||
const laborRt2 = activateMods(['labor'])
|
||||
console.log('berry item =', JSON.stringify(laborRt2.items.berry))
|
||||
console.log('wheat item =', !!laborRt2.items.wheat)
|
||||
|
||||
const office = { type: 'laboroffice', id: 5 }
|
||||
laborRt2.entKinds.laboroffice.init(office); office.mx.farmer = 1
|
||||
const wh = { type: 'laborwarehouse', x: 0, y: 0, id: 6 }
|
||||
laborRt2.entKinds.laborwarehouse.init(wh)
|
||||
wh.mx.boxes[0].items.berry = 10
|
||||
console.log('boxes[0] =', JSON.stringify(wh.mx.boxes[0].items))
|
||||
|
||||
const players = [{ x: 0, y: 0, dead: false, seat: 0, slots: [], pack: null }]
|
||||
const g = {
|
||||
entities: [office, wh],
|
||||
monsters: [{ kind: 'laborfarmer', x: 0, y: 0, id: 901, office: 5 }],
|
||||
players,
|
||||
mon: laborRt2.mon,
|
||||
items: laborRt2.items,
|
||||
me: () => players[0],
|
||||
markDirty() {}, tip() {}, ptip() {},
|
||||
}
|
||||
const hooks = (laborRt2.hooks.onNewDay || [])
|
||||
console.log('onNewDay hooks =', hooks.map((h) => h.id))
|
||||
hooks.forEach((h) => h.fn(g))
|
||||
console.log('after onNewDay strike =', g.monsters[0].strike, 'berry =', wh.mx.boxes.reduce((n, b) => n + (b.items.berry || 0), 0))
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 } }
|
||||
})
|
||||
|
||||
589
src/games/starve/mods/agri.js
Normal file
589
src/games/starve/mods/agri.js
Normal file
@@ -0,0 +1,589 @@
|
||||
// 农业文明:犁锄开垦良田 + 水井灌溉加速 + 粮仓储粮 + 磨坊磨面,把荒野变粮仓
|
||||
// - 犁锄(1 级科技,耐久 40):点击在面前开垦一块良田
|
||||
// - 良田:200 秒三阶段生长(比 farm.js 快),冬季暂停;140px 内有水井则生长 ×1.6
|
||||
// - 水井:打水回理智(60 秒冷却),同时是良田的灌溉水源
|
||||
// - 粮仓:存入/取出 wheat/pumpkin/berry/carrot 类作物
|
||||
// - 磨坊:wheat×3 → flour×2
|
||||
// - 采草 12% 掉小麦种子、采浆果丛 12% 掉南瓜种子
|
||||
const TAU = Math.PI * 2
|
||||
const GROW_TOTAL = 200 // 生长总时长(秒):比 farm.js 的 320 秒更快
|
||||
|
||||
// 种子 → 成熟作物 code
|
||||
const SEED_CROP = { wheatseeds: 'wheat', pumpkinseeds: 'pumpkin', berryseeds: 'berry' }
|
||||
// 粮仓可收纳的作物
|
||||
const CROPS = ['wheat', 'pumpkin', 'berry', 'carrot']
|
||||
|
||||
function stageOf(t) {
|
||||
if (t >= GROW_TOTAL) return 'ready'
|
||||
if (t >= 133) return 'grow2'
|
||||
if (t >= 66) return 'grow1'
|
||||
return 'grow0'
|
||||
}
|
||||
|
||||
function plant(p, plot, seedCode, g) {
|
||||
plot.mx = { seed: SEED_CROP[seedCode], t: 0 }
|
||||
plot.state = 'grow0'
|
||||
g.markDirty(plot)
|
||||
g.swing(p, 'dig')
|
||||
g.ptip(p, `种下了${g.items[SEED_CROP[seedCode]]?.name || '作物'}种子`)
|
||||
}
|
||||
|
||||
export default {
|
||||
id: 'agri',
|
||||
name: '农业文明',
|
||||
desc: '良田、水井与粮仓:把荒野变成粮仓',
|
||||
factor: 1.05,
|
||||
tag: 'content',
|
||||
tabs: [{ code: 'agri', name: '农业', icon: 'agrihoe' }],
|
||||
items: {
|
||||
agrihoe: { name: '犁锄', equip: 'hand', tool: 'dig', dmg: 20, uses: 40, stack: 1 },
|
||||
wheatseeds: { name: '小麦种子', stack: 40 },
|
||||
wheat: { name: '小麦', food: { hunger: 12.5, hp: 0, san: 0 }, perish: 10, veg: 1 },
|
||||
flour: { name: '面粉', food: { hunger: 12.5, hp: 1, san: 0 }, perish: 20 },
|
||||
bread: { name: '面包', food: { hunger: 62.5, hp: 8, san: 3 }, perish: 12 },
|
||||
pumpkin: { name: '南瓜', food: { hunger: 37.5, hp: 3, san: 0 }, perish: 20, veg: 1 },
|
||||
pumpkinseeds: { name: '南瓜种子', stack: 40 },
|
||||
fertilizer: { name: '肥料', stack: 20 },
|
||||
},
|
||||
recipes: [
|
||||
{ code: 'agrihoe', name: '犁锄', tab: 'agri', tech: 1, cost: { twig: 2, flint: 2, rope: 1 }, desc: '开垦良田 · 耐久 40 次' },
|
||||
{ code: 'well', name: '水井', tab: 'agri', tech: 1, cost: { rock: 12, rope: 2 }, build: true, desc: '灌溉周边良田,生长加速' },
|
||||
{ code: 'granary', name: '粮仓', tab: 'agri', tech: 1, cost: { log: 8, rope: 2 }, build: true, desc: '存储作物,防潮保鲜' },
|
||||
{ code: 'mill', name: '磨坊', tab: 'agri', tech: 2, cost: { log: 4, rock: 6, gold: 2 }, build: true, desc: '小麦磨面粉' },
|
||||
{ code: 'fertilizer', name: '肥料', tab: 'agri', tech: 1, cost: { rot: 2, ash: 1 }, desc: '施于良田,生长 +50%' },
|
||||
],
|
||||
icons: {
|
||||
agrihoe: (c) => {
|
||||
c.strokeStyle = '#7a5b33'
|
||||
c.lineWidth = 3
|
||||
c.beginPath(); c.moveTo(-9, 12); c.lineTo(5, -9); c.stroke()
|
||||
c.fillStyle = '#8d9097'
|
||||
c.strokeStyle = '#1d1409'
|
||||
c.lineWidth = 1.6
|
||||
c.beginPath(); c.moveTo(2, -6); c.lineTo(13, -3); c.lineTo(13, -10); c.lineTo(4, -12); c.closePath()
|
||||
c.fill(); c.stroke()
|
||||
c.strokeStyle = '#6f7278'
|
||||
c.lineWidth = 1.2
|
||||
c.beginPath(); c.moveTo(6, -11); c.lineTo(9, -4); c.stroke()
|
||||
},
|
||||
wheatseeds: (c) => {
|
||||
c.fillStyle = '#d8b06a'
|
||||
c.strokeStyle = '#1d1409'
|
||||
c.lineWidth = 1.4
|
||||
;[[-5, 2], [3, -3], [6, 6], [-3, 8], [0, -7]].forEach(([x, y]) => {
|
||||
c.beginPath(); c.ellipse(x, y, 3, 4.2, 0.4, 0, TAU); c.fill(); c.stroke()
|
||||
})
|
||||
c.fillStyle = '#e6c34a'
|
||||
c.beginPath(); c.ellipse(0, -1, 2, 2.6, 0, 0, TAU); c.fill()
|
||||
},
|
||||
wheat: (c) => {
|
||||
c.strokeStyle = '#5d8a45'
|
||||
c.lineWidth = 2.4
|
||||
c.beginPath(); c.moveTo(0, 13); c.lineTo(0, -6); c.stroke()
|
||||
c.fillStyle = '#e6c34a'
|
||||
c.strokeStyle = '#1d1409'
|
||||
c.lineWidth = 1.2
|
||||
;[-7, -2, 3, 8].forEach((y) => {
|
||||
;[-1, 1].forEach((sgn) => {
|
||||
c.beginPath(); c.ellipse(sgn * 4, y, 2.4, 4, sgn * 0.5, 0, TAU); c.fill(); c.stroke()
|
||||
})
|
||||
})
|
||||
c.beginPath(); c.ellipse(0, -11, 2.4, 4.5, 0, 0, TAU); c.fill(); c.stroke()
|
||||
},
|
||||
flour: (c) => {
|
||||
c.fillStyle = '#e8e2d0'
|
||||
c.strokeStyle = '#1d1409'
|
||||
c.lineWidth = 1.8
|
||||
c.beginPath()
|
||||
c.moveTo(-9, 12); c.lineTo(-7, -3); c.quadraticCurveTo(0, -12, 7, -3); c.lineTo(9, 12)
|
||||
c.closePath()
|
||||
c.fill(); c.stroke()
|
||||
c.strokeStyle = '#b0a888'
|
||||
c.lineWidth = 1.4
|
||||
c.beginPath(); c.moveTo(-6, 2); c.lineTo(6, 2); c.stroke()
|
||||
c.fillStyle = '#f5f1e6'
|
||||
c.beginPath(); c.arc(0, -5, 2.4, 0, TAU); c.fill()
|
||||
},
|
||||
bread: (c) => {
|
||||
c.fillStyle = '#c98a44'
|
||||
c.strokeStyle = '#1d1409'
|
||||
c.lineWidth = 1.8
|
||||
c.beginPath(); c.roundRect(-12, -4, 24, 14, 5); c.fill(); c.stroke()
|
||||
c.strokeStyle = '#8a5a28'
|
||||
c.lineWidth = 1.6
|
||||
;[-6, 0, 6].forEach((x) => {
|
||||
c.beginPath(); c.moveTo(x, -1); c.lineTo(x - 2, 6); c.stroke()
|
||||
})
|
||||
c.fillStyle = '#e0a85a'
|
||||
c.beginPath(); c.ellipse(-4, -2, 5, 2.2, 0, 0, TAU); c.fill()
|
||||
},
|
||||
pumpkin: (c) => {
|
||||
c.fillStyle = '#e08a3c'
|
||||
c.strokeStyle = '#1d1409'
|
||||
c.lineWidth = 1.8
|
||||
c.beginPath(); c.ellipse(0, 2, 12, 10, 0, 0, TAU); c.fill(); c.stroke()
|
||||
c.strokeStyle = '#b86a28'
|
||||
c.lineWidth = 1.4
|
||||
;[-5, 0, 5].forEach((x) => {
|
||||
c.beginPath(); c.moveTo(x, -7); c.quadraticCurveTo(x * 1.5, 2, x, 11); c.stroke()
|
||||
})
|
||||
c.strokeStyle = '#5d8a45'
|
||||
c.lineWidth = 2.6
|
||||
c.beginPath(); c.moveTo(0, -8); c.lineTo(0, -13); c.stroke()
|
||||
},
|
||||
pumpkinseeds: (c) => {
|
||||
c.fillStyle = '#e6d7a8'
|
||||
c.strokeStyle = '#1d1409'
|
||||
c.lineWidth = 1.4
|
||||
;[[-5, 2], [3, -3], [6, 6], [-3, 8], [0, -7]].forEach(([x, y]) => {
|
||||
c.beginPath(); c.ellipse(x, y, 3, 4.2, 0.4, 0, TAU); c.fill(); c.stroke()
|
||||
})
|
||||
c.fillStyle = '#e08a3c'
|
||||
c.beginPath(); c.ellipse(0, -1, 2, 2.6, 0, 0, TAU); c.fill()
|
||||
},
|
||||
fertilizer: (c) => {
|
||||
c.fillStyle = '#6b4a2e'
|
||||
c.strokeStyle = '#1d1409'
|
||||
c.lineWidth = 1.8
|
||||
c.beginPath()
|
||||
c.moveTo(-11, 10); c.quadraticCurveTo(-8, -6, 0, -8); c.quadraticCurveTo(8, -6, 11, 10)
|
||||
c.closePath()
|
||||
c.fill(); c.stroke()
|
||||
c.fillStyle = '#8f9a3e'
|
||||
c.strokeStyle = '#1d1409'
|
||||
c.lineWidth = 1
|
||||
;[[-4, 1], [3, 4], [0, -3], [5, -2]].forEach(([x, y]) => {
|
||||
c.beginPath(); c.ellipse(x, y, 2, 1.4, 0.3, 0, TAU); c.fill(); c.stroke()
|
||||
})
|
||||
},
|
||||
well: (c) => {
|
||||
c.fillStyle = '#7a5230'
|
||||
c.strokeStyle = '#1d1409'
|
||||
c.lineWidth = 1.6
|
||||
;[-9, 9].forEach((x) => {
|
||||
c.beginPath(); c.roundRect(x - 2, -10, 4, 18, 1); c.fill(); c.stroke()
|
||||
})
|
||||
c.beginPath(); c.roundRect(-11, -13, 22, 4, 1.5); c.fill(); c.stroke()
|
||||
c.fillStyle = '#8d9097'
|
||||
c.beginPath(); c.ellipse(0, 8, 12, 5, 0, 0, TAU); c.fill(); c.stroke()
|
||||
c.fillStyle = '#12100c'
|
||||
c.beginPath(); c.ellipse(0, 7, 6.5, 2.6, 0, 0, TAU); c.fill()
|
||||
c.strokeStyle = '#c9a36b'
|
||||
c.lineWidth = 1.2
|
||||
c.beginPath(); c.moveTo(0, -9); c.lineTo(0, 1); c.stroke()
|
||||
},
|
||||
granary: (c) => {
|
||||
c.fillStyle = '#8d9097'
|
||||
c.strokeStyle = '#1d1409'
|
||||
c.lineWidth = 1.6
|
||||
c.beginPath(); c.roundRect(-11, 6, 22, 5, 1.5); c.fill(); c.stroke()
|
||||
c.fillStyle = '#a97b47'
|
||||
c.beginPath(); c.roundRect(-9, -8, 18, 15, 2); c.fill(); c.stroke()
|
||||
c.fillStyle = '#c8a24a'
|
||||
c.beginPath(); c.moveTo(-13, -8); c.lineTo(13, -8); c.lineTo(0, -19); c.closePath()
|
||||
c.fill(); c.stroke()
|
||||
},
|
||||
mill: (c) => {
|
||||
c.fillStyle = '#8d9097'
|
||||
c.strokeStyle = '#1d1409'
|
||||
c.lineWidth = 1.6
|
||||
c.beginPath(); c.ellipse(0, 8, 11, 6, 0, 0, TAU); c.fill(); c.stroke()
|
||||
c.fillStyle = '#7a5230'
|
||||
c.beginPath(); c.roundRect(-3, -8, 6, 17, 1.5); c.fill(); c.stroke()
|
||||
c.save()
|
||||
c.translate(0, -10)
|
||||
c.rotate(-0.5)
|
||||
c.fillStyle = '#c9a36b'
|
||||
c.strokeStyle = '#1d1409'
|
||||
c.lineWidth = 1.2
|
||||
for (let k = 0; k < 4; k++) {
|
||||
c.rotate(Math.PI / 2)
|
||||
c.beginPath(); c.moveTo(0, 0); c.lineTo(3, -10); c.lineTo(7, -10); c.lineTo(4, 0); c.closePath()
|
||||
c.fill(); c.stroke()
|
||||
}
|
||||
c.restore()
|
||||
c.fillStyle = '#1d1409'
|
||||
c.beginPath(); c.arc(0, -10, 2, 0, TAU); c.fill()
|
||||
},
|
||||
},
|
||||
ents: {
|
||||
agrifarm: {
|
||||
name: '良田',
|
||||
conf: { size: 26 },
|
||||
init(e) {
|
||||
if (!e.state) e.state = 'empty'
|
||||
},
|
||||
update(e, dt, g) {
|
||||
if (!e.mx || e.state === 'empty' || e.state === 'ready') return
|
||||
if (g.season === 'winter') return // 冬季暂停生长(与官方一致)
|
||||
// 灌溉加成:140px 内存在水井则生长 ×1.6
|
||||
const watered = g.entities.some((o) => o.type === 'well' && Math.hypot(o.x - e.x, o.y - e.y) < 140)
|
||||
e.mx.t += dt * (watered ? 1.6 : 1)
|
||||
const st = stageOf(e.mx.t)
|
||||
if (st !== e.state) {
|
||||
e.state = st
|
||||
g.markDirty(e)
|
||||
if (st === 'ready') g.tip('良田里的作物成熟了!')
|
||||
}
|
||||
},
|
||||
interact(p, e, g) {
|
||||
if (e.state === 'ready') {
|
||||
const crop = e.mx?.seed || 'wheat'
|
||||
const n = g.rand(3, 4)
|
||||
g.addItem(p, crop, n)
|
||||
g.addScore(12, p)
|
||||
g.swing(p, 'chop')
|
||||
g.ptip(p, `收获${g.items[crop]?.name || '作物'} ×${n}`)
|
||||
e.state = 'empty'
|
||||
e.mx = null
|
||||
g.markDirty(e)
|
||||
return
|
||||
}
|
||||
if (e.state === 'empty') {
|
||||
// 身上有种子就直接播种(berryseeds 依赖 farm.js,缺了就只是不能种)
|
||||
const seed = ['wheatseeds', 'pumpkinseeds', 'berryseeds'].find((code) => g.items[code] && g.countItem(p, code) > 0)
|
||||
if (seed) {
|
||||
g.payCost(p, { [seed]: 1 })
|
||||
plant(p, e, seed, g)
|
||||
} else {
|
||||
g.ptip(p, '空良田:需要小麦/南瓜/浆果种子')
|
||||
}
|
||||
return
|
||||
}
|
||||
const pct = Math.min(99, Math.floor(((e.mx?.t || 0) / GROW_TOTAL) * 100))
|
||||
g.ptip(p, `作物生长中…… ${pct}%(靠近水井更快)`)
|
||||
},
|
||||
draw(ctx, e) {
|
||||
// 垄起的土地
|
||||
ctx.fillStyle = '#6b4a2e'
|
||||
ctx.strokeStyle = '#1d1409'
|
||||
ctx.lineWidth = 2
|
||||
ctx.beginPath()
|
||||
ctx.ellipse(0, 0, 24, 14, 0, 0, TAU)
|
||||
ctx.fill()
|
||||
ctx.stroke()
|
||||
// 两条田垄线(良田更规整)
|
||||
ctx.strokeStyle = '#553a24'
|
||||
ctx.lineWidth = 2
|
||||
;[-5, 5].forEach((y) => {
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(-18, y)
|
||||
ctx.lineTo(18, y)
|
||||
ctx.stroke()
|
||||
})
|
||||
const st = e.state
|
||||
if (st === 'empty' || !st) return
|
||||
const crop = e.mx?.seed
|
||||
if (st === 'grow0') {
|
||||
ctx.strokeStyle = '#7fb069'
|
||||
ctx.lineWidth = 2
|
||||
ctx.beginPath(); ctx.moveTo(0, -2); ctx.lineTo(0, -9); ctx.stroke()
|
||||
ctx.fillStyle = '#8f9a3e'
|
||||
ctx.beginPath(); ctx.ellipse(2, -9, 3, 1.8, -0.4, 0, TAU); ctx.fill()
|
||||
} else if (st === 'grow1') {
|
||||
ctx.strokeStyle = '#6f9a52'
|
||||
ctx.lineWidth = 2.4
|
||||
;[-6, 0, 6].forEach((x) => {
|
||||
ctx.beginPath(); ctx.moveTo(x, 2); ctx.lineTo(x * 1.3, -12); ctx.stroke()
|
||||
})
|
||||
} else if (st === 'grow2') {
|
||||
ctx.strokeStyle = '#5d8a45'
|
||||
ctx.lineWidth = 2.8
|
||||
;[-8, 0, 8].forEach((x) => {
|
||||
ctx.beginPath(); ctx.moveTo(x, 2); ctx.quadraticCurveTo(x * 1.4, -10, x * 1.1, -19); ctx.stroke()
|
||||
})
|
||||
ctx.fillStyle = crop === 'pumpkin' ? '#7a9a52' : '#8f9a3e'
|
||||
ctx.beginPath(); ctx.ellipse(0, -17, 9, 5, 0, 0, TAU); ctx.fill()
|
||||
} else {
|
||||
// ready:结出作物
|
||||
ctx.strokeStyle = '#5d8a45'
|
||||
ctx.lineWidth = 2.8
|
||||
;[-9, 0, 9].forEach((x) => {
|
||||
ctx.beginPath(); ctx.moveTo(x, 2); ctx.quadraticCurveTo(x * 1.4, -12, x * 1.1, -21); ctx.stroke()
|
||||
})
|
||||
if (crop === 'pumpkin') {
|
||||
ctx.fillStyle = '#e08a3c'
|
||||
ctx.strokeStyle = '#1d1409'
|
||||
ctx.lineWidth = 1.6
|
||||
ctx.beginPath(); ctx.ellipse(0, -6, 8, 6.5, 0, 0, TAU); ctx.fill(); ctx.stroke()
|
||||
ctx.strokeStyle = '#5d8a45'
|
||||
ctx.lineWidth = 2
|
||||
ctx.beginPath(); ctx.moveTo(0, -12); ctx.lineTo(0, -6); ctx.stroke()
|
||||
} else if (crop === 'berry') {
|
||||
ctx.fillStyle = '#c0455a'
|
||||
ctx.strokeStyle = '#1d1409'
|
||||
ctx.lineWidth = 1.4
|
||||
;[[-8, -15], [0, -20], [8, -14]].forEach(([x, y]) => {
|
||||
ctx.beginPath(); ctx.arc(x, y, 3.6, 0, TAU); ctx.fill(); ctx.stroke()
|
||||
})
|
||||
} else {
|
||||
// 小麦:金黄色麦穗
|
||||
ctx.fillStyle = '#e6c34a'
|
||||
ctx.strokeStyle = '#1d1409'
|
||||
ctx.lineWidth = 1.4
|
||||
;[-9, 0, 9].forEach((x, k) => {
|
||||
const top = k === 1 ? -26 : -23
|
||||
;[0, 5, 10].forEach((dy, j) => {
|
||||
const ex = x + (j % 2 ? 2.4 : -2.4)
|
||||
ctx.beginPath()
|
||||
ctx.ellipse(ex, top + dy, 2.6, 4, j % 2 ? 0.4 : -0.4, 0, TAU)
|
||||
ctx.fill(); ctx.stroke()
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
well: {
|
||||
name: '水井',
|
||||
conf: { size: 30, struct: true },
|
||||
init(e) {
|
||||
if (e.cd == null) e.cd = 0
|
||||
},
|
||||
update(e, dt, g) {
|
||||
if (e.cd > 0) e.cd = Math.max(0, e.cd - dt)
|
||||
},
|
||||
interact(p, e, g) {
|
||||
if (e.cd > 0) {
|
||||
g.ptip(p, `井水还没续满……(${Math.ceil(e.cd)} 秒)`)
|
||||
return
|
||||
}
|
||||
p.san += 5
|
||||
e.cd = 60
|
||||
g.markDirty(e)
|
||||
g.swing(p, 'eat')
|
||||
g.ptip(p, '打了一瓢清水,理智 +5')
|
||||
},
|
||||
draw(ctx, e, env) {
|
||||
// 投影
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.25)'
|
||||
ctx.beginPath(); ctx.ellipse(0, 4, 18, 6, 0, 0, TAU); ctx.fill()
|
||||
// 井沿(石砌圆环)
|
||||
ctx.fillStyle = '#8d9097'
|
||||
ctx.strokeStyle = '#1d1409'
|
||||
ctx.lineWidth = 2
|
||||
ctx.beginPath(); ctx.ellipse(0, -4, 16, 8, 0, 0, TAU); ctx.fill(); ctx.stroke()
|
||||
// 井内黑洞
|
||||
ctx.fillStyle = '#12100c'
|
||||
ctx.beginPath(); ctx.ellipse(0, -5, 10, 4.5, 0, 0, TAU); ctx.fill()
|
||||
// 石缝
|
||||
ctx.strokeStyle = '#6f7278'
|
||||
ctx.lineWidth = 1.4
|
||||
;[-11, -4, 4, 11].forEach((x) => {
|
||||
ctx.beginPath(); ctx.moveTo(x, -11); ctx.lineTo(x + 2, 2); ctx.stroke()
|
||||
})
|
||||
// 木架:两根立柱 + 顶梁
|
||||
ctx.fillStyle = '#7a5230'
|
||||
ctx.strokeStyle = '#1d1409'
|
||||
ctx.lineWidth = 2
|
||||
;[-12, 12].forEach((x) => {
|
||||
ctx.beginPath(); ctx.roundRect(x - 2, -34, 4, 26, 1.5); ctx.fill(); ctx.stroke()
|
||||
})
|
||||
ctx.beginPath(); ctx.roundRect(-14, -38, 28, 5, 2); ctx.fill(); ctx.stroke()
|
||||
// 辘轳轴
|
||||
ctx.beginPath(); ctx.arc(0, -35, 3, 0, TAU); ctx.fill(); ctx.stroke()
|
||||
// 悬挂的水桶(轻微摆动)
|
||||
const sway = Math.sin((env?.time || 0) * 1.6 + e.id) * 1.2
|
||||
ctx.strokeStyle = '#c9a36b'
|
||||
ctx.lineWidth = 1.4
|
||||
ctx.beginPath(); ctx.moveTo(0, -33); ctx.lineTo(sway, -20); ctx.stroke()
|
||||
ctx.fillStyle = '#7a5230'
|
||||
ctx.strokeStyle = '#1d1409'
|
||||
ctx.lineWidth = 1.6
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(sway - 4, -20); ctx.lineTo(sway + 4, -20); ctx.lineTo(sway + 3, -12); ctx.lineTo(sway - 3, -12)
|
||||
ctx.closePath(); ctx.fill(); ctx.stroke()
|
||||
// 水面
|
||||
ctx.fillStyle = '#5aa7c4'
|
||||
ctx.beginPath(); ctx.ellipse(sway, -19, 3.4, 1.4, 0, 0, TAU); ctx.fill()
|
||||
},
|
||||
},
|
||||
granary: {
|
||||
name: '粮仓',
|
||||
conf: { size: 36, struct: true },
|
||||
init(e) {
|
||||
// 持久化数据只能放进 e.mx(核心 extraOf 只序列化 e.mx / e.state)
|
||||
if (!e.mx) e.mx = {}
|
||||
if (!e.mx.store) e.mx.store = {}
|
||||
},
|
||||
interact(p, e, g) {
|
||||
// 容忍 e.mx 缺失(旧存档 / 直接构造的实体)
|
||||
if (!e.mx) e.mx = {}
|
||||
if (!e.mx.store) e.mx.store = {}
|
||||
const store = e.mx.store
|
||||
// 先尝试存入身上全部作物
|
||||
let stored = 0
|
||||
CROPS.forEach((code) => {
|
||||
if (!g.items[code]) return
|
||||
const n = g.countItem(p, code)
|
||||
if (n > 0 && g.payCost(p, { [code]: n })) {
|
||||
store[code] = (store[code] || 0) + n
|
||||
stored += n
|
||||
}
|
||||
})
|
||||
if (stored > 0) {
|
||||
g.markDirty(e)
|
||||
g.ptip(p, `存入粮仓 ×${stored}`)
|
||||
return
|
||||
}
|
||||
// 身上没有作物:取出仓里全部
|
||||
const total = Object.values(store).reduce((a, b) => a + b, 0)
|
||||
if (total > 0) {
|
||||
const got = []
|
||||
Object.entries(store).forEach(([code, n]) => {
|
||||
if (n > 0) {
|
||||
g.addItem(p, code, n)
|
||||
got.push(`${g.items[code]?.name || code} ×${n}`)
|
||||
}
|
||||
})
|
||||
e.mx.store = {}
|
||||
g.markDirty(e)
|
||||
g.ptip(p, `取出:${got.join('、')}`)
|
||||
return
|
||||
}
|
||||
g.ptip(p, '粮仓空着(点击存入身上的作物)')
|
||||
},
|
||||
draw(ctx, e, env) {
|
||||
// 投影
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.25)'
|
||||
ctx.beginPath(); ctx.ellipse(0, 4, 20, 7, 0, 0, TAU); ctx.fill()
|
||||
// 石基座
|
||||
ctx.fillStyle = '#8d9097'
|
||||
ctx.strokeStyle = '#1d1409'
|
||||
ctx.lineWidth = 2
|
||||
ctx.beginPath(); ctx.roundRect(-17, -2, 34, 9, 2); ctx.fill(); ctx.stroke()
|
||||
// 木圆仓
|
||||
ctx.fillStyle = '#a97b47'
|
||||
ctx.beginPath(); ctx.roundRect(-14, -26, 28, 26, 3); ctx.fill(); ctx.stroke()
|
||||
// 木纹
|
||||
ctx.strokeStyle = '#7a5230'
|
||||
ctx.lineWidth = 1.4
|
||||
;[-8, -1, 6].forEach((y) => {
|
||||
ctx.beginPath(); ctx.moveTo(-14, y); ctx.lineTo(14, y); ctx.stroke()
|
||||
})
|
||||
// 锥形茅草顶
|
||||
ctx.fillStyle = '#c8a24a'
|
||||
ctx.strokeStyle = '#1d1409'
|
||||
ctx.lineWidth = 2
|
||||
ctx.beginPath(); ctx.moveTo(-20, -26); ctx.lineTo(20, -26); ctx.lineTo(0, -48); ctx.closePath()
|
||||
ctx.fill(); ctx.stroke()
|
||||
ctx.strokeStyle = '#a8813a'
|
||||
ctx.lineWidth = 1.2
|
||||
;[-12, -4, 4, 12].forEach((x) => {
|
||||
ctx.beginPath(); ctx.moveTo(x, -26); ctx.lineTo(x * 0.4, -44); ctx.stroke()
|
||||
})
|
||||
// 谷袋
|
||||
ctx.fillStyle = '#c9a36b'
|
||||
ctx.strokeStyle = '#1d1409'
|
||||
ctx.lineWidth = 1.6
|
||||
ctx.beginPath(); ctx.ellipse(12, -6, 5, 6, 0.2, 0, TAU); ctx.fill(); ctx.stroke()
|
||||
ctx.strokeStyle = '#8a5a28'
|
||||
ctx.lineWidth = 1.2
|
||||
ctx.beginPath(); ctx.moveTo(9, -10); ctx.lineTo(15, -10); ctx.stroke()
|
||||
},
|
||||
},
|
||||
mill: {
|
||||
name: '磨坊',
|
||||
conf: { size: 32, struct: true },
|
||||
interact(p, e, g) {
|
||||
if (!g.hasCost(p, { wheat: 3 })) {
|
||||
g.ptip(p, '磨坊需要 3 个小麦才能磨面')
|
||||
return
|
||||
}
|
||||
g.payCost(p, { wheat: 3 })
|
||||
g.addItem(p, 'flour', 2)
|
||||
g.ptip(p, '磨出面粉 ×2')
|
||||
},
|
||||
draw(ctx, e, env) {
|
||||
// 投影
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.25)'
|
||||
ctx.beginPath(); ctx.ellipse(0, 4, 18, 6, 0, 0, TAU); ctx.fill()
|
||||
// 石磨盘
|
||||
ctx.fillStyle = '#8d9097'
|
||||
ctx.strokeStyle = '#1d1409'
|
||||
ctx.lineWidth = 2
|
||||
ctx.beginPath(); ctx.ellipse(0, -2, 15, 9, 0, 0, TAU); ctx.fill(); ctx.stroke()
|
||||
ctx.fillStyle = '#6f7278'
|
||||
ctx.beginPath(); ctx.ellipse(0, -3, 6, 3.5, 0, 0, TAU); ctx.fill()
|
||||
ctx.beginPath(); ctx.arc(0, -3, 1.6, 0, TAU); ctx.fill()
|
||||
// 木架立柱
|
||||
ctx.fillStyle = '#7a5230'
|
||||
ctx.strokeStyle = '#1d1409'
|
||||
ctx.lineWidth = 2
|
||||
ctx.beginPath(); ctx.roundRect(-4, -30, 8, 24, 2); ctx.fill(); ctx.stroke()
|
||||
// 风车叶片(env.time 驱动旋转)
|
||||
ctx.save()
|
||||
ctx.translate(0, -32)
|
||||
ctx.rotate((env?.time || 0) * 1.5 + e.id)
|
||||
ctx.fillStyle = '#c9a36b'
|
||||
ctx.strokeStyle = '#1d1409'
|
||||
ctx.lineWidth = 1.6
|
||||
for (let k = 0; k < 4; k++) {
|
||||
ctx.rotate(Math.PI / 2)
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(0, 0); ctx.lineTo(4, -14); ctx.lineTo(9, -14); ctx.lineTo(6, 0)
|
||||
ctx.closePath()
|
||||
ctx.fill(); ctx.stroke()
|
||||
}
|
||||
ctx.restore()
|
||||
ctx.fillStyle = '#1d1409'
|
||||
ctx.beginPath(); ctx.arc(0, -32, 3, 0, TAU); ctx.fill()
|
||||
},
|
||||
},
|
||||
},
|
||||
hooks: {
|
||||
onUseItem(g, p, s, area, i) {
|
||||
if (s.code === 'agrihoe') {
|
||||
const spot = g.findLand(p.x + p.dir * 46, p.y + 8, 0, 24)
|
||||
const crowded = g.entities.some((e) => !e.deco && !e.loot && Math.hypot(e.x - spot.x, e.y - spot.y) < 34)
|
||||
if (crowded) {
|
||||
g.ptip(p, '这里太挤了,换块空地开垦')
|
||||
return true
|
||||
}
|
||||
g.addEntity('agrifarm', spot.x, spot.y)
|
||||
g.swing(p, 'dig')
|
||||
s.dur = (s.dur ?? g.items.agrihoe.uses) - 1
|
||||
if (s.dur <= 0) {
|
||||
g.consumeAt(p, area, i)
|
||||
g.ptip(p, '开垦出良田 · 犁锄用坏了')
|
||||
} else {
|
||||
g.ptip(p, '开垦出一块良田(种子点击播种)')
|
||||
}
|
||||
g.invalidate()
|
||||
return true
|
||||
}
|
||||
if (s.code === 'wheatseeds' || s.code === 'pumpkinseeds') {
|
||||
const plot = g.entities.find((e) => e.type === 'agrifarm' && e.state === 'empty' && Math.hypot(e.x - p.x, e.y - p.y) < 80)
|
||||
if (!plot) {
|
||||
g.ptip(p, '需要站在空良田旁播种(先用犁锄开垦)')
|
||||
return true
|
||||
}
|
||||
const code = s.code
|
||||
g.consumeAt(p, area, i)
|
||||
plant(p, plot, code, g)
|
||||
return true
|
||||
}
|
||||
if (s.code === 'fertilizer') {
|
||||
const plot = g.entities.find((e) => e.type === 'agrifarm' && e.mx && e.state !== 'ready' && Math.hypot(e.x - p.x, e.y - p.y) < 80)
|
||||
if (!plot) {
|
||||
g.ptip(p, '附近没有生长中的良田可施肥')
|
||||
return true
|
||||
}
|
||||
plot.mx.t += 100 // 生长进度 +100 秒(总时长 200 秒 ≈ +50%)
|
||||
g.markDirty(plot)
|
||||
g.consumeAt(p, area, i)
|
||||
g.ptip(p, '撒下肥料,作物猛地蹿高了一截')
|
||||
return true
|
||||
}
|
||||
},
|
||||
onGather(g, p, e) {
|
||||
if (e.type === 'grass' && Math.random() < 0.12) {
|
||||
g.addItem(p, 'wheatseeds', 1)
|
||||
g.ptip(p, '草丛里翻出了小麦种子!')
|
||||
} else if (e.type === 'berry' && Math.random() < 0.12) {
|
||||
g.addItem(p, 'pumpkinseeds', 1)
|
||||
g.ptip(p, '摘到了一把南瓜种子')
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
605
src/games/starve/mods/camp.js
Normal file
605
src/games/starve/mods/camp.js
Normal file
@@ -0,0 +1,605 @@
|
||||
// 营地:出生点自带房屋、长明篝火与无敌守卫,可建造营地仓储
|
||||
// - 房屋 camphouse:点击休息,回复理智/生命(每天一次)
|
||||
// - 守卫 guard:无限血,营地半径内巡逻并拦截、击杀敌对生物
|
||||
// - 营地仓储 campstore:8 个箱子,每箱 6 种、每种最多 999(共 48 种容量)
|
||||
// - 领地:以房屋为圆心 CAMP_R 范围,通过核心 onMobVictim 否决钩子让敌对生物不再锁定营地内的玩家;守卫自动拦截入侵者
|
||||
// 时间/数值基准:三围 150/150/200,一天 160 秒
|
||||
const TAU = Math.PI * 2
|
||||
const CAMP_R = 420 // 营地安全半径(以 camphouse 为圆心)
|
||||
const BOXES = 8 // 箱子数
|
||||
const BOX_KINDS = 6 // 每箱最多物品种类
|
||||
const BOX_CAP = 999 // 每种最多数量
|
||||
|
||||
// ---- 模块级面板状态(不入存档;面板只是一次交互会话) ----
|
||||
let storeOpen = null // 当前打开的 campstore 实体 id
|
||||
let uiCamX = 0 // onDrawUI 记录的相机位置,供 onClick 做世界→屏幕换算
|
||||
let uiCamY = 0
|
||||
let uiPanel = null // 面板矩形(屏幕坐标)
|
||||
let uiClose = null // 关闭按钮矩形
|
||||
let uiRows = [] // 每行箱子的按钮矩形 [{ i, dep, wit }]
|
||||
|
||||
// 相机无关的“点是否在矩形内”
|
||||
function inside(x, y, r) {
|
||||
return !!r && x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h
|
||||
}
|
||||
// 可堆叠物品判定(装备/工具/护甲不算)
|
||||
function isStackable(g, code) {
|
||||
const def = g.items[code]
|
||||
if (!def) return false
|
||||
if (def.equip || def.tool || def.armor) return false
|
||||
const max = def.stack || (g.tune && g.tune.stackMax) || 20
|
||||
return max > 1
|
||||
}
|
||||
// 规范化营地的 8 个箱子(容忍旧存档 / 缺字段)
|
||||
function boxesOf(e) {
|
||||
if (!e.mx) e.mx = { boxes: [] }
|
||||
if (!Array.isArray(e.mx.boxes) || e.mx.boxes.length !== BOXES) {
|
||||
e.mx.boxes = Array.from({ length: BOXES }, () => ({ items: {} }))
|
||||
}
|
||||
// 逐个兜底:容忍旧存档 / 缺字段的箱子
|
||||
for (let i = 0; i < BOXES; i++) {
|
||||
if (!e.mx.boxes[i] || typeof e.mx.boxes[i] !== 'object') e.mx.boxes[i] = { items: {} }
|
||||
if (!e.mx.boxes[i].items) e.mx.boxes[i].items = {}
|
||||
}
|
||||
return e.mx.boxes
|
||||
}
|
||||
function usedKinds(box) {
|
||||
return Object.keys(box.items || {}).filter((k) => (box.items[k] || 0) > 0).length
|
||||
}
|
||||
// 把玩家背包里所有可堆叠物品存入指定箱子(尊重箱容量限制),返回实际存入件数
|
||||
function depositBox(g, p, box) {
|
||||
if (!box.items) box.items = {}
|
||||
const codes = new Set()
|
||||
;[p.slots, p.pack].filter(Boolean).forEach((area) => {
|
||||
area.forEach((s) => {
|
||||
if (s && isStackable(g, s.code)) codes.add(s.code)
|
||||
})
|
||||
})
|
||||
let moved = 0
|
||||
codes.forEach((code) => {
|
||||
const have = g.countItem(p, code)
|
||||
if (have <= 0) return
|
||||
const cur = box.items[code] || 0
|
||||
const kinds = usedKinds(box)
|
||||
if (cur <= 0 && kinds >= BOX_KINDS) return // 新物种种类已满
|
||||
const room = BOX_CAP - cur
|
||||
if (room <= 0) return
|
||||
const add = Math.min(have, room)
|
||||
if (add <= 0) return
|
||||
if (!g.payCost(p, { [code]: add })) return
|
||||
box.items[code] = cur + add
|
||||
moved += add
|
||||
})
|
||||
return moved
|
||||
}
|
||||
// 把指定箱子全部取出到玩家背包,返回取回件数
|
||||
function withdrawBox(g, p, box) {
|
||||
if (!box.items) {
|
||||
box.items = {}
|
||||
return 0
|
||||
}
|
||||
let moved = 0
|
||||
Object.entries(box.items).forEach(([code, n]) => {
|
||||
if (n > 0) {
|
||||
g.addItem(p, code, n)
|
||||
moved += n
|
||||
}
|
||||
})
|
||||
box.items = {}
|
||||
return moved
|
||||
}
|
||||
// 营地圆心(优先取第一座房屋,缺则退回世界中心)
|
||||
function campCenter(g) {
|
||||
const house = g.entities.find((e) => e.type === 'camphouse')
|
||||
if (house) return { x: house.x, y: house.y }
|
||||
return { x: g.worldW / 2, y: g.worldH / 2 }
|
||||
}
|
||||
export default {
|
||||
id: 'camp',
|
||||
name: '营地',
|
||||
desc: '出生点自带房屋、长明篝火与无敌守卫,可建造营地仓储',
|
||||
factor: 0.75, // 大幅降低难度
|
||||
tag: 'easy',
|
||||
tabs: [{ code: 'camp', name: '营地', icon: 'camphouse' }],
|
||||
recipes: [
|
||||
{ code: 'campstore', name: '营地仓储', tab: 'camp', tech: 1, cost: { log: 10, rock: 4 }, build: true, desc: '8 个箱子,可建造多个' },
|
||||
{ code: 'camphouse', name: '营地房屋', tab: 'camp', tech: 1, cost: { log: 12, rock: 8 }, build: true, desc: '休息回复理智与生命' },
|
||||
],
|
||||
icons: {
|
||||
camphouse: (c) => {
|
||||
// 石砌地基
|
||||
c.fillStyle = '#8d9097'
|
||||
c.strokeStyle = '#1d1409'
|
||||
c.lineWidth = 1.6
|
||||
c.beginPath(); c.roundRect(-14, 6, 28, 6, 1.5); c.fill(); c.stroke()
|
||||
// 木板墙
|
||||
c.fillStyle = '#b07f47'
|
||||
c.beginPath(); c.roundRect(-11, -8, 22, 15, 1.5); c.fill(); c.stroke()
|
||||
c.strokeStyle = '#8a5f30'
|
||||
c.lineWidth = 1
|
||||
;[-4, 0, 4].forEach((y) => { c.beginPath(); c.moveTo(-11, y); c.lineTo(11, y); c.stroke() })
|
||||
// 门窗
|
||||
c.fillStyle = '#3a2a16'
|
||||
c.beginPath(); c.roundRect(-6, 0, 6, 7, 1); c.fill()
|
||||
c.fillStyle = '#ffd98a'
|
||||
c.beginPath(); c.roundRect(3, -4, 5, 5, 1); c.fill()
|
||||
// 斜屋顶
|
||||
c.fillStyle = '#7a5230'
|
||||
c.strokeStyle = '#1d1409'
|
||||
c.lineWidth = 1.8
|
||||
c.beginPath(); c.moveTo(-15, -8); c.lineTo(15, -8); c.lineTo(0, -19); c.closePath(); c.fill(); c.stroke()
|
||||
c.strokeStyle = '#5d3f24'
|
||||
c.lineWidth = 1
|
||||
;[-9, -4, 4, 9].forEach((x) => { c.beginPath(); c.moveTo(x, -8); c.lineTo(x * 0.42, -18); c.stroke() })
|
||||
// 烟囱
|
||||
c.fillStyle = '#9a6a3c'
|
||||
c.beginPath(); c.roundRect(6, -18, 4, 7, 1); c.fill(); c.stroke()
|
||||
},
|
||||
campstore: (c) => {
|
||||
// 仓房
|
||||
c.fillStyle = '#a9773c'
|
||||
c.strokeStyle = '#1d1409'
|
||||
c.lineWidth = 1.8
|
||||
c.beginPath(); c.roundRect(-13, -12, 26, 20, 2); c.fill(); c.stroke()
|
||||
c.fillStyle = '#7a5230'
|
||||
c.beginPath(); c.moveTo(-15, -12); c.lineTo(0, -22); c.lineTo(15, -12); c.closePath(); c.fill(); c.stroke()
|
||||
c.fillStyle = '#2a1c10'
|
||||
c.beginPath(); c.roundRect(-6, -2, 12, 10, 1); c.fill(); c.stroke()
|
||||
// 门口木箱
|
||||
c.fillStyle = '#c9a36b'
|
||||
c.strokeStyle = '#1d1409'
|
||||
c.lineWidth = 1.4
|
||||
c.beginPath(); c.roundRect(-14, 6, 12, 10, 1.5); c.fill(); c.stroke()
|
||||
c.beginPath(); c.roundRect(1, 8, 12, 8, 1.5); c.fill(); c.stroke()
|
||||
c.strokeStyle = '#8a5f30'
|
||||
c.lineWidth = 1
|
||||
c.beginPath(); c.moveTo(-14, 11); c.lineTo(-2, 11); c.moveTo(1, 12); c.lineTo(13, 12); c.stroke()
|
||||
},
|
||||
},
|
||||
ents: {
|
||||
camphouse: {
|
||||
name: '房屋',
|
||||
conf: { size: 52, struct: true },
|
||||
init(e) {
|
||||
if (!e.mx) e.mx = { restDay: 0 }
|
||||
if (e.mx.restDay == null) e.mx.restDay = 0
|
||||
},
|
||||
interact(p, e, g) {
|
||||
if (!e.mx) e.mx = { restDay: 0 }
|
||||
if (e.mx.restDay === g.day) {
|
||||
g.ptip(p, '今天已经休息过了')
|
||||
return
|
||||
}
|
||||
p.san = Math.min(200, p.san + 30)
|
||||
p.hp = Math.min(150, p.hp + 20)
|
||||
e.mx.restDay = g.day
|
||||
g.markDirty(e)
|
||||
g.ptip(p, '在屋里歇了一会儿:理智 +30、生命 +20')
|
||||
g.swing(p, 'eat')
|
||||
},
|
||||
draw(ctx, e, env) {
|
||||
// 底部投影
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.25)'
|
||||
ctx.beginPath(); ctx.ellipse(0, 8, 34, 9, 0, 0, TAU); ctx.fill()
|
||||
// 石砌地基
|
||||
ctx.fillStyle = '#8d9097'
|
||||
ctx.strokeStyle = '#1d1409'
|
||||
ctx.lineWidth = 2
|
||||
ctx.beginPath(); ctx.roundRect(-30, -4, 60, 12, 2); ctx.fill(); ctx.stroke()
|
||||
ctx.strokeStyle = '#6f7278'
|
||||
ctx.lineWidth = 1
|
||||
;[-20, -10, 0, 10, 20].forEach((x) => { ctx.beginPath(); ctx.moveTo(x, -4); ctx.lineTo(x, 8); ctx.stroke() })
|
||||
// 木板墙
|
||||
ctx.fillStyle = '#b07f47'
|
||||
ctx.strokeStyle = '#1d1409'
|
||||
ctx.lineWidth = 2
|
||||
ctx.beginPath(); ctx.roundRect(-26, -46, 52, 44, 3); ctx.fill(); ctx.stroke()
|
||||
// 横向木纹
|
||||
ctx.strokeStyle = '#8a5f30'
|
||||
ctx.lineWidth = 1.2
|
||||
;[-38, -30, -22, -14, -6].forEach((y) => { ctx.beginPath(); ctx.moveTo(-26, y); ctx.lineTo(26, y); ctx.stroke() })
|
||||
// 竖向立柱
|
||||
;[-18, 0, 18].forEach((x) => { ctx.beginPath(); ctx.moveTo(x, -46); ctx.lineTo(x, -2); ctx.stroke() })
|
||||
// 门(深色木门 + 门把手)
|
||||
ctx.fillStyle = '#3a2a16'
|
||||
ctx.strokeStyle = '#1d1409'
|
||||
ctx.lineWidth = 2
|
||||
ctx.beginPath(); ctx.roundRect(-11, -26, 20, 24, [3, 3, 0, 0]); ctx.fill(); ctx.stroke()
|
||||
ctx.fillStyle = '#e0c060'
|
||||
ctx.beginPath(); ctx.arc(4, -14, 1.6, 0, TAU); ctx.fill()
|
||||
// 亮着暖光的窗
|
||||
ctx.fillStyle = '#ffd98a'
|
||||
ctx.strokeStyle = '#1d1409'
|
||||
ctx.lineWidth = 2
|
||||
ctx.beginPath(); ctx.roundRect(7, -40, 15, 13, 1.5); ctx.fill(); ctx.stroke()
|
||||
ctx.strokeStyle = '#8a6a34'
|
||||
ctx.lineWidth = 1.2
|
||||
ctx.beginPath(); ctx.moveTo(7, -33.5); ctx.lineTo(22, -33.5); ctx.moveTo(14.5, -40); ctx.lineTo(14.5, -27); ctx.stroke()
|
||||
// 斜屋顶(瓦片纹)
|
||||
ctx.fillStyle = '#7a5230'
|
||||
ctx.strokeStyle = '#1d1409'
|
||||
ctx.lineWidth = 2
|
||||
ctx.beginPath(); ctx.moveTo(-34, -46); ctx.lineTo(34, -46); ctx.lineTo(0, -76); ctx.closePath(); ctx.fill(); ctx.stroke()
|
||||
ctx.strokeStyle = '#5d3f24'
|
||||
ctx.lineWidth = 1.2
|
||||
;[-26, -16, -6, 6, 16, 26].forEach((x) => { ctx.beginPath(); ctx.moveTo(x, -46); ctx.lineTo(x * 0.4, -72); ctx.stroke() })
|
||||
// 烟囱
|
||||
ctx.fillStyle = '#9a6a3c'
|
||||
ctx.strokeStyle = '#1d1409'
|
||||
ctx.lineWidth = 1.8
|
||||
ctx.beginPath(); ctx.roundRect(12, -72, 11, 20, 2); ctx.fill(); ctx.stroke()
|
||||
// 炊烟(缓慢飘动)
|
||||
const t = env?.time || 0
|
||||
ctx.fillStyle = 'rgba(220,220,220,0.4)'
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const ph = (t * 0.5 + i * 0.5 + e.id * 0.1) % 1.5
|
||||
ctx.beginPath()
|
||||
ctx.arc(17 + Math.sin(t + i) * 3, -80 - ph * 22, 3 + ph * 3, 0, TAU)
|
||||
ctx.fill()
|
||||
}
|
||||
// 门口小台阶
|
||||
ctx.fillStyle = '#9a6a3c'
|
||||
ctx.strokeStyle = '#1d1409'
|
||||
ctx.lineWidth = 1.6
|
||||
ctx.beginPath(); ctx.roundRect(-13, -2, 24, 5, 1.5); ctx.fill(); ctx.stroke()
|
||||
},
|
||||
},
|
||||
campstore: {
|
||||
name: '营地仓储',
|
||||
conf: { size: 40, struct: true },
|
||||
init(e) {
|
||||
if (!e.mx) e.mx = { boxes: Array.from({ length: BOXES }, () => ({ items: {} })) }
|
||||
boxesOf(e)
|
||||
},
|
||||
interact(p, e, g) {
|
||||
boxesOf(e)
|
||||
storeOpen = e.id
|
||||
g.ptip(p, '打开营地仓储(背包可堆叠物资可存入箱子)')
|
||||
},
|
||||
draw(ctx, e, env) {
|
||||
// 底部投影
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.25)'
|
||||
ctx.beginPath(); ctx.ellipse(0, 8, 28, 8, 0, 0, TAU); ctx.fill()
|
||||
// 木石仓房
|
||||
ctx.fillStyle = '#a9773c'
|
||||
ctx.strokeStyle = '#1d1409'
|
||||
ctx.lineWidth = 2
|
||||
ctx.beginPath(); ctx.roundRect(-24, -40, 48, 46, 3); ctx.fill(); ctx.stroke()
|
||||
// 竖向木板纹
|
||||
ctx.strokeStyle = '#8a5f30'
|
||||
ctx.lineWidth = 1.2
|
||||
;[-14, -4, 6, 16].forEach((x) => { ctx.beginPath(); ctx.moveTo(x, -40); ctx.lineTo(x, 6); ctx.stroke() })
|
||||
// 大门
|
||||
ctx.fillStyle = '#3a2a16'
|
||||
ctx.strokeStyle = '#1d1409'
|
||||
ctx.lineWidth = 2
|
||||
ctx.beginPath(); ctx.roundRect(-12, -22, 24, 28, [3, 3, 0, 0]); ctx.fill(); ctx.stroke()
|
||||
ctx.strokeStyle = '#5d3f24'
|
||||
ctx.lineWidth = 1.2
|
||||
ctx.beginPath(); ctx.moveTo(0, -22); ctx.lineTo(0, 6); ctx.stroke()
|
||||
// 斜屋顶
|
||||
ctx.fillStyle = '#7a5230'
|
||||
ctx.strokeStyle = '#1d1409'
|
||||
ctx.lineWidth = 2
|
||||
ctx.beginPath(); ctx.moveTo(-30, -40); ctx.lineTo(30, -40); ctx.lineTo(0, -64); ctx.closePath(); ctx.fill(); ctx.stroke()
|
||||
ctx.strokeStyle = '#5d3f24'
|
||||
ctx.lineWidth = 1
|
||||
;[-22, -12, 0, 12, 22].forEach((x) => { ctx.beginPath(); ctx.moveTo(x, -40); ctx.lineTo(x * 0.4, -61); ctx.stroke() })
|
||||
// 门口码放的木箱(3~4 个叠放,体现"很多箱子")
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.18)'
|
||||
ctx.beginPath(); ctx.ellipse(24, 8, 20, 6, 0, 0, TAU); ctx.fill()
|
||||
const crate = (x, y, w, h) => {
|
||||
ctx.fillStyle = '#c9a36b'
|
||||
ctx.strokeStyle = '#1d1409'
|
||||
ctx.lineWidth = 2
|
||||
ctx.beginPath(); ctx.roundRect(x, y, w, h, 1.5); ctx.fill(); ctx.stroke()
|
||||
ctx.strokeStyle = '#8a5f30'
|
||||
ctx.lineWidth = 1.1
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x, y); ctx.lineTo(x + w, y + h)
|
||||
ctx.moveTo(x + w, y); ctx.lineTo(x, y + h)
|
||||
ctx.stroke()
|
||||
}
|
||||
crate(-30, -8, 16, 16) // 左前
|
||||
crate(-14, -4, 16, 16) // 中间偏前
|
||||
crate(-30, -24, 16, 16) // 左后上叠
|
||||
crate(14, -10, 18, 18) // 右侧一只
|
||||
},
|
||||
},
|
||||
},
|
||||
mobs: {
|
||||
guard: {
|
||||
name: '守卫',
|
||||
friendly: true,
|
||||
conf: { hp: 9999, speed: 150, dmg: 60, score: 0, friendly: true, name: '守卫' },
|
||||
drops: [],
|
||||
update(m, dt, g) {
|
||||
const conf = g.mon[m.kind] || this.conf
|
||||
// 无限血:核心所有伤害路径都是 m.hp -= dmg 后判 <=0,每帧回满即可免疫
|
||||
m.hp = 1e9
|
||||
const c = campCenter(g)
|
||||
// 找营地半径内的敌对生物
|
||||
let target = null
|
||||
let bd = CAMP_R
|
||||
g.monsters.forEach((q) => {
|
||||
if (q === m) return
|
||||
if (!g.mon[q.kind]?.hostile) return
|
||||
const d = Math.hypot(q.x - c.x, q.y - c.y)
|
||||
if (d < bd) { bd = d; target = q }
|
||||
})
|
||||
if (target) {
|
||||
const d = Math.hypot(target.x - m.x, target.y - m.y)
|
||||
if (d > 34) {
|
||||
g.moveToward(m, target.x, target.y, conf.speed, dt)
|
||||
} else if ((m.cd || 0) <= 0) {
|
||||
m.cd = 0.9
|
||||
g.damageMob(target, conf.dmg, null)
|
||||
}
|
||||
return
|
||||
}
|
||||
// 无敌人:在房屋周围小范围巡逻待命
|
||||
m.wanderT = (m.wanderT || 0) - dt
|
||||
if (m.wanderT <= 0) {
|
||||
m.wanderT = 2 + Math.random() * 3
|
||||
const a = Math.random() * TAU
|
||||
const r = 60 + Math.random() * 110
|
||||
m.tx = c.x + Math.cos(a) * r
|
||||
m.ty = c.y + Math.sin(a) * r
|
||||
}
|
||||
if (m.tx !== undefined && Math.hypot(m.tx - m.x, m.ty - m.y) > 8) {
|
||||
g.moveToward(m, m.tx, m.ty, conf.speed * 0.4, dt)
|
||||
} else {
|
||||
m.moving = false
|
||||
}
|
||||
},
|
||||
interact(p, m, g) {
|
||||
g.ptip(p, '守卫挺直身板:营地范围里很安全')
|
||||
},
|
||||
draw(ctx, m, time) {
|
||||
ctx.save()
|
||||
ctx.scale(m.dir || 1, 1)
|
||||
const hop = Math.sin(time * 7 + (m.bob || 0)) * (m.moving ? 1.8 : 0.5)
|
||||
// 底部投影
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.25)'
|
||||
ctx.beginPath(); ctx.ellipse(0, 5, 15, 6, 0, 0, TAU); ctx.fill()
|
||||
// 腿(皮靴)
|
||||
ctx.strokeStyle = '#5b3a1e'
|
||||
ctx.lineWidth = 4.6
|
||||
const leg = m.moving ? Math.sin(time * 9) * 2.6 : 0
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(-5, -10); ctx.lineTo(-6 + leg, 3)
|
||||
ctx.moveTo(5, -10); ctx.lineTo(6 - leg, 3)
|
||||
ctx.stroke()
|
||||
// 身体:皮甲(棕褐)
|
||||
ctx.fillStyle = '#8a5f34'
|
||||
ctx.strokeStyle = '#1d1409'
|
||||
ctx.lineWidth = 2
|
||||
ctx.beginPath(); ctx.roundRect(-11, -32 + hop * 0.3, 22, 24, 5); ctx.fill(); ctx.stroke()
|
||||
// 皮甲护胸横条
|
||||
ctx.strokeStyle = '#6b4a2a'
|
||||
ctx.lineWidth = 1.4
|
||||
;[-27, -22, -17].forEach((y) => { ctx.beginPath(); ctx.moveTo(-9, y); ctx.lineTo(9, y); ctx.stroke() })
|
||||
// 肩甲
|
||||
ctx.fillStyle = '#a97b47'
|
||||
ctx.strokeStyle = '#1d1409'
|
||||
ctx.lineWidth = 1.6
|
||||
ctx.beginPath(); ctx.arc(-11, -30 + hop * 0.3, 5, 0, TAU); ctx.fill(); ctx.stroke()
|
||||
ctx.beginPath(); ctx.arc(11, -30 + hop * 0.3, 5, 0, TAU); ctx.fill(); ctx.stroke()
|
||||
// 长矛(斜握)
|
||||
ctx.strokeStyle = '#7a5230'
|
||||
ctx.lineWidth = 3
|
||||
ctx.beginPath(); ctx.moveTo(8, 2); ctx.lineTo(24, -40); ctx.stroke()
|
||||
ctx.fillStyle = '#c6ccd4'
|
||||
ctx.strokeStyle = '#1d1409'
|
||||
ctx.lineWidth = 1.4
|
||||
ctx.beginPath(); ctx.moveTo(22, -38); ctx.lineTo(27, -47); ctx.lineTo(30, -40); ctx.closePath(); ctx.fill(); ctx.stroke()
|
||||
// 头
|
||||
ctx.fillStyle = '#e8c9a0'
|
||||
ctx.strokeStyle = '#1d1409'
|
||||
ctx.lineWidth = 2
|
||||
ctx.beginPath(); ctx.arc(0, -40 + hop * 0.5, 7, 0, TAU); ctx.fill(); ctx.stroke()
|
||||
// 金属头盔
|
||||
ctx.fillStyle = '#9aa0a8'
|
||||
ctx.strokeStyle = '#1d1409'
|
||||
ctx.lineWidth = 1.8
|
||||
ctx.beginPath(); ctx.arc(0, -42 + hop * 0.5, 8, Math.PI, TAU); ctx.fill(); ctx.stroke()
|
||||
ctx.beginPath(); ctx.roundRect(-8, -43 + hop * 0.5, 16, 3, 1); ctx.fill(); ctx.stroke()
|
||||
// 头盔红缨
|
||||
ctx.fillStyle = '#cf3b34'
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(0, -50 + hop * 0.5); ctx.quadraticCurveTo(4, -56 + hop * 0.5, 1, -60 + hop * 0.5)
|
||||
ctx.quadraticCurveTo(-2, -56 + hop * 0.5, -2, -50 + hop * 0.5)
|
||||
ctx.closePath(); ctx.fill(); ctx.stroke()
|
||||
// 目光
|
||||
ctx.fillStyle = '#1d1409'
|
||||
ctx.beginPath(); ctx.arc(3, -40 + hop * 0.5, 1.2, 0, TAU); ctx.fill()
|
||||
ctx.restore()
|
||||
},
|
||||
},
|
||||
},
|
||||
hooks: {
|
||||
onWorldGen(g) {
|
||||
// 出生点即世界中心,营地在中心附近错开摆位(避免卡住玩家)
|
||||
const cx = g.worldW / 2
|
||||
const cy = g.worldH / 2
|
||||
const house = g.findLand(cx - 40, cy - 30, 0, 60)
|
||||
g.addEntity('camphouse', house.x, house.y)
|
||||
const gs = g.findLand(cx + 60, cy + 40, 0, 80)
|
||||
g.spawnMob('guard', gs.x, gs.y, {})
|
||||
// 长明篝火:永不熄灭,营地夜间照明
|
||||
g.addFire(cx + 90, cy + 70, { pit: true, eternal: true, ttl: 150, max: 150 })
|
||||
g.tip('营地已就绪:房屋、长明篝火与守卫')
|
||||
},
|
||||
onMobVictim(g, m, victim) {
|
||||
// 核心 nearestVictim() 会调此钩子:返回 false = 这只怪不把该玩家算作可选目标。
|
||||
// 只对敌对生物生效(保留「怪可以去打守卫/猪人」的能力)。
|
||||
if (!g.mon[m.kind]?.hostile) return
|
||||
// 判断依据是「受害者在不在营地内」,怪本身不做任何位移(无跳变)。
|
||||
const center = g.entities.find((e) => e.type === 'camphouse') || { x: g.worldW / 2, y: g.worldH / 2 }
|
||||
if (Math.hypot(victim.x - center.x, victim.y - center.y) < CAMP_R) return false
|
||||
},
|
||||
onDraw(g, ctx, env) {
|
||||
// 虚线画出营地领地边界(世界坐标位置,画布为屏幕坐标系,故减去相机)
|
||||
const c = campCenter(g)
|
||||
const sx = c.x - env.camX
|
||||
const sy = c.y - env.camY
|
||||
ctx.save()
|
||||
ctx.strokeStyle = 'rgba(255,220,140,0.35)'
|
||||
ctx.lineWidth = 2
|
||||
ctx.setLineDash([10, 8])
|
||||
ctx.beginPath()
|
||||
ctx.ellipse(sx, sy, CAMP_R, CAMP_R * 0.62, 0, 0, TAU)
|
||||
ctx.stroke()
|
||||
ctx.restore()
|
||||
},
|
||||
onDrawUI(g, ctx, env) {
|
||||
// 记录相机位置,供 onClick 做世界→屏幕换算
|
||||
uiCamX = env.camX
|
||||
uiCamY = env.camY
|
||||
if (storeOpen == null) return
|
||||
const e = g.entities.find((x) => x.id === storeOpen)
|
||||
const p = g.me()
|
||||
if (!e || !p) {
|
||||
storeOpen = null
|
||||
return
|
||||
}
|
||||
const boxes = boxesOf(e)
|
||||
const W = env.W
|
||||
const H = env.H
|
||||
const rowH = 30
|
||||
const headerH = 40
|
||||
const footerH = 42
|
||||
const pad = 10
|
||||
const panelW = Math.min(470, W - 24)
|
||||
const panelH = headerH + BOXES * rowH + footerH + pad
|
||||
const px = Math.round((W - panelW) / 2)
|
||||
const py = Math.round((H - panelH) / 2)
|
||||
uiPanel = { x: px, y: py, w: panelW, h: panelH }
|
||||
|
||||
// 深色木质面板
|
||||
ctx.save()
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.45)'
|
||||
ctx.fillRect(0, 0, W, H)
|
||||
ctx.fillStyle = '#3a2a18'
|
||||
ctx.strokeStyle = '#1d1409'
|
||||
ctx.lineWidth = 3
|
||||
ctx.beginPath(); ctx.roundRect(px, py, panelW, panelH, 10); ctx.fill(); ctx.stroke()
|
||||
// 标题栏
|
||||
ctx.fillStyle = '#5a4022'
|
||||
ctx.beginPath(); ctx.roundRect(px + 3, py + 3, panelW - 6, headerH - 6, [8, 8, 0, 0]); ctx.fill()
|
||||
ctx.fillStyle = '#f4ead2'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'middle'
|
||||
ctx.font = 'bold 17px system-ui, sans-serif'
|
||||
ctx.fillText('营地仓储', px + panelW / 2, py + headerH / 2 - 2)
|
||||
ctx.font = '11px system-ui, sans-serif'
|
||||
ctx.fillStyle = 'rgba(244,234,210,0.7)'
|
||||
ctx.fillText(`共 ${BOXES} 箱 · 每箱 ${BOX_KINDS} 种 · 每种上限 ${BOX_CAP}`, px + panelW / 2, py + headerH - 6)
|
||||
|
||||
// 行
|
||||
uiRows = []
|
||||
const rightEdge = px + panelW - pad - 4
|
||||
const btnH = 20
|
||||
const depW = 66
|
||||
const witW = 60
|
||||
const gap = 6
|
||||
const fontSize = 13
|
||||
for (let i = 0; i < BOXES; i++) {
|
||||
const rowY = py + headerH + i * rowH
|
||||
const box = boxes[i]
|
||||
const used = usedKinds(box)
|
||||
// 交替底色
|
||||
ctx.fillStyle = i % 2 ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.14)'
|
||||
ctx.beginPath(); ctx.roundRect(px + pad, rowY + 2, panelW - pad * 2, rowH - 5, 4); ctx.fill()
|
||||
// 箱子 N + 用量
|
||||
ctx.textAlign = 'left'
|
||||
ctx.textBaseline = 'middle'
|
||||
ctx.fillStyle = used > 0 ? '#f4ead2' : 'rgba(244,234,210,0.5)'
|
||||
ctx.font = `bold ${fontSize}px system-ui, sans-serif`
|
||||
ctx.fillText(`箱子 ${i + 1}`, px + pad + 8, rowY + rowH / 2 - 1)
|
||||
ctx.fillStyle = 'rgba(255,220,140,0.85)'
|
||||
ctx.font = `11px system-ui, sans-serif`
|
||||
ctx.fillText(`${used}/${BOX_KINDS} 种`, px + pad + 62, rowY + rowH / 2 - 1)
|
||||
// 内容摘要(最多 3 种)
|
||||
const entries = Object.entries(box.items || {}).filter(([, n]) => n > 0)
|
||||
const shown = entries.slice(0, 3).map(([code, n]) => `${g.items[code]?.name || code}×${n}`)
|
||||
const more = entries.length > 3 ? ` 等${entries.length}种` : ''
|
||||
ctx.fillStyle = 'rgba(230,220,200,0.8)'
|
||||
ctx.font = `12px system-ui, sans-serif`
|
||||
ctx.fillText(entries.length ? shown.join(' ') + more : '(空)', px + pad + 108, rowY + rowH / 2 - 1)
|
||||
// 按钮
|
||||
const by = rowY + (rowH - 3 - btnH) / 2 + 1
|
||||
const dep = { x: rightEdge - witW - gap - depW, y: by, w: depW, h: btnH }
|
||||
const wit = { x: rightEdge - witW, y: by, w: witW, h: btnH }
|
||||
const drawBtn = (r, label, bg) => {
|
||||
ctx.fillStyle = bg
|
||||
ctx.strokeStyle = '#1d1409'
|
||||
ctx.lineWidth = 1.4
|
||||
ctx.beginPath(); ctx.roundRect(r.x, r.y, r.w, r.h, 5); ctx.fill(); ctx.stroke()
|
||||
ctx.fillStyle = '#f4ead2'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.font = `bold 12px system-ui, sans-serif`
|
||||
ctx.fillText(label, r.x + r.w / 2, r.y + r.h / 2 + 0.5)
|
||||
}
|
||||
drawBtn(dep, '↓ 存入', '#3f6b34')
|
||||
drawBtn(wit, '↑ 取出', '#8a6a34')
|
||||
uiRows.push({ i, dep, wit })
|
||||
}
|
||||
|
||||
// 关闭按钮
|
||||
const cw = 120
|
||||
const ch = 26
|
||||
const cx2 = px + panelW / 2 - cw / 2
|
||||
const cy2 = py + panelH - footerH + 8
|
||||
uiClose = { x: cx2, y: cy2, w: cw, h: ch }
|
||||
ctx.fillStyle = '#7a2f24'
|
||||
ctx.strokeStyle = '#1d1409'
|
||||
ctx.lineWidth = 2
|
||||
ctx.beginPath(); ctx.roundRect(cx2, cy2, cw, ch, 6); ctx.fill(); ctx.stroke()
|
||||
ctx.fillStyle = '#f4ead2'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'middle'
|
||||
ctx.font = 'bold 14px system-ui, sans-serif'
|
||||
ctx.fillText('关闭', cx2 + cw / 2, cy2 + ch / 2 + 0.5)
|
||||
ctx.restore()
|
||||
},
|
||||
onClick(g, wx, wy) {
|
||||
if (storeOpen == null) return undefined // 没有面板:不干扰游戏点击
|
||||
const sx = wx - uiCamX
|
||||
const sy = wy - uiCamY
|
||||
const e = g.entities.find((x) => x.id === storeOpen)
|
||||
const p = g.me()
|
||||
if (!e || !p) {
|
||||
storeOpen = null
|
||||
return true
|
||||
}
|
||||
// 关闭按钮
|
||||
if (inside(sx, sy, uiClose)) {
|
||||
storeOpen = null
|
||||
return true
|
||||
}
|
||||
// 每行的存入/取出
|
||||
for (const r of uiRows) {
|
||||
if (inside(sx, sy, r.dep)) {
|
||||
const box = boxesOf(e)[r.i]
|
||||
const moved = depositBox(g, p, box)
|
||||
g.markDirty(e)
|
||||
g.ptip(p, moved > 0 ? `存入 ${moved} 件物资` : '背包里没有可堆入的物资')
|
||||
return true
|
||||
}
|
||||
if (inside(sx, sy, r.wit)) {
|
||||
const box = boxesOf(e)[r.i]
|
||||
const moved = withdrawBox(g, p, box)
|
||||
g.markDirty(e)
|
||||
g.ptip(p, moved > 0 ? `取回 ${moved} 件物资` : '这个箱子是空的')
|
||||
return true
|
||||
}
|
||||
}
|
||||
// 面板内空白:吃掉点击
|
||||
if (inside(sx, sy, uiPanel)) return true
|
||||
// 面板外:关闭
|
||||
storeOpen = null
|
||||
return true
|
||||
},
|
||||
},
|
||||
}
|
||||
485
src/games/starve/mods/husbandry.js
Normal file
485
src/games/starve/mods/husbandry.js
Normal file
@@ -0,0 +1,485 @@
|
||||
// 牧业文明:栅栏圈养、饲料槽投喂 —— 羊毛与鸡蛋的牧场经济
|
||||
// - 剪刀(科学机器合成,耐久 30):点击绵羊剪毛,每只羊每天一次(给羊毛 ×2)
|
||||
// - 饲料槽:投喂浆果/作物,让附近鸡舍每日囤更多蛋;点击鸡舍收取鸡蛋
|
||||
// - 羊毛可做便宜的保暖帽(保暖 60),鸡蛋/牛奶/奶酪补充食物
|
||||
// 数值基准:三围 150/150/200,一天 160 秒,官方食物折算
|
||||
const TAU = Math.PI * 2
|
||||
|
||||
// 绵羊觅食:无害短距游荡(照抄 loyaldog 流浪分支,速度乘 0.5)
|
||||
function wander(m, dt, g, speed) {
|
||||
m.wanderT = (m.wanderT || 0) - dt
|
||||
if (m.wanderT <= 0) {
|
||||
m.wanderT = 2 + Math.random() * 3
|
||||
const a = Math.random() * TAU
|
||||
m.tx = m.x + Math.cos(a) * 80
|
||||
m.ty = m.y + Math.sin(a) * 80
|
||||
}
|
||||
if (m.tx !== undefined && Math.hypot(m.tx - m.x, m.ty - m.y) > 8) {
|
||||
g.moveToward(m, m.tx, m.ty, speed, dt)
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
id: 'husbandry',
|
||||
name: '牧业文明',
|
||||
desc: '栅栏圈养、饲料槽投喂:羊毛与鸡蛋的牧场经济',
|
||||
factor: 1.05,
|
||||
tag: 'content',
|
||||
tabs: [{ code: 'husbandry', name: '牧业', icon: 'husbshears' }],
|
||||
items: {
|
||||
husbshears: { name: '剪刀', stack: 1, equip: 'hand', dmg: 10, uses: 30 },
|
||||
wool: { name: '羊毛', stack: 40 },
|
||||
woolhat: { name: '羊毛帽', stack: 1, equip: 'head', insul: 60 },
|
||||
egg: { name: '鸡蛋', food: { hunger: 12.5, hp: 1, san: 0 }, cook: 'cookedegg', perish: 20 },
|
||||
cookedegg: { name: '煎蛋', food: { hunger: 18.75, hp: 3, san: 0 }, perish: 10 },
|
||||
milk: { name: '牛奶', food: { hunger: 12.5, hp: 3, san: 0 }, perish: 10 },
|
||||
cheese: { name: '奶酪', food: { hunger: 25, hp: 8, san: 5 }, perish: 20 },
|
||||
},
|
||||
recipes: [
|
||||
{ code: 'husbshears', name: '剪刀', tab: 'husbandry', tech: 1, cost: { gold: 2, rope: 1 }, desc: '给绵羊剪毛 · 耐久 30 次' },
|
||||
{ code: 'fence', name: '栅栏', tab: 'husbandry', tech: 1, cost: { twig: 4, grass: 2 }, build: true, desc: '圈养动物的围栏' },
|
||||
{ code: 'trough', name: '饲料槽', tab: 'husbandry', tech: 1, cost: { log: 4, rock: 2 }, build: true, desc: '投喂作物,提升牧场产出' },
|
||||
{ code: 'coop', name: '鸡舍', tab: 'husbandry', tech: 2, cost: { log: 6, silk: 4, rope: 2 }, build: true, desc: '每日自动囤蛋' },
|
||||
{ code: 'woolhat', name: '羊毛帽', tab: 'husbandry', tech: 2, cost: { wool: 6, rope: 1 }, desc: '保暖 60(弱于冬帽但便宜)' },
|
||||
],
|
||||
icons: {
|
||||
husbshears: (c) => {
|
||||
// 指环
|
||||
c.strokeStyle = '#6f767d'
|
||||
c.lineWidth = 3
|
||||
c.beginPath(); c.arc(-8, 11, 4.5, 0, TAU); c.stroke()
|
||||
c.beginPath(); c.arc(8, 11, 4.5, 0, TAU); c.stroke()
|
||||
// 刀身
|
||||
c.strokeStyle = '#b7bec7'
|
||||
c.lineWidth = 3
|
||||
c.beginPath(); c.moveTo(-6, -14); c.lineTo(6, 6); c.stroke()
|
||||
c.beginPath(); c.moveTo(6, -14); c.lineTo(-6, 6); c.stroke()
|
||||
// 内刃
|
||||
c.strokeStyle = '#8d949c'
|
||||
c.lineWidth = 1
|
||||
c.beginPath(); c.moveTo(-5, -13); c.lineTo(5, 5); c.stroke()
|
||||
// 轴钉
|
||||
c.fillStyle = '#4a4f55'
|
||||
c.beginPath(); c.arc(0, -4, 2.4, 0, TAU); c.fill()
|
||||
},
|
||||
wool: (c) => {
|
||||
c.fillStyle = '#f2ede1'
|
||||
c.strokeStyle = '#1d1409'
|
||||
c.lineWidth = 1.5
|
||||
const puffs = [[-6, 2, 6.5], [6, 2, 6.5], [0, -4, 7], [0, 5, 6]]
|
||||
puffs.forEach(([x, y, r]) => { c.moveTo(x + r, y); c.arc(x, y, r, 0, TAU) })
|
||||
c.fill()
|
||||
puffs.forEach(([x, y, r]) => { c.beginPath(); c.arc(x, y, r, 0, TAU); c.stroke() })
|
||||
},
|
||||
woolhat: (c) => {
|
||||
c.fillStyle = '#efe7d6'
|
||||
c.strokeStyle = '#1d1409'
|
||||
c.lineWidth = 1.8
|
||||
c.beginPath()
|
||||
c.moveTo(-12, 6); c.quadraticCurveTo(-13, -12, 0, -14); c.quadraticCurveTo(13, -12, 12, 6)
|
||||
c.closePath(); c.fill(); c.stroke()
|
||||
// 帽檐
|
||||
c.fillStyle = '#d9cdb4'
|
||||
c.beginPath(); c.roundRect(-13, 4, 26, 6, 2); c.fill(); c.stroke()
|
||||
// 顶部绒球
|
||||
c.fillStyle = '#f6f1e6'
|
||||
c.beginPath(); c.arc(0, -15, 4, 0, TAU); c.fill(); c.stroke()
|
||||
},
|
||||
egg: (c) => {
|
||||
c.fillStyle = '#f6efe0'
|
||||
c.strokeStyle = '#1d1409'
|
||||
c.lineWidth = 1.8
|
||||
c.beginPath()
|
||||
c.moveTo(0, -12); c.bezierCurveTo(8, -10, 10, 4, 0, 12); c.bezierCurveTo(-10, 4, -8, -10, 0, -12)
|
||||
c.closePath(); c.fill(); c.stroke()
|
||||
c.fillStyle = '#fffdf6'
|
||||
c.beginPath(); c.ellipse(-3, -3, 2.6, 4, -0.3, 0, TAU); c.fill()
|
||||
},
|
||||
cookedegg: (c) => {
|
||||
c.fillStyle = '#f7f2e6'
|
||||
c.strokeStyle = '#1d1409'
|
||||
c.lineWidth = 1.6
|
||||
c.beginPath()
|
||||
c.moveTo(-13, 2); c.quadraticCurveTo(-14, -8, -4, -9); c.quadraticCurveTo(2, -14, 9, -7)
|
||||
c.quadraticCurveTo(15, -4, 12, 4); c.quadraticCurveTo(8, 12, -2, 10); c.quadraticCurveTo(-11, 9, -13, 2)
|
||||
c.closePath(); c.fill(); c.stroke()
|
||||
// 蛋黄
|
||||
c.fillStyle = '#f2b53a'
|
||||
c.beginPath(); c.arc(-1, -1, 5.5, 0, TAU); c.fill(); c.stroke()
|
||||
c.fillStyle = '#f8d374'
|
||||
c.beginPath(); c.arc(-2.5, -2.5, 2, 0, TAU); c.fill()
|
||||
},
|
||||
milk: (c) => {
|
||||
// 瓶身
|
||||
c.fillStyle = '#f4f1ea'
|
||||
c.strokeStyle = '#1d1409'
|
||||
c.lineWidth = 1.6
|
||||
c.beginPath(); c.roundRect(-7, -2, 14, 16, 2); c.fill(); c.stroke()
|
||||
// 瓶颈
|
||||
c.beginPath(); c.roundRect(-4, -12, 8, 11, 1.5); c.fill(); c.stroke()
|
||||
// 瓶盖
|
||||
c.fillStyle = '#c0455a'
|
||||
c.beginPath(); c.roundRect(-5, -15, 10, 4, 1.5); c.fill(); c.stroke()
|
||||
// 奶面
|
||||
c.fillStyle = '#ffffff'
|
||||
c.beginPath(); c.roundRect(-5, 3, 10, 9, 1.5); c.fill()
|
||||
},
|
||||
cheese: (c) => {
|
||||
// 侧面
|
||||
c.fillStyle = '#d99f34'
|
||||
c.strokeStyle = '#1d1409'
|
||||
c.lineWidth = 1.8
|
||||
c.beginPath(); c.moveTo(-12, 4); c.lineTo(12, 2); c.lineTo(12, 8); c.lineTo(-12, 8); c.closePath()
|
||||
c.fill(); c.stroke()
|
||||
// 顶面
|
||||
c.fillStyle = '#f0c04a'
|
||||
c.beginPath(); c.moveTo(-12, 4); c.lineTo(-12, -3); c.lineTo(10, -8); c.lineTo(12, 2); c.closePath()
|
||||
c.fill(); c.stroke()
|
||||
// 孔洞
|
||||
c.fillStyle = '#c07f22'
|
||||
;[[-4, 0, 2.2], [4, -3, 1.6], [7, 5, 1.4]].forEach(([x, y, r]) => {
|
||||
c.beginPath(); c.arc(x, y, r, 0, TAU); c.fill()
|
||||
})
|
||||
},
|
||||
fence: (c) => {
|
||||
c.fillStyle = '#a9773c'
|
||||
c.strokeStyle = '#1d1409'
|
||||
c.lineWidth = 1.8
|
||||
;[-10, 10].forEach((x) => {
|
||||
c.beginPath(); c.roundRect(x - 3, -13, 6, 26, 1.5); c.fill(); c.stroke()
|
||||
})
|
||||
// 横木
|
||||
c.beginPath(); c.roundRect(-14, -6, 28, 4, 1.5); c.fill(); c.stroke()
|
||||
c.beginPath(); c.roundRect(-14, 3, 28, 4, 1.5); c.fill(); c.stroke()
|
||||
// 木纹
|
||||
c.strokeStyle = '#7a5230'
|
||||
c.lineWidth = 1
|
||||
c.beginPath(); c.moveTo(-10, -13); c.lineTo(-10, 13); c.moveTo(10, -13); c.lineTo(10, 13); c.stroke()
|
||||
},
|
||||
trough: (c) => {
|
||||
c.fillStyle = '#8a5f38'
|
||||
c.strokeStyle = '#1d1409'
|
||||
c.lineWidth = 1.8
|
||||
c.beginPath(); c.moveTo(-14, -6); c.lineTo(14, -6); c.lineTo(10, 10); c.lineTo(-10, 10); c.closePath()
|
||||
c.fill(); c.stroke()
|
||||
// 槽内
|
||||
c.fillStyle = '#5d3f24'
|
||||
c.beginPath(); c.roundRect(-11, -7, 22, 5, 1); c.fill()
|
||||
// 谷粒
|
||||
c.fillStyle = '#e0c060'
|
||||
;[[-6, -5], [-1, -6], [4, -5], [-3, -3], [6, -3]].forEach(([x, y]) => {
|
||||
c.beginPath(); c.arc(x, y, 1.6, 0, TAU); c.fill()
|
||||
})
|
||||
},
|
||||
coop: (c) => {
|
||||
// 屋体
|
||||
c.fillStyle = '#a9773c'
|
||||
c.strokeStyle = '#1d1409'
|
||||
c.lineWidth = 1.8
|
||||
c.beginPath(); c.roundRect(-11, -4, 22, 16, 2); c.fill(); c.stroke()
|
||||
// 屋顶
|
||||
c.fillStyle = '#7a5230'
|
||||
c.beginPath(); c.moveTo(-14, -4); c.lineTo(0, -15); c.lineTo(14, -4); c.closePath(); c.fill(); c.stroke()
|
||||
// 出入口
|
||||
c.fillStyle = '#2a1c10'
|
||||
c.beginPath(); c.roundRect(-4, 2, 8, 10, 3); c.fill(); c.stroke()
|
||||
// 烟囱
|
||||
c.fillStyle = '#8a5f38'
|
||||
c.beginPath(); c.roundRect(5, -15, 4, 7, 1); c.fill(); c.stroke()
|
||||
},
|
||||
},
|
||||
ents: {
|
||||
fence: {
|
||||
name: '栅栏',
|
||||
conf: { size: 24, struct: true, solid: true },
|
||||
draw(ctx) {
|
||||
// 底部投影
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.25)'
|
||||
ctx.beginPath(); ctx.ellipse(0, 4, 16, 5, 0, 0, TAU); ctx.fill()
|
||||
// 两根竖木桩
|
||||
ctx.fillStyle = '#a9773c'
|
||||
ctx.strokeStyle = '#1d1409'
|
||||
ctx.lineWidth = 2
|
||||
;[-11, 11].forEach((x) => {
|
||||
ctx.beginPath(); ctx.roundRect(x - 4, -28, 8, 32, 2); ctx.fill(); ctx.stroke()
|
||||
})
|
||||
// 两道横木
|
||||
ctx.beginPath(); ctx.roundRect(-16, -20, 32, 5, 2); ctx.fill(); ctx.stroke()
|
||||
ctx.beginPath(); ctx.roundRect(-16, -8, 32, 5, 2); ctx.fill(); ctx.stroke()
|
||||
// 木纹
|
||||
ctx.strokeStyle = '#7a5230'
|
||||
ctx.lineWidth = 1.2
|
||||
ctx.beginPath(); ctx.moveTo(-11, -28); ctx.lineTo(-11, 4); ctx.moveTo(11, -28); ctx.lineTo(11, 4); ctx.stroke()
|
||||
},
|
||||
},
|
||||
trough: {
|
||||
name: '饲料槽',
|
||||
conf: { size: 26, struct: true },
|
||||
init(e) {
|
||||
// 饲料数必须放进 e.mx 才能随存档/联机同步(e.feed 不会被序列化)
|
||||
if (!e.mx) e.mx = {}
|
||||
if (e.mx.feed == null) e.mx.feed = 0
|
||||
},
|
||||
interact(p, e, g) {
|
||||
if (!e.mx) e.mx = {}
|
||||
if (e.mx.feed == null) e.mx.feed = 0
|
||||
// 只投喂实际存在于物品表里的作物/浆果
|
||||
const feeds = ['berry', 'wheat', 'pumpkin', 'carrot'].filter((code) => g.items[code])
|
||||
const code = feeds.find((c2) => g.countItem(p, c2) > 0)
|
||||
if (!code) {
|
||||
g.ptip(p, '需要投喂作物或浆果')
|
||||
return
|
||||
}
|
||||
g.payCost(p, { [code]: 1 })
|
||||
e.mx.feed = (e.mx.feed || 0) + 1
|
||||
g.markDirty(e)
|
||||
g.ptip(p, `投喂了一份饲料(现有 ${e.mx.feed} 份)`)
|
||||
},
|
||||
draw(ctx, e) {
|
||||
// 底部投影
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.25)'
|
||||
ctx.beginPath(); ctx.ellipse(0, 4, 18, 5, 0, 0, TAU); ctx.fill()
|
||||
const feed = e.mx?.feed || 0
|
||||
// 木质槽体
|
||||
ctx.fillStyle = '#8a5f38'
|
||||
ctx.strokeStyle = '#1d1409'
|
||||
ctx.lineWidth = 2
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(-18, -14); ctx.lineTo(18, -14); ctx.lineTo(13, 6); ctx.lineTo(-13, 6); ctx.closePath()
|
||||
ctx.fill(); ctx.stroke()
|
||||
// 槽内暗面
|
||||
ctx.fillStyle = '#5d3f24'
|
||||
ctx.beginPath(); ctx.roundRect(-15, -15, 30, 7, 2); ctx.fill()
|
||||
// 谷粒:有饲料时更满更亮
|
||||
const bright = feed > 0
|
||||
const full = Math.min(1, feed / 4)
|
||||
ctx.fillStyle = bright ? '#ecd06a' : '#8a7a4a'
|
||||
const grains = bright ? 10 : 4
|
||||
for (let i = 0; i < grains; i++) {
|
||||
const gx = -13 + i * 2.9
|
||||
const gy = -13 + Math.sin(i * 1.7) * 1.6
|
||||
ctx.beginPath(); ctx.arc(gx, gy, 2 + full, 0, TAU); ctx.fill()
|
||||
}
|
||||
// 高光边
|
||||
ctx.strokeStyle = '#b08a54'
|
||||
ctx.lineWidth = 1.2
|
||||
ctx.beginPath(); ctx.moveTo(-16, -12); ctx.lineTo(16, -12); ctx.stroke()
|
||||
},
|
||||
},
|
||||
coop: {
|
||||
name: '鸡舍',
|
||||
conf: { size: 34, struct: true },
|
||||
init(e) {
|
||||
// 囤蛋数必须放进 e.mx 才能随存档/联机同步(e.stored 不会被序列化)
|
||||
if (!e.mx) e.mx = {}
|
||||
if (e.mx.stored == null) e.mx.stored = 0
|
||||
},
|
||||
interact(p, e, g) {
|
||||
if (!e.mx) e.mx = {}
|
||||
const stored = e.mx.stored || 0
|
||||
if (stored > 0) {
|
||||
g.addItem(p, 'egg', stored)
|
||||
e.mx.stored = 0
|
||||
g.markDirty(e)
|
||||
g.ptip(p, `收取鸡蛋 ×${stored}`)
|
||||
} else {
|
||||
g.ptip(p, '鸡舍里暂时没有蛋')
|
||||
}
|
||||
},
|
||||
draw(ctx, e) {
|
||||
// 底部投影
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.25)'
|
||||
ctx.beginPath(); ctx.ellipse(0, 5, 22, 7, 0, 0, TAU); ctx.fill()
|
||||
// 屋体
|
||||
ctx.fillStyle = '#a9773c'
|
||||
ctx.strokeStyle = '#1d1409'
|
||||
ctx.lineWidth = 2
|
||||
ctx.beginPath(); ctx.roundRect(-18, -22, 36, 28, 3); ctx.fill(); ctx.stroke()
|
||||
// 木板纹
|
||||
ctx.strokeStyle = '#8a5f38'
|
||||
ctx.lineWidth = 1.2
|
||||
ctx.beginPath(); ctx.moveTo(-6, -22); ctx.lineTo(-6, 6); ctx.moveTo(6, -22); ctx.lineTo(6, 6); ctx.stroke()
|
||||
// 斜屋顶
|
||||
ctx.fillStyle = '#7a5230'
|
||||
ctx.strokeStyle = '#1d1409'
|
||||
ctx.lineWidth = 2
|
||||
ctx.beginPath(); ctx.moveTo(-23, -22); ctx.lineTo(0, -40); ctx.lineTo(23, -22); ctx.closePath(); ctx.fill(); ctx.stroke()
|
||||
// 屋檐压线
|
||||
ctx.strokeStyle = '#5d3f24'
|
||||
ctx.lineWidth = 1.4
|
||||
ctx.beginPath(); ctx.moveTo(-20, -25); ctx.lineTo(20, -25); ctx.stroke()
|
||||
// 圆形出入口
|
||||
ctx.fillStyle = '#2a1c10'
|
||||
ctx.strokeStyle = '#1d1409'
|
||||
ctx.lineWidth = 2
|
||||
ctx.beginPath(); ctx.roundRect(-8, -10, 16, 16, [8, 8, 2, 2]); ctx.fill(); ctx.stroke()
|
||||
// 屋顶小烟囱
|
||||
ctx.fillStyle = '#8a5f38'
|
||||
ctx.beginPath(); ctx.roundRect(10, -44, 7, 10, 2); ctx.fill(); ctx.stroke()
|
||||
// 囤蛋提示
|
||||
if ((e.mx?.stored || 0) > 0) {
|
||||
ctx.fillStyle = '#f6efe0'
|
||||
ctx.strokeStyle = '#1d1409'
|
||||
ctx.lineWidth = 1.4
|
||||
ctx.beginPath(); ctx.ellipse(-2, 12, 3.4, 4, 0, 0, TAU); ctx.fill(); ctx.stroke()
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
mobs: {
|
||||
sheep: {
|
||||
name: '绵羊',
|
||||
conf: { hp: 250, speed: 70, dmg: 8, score: 0, friendly: true, name: '绵羊' },
|
||||
drops: [[1, 'meat', 1], [1, 'wool', 1]],
|
||||
update(m, dt, g) {
|
||||
const conf = g.mon[m.kind] || this.conf
|
||||
if (m.hp <= 0) return
|
||||
wander(m, dt, g, conf.speed * 0.5)
|
||||
},
|
||||
interact(p, m, g) {
|
||||
if (g.countItem(p, 'husbshears') <= 0) {
|
||||
g.ptip(p, '需要剪刀才能剪羊毛')
|
||||
return
|
||||
}
|
||||
if (m.shearDay === g.day) {
|
||||
g.ptip(p, '羊毛还没长回来(需等到明天)')
|
||||
return
|
||||
}
|
||||
g.addItem(p, 'wool', 2)
|
||||
m.shearDay = g.day
|
||||
g.ptip(p, '给绵羊剪下一团羊毛')
|
||||
g.swing(p, 'chop')
|
||||
},
|
||||
draw(ctx, m, time) {
|
||||
ctx.save()
|
||||
ctx.scale(m.dir || 1, 1)
|
||||
const hop = Math.sin(time * 6 + (m.bob || 0)) * (m.moving ? 1.6 : 0.4)
|
||||
// 底部投影
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.25)'
|
||||
ctx.beginPath(); ctx.ellipse(0, 4, 18, 6, 0, 0, TAU); ctx.fill()
|
||||
// 黑色细腿
|
||||
ctx.strokeStyle = '#3a3a3a'
|
||||
ctx.lineWidth = 3.4
|
||||
const leg = m.moving ? Math.sin(time * 9) * 2.4 : 0
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(-7, -8); ctx.lineTo(-8 + leg, 3)
|
||||
ctx.moveTo(6, -8); ctx.lineTo(7 - leg, 3)
|
||||
ctx.stroke()
|
||||
// 白色蓬松羊毛(多团叠加)
|
||||
ctx.fillStyle = '#f4f0e6'
|
||||
ctx.strokeStyle = '#1d1409'
|
||||
ctx.lineWidth = 2
|
||||
const puffs = [[-9, -13, 9], [0, -16, 11], [9, -13, 9], [-4, -8, 9], [5, -8, 9]]
|
||||
puffs.forEach(([x, y, r]) => { ctx.moveTo(x + r, y); ctx.arc(x, y, r, 0, TAU) })
|
||||
ctx.fill()
|
||||
puffs.forEach(([x, y, r]) => { ctx.beginPath(); ctx.arc(x, y, r, 0, TAU); ctx.stroke() })
|
||||
// 深灰头部
|
||||
ctx.fillStyle = '#5b5b5b'
|
||||
ctx.beginPath(); ctx.ellipse(15, -12 + hop * 0.3, 6.5, 6, 0, 0, TAU); ctx.fill(); ctx.stroke()
|
||||
// 耳朵
|
||||
ctx.fillStyle = '#4a4a4a'
|
||||
ctx.beginPath(); ctx.moveTo(11, -17); ctx.quadraticCurveTo(8, -22, 13, -20); ctx.closePath(); ctx.fill(); ctx.stroke()
|
||||
// 眼
|
||||
ctx.fillStyle = '#f4ead2'
|
||||
ctx.beginPath(); ctx.arc(17, -13, 1.6, 0, TAU); ctx.fill()
|
||||
ctx.fillStyle = '#1d1409'
|
||||
ctx.beginPath(); ctx.arc(18.4, -12.4, 1.1, 0, TAU); ctx.fill()
|
||||
ctx.restore()
|
||||
},
|
||||
},
|
||||
chicken: {
|
||||
name: '鸡',
|
||||
conf: { hp: 50, speed: 90, dmg: 5, score: 0, friendly: true, name: '鸡' },
|
||||
drops: [[1, 'morsel', 1]],
|
||||
update(m, dt, g) {
|
||||
const conf = g.mon[m.kind] || this.conf
|
||||
if (m.hp <= 0) return
|
||||
wander(m, dt, g, conf.speed * 0.5)
|
||||
},
|
||||
interact(p, m, g) {
|
||||
g.ptip(p, '咕咕咕……')
|
||||
},
|
||||
draw(ctx, m, time) {
|
||||
ctx.save()
|
||||
ctx.scale(m.dir || 1, 1)
|
||||
const hop = Math.sin(time * 10 + (m.bob || 0)) * (m.moving ? 1.4 : 0.5)
|
||||
// 底部投影
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.25)'
|
||||
ctx.beginPath(); ctx.ellipse(0, 3, 11, 4, 0, 0, TAU); ctx.fill()
|
||||
// 橙色腿
|
||||
ctx.strokeStyle = '#e08a3c'
|
||||
ctx.lineWidth = 2.4
|
||||
const leg = m.moving ? Math.sin(time * 14) * 2 : 0
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(-2, -6); ctx.lineTo(-3 + leg, 2)
|
||||
ctx.moveTo(3, -6); ctx.lineTo(4 - leg, 2)
|
||||
ctx.stroke()
|
||||
// 米黄身体
|
||||
ctx.fillStyle = '#f3ead6'
|
||||
ctx.strokeStyle = '#1d1409'
|
||||
ctx.lineWidth = 2
|
||||
ctx.beginPath(); ctx.ellipse(-1, -12 + hop * 0.3, 10, 8, 0, 0, TAU); ctx.fill(); ctx.stroke()
|
||||
// 翅膀
|
||||
ctx.fillStyle = '#e4d6ba'
|
||||
ctx.beginPath(); ctx.ellipse(-2, -12, 5, 4, 0.2, 0, TAU); ctx.fill(); ctx.stroke()
|
||||
// 尾羽
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(-10, -14); ctx.quadraticCurveTo(-16, -18, -15, -10); ctx.quadraticCurveTo(-13, -12, -10, -11)
|
||||
ctx.closePath(); ctx.fill(); ctx.stroke()
|
||||
// 头
|
||||
ctx.fillStyle = '#f6efdd'
|
||||
ctx.beginPath(); ctx.arc(8, -20 + hop * 0.4, 5, 0, TAU); ctx.fill(); ctx.stroke()
|
||||
// 红鸡冠
|
||||
ctx.fillStyle = '#d8483c'
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(6 + 2.4, -25); ctx.arc(6, -25, 2.4, 0, TAU)
|
||||
ctx.moveTo(9 + 2.6, -26); ctx.arc(9, -26, 2.6, 0, TAU)
|
||||
ctx.moveTo(11.5 + 2.2, -24); ctx.arc(11.5, -24, 2.2, 0, TAU)
|
||||
ctx.fill(); ctx.stroke()
|
||||
// 橙喙
|
||||
ctx.fillStyle = '#e8a33c'
|
||||
ctx.beginPath(); ctx.moveTo(12.5, -20); ctx.lineTo(17, -19); ctx.lineTo(12.5, -17.6); ctx.closePath(); ctx.fill(); ctx.stroke()
|
||||
// 红肉垂
|
||||
ctx.fillStyle = '#d8483c'
|
||||
ctx.beginPath(); ctx.ellipse(11, -15.5, 2, 3, 0, 0, TAU); ctx.fill(); ctx.stroke()
|
||||
// 眼
|
||||
ctx.fillStyle = '#1d1409'
|
||||
ctx.beginPath(); ctx.arc(9.4, -21, 1.1, 0, TAU); ctx.fill()
|
||||
ctx.restore()
|
||||
},
|
||||
},
|
||||
},
|
||||
hooks: {
|
||||
onWorldGen(g) {
|
||||
// 草原上散落绵羊与鸡(寻找邻近陆地投放)
|
||||
const sheepN = g.rand(4, 6)
|
||||
const chickN = g.rand(5, 8)
|
||||
for (let i = 0; i < sheepN; i++) {
|
||||
const spot = g.findLand(80 + Math.random() * (g.worldW - 160), 80 + Math.random() * (g.worldH - 160), 0, 20)
|
||||
g.spawnMob('sheep', spot.x, spot.y, {})
|
||||
}
|
||||
for (let i = 0; i < chickN; i++) {
|
||||
const spot = g.findLand(80 + Math.random() * (g.worldW - 160), 80 + Math.random() * (g.worldH - 160), 0, 20)
|
||||
g.spawnMob('chicken', spot.x, spot.y, {})
|
||||
}
|
||||
g.tip('草原上传来羊叫与鸡鸣……')
|
||||
},
|
||||
onNewDay(g) {
|
||||
// 结算每座鸡舍的囤蛋:附近有鸡或喂了饲料槽则翻倍,每日上限 4
|
||||
g.entities.filter((e) => e.type === 'coop').forEach((coop) => {
|
||||
if (!coop.mx) coop.mx = {}
|
||||
if (coop.mx.stored == null) coop.mx.stored = 0
|
||||
const chickenNear = g.monsters.some((m) => m.kind === 'chicken' && Math.hypot(m.x - coop.x, m.y - coop.y) < 180)
|
||||
const trough = g.entities.find((t) => t.type === 'trough' && (t.mx?.feed || 0) > 0 && Math.hypot(t.x - coop.x, t.y - coop.y) < 160)
|
||||
let inc = 1
|
||||
if (chickenNear || trough) inc = 2
|
||||
if (trough) {
|
||||
trough.mx.feed -= 1
|
||||
g.markDirty(trough)
|
||||
}
|
||||
coop.mx.stored = Math.min(4, (coop.mx.stored || 0) + inc)
|
||||
g.markDirty(coop)
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
465
src/games/starve/mods/industry.js
Normal file
465
src/games/starve/mods/industry.js
Normal file
@@ -0,0 +1,465 @@
|
||||
// 工业文明:蒸汽时代 —— 熔炉炼锭、矿机自动开采、工厂流水线
|
||||
// 产业进阶链(无循环依赖):
|
||||
// 熔炉(石+木) → 金属锭 → 蒸汽机(锭) → 供能 → 工厂(锭) → 齿轮 → 矿机(锭+齿轮)
|
||||
// - 熔炉:投入 石头×3 + 木头×1 产出 1 金属锭
|
||||
// - 蒸汽机:投入木头当燃料,为 300px 内的工厂/矿机供能
|
||||
// - 工厂:有蒸汽时投入 金属锭×2 产出齿轮;矿机:每日自动开采,蒸汽下翻倍
|
||||
// 数值基准:三围 150/150/200,一天 160 秒
|
||||
const TAU = Math.PI * 2
|
||||
const STEAM_RANGE = 300 // 蒸汽机供能半径
|
||||
|
||||
export default {
|
||||
id: 'industry',
|
||||
name: '工业文明',
|
||||
desc: '蒸汽时代:熔炉炼锭、矿机自动开采、工厂流水线',
|
||||
factor: 1.1,
|
||||
tag: 'content',
|
||||
tabs: [{ code: 'industry', name: '工业', icon: 'indpickaxe' }],
|
||||
items: {
|
||||
indpickaxe: { name: '钢镐', stack: 1, equip: 'hand', tool: 'pick', dmg: 34, uses: 60 },
|
||||
indaxe: { name: '机械斧', stack: 1, equip: 'hand', tool: 'axe', dmg: 34, uses: 80 },
|
||||
ingot: { name: '金属锭', stack: 40 },
|
||||
gear: { name: '齿轮', stack: 40 },
|
||||
steelarmor: { name: '钢甲', stack: 1, equip: 'body', absorb: 0.85, armor: 525 },
|
||||
steelhelmet: { name: '钢盔', stack: 1, equip: 'head', absorb: 0.85, armor: 525 },
|
||||
},
|
||||
recipes: [
|
||||
{ code: 'smelter', name: '熔炉', tab: 'industry', tech: 1, cost: { rock: 12, log: 6 }, build: true, desc: '石头+木头 → 金属锭' },
|
||||
{ code: 'steamengine', name: '蒸汽机', tab: 'industry', tech: 2, cost: { gold: 8, rock: 10, ingot: 4 }, build: true, desc: '范围供能 · 需投木为燃料' },
|
||||
{ code: 'factory', name: '工厂', tab: 'industry', tech: 2, cost: { ingot: 6, log: 8, rock: 6 }, build: true, desc: '消耗金属锭产出齿轮' },
|
||||
{ code: 'miner', name: '自动矿机', tab: 'industry', tech: 2, cost: { ingot: 4, gear: 2, log: 6 }, build: true, desc: '每日自动开采,蒸汽下翻倍' },
|
||||
{ code: 'indpickaxe', name: '钢镐', tab: 'industry', tech: 2, cost: { ingot: 2, log: 2 }, desc: '采矿 · 耐久 60 次' },
|
||||
{ code: 'indaxe', name: '机械斧', tab: 'industry', tech: 2, cost: { ingot: 2, log: 2 }, desc: '砍树 · 耐久 80 次' },
|
||||
{ code: 'steelarmor', name: '钢甲', tab: 'industry', tech: 2, cost: { ingot: 6, rope: 2 }, desc: '减伤 85% · 耐久 525' },
|
||||
{ code: 'steelhelmet', name: '钢盔', tab: 'industry', tech: 2, cost: { ingot: 4, rope: 1 }, desc: '减伤 85% · 耐久 525' },
|
||||
],
|
||||
icons: {
|
||||
indpickaxe: (c) => {
|
||||
// 弧形钢头
|
||||
c.fillStyle = '#8d949c'
|
||||
c.strokeStyle = '#1d1409'
|
||||
c.lineWidth = 1.6
|
||||
c.beginPath()
|
||||
c.moveTo(-14, -6); c.quadraticCurveTo(0, -16, 14, -6); c.quadraticCurveTo(0, -9, -14, -6)
|
||||
c.closePath(); c.fill(); c.stroke()
|
||||
// 木柄
|
||||
c.strokeStyle = '#7a5b33'
|
||||
c.lineWidth = 3.4
|
||||
c.beginPath(); c.moveTo(0, -8); c.lineTo(0, 15); c.stroke()
|
||||
c.strokeStyle = '#5f4626'
|
||||
c.lineWidth = 1
|
||||
c.beginPath(); c.moveTo(-2, 15); c.lineTo(2, 15); c.stroke()
|
||||
},
|
||||
indaxe: (c) => {
|
||||
// 木柄
|
||||
c.strokeStyle = '#7a5b33'
|
||||
c.lineWidth = 3.2
|
||||
c.beginPath(); c.moveTo(-8, 14); c.lineTo(6, -10); c.stroke()
|
||||
// 钢刃
|
||||
c.fillStyle = '#b7bec7'
|
||||
c.strokeStyle = '#1d1409'
|
||||
c.lineWidth = 1.8
|
||||
c.beginPath()
|
||||
c.moveTo(4, -16); c.quadraticCurveTo(15, -12, 12, 0); c.quadraticCurveTo(9, -4, 3, -8)
|
||||
c.closePath(); c.fill(); c.stroke()
|
||||
c.fillStyle = '#8d949c'
|
||||
c.beginPath(); c.moveTo(4, -16); c.lineTo(8, -12); c.lineTo(5, -8); c.closePath(); c.fill()
|
||||
// 齿轮轴钉
|
||||
c.fillStyle = '#c9a34a'
|
||||
c.beginPath(); c.arc(0, -4, 2.2, 0, TAU); c.fill()
|
||||
},
|
||||
ingot: (c) => {
|
||||
c.fillStyle = '#b7bec7'
|
||||
c.strokeStyle = '#1d1409'
|
||||
c.lineWidth = 1.8
|
||||
c.beginPath()
|
||||
c.moveTo(-13, 6); c.lineTo(-9, -6); c.lineTo(9, -6); c.lineTo(13, 6); c.closePath()
|
||||
c.fill(); c.stroke()
|
||||
// 顶面
|
||||
c.fillStyle = '#d7dde4'
|
||||
c.beginPath(); c.moveTo(-9, -6); c.lineTo(9, -6); c.lineTo(7, -2); c.lineTo(-7, -2); c.closePath(); c.fill()
|
||||
// 高光
|
||||
c.strokeStyle = '#eef2f6'
|
||||
c.lineWidth = 1.4
|
||||
c.beginPath(); c.moveTo(-6, -4); c.lineTo(4, -4); c.stroke()
|
||||
},
|
||||
gear: (c) => {
|
||||
c.fillStyle = '#c9a34a'
|
||||
c.strokeStyle = '#1d1409'
|
||||
c.lineWidth = 1.4
|
||||
for (let i = 0; i < 8; i++) {
|
||||
c.save()
|
||||
c.rotate((i / 8) * TAU)
|
||||
c.beginPath(); c.roundRect(-2.6, -14, 5.2, 6, 1.4); c.fill(); c.stroke()
|
||||
c.restore()
|
||||
}
|
||||
c.lineWidth = 1.6
|
||||
c.beginPath(); c.arc(0, 0, 10, 0, TAU); c.fill(); c.stroke()
|
||||
c.fillStyle = '#8a6a24'
|
||||
c.beginPath(); c.arc(0, 0, 3.6, 0, TAU); c.fill(); c.stroke()
|
||||
c.fillStyle = '#f0d488'
|
||||
c.beginPath(); c.arc(-3, -3, 2.2, 0, TAU); c.fill()
|
||||
},
|
||||
steelarmor: (c) => {
|
||||
c.fillStyle = '#aab2ba'
|
||||
c.strokeStyle = '#1d1409'
|
||||
c.lineWidth = 1.8
|
||||
c.beginPath()
|
||||
c.moveTo(-11, -10); c.lineTo(-4, -13); c.quadraticCurveTo(0, -11, 4, -13); c.lineTo(11, -10)
|
||||
c.lineTo(9, 4); c.quadraticCurveTo(0, 10, -9, 4); c.closePath()
|
||||
c.fill(); c.stroke()
|
||||
// 中央脊线
|
||||
c.strokeStyle = '#7f878f'
|
||||
c.lineWidth = 1.4
|
||||
c.beginPath(); c.moveTo(0, -11); c.lineTo(0, 7); c.stroke()
|
||||
// 铆钉
|
||||
c.fillStyle = '#5f666d'
|
||||
;[[-6, -6], [6, -6], [0, 6]].forEach(([x, y]) => {
|
||||
c.beginPath(); c.arc(x, y, 1.6, 0, TAU); c.fill()
|
||||
})
|
||||
},
|
||||
steelhelmet: (c) => {
|
||||
c.fillStyle = '#aab2ba'
|
||||
c.strokeStyle = '#1d1409'
|
||||
c.lineWidth = 1.8
|
||||
c.beginPath(); c.arc(0, 0, 12, Math.PI, 0); c.closePath(); c.fill(); c.stroke()
|
||||
// 下沿
|
||||
c.beginPath(); c.roundRect(-13, 0, 26, 5, 2); c.fill(); c.stroke()
|
||||
// 面甲缝
|
||||
c.fillStyle = '#2a2f34'
|
||||
c.beginPath(); c.roundRect(-8, -7, 16, 4, 1.5); c.fill()
|
||||
// 高光
|
||||
c.fillStyle = '#cfd6dd'
|
||||
c.beginPath(); c.ellipse(-5, -6, 2.4, 3.4, -0.3, 0, TAU); c.fill()
|
||||
},
|
||||
smelter: (c) => {
|
||||
// 石砌炉体
|
||||
c.fillStyle = '#8d9097'
|
||||
c.strokeStyle = '#1d1409'
|
||||
c.lineWidth = 1.8
|
||||
c.beginPath(); c.moveTo(-12, 12); c.lineTo(-9, -6); c.lineTo(9, -6); c.lineTo(12, 12); c.closePath()
|
||||
c.fill(); c.stroke()
|
||||
// 烟囱
|
||||
c.fillStyle = '#6f767d'
|
||||
c.beginPath(); c.roundRect(-3, -15, 6, 9, 1.5); c.fill(); c.stroke()
|
||||
// 炉口火光
|
||||
c.fillStyle = '#e0662e'
|
||||
c.beginPath(); c.roundRect(-5, 0, 10, 8, 2); c.fill(); c.stroke()
|
||||
c.fillStyle = '#f0c04a'
|
||||
c.beginPath(); c.arc(0, 5, 2.4, 0, TAU); c.fill()
|
||||
// 砖缝
|
||||
c.strokeStyle = '#6f767d'
|
||||
c.lineWidth = 1
|
||||
c.beginPath(); c.moveTo(-10, 4); c.lineTo(10, 4); c.stroke()
|
||||
},
|
||||
steamengine: (c) => {
|
||||
// 锅炉
|
||||
c.fillStyle = '#c9a34a'
|
||||
c.strokeStyle = '#1d1409'
|
||||
c.lineWidth = 1.8
|
||||
c.beginPath(); c.roundRect(-12, -6, 24, 16, 3); c.fill(); c.stroke()
|
||||
// 压力表
|
||||
c.fillStyle = '#e8e3d6'
|
||||
c.beginPath(); c.arc(0, 0, 5, 0, TAU); c.fill(); c.stroke()
|
||||
c.strokeStyle = '#c0455a'
|
||||
c.lineWidth = 1.4
|
||||
c.beginPath(); c.moveTo(0, 0); c.lineTo(3, -3); c.stroke()
|
||||
// 活塞
|
||||
c.fillStyle = '#8d949c'
|
||||
c.strokeStyle = '#1d1409'
|
||||
c.lineWidth = 1.6
|
||||
c.beginPath(); c.roundRect(-5, -15, 10, 9, 2); c.fill(); c.stroke()
|
||||
c.beginPath(); c.roundRect(-2, -19, 4, 5, 1); c.fill(); c.stroke()
|
||||
// 蒸汽
|
||||
c.fillStyle = 'rgba(240,244,248,0.9)'
|
||||
;[[-5, -22, 2.4], [2, -24, 3], [7, -20, 2]].forEach(([x, y, r]) => {
|
||||
c.beginPath(); c.arc(x, y, r, 0, TAU); c.fill()
|
||||
})
|
||||
},
|
||||
factory: (c) => {
|
||||
// 厂房
|
||||
c.fillStyle = '#b06a4a'
|
||||
c.strokeStyle = '#1d1409'
|
||||
c.lineWidth = 1.8
|
||||
c.beginPath(); c.roundRect(-13, -8, 26, 20, 2); c.fill(); c.stroke()
|
||||
// 屋顶横梁
|
||||
c.fillStyle = '#8a5040'
|
||||
c.beginPath(); c.roundRect(-14, -12, 28, 6, 2); c.fill(); c.stroke()
|
||||
// 烟囱
|
||||
c.fillStyle = '#6f5040'
|
||||
c.beginPath(); c.roundRect(6, -19, 5, 8, 1.5); c.fill(); c.stroke()
|
||||
// 齿轮窗
|
||||
c.fillStyle = '#c9a34a'
|
||||
c.strokeStyle = '#1d1409'
|
||||
c.lineWidth = 1.2
|
||||
for (let i = 0; i < 6; i++) {
|
||||
c.save()
|
||||
c.rotate((i / 6) * TAU)
|
||||
c.beginPath(); c.roundRect(-1.4, -8, 2.8, 3, 1); c.fill()
|
||||
c.restore()
|
||||
}
|
||||
c.lineWidth = 1.6
|
||||
c.beginPath(); c.arc(0, 2, 5.2, 0, TAU); c.fill(); c.stroke()
|
||||
c.fillStyle = '#8a6a24'
|
||||
c.beginPath(); c.arc(0, 2, 1.8, 0, TAU); c.fill()
|
||||
},
|
||||
miner: (c) => {
|
||||
// 机身
|
||||
c.fillStyle = '#8d949c'
|
||||
c.strokeStyle = '#1d1409'
|
||||
c.lineWidth = 1.8
|
||||
c.beginPath(); c.roundRect(-12, -8, 24, 14, 2); c.fill(); c.stroke()
|
||||
// 钻头
|
||||
c.fillStyle = '#b7bec7'
|
||||
c.beginPath(); c.moveTo(-4, -8); c.lineTo(4, -8); c.lineTo(0, -18); c.closePath(); c.fill(); c.stroke()
|
||||
// 履带
|
||||
c.fillStyle = '#4a4f55'
|
||||
c.beginPath(); c.roundRect(-13, 6, 26, 6, 3); c.fill(); c.stroke()
|
||||
c.fillStyle = '#6f767d'
|
||||
;[-9, -3, 3, 9].forEach((x) => {
|
||||
c.beginPath(); c.arc(x, 9, 1.6, 0, TAU); c.fill()
|
||||
})
|
||||
// 指示灯
|
||||
c.fillStyle = '#f0c04a'
|
||||
c.beginPath(); c.arc(0, 0, 2.4, 0, TAU); c.fill()
|
||||
},
|
||||
},
|
||||
ents: {
|
||||
smelter: {
|
||||
name: '熔炉',
|
||||
conf: { size: 36, struct: true },
|
||||
interact(p, e, g) {
|
||||
if (g.hasCost(p, { rock: 3, log: 1 })) {
|
||||
g.payCost(p, { rock: 3, log: 1 })
|
||||
g.addItem(p, 'ingot', 1)
|
||||
g.swing(p, 'chop')
|
||||
g.shake(1)
|
||||
g.ptip(p, '熔炉产出 1 块金属锭')
|
||||
} else {
|
||||
g.ptip(p, '需要 石头×3、木头×1')
|
||||
}
|
||||
},
|
||||
draw(ctx, e, env) {
|
||||
// 底部投影
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.25)'
|
||||
ctx.beginPath(); ctx.ellipse(0, 5, 22, 7, 0, 0, TAU); ctx.fill()
|
||||
// 石砌炉体
|
||||
ctx.fillStyle = '#8d9097'
|
||||
ctx.strokeStyle = '#1d1409'
|
||||
ctx.lineWidth = 2
|
||||
ctx.beginPath(); ctx.moveTo(-20, 6); ctx.lineTo(-15, -22); ctx.lineTo(15, -22); ctx.lineTo(20, 6); ctx.closePath()
|
||||
ctx.fill(); ctx.stroke()
|
||||
// 砖缝
|
||||
ctx.strokeStyle = '#6f767d'
|
||||
ctx.lineWidth = 1.2
|
||||
;[-14, -4].forEach((y) => {
|
||||
const half = 15 + ((y + 22) / 28) * 5
|
||||
ctx.beginPath(); ctx.moveTo(-half, y); ctx.lineTo(half, y); ctx.stroke()
|
||||
})
|
||||
ctx.beginPath(); ctx.moveTo(0, -22); ctx.lineTo(0, 6); ctx.stroke()
|
||||
// 顶部烟囱
|
||||
ctx.fillStyle = '#6f767d'
|
||||
ctx.strokeStyle = '#1d1409'
|
||||
ctx.lineWidth = 2
|
||||
ctx.beginPath(); ctx.roundRect(-5, -34, 10, 14, 2); ctx.fill(); ctx.stroke()
|
||||
// 炉口(呼吸火光)
|
||||
const k = 0.6 + Math.sin((env?.time || 0) * 3 + (e.id || 0)) * 0.4
|
||||
ctx.fillStyle = `rgba(224,102,46,${0.7 + k * 0.3})`
|
||||
ctx.beginPath(); ctx.roundRect(-7, -6, 14, 12, 3); ctx.fill()
|
||||
ctx.fillStyle = `rgba(240,192,74,${0.5 + k * 0.5})`
|
||||
ctx.beginPath(); ctx.arc(0, 2, 3 + k * 2, 0, TAU); ctx.fill()
|
||||
// 顶烟
|
||||
ctx.fillStyle = `rgba(200,200,200,${0.14 + k * 0.06})`
|
||||
ctx.beginPath(); ctx.arc(0, -40, 5, 0, TAU); ctx.fill()
|
||||
},
|
||||
},
|
||||
steamengine: {
|
||||
name: '蒸汽机',
|
||||
conf: { size: 38, struct: true },
|
||||
init(e) {
|
||||
if (e.fuel == null) e.fuel = 0
|
||||
if (e.on == null) e.on = false
|
||||
},
|
||||
update(e, dt, g) {
|
||||
e.on = (e.fuel || 0) > 0
|
||||
},
|
||||
interact(p, e, g) {
|
||||
if (g.hasCost(p, { log: 1 })) {
|
||||
g.payCost(p, { log: 1 })
|
||||
e.fuel = (e.fuel || 0) + 3
|
||||
g.markDirty(e)
|
||||
g.ptip(p, `投入木头,蒸汽机燃料剩余 ${e.fuel}`)
|
||||
} else {
|
||||
g.ptip(p, '蒸汽机需要木头作燃料')
|
||||
}
|
||||
},
|
||||
draw(ctx, e, env) {
|
||||
const t = env?.time || 0
|
||||
const on = !!e.on
|
||||
// 底部投影
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.25)'
|
||||
ctx.beginPath(); ctx.ellipse(0, 6, 22, 7, 0, 0, TAU); ctx.fill()
|
||||
// 底座
|
||||
ctx.fillStyle = '#5f4626'
|
||||
ctx.strokeStyle = '#1d1409'
|
||||
ctx.lineWidth = 2
|
||||
ctx.beginPath(); ctx.roundRect(-20, 2, 40, 8, 2); ctx.fill(); ctx.stroke()
|
||||
// 黄铜锅炉
|
||||
ctx.fillStyle = on ? '#c9a34a' : '#8a7a4a'
|
||||
ctx.beginPath(); ctx.roundRect(-18, -16, 36, 20, 5); ctx.fill(); ctx.stroke()
|
||||
// 压力表
|
||||
ctx.fillStyle = '#e8e3d6'
|
||||
ctx.beginPath(); ctx.arc(-6, -6, 6, 0, TAU); ctx.fill(); ctx.stroke()
|
||||
ctx.strokeStyle = '#c0455a'
|
||||
ctx.lineWidth = 1.6
|
||||
ctx.beginPath(); ctx.moveTo(-6, -6); ctx.lineTo(-6 + (on ? 4 : 2), -10); ctx.stroke()
|
||||
// 铆钉
|
||||
ctx.fillStyle = on ? '#8a6a24' : '#5f5432'
|
||||
;[[8, -12], [14, -12], [8, 0], [14, 0]].forEach(([x, y]) => {
|
||||
ctx.beginPath(); ctx.arc(x, y, 1.6, 0, TAU); ctx.fill()
|
||||
})
|
||||
// 活塞汽缸
|
||||
ctx.fillStyle = '#8d949c'
|
||||
ctx.strokeStyle = '#1d1409'
|
||||
ctx.lineWidth = 2
|
||||
ctx.beginPath(); ctx.roundRect(-7, -28, 14, 12, 3); ctx.fill(); ctx.stroke()
|
||||
const piston = on ? Math.sin(t * 6 + (e.id || 0)) * 4 : 0
|
||||
ctx.beginPath(); ctx.roundRect(-2.5, -36 + piston, 5, 12, 2); ctx.fill(); ctx.stroke()
|
||||
// 蒸汽白烟(供能时冒烟)
|
||||
if (on) {
|
||||
ctx.fillStyle = '#f0f4f8'
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const p = (t * 1.4 + i * 0.33 + (e.id || 0) * 0.1) % 1
|
||||
ctx.globalAlpha = 1 - p
|
||||
ctx.beginPath(); ctx.arc(0, -40 - p * 18, 3 + p * 5, 0, TAU); ctx.fill()
|
||||
}
|
||||
ctx.globalAlpha = 1
|
||||
}
|
||||
},
|
||||
},
|
||||
factory: {
|
||||
name: '工厂',
|
||||
conf: { size: 40, struct: true },
|
||||
interact(p, e, g) {
|
||||
const powered = g.entities.some((s) => s.type === 'steamengine' && s.on && Math.hypot(s.x - e.x, s.y - e.y) < STEAM_RANGE)
|
||||
if (!powered) {
|
||||
g.ptip(p, '需要通蒸汽动力(在附近建蒸汽机并加燃料)')
|
||||
return
|
||||
}
|
||||
if (g.hasCost(p, { ingot: 2 })) {
|
||||
g.payCost(p, { ingot: 2 })
|
||||
g.addItem(p, 'gear', 1)
|
||||
g.ptip(p, '齿轮 ×1')
|
||||
} else {
|
||||
g.ptip(p, '需要 金属锭×2')
|
||||
}
|
||||
},
|
||||
draw(ctx, e, env) {
|
||||
const t = env?.time || 0
|
||||
// 底部投影
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.25)'
|
||||
ctx.beginPath(); ctx.ellipse(0, 8, 24, 8, 0, 0, TAU); ctx.fill()
|
||||
// 砖砌厂房
|
||||
ctx.fillStyle = '#b06a4a'
|
||||
ctx.strokeStyle = '#1d1409'
|
||||
ctx.lineWidth = 2
|
||||
ctx.beginPath(); ctx.roundRect(-22, -26, 44, 32, 3); ctx.fill(); ctx.stroke()
|
||||
// 砖纹
|
||||
ctx.strokeStyle = '#8f4f34'
|
||||
ctx.lineWidth = 1
|
||||
;[-20, -14, -8, -2, 4].forEach((y) => {
|
||||
ctx.beginPath(); ctx.moveTo(-22, y); ctx.lineTo(22, y); ctx.stroke()
|
||||
})
|
||||
// 屋顶横梁
|
||||
ctx.fillStyle = '#8a5040'
|
||||
ctx.strokeStyle = '#1d1409'
|
||||
ctx.lineWidth = 2
|
||||
ctx.beginPath(); ctx.roundRect(-24, -30, 48, 6, 2); ctx.fill(); ctx.stroke()
|
||||
// 烟囱
|
||||
ctx.fillStyle = '#6f5040'
|
||||
ctx.beginPath(); ctx.roundRect(12, -40, 7, 12, 2); ctx.fill(); ctx.stroke()
|
||||
// 齿轮窗(旋转)
|
||||
ctx.save()
|
||||
ctx.translate(0, -8)
|
||||
ctx.rotate(t * 1.2 + (e.id || 0))
|
||||
ctx.fillStyle = '#c9a34a'
|
||||
ctx.strokeStyle = '#1d1409'
|
||||
ctx.lineWidth = 1.4
|
||||
for (let i = 0; i < 8; i++) {
|
||||
ctx.save()
|
||||
ctx.rotate((i / 8) * TAU)
|
||||
ctx.beginPath(); ctx.roundRect(-2, -10, 4, 4.5, 1.2); ctx.fill(); ctx.stroke()
|
||||
ctx.restore()
|
||||
}
|
||||
ctx.lineWidth = 1.8
|
||||
ctx.beginPath(); ctx.arc(0, 0, 7, 0, TAU); ctx.fill(); ctx.stroke()
|
||||
ctx.fillStyle = '#8a6a24'
|
||||
ctx.beginPath(); ctx.arc(0, 0, 2.6, 0, TAU); ctx.fill()
|
||||
ctx.restore()
|
||||
},
|
||||
},
|
||||
miner: {
|
||||
name: '自动矿机',
|
||||
conf: { size: 34, struct: true },
|
||||
interact(p, e, g) {
|
||||
const powered = g.entities.some((s) => s.type === 'steamengine' && s.on && Math.hypot(s.x - e.x, s.y - e.y) < STEAM_RANGE)
|
||||
g.ptip(p, powered ? '矿机正在满速运转(每台每天自动开采)' : '缺少蒸汽动力,效率减半')
|
||||
},
|
||||
draw(ctx, e, env) {
|
||||
const t = env?.time || 0
|
||||
// 底部投影
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.25)'
|
||||
ctx.beginPath(); ctx.ellipse(0, 6, 20, 7, 0, 0, TAU); ctx.fill()
|
||||
// 履带底盘
|
||||
ctx.fillStyle = '#4a4f55'
|
||||
ctx.strokeStyle = '#1d1409'
|
||||
ctx.lineWidth = 2
|
||||
ctx.beginPath(); ctx.roundRect(-18, -2, 36, 8, 4); ctx.fill(); ctx.stroke()
|
||||
ctx.fillStyle = '#6f767d'
|
||||
;[-12, -4, 4, 12].forEach((x) => {
|
||||
ctx.beginPath(); ctx.arc(x, 2, 2, 0, TAU); ctx.fill()
|
||||
})
|
||||
// 金属机身
|
||||
ctx.fillStyle = '#8d949c'
|
||||
ctx.strokeStyle = '#1d1409'
|
||||
ctx.lineWidth = 2
|
||||
ctx.beginPath(); ctx.roundRect(-14, -22, 28, 20, 3); ctx.fill(); ctx.stroke()
|
||||
// 铆钉
|
||||
ctx.fillStyle = '#5f666d'
|
||||
;[[-10, -18], [10, -18], [-10, -6], [10, -6]].forEach(([x, y]) => {
|
||||
ctx.beginPath(); ctx.arc(x, y, 1.6, 0, TAU); ctx.fill()
|
||||
})
|
||||
// 指示灯
|
||||
ctx.fillStyle = '#f0c04a'
|
||||
ctx.beginPath(); ctx.arc(0, -14, 2.4, 0, TAU); ctx.fill()
|
||||
// 钻头(上下活塞)
|
||||
const d = Math.sin(t * 7 + (e.id || 0)) * 5
|
||||
ctx.fillStyle = '#b7bec7'
|
||||
ctx.strokeStyle = '#1d1409'
|
||||
ctx.lineWidth = 2
|
||||
ctx.beginPath(); ctx.moveTo(-6, -22); ctx.lineTo(6, -22); ctx.lineTo(0, -34 - d); ctx.closePath()
|
||||
ctx.fill(); ctx.stroke()
|
||||
// 钻纹
|
||||
ctx.strokeStyle = '#7f878f'
|
||||
ctx.lineWidth = 1.2
|
||||
ctx.beginPath(); ctx.moveTo(-4, -26 - d * 0.4); ctx.lineTo(4, -26 - d * 0.4); ctx.stroke()
|
||||
},
|
||||
},
|
||||
},
|
||||
hooks: {
|
||||
onNewDay(g) {
|
||||
// 自动矿机开采:附近有供能的蒸汽机则翻倍
|
||||
g.entities.filter((e) => e.type === 'miner').forEach((m) => {
|
||||
const powered = g.entities.some((s) => s.type === 'steamengine' && s.on && Math.hypot(s.x - m.x, s.y - m.y) < STEAM_RANGE)
|
||||
g.dropLoot(m.x + g.rand(-24, 24), m.y + g.rand(-24, 24), 'rock', powered ? 4 : 2)
|
||||
g.dropLoot(m.x + g.rand(-24, 24), m.y + g.rand(-24, 24), 'flint', powered ? 2 : 1)
|
||||
})
|
||||
// 蒸汽机每日消耗 1 份燃料
|
||||
g.entities.filter((e) => e.type === 'steamengine').forEach((s) => {
|
||||
s.fuel = Math.max(0, (s.fuel || 0) - 1)
|
||||
s.on = s.fuel > 0
|
||||
g.markDirty(s)
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
1178
src/games/starve/mods/labor.js
Normal file
1178
src/games/starve/mods/labor.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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]) => {
|
||||
|
||||
131
src/games/starve/mods/startchest.js
Normal file
131
src/games/starve/mods/startchest.js
Normal file
@@ -0,0 +1,131 @@
|
||||
// 文明火种:出生点旁放一只扎红绸木箱,首次开启一次性发齐开局物资(工具/种子/建材)
|
||||
// - 与 chest.js 的散落宝箱区分:出生点固定 1 只、系红绸带、只发固定清单不做随机
|
||||
// - 防御式发放:只给"当前物品表里确实存在"的物品,保证独立开启或任意 mod 组合都不报错
|
||||
const TAU = Math.PI * 2
|
||||
|
||||
// 固定发放(vanilla 核心物资,必定存在)
|
||||
const GIVE_ALWAYS = [
|
||||
['axe', 1], ['pickaxe', 1], ['torch', 2], ['grass', 10], ['twig', 10],
|
||||
['log', 8], ['rock', 8], ['rope', 2], ['berry', 6], ['healingsalve', 2],
|
||||
]
|
||||
// 条件发放(依赖其它 mod:农业/农场等;不存在则跳过)
|
||||
const GIVE_IF = [
|
||||
['carrotseeds', 3], ['berryseeds', 3], ['wheatseeds', 3],
|
||||
['pumpkinseeds', 3], ['reeds', 2], ['flint', 6],
|
||||
]
|
||||
|
||||
function openBox(p, e, g) {
|
||||
e.state = 'open'
|
||||
g.markDirty(e)
|
||||
g.swing(p, 'chop')
|
||||
const got = []
|
||||
const give = (code, n) => {
|
||||
g.addItem(p, code, n)
|
||||
got.push(`${g.items[code]?.name || code} ×${n}`)
|
||||
}
|
||||
GIVE_ALWAYS.forEach(([code, n]) => {
|
||||
if (g.items[code]) give(code, n)
|
||||
})
|
||||
GIVE_IF.forEach(([code, n]) => {
|
||||
if (g.items[code]) give(code, n)
|
||||
})
|
||||
g.addScore(15, p)
|
||||
g.shake(1.5)
|
||||
g.ptip(p, `打开木箱:${got.join('、')}`)
|
||||
}
|
||||
|
||||
export default {
|
||||
id: 'startchest',
|
||||
name: '文明火种',
|
||||
desc: '出生点旁的起始宝箱:工具、种子与建材一次到位',
|
||||
factor: 0.9,
|
||||
tag: 'easy',
|
||||
ents: {
|
||||
startchest: {
|
||||
name: '文明火种木箱',
|
||||
conf: { size: 30 },
|
||||
init(e) {
|
||||
if (!e.state) e.state = 'closed'
|
||||
},
|
||||
interact(p, e, g) {
|
||||
if (e.state === 'closed') {
|
||||
openBox(p, e, g)
|
||||
return
|
||||
}
|
||||
g.ptip(p, '箱子已经空了')
|
||||
},
|
||||
draw(ctx, e, env) {
|
||||
const open = e.state === 'open'
|
||||
// 底部投影
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.25)'
|
||||
ctx.beginPath()
|
||||
ctx.ellipse(0, 4, 18, 6, 0, 0, TAU)
|
||||
ctx.fill()
|
||||
// 箱体(红木)
|
||||
ctx.fillStyle = '#8a4b32'
|
||||
ctx.strokeStyle = '#1d1409'
|
||||
ctx.lineWidth = 2
|
||||
ctx.beginPath()
|
||||
ctx.roundRect(-17, -12, 34, 18, 3)
|
||||
ctx.fill()
|
||||
ctx.stroke()
|
||||
// 盖子:闭合盖在上方 / 打开向后仰并露出内部
|
||||
ctx.fillStyle = open ? '#5d3320' : '#a05a3c'
|
||||
ctx.beginPath()
|
||||
if (open) {
|
||||
ctx.roundRect(-17, -26, 34, 9, 3)
|
||||
ctx.fill()
|
||||
ctx.stroke()
|
||||
ctx.fillStyle = '#2a160e'
|
||||
ctx.fillRect(-14, -12, 28, 6)
|
||||
} else {
|
||||
ctx.roundRect(-18, -18, 36, 9, 3)
|
||||
ctx.fill()
|
||||
ctx.stroke()
|
||||
}
|
||||
// 金色包边 + 锁扣
|
||||
ctx.strokeStyle = '#d8b04a'
|
||||
ctx.lineWidth = 2
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(-11, open ? -12 : -18)
|
||||
ctx.lineTo(-11, 6)
|
||||
ctx.moveTo(11, open ? -12 : -18)
|
||||
ctx.lineTo(11, 6)
|
||||
ctx.stroke()
|
||||
// 红绸带(与普通宝箱区分)
|
||||
ctx.strokeStyle = '#c8323f'
|
||||
ctx.lineWidth = 3
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(0, open ? -26 : -18)
|
||||
ctx.lineTo(0, 6)
|
||||
ctx.stroke()
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(-18, -4)
|
||||
ctx.lineTo(18, -4)
|
||||
ctx.stroke()
|
||||
if (!open) {
|
||||
ctx.fillStyle = '#d8b04a'
|
||||
ctx.strokeStyle = '#1d1409'
|
||||
ctx.lineWidth = 1.4
|
||||
ctx.beginPath()
|
||||
ctx.roundRect(-3, -12, 6, 7, 1.5)
|
||||
ctx.fill()
|
||||
ctx.stroke()
|
||||
// 呼吸微光
|
||||
const tw = 0.5 + Math.sin((env?.time || 0) * 3 + e.id) * 0.5
|
||||
ctx.fillStyle = `rgba(255,230,150,${0.22 + tw * 0.3})`
|
||||
ctx.beginPath()
|
||||
ctx.arc(7, -15, 1.6 + tw, 0, TAU)
|
||||
ctx.fill()
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
hooks: {
|
||||
onWorldGen(g) {
|
||||
const spot = g.findLand(g.worldW / 2 + 70, g.worldH / 2 + 40, 0, 30)
|
||||
g.addEntity('startchest', spot.x, spot.y)
|
||||
g.tip('脚边放着一只扎红绸的木箱……')
|
||||
},
|
||||
},
|
||||
}
|
||||
10
src/games/starve/mods/wideworld.js
Normal file
10
src/games/starve/mods/wideworld.js
Normal file
@@ -0,0 +1,10 @@
|
||||
// 辽阔世界:把世界放大 1.5 倍,多出近一倍土地可供开垦
|
||||
// 本 mod 不含任何物品/实体/配方/钩子,唯一作用是通过 flags.worldScale 让核心重建更大的世界
|
||||
export default {
|
||||
id: 'wideworld',
|
||||
name: '辽阔世界',
|
||||
desc: '把世界放大 1.5 倍,多出近一倍土地可供开垦',
|
||||
factor: 0.9, // 更容易 → 得分打折
|
||||
tag: 'rule',
|
||||
flags: { worldScale: 1.5 },
|
||||
}
|
||||
@@ -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' },
|
||||
|
||||
1076
test-starve.mjs
1076
test-starve.mjs
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user