网页端新增 Canvas 二维农场地图

为什么:地块和牲畜原本是响应式卡片列表,信息全但没有"农场"的形状感。
改成一张俯视像素风地图作主视觉:农田按 4 列排成方阵(地块多自动改 6 列),
土壤颜色随水分深浅变化、作物随生长进度放大、成熟发金光、缺水画干裂;
牧场围栏里牲畜随机漫步,待收产出挂金色气泡、没喂食标 💢;
已拥有的建筑在底部建筑带列队;五种天气各有天色,雨/暴雨有雨滴动画。
悬停出提示框,点地块即播种/浇水/收获、点牲畜即喂食/收取——全部复用
既有 openCropPick/act() 流程,卡片面板原样保留作精确操作入口。

实现要点:画布用逻辑坐标绘制、按容器宽 × devicePixelRatio 缩放,
高分屏不糊、窄屏等比缩;命中检测在逻辑坐标上自己算。
服务端零改动——state 里的 plots/animals/buildings 现成够用。

修复一个真机才抓得到的初始化 bug:动画循环首帧同步执行,原顺序
"先绑定后布局"让首帧在未初始化的字段上 TypeError,rAF 注册被一起吞掉,
表现为画布全白但点击命中正常(命中不经过画布)。改为先布局再绑定,
并给 mapStep/mapDraw 加空值护栏。

真浏览器端到端验证:登录、canvas 坐标点击浇水(AP/水分/toast 全对)、
悬停提示、动画循环存活(云位移 5.24px/600ms)、截图目检渲染效果,
errors/console 干净。534 项测试全绿,ruff/pyright 无告警。
This commit is contained in:
snowy-yang committed 2026-09-25 22:24:08 +08:00
1 parent cb4c1e7800
commit 92b1a628b1
4 files changed
+437 -1

No files matched your search

+383
View File
@@ -102,6 +102,8 @@ DASHBOARD_HTML = r"""<!DOCTYPE html>
.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; }
</style>
</head>
<body>
@@ -117,6 +119,11 @@ DASHBOARD_HTML = r"""<!DOCTYPE html>
<main>
<div class="stats" id="stats"></div>
<div class="card" style="margin-top:14px">
<h2>农场地图 <em id="maphint">点空地播种 · 点熟地收获 · 缺水的地点一下浇水 · 点牲畜喂食/收取</em></h2>
<canvas id="farmmap">你的浏览器不支持 canvas</canvas>
</div>
<div class="cols">
<div>
<div class="card">
@@ -434,6 +441,7 @@ function render() {
' title="' + b.effect + '">' + label + '</button>';
}).join("") : '<span style="color:#6b7a66">目录加载中…</span>';
updateMap();
renderLog(null);
renderBoard();
}
@@ -471,6 +479,381 @@ async function renderBoard() {
r.online + '</td></tr>').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;