网页端新增 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
@@ -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 项测试全绿。
|
||||||
|
|
||||||
@@ -90,6 +90,13 @@ PATH="/c/Program Files/nodejs:$PATH" agent-browser --cdp 9333 eval "..." # 页
|
|||||||
必须自己起进程再 `--cdp` 连;`find text "登录"` 会点到标题而非按钮,
|
必须自己起进程再 `--cdp` 连;`find text "登录"` 会点到标题而非按钮,
|
||||||
用 `eval` 精确 `document.getElementById('dologin').click()`。
|
用 `eval` 精确 `document.getElementById('dologin').click()`。
|
||||||
|
|
||||||
|
Canvas / rAF 动画类改动的额外坑(2026-09-25 踩过):动画循环的**第一帧是同步
|
||||||
|
执行**的——必须先跑完布局再绑定循环,否则首帧 TypeError 会把
|
||||||
|
requestAnimationFrame 的注册一起吞掉,表现为「画布全白但点击命中正常」
|
||||||
|
(命中是纯数学,不经过画布)。且 headless 截图空白 ≠ 没画:先
|
||||||
|
`ctx.getImageData` 读像素确认「画了没」,再放 rAF 计数探针 / 记录动画对象
|
||||||
|
位移确认「循环活着没」,两连才能定位。
|
||||||
|
|
||||||
## 第 3 步:写记忆
|
## 第 3 步:写记忆
|
||||||
|
|
||||||
- 当天日志追加到 `.workbuddy/memory/YYYY-MM-DD.md`(append-only)。
|
- 当天日志追加到 `.workbuddy/memory/YYYY-MM-DD.md`(append-only)。
|
||||||
|
|||||||
@@ -38,7 +38,9 @@ uv run main.py stop # ⑤ 优雅停机(会把所有牧场
|
|||||||
```
|
```
|
||||||
|
|
||||||
浏览器打开 <http://127.0.0.1:8765/> 就是网页版客户端(可多开几个标签页,用不同牧场名,
|
浏览器打开 <http://127.0.0.1:8765/> 就是网页版客户端(可多开几个标签页,用不同牧场名,
|
||||||
能实时看到对方的操作)。
|
能实时看到对方的操作)。页面顶部是 **Canvas 二维农场地图**:农田方阵、牧场围栏、
|
||||||
|
建筑带画在一张俯视图上,作物随生长进度变大、成熟发光,雨/旱/暴风雨有对应天气效果;
|
||||||
|
点地块直接播种/浇水/收获,点牲畜喂食/收取(下方的卡片面板保留,作精确操作用)。
|
||||||
|
|
||||||
不想装任何第三方库、只想单机玩玩:
|
不想装任何第三方库、只想单机玩玩:
|
||||||
|
|
||||||
|
|||||||
@@ -102,6 +102,8 @@ DASHBOARD_HTML = r"""<!DOCTYPE html>
|
|||||||
.hidden { display: none; }
|
.hidden { display: none; }
|
||||||
.animals li { display: flex; align-items: center; gap: 8px; padding: 4px 0; border-bottom: 1px dashed var(--line); list-style: 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; }
|
ul { padding: 0; margin: 0; }
|
||||||
|
/* Canvas 农场地图:宽度跟随容器,高度按内部逻辑尺寸等比缩放 */
|
||||||
|
#farmmap { width: 100%; height: auto; display: block; border-radius: 10px; cursor: pointer; touch-action: manipulation; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -117,6 +119,11 @@ DASHBOARD_HTML = r"""<!DOCTYPE html>
|
|||||||
<main>
|
<main>
|
||||||
<div class="stats" id="stats"></div>
|
<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 class="cols">
|
||||||
<div>
|
<div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
@@ -434,6 +441,7 @@ function render() {
|
|||||||
' title="' + b.effect + '">' + label + '</button>';
|
' title="' + b.effect + '">' + label + '</button>';
|
||||||
}).join("") : '<span style="color:#6b7a66">目录加载中…</span>';
|
}).join("") : '<span style="color:#6b7a66">目录加载中…</span>';
|
||||||
|
|
||||||
|
updateMap();
|
||||||
renderLog(null);
|
renderLog(null);
|
||||||
renderBoard();
|
renderBoard();
|
||||||
}
|
}
|
||||||
@@ -471,6 +479,381 @@ async function renderBoard() {
|
|||||||
r.online + '</td></tr>').join("");
|
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) => {
|
document.addEventListener("click", (ev) => {
|
||||||
const t = ev.target;
|
const t = ev.target;
|
||||||
|
|||||||
Reference in new issue
Block a user