网页端新增 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:
1 parent
cb4c1e7800
commit
92b1a628b1
4 files changed
+437
-1
No files matched your search
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user