diff --git a/.workbuddy/memory/2026-09-25.md b/.workbuddy/memory/2026-09-25.md new file mode 100644 index 0000000..cec5120 --- /dev/null +++ b/.workbuddy/memory/2026-09-25.md @@ -0,0 +1,44 @@ + +## 2026-09-25 四座自动化设施 + +- 用户要「添加自动化设施」,AskUserQuestion 给了 4 个候选,用户全选: + 喷灌系统 / 自动收割机 / 自动售货机 / 饲料磨坊。 +- 设计上沿用既有建筑哲学:**只替代行动点动作,绝不自动花钱**。 + `_run_buildings` 扩成六段流水线(顺序即依赖): + 水井 → 喷灌 → 收割机 → 磨坊 → 喂食器 → 售货机。 + 磨坊刻意排在喂食器前——早上磨的饲料当天就能喂上; + 收割机遇满仓停摆、作物留地(同喂食器缺料停摆)。 +- 数值:喷灌 2800/限1/全量浇水;收割机 900/限3/每天各收 2 块熟地、 + **刻意不给经验**(机器收的不算劳动,给手动玩法留价值); + 售货机 1200/限1/市价>基准价才卖、每天上限 5 份、越贵越先卖 + (_earn 复用 sell 的入账逻辑);磨坊 500/限1/1 份最便宜的作物磨 3 份饲料 + (FEED_UNIT_PRICE=6,小麦 14 磨 18 金的料,微赚但吃掉作物)。 +- 架构红利:config.BUILDINGS 驱动一切(build 动作、catalog、网页建筑区、 + 可用动作推荐),新增建筑 engine 之外零改动。 +- 测试 +6,途中确认两个既有行为:引擎自带 START_FEED=5(feed 断言要先清零); + advance_day 牲畜阶段末尾会把 fed 复位(「喂到了」的证据是 pending 累计, + 鸡 cycle=1 当天喂到就 pending=1)。 +- 门禁 534 项 OK(skip 39)、ruff/pyright 全绿;提交 cb4c1e7 已推送。 + 服务器已重启到新代码,catalog 8 座建筑齐全。 + +## 2026-09-25 Canvas 二维农场地图 + +- 用户问「能不能做成二维的」,AskUserQuestion 三选一:CSS 农田视图 / + Canvas 像素农场 / 网页+TUI 都改,用户选 **Canvas 像素农场**。 +- 实现:全在 webui.py(服务端零改动,state 里现成的 plots/animals/buildings + 够用)。页首插一张全宽 canvas 卡片:逻辑坐标绘制(456×152 起,随地块数变)、 + 实际像素 = 容器宽 × dpr,命中检测自己算(逻辑坐标 = clientX × MAP.w/rect.width)。 + 农田 4 列方阵(>12 块改 6 列)土壤颜色随水分插值、作物 emoji 随生长放大、 + 成熟金光、干裂画裂纹;牧场围栏里牲畜随机漫步回弹、待收产物金泡、没喂 💢; + 底部建筑带(owned>0 才画);天气五種各有天色+雨滴/云/烈日。 + 交互复用 openCropPick/act():空地点击弹选种、熟地收获、缺水浇水、 + 牲畜收取优先于喂食。下方卡片面板全保留(地图负责沉浸,卡片负责精确)。 +- **踩坑:rAF 循环首帧同步执行**。updateMap 原顺序 bind→layout,首帧 + `f.ts` 在 MAP.field=null 上 TypeError,把 requestAnimationFrame 注册一起吞掉, + 表现是「画布全白但点击命中正常」(命中是纯数学不经过画布)。 + 修复:layout→sync→bind→fit 顺序 + mapStep/mapDraw 空值护栏。 + 排查手法:截图空白先别信,`getImageData` 读像素 + 探针 rAF 计数 + 云位移 + 三连定位「画了没」和「循环活着没」。 +- 真机验收:登录、canvas 点击浇水(AP 4→3、水分 2→3、toast)、 + 悬停 tooltip、截图目检全过;errors/console 干净。534 项测试全绿。 + diff --git a/.workbuddy/skills/simplefarm-verify-and-ship/SKILL.md b/.workbuddy/skills/simplefarm-verify-and-ship/SKILL.md index 18accea..778b4c1 100644 --- a/.workbuddy/skills/simplefarm-verify-and-ship/SKILL.md +++ b/.workbuddy/skills/simplefarm-verify-and-ship/SKILL.md @@ -90,6 +90,13 @@ PATH="/c/Program Files/nodejs:$PATH" agent-browser --cdp 9333 eval "..." # 页 必须自己起进程再 `--cdp` 连;`find text "登录"` 会点到标题而非按钮, 用 `eval` 精确 `document.getElementById('dologin').click()`。 +Canvas / rAF 动画类改动的额外坑(2026-09-25 踩过):动画循环的**第一帧是同步 +执行**的——必须先跑完布局再绑定循环,否则首帧 TypeError 会把 +requestAnimationFrame 的注册一起吞掉,表现为「画布全白但点击命中正常」 +(命中是纯数学,不经过画布)。且 headless 截图空白 ≠ 没画:先 +`ctx.getImageData` 读像素确认「画了没」,再放 rAF 计数探针 / 记录动画对象 +位移确认「循环活着没」,两连才能定位。 + ## 第 3 步:写记忆 - 当天日志追加到 `.workbuddy/memory/YYYY-MM-DD.md`(append-only)。 diff --git a/README.md b/README.md index a4193d8..f520936 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,9 @@ uv run main.py stop # ⑤ 优雅停机(会把所有牧场 ``` 浏览器打开 就是网页版客户端(可多开几个标签页,用不同牧场名, -能实时看到对方的操作)。 +能实时看到对方的操作)。页面顶部是 **Canvas 二维农场地图**:农田方阵、牧场围栏、 +建筑带画在一张俯视图上,作物随生长进度变大、成熟发光,雨/旱/暴风雨有对应天气效果; +点地块直接播种/浇水/收获,点牲畜喂食/收取(下方的卡片面板保留,作精确操作用)。 不想装任何第三方库、只想单机玩玩: diff --git a/farm/server/webui.py b/farm/server/webui.py index 37fc79d..1123586 100644 --- a/farm/server/webui.py +++ b/farm/server/webui.py @@ -102,6 +102,8 @@ DASHBOARD_HTML = r""" .hidden { display: none; } .animals li { display: flex; align-items: center; gap: 8px; padding: 4px 0; border-bottom: 1px dashed var(--line); list-style: none; } ul { padding: 0; margin: 0; } + /* Canvas 农场地图:宽度跟随容器,高度按内部逻辑尺寸等比缩放 */ + #farmmap { width: 100%; height: auto; display: block; border-radius: 10px; cursor: pointer; touch-action: manipulation; } @@ -117,6 +119,11 @@ DASHBOARD_HTML = r"""
+
+

农场地图 点空地播种 · 点熟地收获 · 缺水的地点一下浇水 · 点牲畜喂食/收取

+ 你的浏览器不支持 canvas +
+
@@ -434,6 +441,7 @@ function render() { ' title="' + b.effect + '">' + label + ''; }).join("") : '目录加载中…'; + updateMap(); renderLog(null); renderBoard(); } @@ -471,6 +479,381 @@ async function renderBoard() { r.online + '').join(""); } +/* ---- 农场地图:Canvas 俯视像素风 ---- + 画布用"逻辑坐标"绘制(MAP.w/h),实际像素按容器宽度 × devicePixelRatio + 缩放,所以手机上小屏也看得清、高分屏不糊。所有交互(播种/浇水/收获/ + 喂食/收取)最终都复用下面的 openCropPick / act(),地图只是同一批动作 + 的另一张脸。 */ +const MAP = { + cv: null, ctx: null, scale: 1, w: 0, h: 0, + field: null, pen: null, builds: [], stripH: 0, + walkers: [], animalIds: "", drops: null, clouds: null, hover: null +}; +const cropEmoji = { wheat: "🌾", carrot: "🥕", tomato: "🍅", corn: "🌽", pumpkin: "🎃", strawberry: "🍓" }; +const animalEmoji = { chicken: "🐔", sheep: "🐑", cow: "🐄" }; +const buildingInfo = { + granary: ["🛖", "谷仓"], well: ["⛲", "水井"], feeder: ["🥣", "喂食器"], + greenhouse: ["🏠", "温室"], sprinkler: ["🚿", "喷灌"], harvester: ["🚜", "收割机"], + vendor: ["🏪", "售货机"], mill: ["⚙️", "磨坊"] +}; + +function mapWeather() { + const w = (state && state.weather) || ""; + if (["sunny", "cloudy", "rain", "drought", "storm"].indexOf(w) >= 0) return w; + return "sunny"; +} + +/* 布局:农田方阵(4 列,地多改 6 列)+ 右侧牧场围栏 + 底部建筑带 */ +function mapLayout() { + const TS = 56, GAP = 8, M = 16; + const plots = (state && state.plots) || []; + const cols = plots.length > 12 ? 6 : 4; + const rows = Math.max(1, Math.ceil(plots.length / cols)); + const fieldW = cols * TS + (cols - 1) * GAP; + const fieldH = rows * TS + (rows - 1) * GAP; + const penW = 150, penH = Math.max(fieldH, 110); + MAP.builds = Object.entries((state && state.buildings) || {}).filter((kv) => kv[1] > 0); + MAP.stripH = MAP.builds.length ? 64 : 0; + MAP.w = M + fieldW + 26 + penW + M; + MAP.h = M + fieldH + (MAP.stripH ? MAP.stripH + 12 : 0) + M; + MAP.field = { x: M, y: M, cols: cols, rows: rows, ts: TS, gap: GAP }; + MAP.pen = { x: M + fieldW + 26, y: M, w: penW, h: penH }; +} + +/* 牲畜漫步者:id 集合没变就复用旧位置(换状态不瞬移),变了才重建 */ +function mapSyncAnimals() { + const list = (state && state.animals) || []; + const ids = list.map((a) => a.id).join(","); + if (ids !== MAP.animalIds || !MAP.walkers.length !== !list.length) { + MAP.animalIds = ids; + const pen = MAP.pen; + MAP.walkers = list.map((a) => ({ + a: a, + x: pen.x + 16 + Math.random() * Math.max(10, pen.w - 32), + y: pen.y + 16 + Math.random() * Math.max(10, pen.h - 32), + vx: (Math.random() - 0.5) * 14, vy: (Math.random() - 0.5) * 10, + ph: Math.random() * 6.28 + })); + } else { + MAP.walkers.forEach((wd) => { + const fresh = list.find((x) => x.id === wd.a.id); + if (fresh) wd.a = fresh; + }); + } +} + +function mapFit() { + if (!MAP.cv || !MAP.w) return; + const dpr = window.devicePixelRatio || 1; + const cssW = MAP.cv.clientWidth || 300; + MAP.cv.width = Math.max(1, Math.round(cssW * dpr)); + MAP.cv.height = Math.max(1, Math.round(cssW * dpr * MAP.h / MAP.w)); + MAP.scale = MAP.cv.width / MAP.w; +} + +function mapLerp(a, b, t) { return a + (b - a) * Math.max(0, Math.min(1, t)); } + +function mapStep(dt, now) { + /* 雨滴:数组按需重建, storm 比 rain 密 */ + const w = mapWeather(); + const wantDrops = w === "storm" ? 150 : (w === "rain" ? 80 : 0); + if (!MAP.drops || MAP.drops.length !== wantDrops) { + MAP.drops = []; + for (let i = 0; i < wantDrops; i++) { + MAP.drops.push({ x: Math.random() * MAP.w, y: Math.random() * MAP.h, + v: 340 + Math.random() * 180, len: 7 + Math.random() * 7 }); + } + } + (MAP.drops || []).forEach((d) => { + d.y += d.v * dt; d.x -= d.v * dt * 0.18; + if (d.y > MAP.h) { d.y = -d.len; d.x = Math.random() * (MAP.w + 60); } + }); + /* 云 */ + const wantClouds = w === "cloudy" || w === "rain" || w === "storm" ? 4 : 1; + if (!MAP.clouds || MAP.clouds.length !== wantClouds) { + MAP.clouds = []; + for (let i = 0; i < wantClouds; i++) { + MAP.clouds.push({ x: Math.random() * MAP.w, y: 8 + Math.random() * 26, + s: 0.7 + Math.random() * 0.8, v: 5 + Math.random() * 7 }); + } + } + (MAP.clouds || []).forEach((c) => { c.x += c.v * dt; if (c.x > MAP.w + 70) c.x = -70; }); + /* 牲畜漫步 + 边界回弹(pen 未布局时直接跳过) */ + const pen = MAP.pen; + if (!pen) return; + (MAP.walkers || []).forEach((wd) => { + wd.x += wd.vx * dt; wd.y += wd.vy * dt; wd.ph += dt * 2; + if (wd.x < pen.x + 10) { wd.x = pen.x + 10; wd.vx = Math.abs(wd.vx); } + if (wd.x > pen.x + pen.w - 10) { wd.x = pen.x + pen.w - 10; wd.vx = -Math.abs(wd.vx); } + if (wd.y < pen.y + 12) { wd.y = pen.y + 12; wd.vy = Math.abs(wd.vy); } + if (wd.y > pen.y + pen.h - 8) { wd.y = pen.y + pen.h - 8; wd.vy = -Math.abs(wd.vy); } + }); +} + +function mapRoundRect(x, y, w, h, r) { + const c = MAP.ctx; + c.beginPath(); + c.moveTo(x + r, y); + c.arcTo(x + w, y, x + w, y + h, r); + c.arcTo(x + w, y + h, x, y + h, r); + c.arcTo(x, y + h, x, y, r); + c.arcTo(x, y, x + w, y, r); + c.closePath(); +} + +function mapDrawTile(p, x, y, ts) { + const c = MAP.ctx; + const wet = p.moisture / (p.water_capacity || 3); + const r = Math.round(mapLerp(206, 118, wet)); + const g = Math.round(mapLerp(168, 80, wet)); + const b = Math.round(mapLerp(112, 48, wet)); + c.fillStyle = "rgb(" + r + "," + g + "," + b + ")"; + mapRoundRect(x, y, ts, ts, 7); c.fill(); + /* 干裂的田:画几道深色裂纹,比只描红边更像"渴" */ + if (p.moisture === 0) { + c.strokeStyle = "rgba(70,40,20,.55)"; c.lineWidth = 1.4; + c.beginPath(); + c.moveTo(x + 12, y + 16); c.lineTo(x + 24, y + 26); c.lineTo(x + 20, y + 38); + c.moveTo(x + 34, y + 12); c.lineTo(x + 40, y + 24); c.lineTo(x + 50, y + 30); + c.stroke(); + } + /* 作物:随生长进度放大,成熟加金光 */ + if (!p.is_empty) { + const ratio = p.grow_days ? p.growth / p.grow_days : 1; + const size = Math.round(16 + ratio * 20); + c.save(); + if (p.is_ripe) { c.shadowColor = "rgba(255,200,40,.9)"; c.shadowBlur = 12; } + c.font = size + 'px "Segoe UI Emoji","Noto Color Emoji",sans-serif'; + c.textAlign = "center"; c.textBaseline = "middle"; + c.fillText(cropEmoji[p.crop] || "🌱", x + ts / 2, y + ts / 2 - 3); + c.restore(); + } else { + c.fillStyle = "rgba(60,40,20,.5)"; + c.font = '11px "Segoe UI",sans-serif'; + c.textAlign = "center"; c.textBaseline = "middle"; + c.fillText("空地", x + ts / 2, y + ts / 2); + } + /* 底部两条迷你进度条:生长(绿)/ 水分(蓝) */ + const bw = ts - 14; + c.fillStyle = "rgba(0,0,0,.22)"; + c.fillRect(x + 7, y + ts - 11, bw, 3); c.fillRect(x + 7, y + ts - 6, bw, 3); + const ratio = p.grow_days ? Math.min(1, p.growth / p.grow_days) : 1; + c.fillStyle = p.is_ripe ? "#ffd54d" : "#7ddf87"; + c.fillRect(x + 7, y + ts - 11, bw * ratio, 3); + c.fillStyle = "#6db9f0"; + c.fillRect(x + 7, y + ts - 6, bw * Math.min(1, wet), 3); + /* 悬停高亮 + 地号 */ + if (MAP.hover && MAP.hover.type === "plot" && MAP.hover.p === p) { + c.strokeStyle = "#fff"; c.lineWidth = 2.4; + mapRoundRect(x - 1.5, y - 1.5, ts + 3, ts + 3, 8); c.stroke(); + } + c.fillStyle = "rgba(255,255,255,.75)"; + c.font = '10px "Segoe UI",sans-serif'; + c.textAlign = "left"; c.textBaseline = "top"; + c.fillText(String(p.position), x + 4, y + 3); +} + +function mapDraw(now) { + const cv = MAP.cv, c = MAP.ctx; + if (!cv || !state || !MAP.field) return; + c.setTransform(MAP.scale, 0, 0, MAP.scale, 0, 0); + const w = mapWeather(); + /* 天空底色:晴朗偏亮、暴雨压暗 */ + const skies = { + sunny: ["#aee2ff", "#cfe9b9"], cloudy: ["#c3cbd2", "#cfe0b4"], + rain: ["#93a7b5", "#b9cf9f"], drought: ["#ffd9a0", "#e8cf96"], + storm: ["#5f6f80", "#9db48a"] + }[w]; + const grad = c.createLinearGradient(0, 0, 0, MAP.h); + grad.addColorStop(0, skies[0]); grad.addColorStop(1, skies[1]); + c.fillStyle = grad; + c.fillRect(0, 0, MAP.w, MAP.h); + /* 太阳 / 云 / 雨 */ + if (w === "sunny" || w === "drought") { + c.save(); + c.shadowColor = "rgba(255,210,60,.95)"; c.shadowBlur = w === "drought" ? 26 : 14; + c.fillStyle = w === "drought" ? "#ff9d3c" : "#ffd94d"; + c.beginPath(); c.arc(MAP.w - 36, 30, w === "drought" ? 17 : 13, 0, 6.29); c.fill(); + c.restore(); + } + (MAP.clouds || []).forEach((cl) => { + c.fillStyle = w === "storm" ? "rgba(70,80,92,.75)" : "rgba(255,255,255,.8)"; + c.beginPath(); + c.ellipse(cl.x, cl.y, 30 * cl.s, 11 * cl.s, 0, 0, 6.29); + c.ellipse(cl.x + 22 * cl.s, cl.y + 3, 20 * cl.s, 9 * cl.s, 0, 0, 6.29); + c.ellipse(cl.x - 20 * cl.s, cl.y + 4, 17 * cl.s, 8 * cl.s, 0, 0, 6.29); + c.fill(); + }); + (MAP.drops || []).forEach((d) => { + c.strokeStyle = "rgba(190,215,255,.6)"; c.lineWidth = 1.2; + c.beginPath(); c.moveTo(d.x, d.y); c.lineTo(d.x - 2, d.y + d.len); c.stroke(); + }); + /* 农田 */ + const f = MAP.field, ts = f.ts; + (state.plots || []).forEach((p, i) => { + const col = i % f.cols, row = Math.floor(i / f.cols); + mapDrawTile(p, f.x + col * (ts + f.gap), f.y + row * (ts + f.gap), ts); + }); + /* 牧场围栏 */ + const pen = MAP.pen; + c.fillStyle = "rgba(150,200,110,.5)"; + mapRoundRect(pen.x, pen.y, pen.w, pen.h, 10); c.fill(); + c.strokeStyle = "#8a6238"; c.lineWidth = 3.4; + mapRoundRect(pen.x, pen.y, pen.w, pen.h, 10); c.stroke(); + c.fillStyle = "#8a6238"; + const posts = 5; + for (let i = 0; i <= posts; i++) { + c.fillRect(pen.x + (pen.w / posts) * i - 2, pen.y - 5, 4, 10); + c.fillRect(pen.x + (pen.w / posts) * i - 2, pen.y + pen.h - 5, 4, 10); + } + c.fillStyle = "rgba(60,45,25,.75)"; + c.font = '11px "Segoe UI","Microsoft YaHei",sans-serif'; + c.textAlign = "center"; c.textBaseline = "top"; + c.fillText("牧场", pen.x + pen.w / 2, pen.y + 3); + (MAP.walkers || []).forEach((wd) => { + const bob = Math.sin(wd.ph) * 1.6; + c.save(); + c.fillStyle = "rgba(0,0,0,.18)"; + c.beginPath(); c.ellipse(wd.x, wd.y + 9, 9, 3, 0, 0, 6.29); c.fill(); + c.font = '20px "Segoe UI Emoji","Noto Color Emoji",sans-serif'; + c.textAlign = "center"; c.textBaseline = "middle"; + c.fillText(animalEmoji[wd.a.kind] || "🐔", wd.x, wd.y + bob); + /* 待收取的产出:金色小气泡 */ + if (wd.a.pending > 0) { + c.fillStyle = "#ffd54d"; c.strokeStyle = "#8a6238"; c.lineWidth = 1; + c.beginPath(); c.arc(wd.x + 10, wd.y - 10 + bob, 7, 0, 6.29); c.fill(); c.stroke(); + c.fillStyle = "#5a3c00"; c.font = '9px "Segoe UI",sans-serif'; + c.fillText(String(wd.a.pending), wd.x + 10, wd.y - 9.5 + bob); + } else if (!wd.a.fed) { + c.font = '12px "Segoe UI Emoji",sans-serif'; + c.fillText("💢", wd.x + 10, wd.y - 10 + bob); + } + if (MAP.hover && MAP.hover.type === "animal" && MAP.hover.a === wd.a) { + c.strokeStyle = "#fff"; c.lineWidth = 2; + c.beginPath(); c.arc(wd.x, wd.y + bob, 14, 0, 6.29); c.stroke(); + } + c.restore(); + }); + /* 建筑带 */ + if (MAP.stripH) { + const y0 = MAP.field.y + MAP.field.rows * ts + (MAP.field.rows - 1) * f.gap + 12; + const cw = MAP.w / Math.max(1, MAP.builds.length); + MAP.builds.forEach((kv, i) => { + const info = buildingInfo[kv[0]] || ["🏗️", kv[0]]; + const cx = cw * i + cw / 2; + c.fillStyle = "rgba(255,255,255,.5)"; + mapRoundRect(cx - 28, y0, 56, MAP.stripH - 6, 8); c.fill(); + c.font = '22px "Segoe UI Emoji","Noto Color Emoji",sans-serif'; + c.textAlign = "center"; c.textBaseline = "top"; + c.fillText(info[0], cx, y0 + 4); + c.fillStyle = "#3a4a33"; c.font = '10px "Segoe UI","Microsoft YaHei",sans-serif'; + c.fillText(kv[1] > 1 ? info[1] + " ×" + kv[1] : info[1], cx, y0 + 32); + }); + } + /* 悬停提示框 */ + const h = MAP.hover; + if (h) { + let lines = null; + if (h.type === "plot") { + const p = h.p; + lines = p.is_empty ? [p.position + " 号地 · 空地", "点一下播种"] + : [p.position + " 号地 · " + (cropEmoji[p.crop] || "") + (p.crop_name || p.crop), + p.is_ripe ? "已成熟!点一下收获" : p.stage + " · 还需 " + p.days_left + " 天", + "水分 " + p.moisture + "/" + p.water_capacity + (p.moisture === 0 ? " · 点一下浇水" : "")]; + } else { + const a = h.a; + lines = ["#" + a.id + " " + (a.name || animalEmoji[a.kind] || a.kind), + a.pending > 0 ? (a.product || "产出") + " ×" + a.pending + " · 点一下收取" + : a.fed ? "今天已喂食" : "还没喂 · 点一下喂食"]; + } + c.font = '12px "Segoe UI","Microsoft YaHei",sans-serif'; + const tw = Math.max.apply(null, lines.map((l) => c.measureText(l).width)) + 18; + const th = lines.length * 17 + 10; + let tx = h.x + 14, ty = h.y + 12; + if (tx + tw > MAP.w) tx = h.x - tw - 10; + if (ty + th > MAP.h) ty = h.y - th - 10; + c.fillStyle = "rgba(28,38,26,.92)"; + mapRoundRect(tx, ty, tw, th, 7); c.fill(); + c.fillStyle = "#fff"; c.textAlign = "left"; c.textBaseline = "top"; + lines.forEach((l, i) => c.fillText(l, tx + 9, ty + 6 + i * 17)); + } +} + +function mapHit(mx, my) { + const f = MAP.field, ts = f.ts; + const plots = (state && state.plots) || []; + for (let i = 0; i < plots.length; i++) { + const col = i % f.cols, row = Math.floor(i / f.cols); + const x = f.x + col * (ts + f.gap), y = f.y + row * (ts + f.gap); + if (mx >= x && mx <= x + ts && my >= y && my <= y + ts) return { type: "plot", p: plots[i] }; + } + const pen = MAP.pen; + if (mx >= pen.x && mx <= pen.x + pen.w && my >= pen.y && my <= pen.y + pen.h) { + let best = null, bd = 1e9; + (MAP.walkers || []).forEach((wd) => { + const d = (wd.x - mx) * (wd.x - mx) + (wd.y - my) * (wd.y - my); + if (d < bd) { bd = d; best = wd; } + }); + if (best && bd < 22 * 22) return { type: "animal", a: best.a }; + return null; + } + return null; +} + +function mapPoint(ev) { + const rect = MAP.cv.getBoundingClientRect(); + return { + x: (ev.clientX - rect.left) * MAP.w / rect.width, + y: (ev.clientY - rect.top) * MAP.h / rect.height + }; +} + +function mapBind() { + MAP.cv = $("farmmap"); + MAP.ctx = MAP.cv.getContext("2d"); + MAP.cv.addEventListener("pointermove", (ev) => { + const pt = mapPoint(ev); + const hit = mapHit(pt.x, pt.y); + MAP.hover = hit ? { type: hit.type, p: hit.p, a: hit.a, x: pt.x, y: pt.y } : null; + }); + MAP.cv.addEventListener("pointerleave", () => { MAP.hover = null; }); + MAP.cv.addEventListener("click", (ev) => { + const pt = mapPoint(ev); + const hit = mapHit(pt.x, pt.y); + if (!hit) return; + if (hit.type === "plot") { + const p = hit.p; + if (p.is_ripe) act("harvest", { plot: p.position }); + else if (p.is_empty) openCropPick(p.position); + else if (p.moisture < p.water_capacity) act("water", { plot: p.position }); + else toast((p.crop_name || p.crop) + " · " + p.stage + ",水分充足"); + } else { + const a = hit.a; + if (a.pending > 0) act("collect", { animal_id: a.id }); + else if (!a.fed) act("feed", { animal_id: a.id }); + else toast("#" + a.id + " 今天已经喂过啦"); + } + }); + window.addEventListener("resize", mapFit); + let last = 0; + (function loop(t) { + const dt = Math.min(0.05, (t - last) / 1000 || 0.016); + last = t; + mapStep(dt, t); + mapDraw(t); + requestAnimationFrame(loop); + })(0); +} + +function updateMap() { + /* 顺序不能变:先布局(MAP.field/pen 才有值),再绑定(第一帧是同步跑的, + 布局没好就在 f.ts 上 TypeError,rAF 注册被吞、动画永远起不来) */ + mapLayout(); + mapSyncAnimals(); + if (!MAP.cv) mapBind(); + mapFit(); +} + /* ---- 事件委托:地块按钮 / 牲畜按钮 / 快捷动作 ---- */ document.addEventListener("click", (ev) => { const t = ev.target;