Files
SimpleFarm/farm/server/webui.py
T
snowy-yang 92b1a628b1 网页端新增 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 无告警。
2026-09-25 22:24:08 +08:00

1003 lines
42 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""内嵌的网页客户端。
为什么要内嵌而不是写一堆静态文件
--------------------------------
服务端只需要一个 ``/`` 路由就能给出完整的可视化客户端,部署时不用管理
静态目录、不用配 CORS,`pip install` 完就能玩。页面本身也是 SSE
协议的一份"活文档":想接自己的客户端时,照着这里的 JS 抄就行。
注意:这个字符串里既有 CSS 又有 JS,花括号很多,所以**不要**用 ``f-string``
渲染它(为此把需要拼接的部分放在 :func:`dashboard_text` 里)。
"""
from __future__ import annotations
DASHBOARD_HTML = r"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>SimpleFarm 服务器</title>
<style>
:root {
--bg: #f4f7f2; --card: #ffffff; --line: #dde5d8; --ink: #23301f;
--dim: #6b7a66; --green: #3f8f4f; --red: #c0392b; --gold: #b8860b;
--blue: #2f6fb0; --cyan: #2b8a8a;
}
* { box-sizing: border-box; }
body {
margin: 0; background: var(--bg); color: var(--ink);
font: 14px/1.6 "Segoe UI", "Microsoft YaHei", system-ui, sans-serif;
}
header {
display: flex; flex-wrap: wrap; gap: 12px; align-items: center;
padding: 14px 20px; background: var(--card); border-bottom: 1px solid var(--line);
position: sticky; top: 0; z-index: 5;
}
header h1 { font-size: 18px; margin: 0; }
header h1 span { font-size: 12px; color: var(--dim); font-weight: normal; margin-left: 6px; }
.spacer { flex: 1; }
input, select, button {
font: inherit; padding: 6px 10px; border: 1px solid var(--line);
border-radius: 8px; background: #fff; color: var(--ink);
}
button { cursor: pointer; transition: .15s; }
button:hover { border-color: var(--green); color: var(--green); }
button.primary { background: var(--green); border-color: var(--green); color: #fff; }
button.primary:hover { background: #357a43; color: #fff; }
button:disabled { opacity: .45; cursor: not-allowed; }
.dot { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--dim); }
.dot i { width: 9px; height: 9px; border-radius: 50%; background: #c9d2c4; display: block; }
.dot.on i { background: var(--green); box-shadow: 0 0 0 3px rgba(63,143,79,.18); }
.dot.bad i { background: var(--red); }
main { padding: 16px 20px 40px; max-width: 1180px; margin: 0 auto; }
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(118px, 1fr)); gap: 10px; }
.stat { background: var(--card); border: 1px solid var(--line); border-radius: 12px; padding: 10px 12px; }
.stat b { display: block; font-size: 18px; }
.stat small { color: var(--dim); }
.cols { display: grid; grid-template-columns: 1.35fr 1fr; gap: 14px; margin-top: 14px; }
@media (max-width: 900px) { .cols { grid-template-columns: 1fr; } }
.card { background: var(--card); border: 1px solid var(--line); border-radius: 12px; padding: 12px 14px; margin-bottom: 14px; }
.card h2 { font-size: 14px; margin: 0 0 10px; display: flex; align-items: center; gap: 8px; }
.card h2 em { font-style: normal; color: var(--dim); font-size: 12px; font-weight: normal; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(152px, 1fr)); gap: 10px; }
.plot { border: 1px solid var(--line); border-radius: 10px; padding: 9px 10px; background: #fbfdfa; }
.plot.ripe { border-color: var(--green); background: #f1faf2; }
.plot.dry { border-color: var(--red); background: #fdf3f2; }
.plot .top { display: flex; justify-content: space-between; font-weight: 600; }
.plot .meta { color: var(--dim); font-size: 12px; }
.bar { height: 6px; border-radius: 4px; background: #e7ede4; overflow: hidden; margin: 5px 0; }
.bar i { display: block; height: 100%; background: var(--green); }
.water i { background: var(--blue); }
.plot .btns { display: flex; gap: 6px; margin-top: 7px; }
.plot .btns button { flex: 1; padding: 3px 0; font-size: 12px; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th, td { text-align: left; padding: 4px 6px; border-bottom: 1px solid var(--line); }
th { color: var(--dim); font-weight: normal; }
td.num { text-align: right; font-variant-numeric: tabular-nums; }
.up { color: var(--red); } .down { color: var(--green); } .flat { color: var(--dim); }
.log { max-height: 190px; overflow: auto; font-size: 13px; }
.log div { padding: 1px 0; }
.log .d { color: var(--dim); margin-right: 6px; font-variant-numeric: tabular-nums; }
.k-good { color: var(--green); } .k-bad { color: var(--red); }
.k-warn { color: var(--gold); } .k-action { color: var(--cyan); }
.notice { font-size: 12px; color: var(--dim); min-height: 18px; }
/* 播种选作物 / 二次确认的页面内弹层(不依赖 window.prompt,嵌入预览里也能用) */
.mask { position: fixed; inset: 0; background: rgba(30,40,30,.35);
display: none; align-items: center; justify-content: center; z-index: 30; }
.mask.show { display: flex; }
.pick { background: var(--card); border: 1px solid var(--line); border-radius: 12px;
padding: 14px 16px; min-width: 230px; box-shadow: 0 10px 30px rgba(0,0,0,.18); }
.pick h3 { margin: 0 0 10px; font-size: 14px; }
.pick .row { display: flex; flex-wrap: wrap; gap: 8px; }
.pick button.danger { border-color: var(--red); color: var(--red); }
.toast {
position: fixed; left: 50%; bottom: 26px; transform: translateX(-50%);
background: #23301f; color: #fff; padding: 9px 18px; border-radius: 20px;
font-size: 13px; opacity: 0; transition: .25s; pointer-events: none; z-index: 20;
}
.toast.show { opacity: .93; }
.row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
.badge { background: var(--card); border: 1px solid var(--line); border-radius: 16px; padding: 5px 12px; font-size: 13px; }
.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>
<header>
<h1>SimpleFarm <span>小型农场游戏服务器</span></h1>
<div class="dot" id="status"><i></i><span id="statustext">未连接</span></div>
<div class="spacer"></div>
<span class="badge" id="who">未登录</span>
<input id="token" size="10" placeholder="令牌(可选)" />
<button id="logout" class="hidden">退出</button>
</header>
<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">
<h2>农田 <em id="farmhint"></em></h2>
<div class="grid" id="plots"></div>
</div>
<div class="card">
<h2>牧场 <em id="animalhint"></em></h2>
<ul class="animals" id="animals"></ul>
<div class="row" style="margin-top:8px">
<select id="buyanimal">
<option value="chicken">鸡 90</option>
<option value="sheep">羊 220</option>
<option value="cow">牛 480</option>
</select>
<button data-act="buy_animal">买牲畜</button>
</div>
</div>
</div>
<div>
<div class="card">
<h2>快捷动作</h2>
<div class="row">
<button class="primary" data-act="harvest_all">一键收获</button>
<button data-act="buy_plot">买地</button>
<button data-act="buy_feed">买饲料x10</button>
<button data-act="pause">暂停/继续</button>
<button data-act="save">存档</button>
<button data-act="reset">重置</button>
</div>
<div class="notice" id="notice"></div>
</div>
<div class="card">
<h2>市场行情 <em>红涨绿跌</em></h2>
<table id="market"></table>
</div>
<div class="card">
<h2>谷仓 <em id="granaryhint"></em></h2>
<table id="granary"></table>
<div class="row" style="margin-top:8px">
<button class="primary" data-act="sell">全部卖出</button>
</div>
</div>
<div class="card">
<h2>建筑 <em>花金币换自动化</em></h2>
<div class="row" id="buildings"></div>
</div>
<div class="card">
<h2>服务器排行榜 <em id="worldinfo"></em></h2>
<table id="board"></table>
</div>
<div class="card">
<h2>农事日志</h2>
<div class="log" id="log"></div>
</div>
</div>
</div>
</main>
<div class="toast" id="toast"></div>
<div class="mask" id="loginmask">
<div class="pick">
<h3>登录到你的牧场</h3>
<p style="color:#6b7a66;margin:6px 0 10px">输入用户名即可——第一次用会自动开一个新牧场。</p>
<div class="row" style="justify-content:center">
<input id="loginname" size="14" placeholder="用户名" />
<button class="primary" id="dologin">登录</button>
</div>
</div>
</div>
<div class="mask" id="pickmask">
<div class="pick">
<h3 id="picktitle">要种什么?</h3>
<div class="row" id="pickcrops"></div>
</div>
</div>
<script>
const $ = (id) => document.getElementById(id);
let es = null, state = null, catalog = null, pollTimer = null, me = null;
const cropNames = {
wheat: "小麦", carrot: "胡萝卜", tomato: "番茄",
corn: "玉米", pumpkin: "南瓜", strawberry: "草莓"
};
function goodsName(key) {
const g = ((catalog && catalog.goods) || []).find((x) => x.key === key);
if (g) return g.name;
if (key.indexOf("animal:") === 0) return key.slice(7);
return cropNames[key] || key;
}
/* sandbox iframe 可能禁用 localStorage(无 allow-same-origin 时访问即抛
SecurityError),任何一处裸调用都会炸掉整个启动脚本。 */
function lsGet(key) {
try { return localStorage.getItem(key); } catch (e) { return null; }
}
function lsSet(key, val) {
try { localStorage.setItem(key, val); } catch (e) {}
}
function toast(text) {
const el = $("toast");
el.textContent = text;
el.classList.add("show");
clearTimeout(el._t);
el._t = setTimeout(() => el.classList.remove("show"), 2200);
}
function setStatus(text, cls) {
$("statustext").textContent = text;
$("status").className = "dot " + (cls || "");
}
function farmName() { return me || "default"; }
function token() { return ($("token").value || "").trim(); }
/* HTTP 头只允许 Latin-1 字符:中文用户名塞进 X-Farm-Id 会让 fetch 在发送前
就抛 TypeError,登录后所有写操作静默失败(表现为“点播种没反应”)。
登录态由 Cookie 鉴权,非 ASCII 的值一律不塞头。 */
function latin1(s) {
for (let i = 0; i < s.length; i++) {
const c = s.charCodeAt(i);
if (c < 32 || c > 126) return false;
}
return true;
}
function headers() {
const h = { "Content-Type": "application/json" };
if (latin1(farmName())) h["X-Farm-Id"] = farmName();
if (token() && latin1(token())) h["X-API-Token"] = token();
return h;
}
async function api(path, options) {
let res;
try {
res = await fetch(path, options);
} catch (e) {
toast("网络错误:" + e.message);
return null;
}
let data = null;
try { data = await res.json(); } catch (e) { data = { ok: false, message: "响应不是 JSON" }; }
if (!res.ok || data.ok === false) {
toast(data.message || ("请求失败 " + res.status));
return null;
}
return data;
}
async function act(action, params) {
const data = await api("/api/actions" + (token() ? "?token=" + encodeURIComponent(token()) : ""), {
method: "POST", headers: headers(),
body: JSON.stringify({ action: action, params: params || {} })
});
if (data) {
toast(data.message);
if (data.state) refreshFromBrief(data.state);
}
}
/* 动作响应里只有精简状态,这里把它合并进完整状态,避免多查一次 */
function refreshFromBrief(brief) {
if (!state) { syncNow(); return; }
Object.assign(state, brief);
render();
}
function syncNow() {
api("/api/state?farm=" + encodeURIComponent(farmName()) +
(token() ? "&token=" + encodeURIComponent(token()) : ""), { headers: headers() })
.then((d) => { if (d) { state = d.state; render(); } });
}
/* 实时通道:SSE 长连接(EventSource 自带断线重连,不用自己写重连逻辑) */
function connect() {
if (es) { try { es.close(); } catch (e) {} }
clearInterval(pollTimer); pollTimer = null;
const url = "/api/stream?farm=" + encodeURIComponent(farmName()) +
(token() ? "&token=" + encodeURIComponent(token()) : "") + "&name=web";
setStatus("连接中…", "");
es = new EventSource(url);
es.onopen = () => {
setStatus("已连接(SSE 实时)", "on");
clearInterval(pollTimer); pollTimer = null;
api("/api/farms", { headers: headers() });
};
es.onmessage = (ev) => {
let msg = null;
try { msg = JSON.parse(ev.data); } catch (e) { return; }
handle(msg);
};
es.onerror = () => {
/* EventSource 会自己重连;这期间先用轮询顶上,体验不中断 */
setStatus("推送断开,轮询中…", "bad");
if (!pollTimer) pollTimer = setInterval(syncNow, 1500);
};
}
function handle(msg) {
if (msg.type === "welcome") {
state = msg.state; catalog = msg.catalog;
$("farmhint").textContent = "共 " + state.plot_count + "/" + state.max_plots + " 块";
render();
} else if (msg.type === "state" || msg.type === "sync") {
state = msg.state; if (msg.catalog) catalog = msg.catalog;
renderLog(msg.log);
render();
} else if (msg.type === "day") {
renderLog(null);
} else if (msg.type === "notice") {
$("notice").textContent = msg.text;
} else if (msg.type === "error") {
toast(msg.message || "服务器返回错误");
}
}
/* 一天 40 分钟,直接显示秒数是一串大数,换算成 m:ss */
function fmtLeft(sec) {
if (sec === null || sec === undefined) return "已暂停";
const t = Math.max(0, Math.round(sec));
if (t < 60) return t + "s";
return Math.floor(t / 60) + ":" + String(t % 60).padStart(2, "0");
}
function render() {
if (!state) return;
const s = state;
$("stats").innerHTML = [
["第几天", s.day],
["天气", (s.weather_name || "—") + " → " + (s.forecast_name || "?")],
["金币", s.gold], ["等级", "Lv." + s.level + " (" + s.exp + "/" + s.exp_needed + ")"],
["行动点", s.ap + "/" + s.max_ap], ["饲料", s.feed],
["仓储", (s.storage_used || 0) + "/" + (s.granary_capacity || 0)],
["净资产", Math.round(s.net_worth)],
["在线", s.online !== undefined ? s.online : "—"],
["下一天", fmtLeft(s.seconds_to_tick)]
].map((p) => '<div class="stat"><small>' + p[0] + '</small><b>' + p[1] + '</b></div>').join("");
/* 农田 */
$("plots").innerHTML = s.plots.map((p) => {
const ratio = p.grow_days ? Math.round(p.growth / p.grow_days * 100) : 0;
const water = Math.round(p.moisture / (p.water_capacity || 3) * 100);
const cls = p.is_ripe ? "ripe" : (p.crop && p.moisture === 0 ? "dry" : "");
const title = p.is_empty ? (p.position + " 号地 · 空地")
: (p.position + " 号地 · " + (cropNames[p.crop] || p.crop) + " · " + p.stage);
const meta = p.is_empty ? "空闲" : (p.is_ripe ? "已成熟,可收获" : "还需 " + p.days_left + " 天");
let btns = "";
if (p.is_ripe) btns = '<button data-h="' + p.position + '">收获</button>';
else if (p.is_empty) btns = '<button data-p="' + p.position + '">播种</button>';
else if (p.moisture < p.water_capacity) btns = '<button data-w="' + p.position + '">浇水</button>';
return '<div class="plot ' + cls + '"><div class="top"><span>' + title +
'</span></div><div class="meta">' + meta + '</div>' +
'<div class="bar"><i style="width:' + ratio + '%"></i></div>' +
'<div class="bar water"><i style="width:' + water + '%"></i></div>' +
'<div class="btns">' + btns + '</div></div>';
}).join("");
/* 牧场 */
$("animalhint").textContent = s.animal_count + "/" + s.max_animals + " 只";
$("animals").innerHTML = s.animals.length ? s.animals.map((a) =>
'<li><b>#' + a.id + ' ' + a.name + '</b><span class="meta">' + (
a.pending > 0 ? '<span class="up">' + a.product + ' x' + a.pending + ' 待收</span>'
: a.is_sick ? '<span class="down">虚弱,急需喂食</span>'
: a.fed ? '已喂食' : '<span class="k-warn">今天还没喂</span>') +
'</span><span class="spacer"></span>' +
(a.fed ? '' : '<button data-f="' + a.id + '">喂食</button>') +
(a.pending > 0 ? '<button data-c="' + a.id + '">收取</button>' : '') + '</li>'
).join("") : '<li style="color:#6b7a66">牧场空空的,先买一只吧</li>';
/* 市场:基准价直接用 catalog.goods,别再在 JS 里抄一份常量表 */
const gmap = {};
((catalog && catalog.goods) || []).forEach((g) => { gmap[g.key] = g; });
$("market").innerHTML = '<tr><th>货物</th><th class="num">现价</th><th class="num">基准</th></tr>' +
Object.entries(s.market).sort((a, b) => b[1] - a[1]).map((kv) => {
const g = gmap[kv[0]] || {};
const b = g.base_price || kv[1];
const cls = kv[1] > b * 1.03 ? "up" : (kv[1] < b * 0.97 ? "down" : "flat");
const arrow = cls === "up" ? "↑" : (cls === "down" ? "↓" : "→");
return '<tr><td>' + goodsName(kv[0]) + '</td><td class="num ' + cls + '">' +
kv[1].toFixed(0) + " " + arrow + '</td><td class="num">' + b.toFixed(0) + '</td></tr>';
}).join("");
/* 谷仓:卖货是唯一把存货变成金币的出口,所以每个存项都直接给一个卖出按钮 */
const inv = Object.entries(s.inventory || {}).filter((kv) => kv[1] > 0);
$("granaryhint").textContent =
"仓容 " + (s.storage_used || 0) + "/" + (s.granary_capacity || 0);
$("granary").innerHTML =
'<tr><th>货物</th><th class="num">存量</th><th class="num">现价</th>' +
'<th class="num">基准</th><th></th></tr>' +
(inv.length ? inv.sort((a, b) => b[1] - a[1]).map((kv) => {
const g = gmap[kv[0]] || {};
const price = g.price !== undefined ? g.price : ((s.market || {})[kv[0]] || 0);
const b = g.base_price || 0;
const cls = b && price > b * 1.03 ? "up" : (b && price < b * 0.97 ? "down" : "flat");
return '<tr><td>' + goodsName(kv[0]) + '</td><td class="num">' + kv[1] +
'</td><td class="num ' + cls + '">' + price.toFixed(0) + '</td>' +
'<td class="num">' + (b ? b.toFixed(0) : "—") + '</td>' +
'<td class="num"><button data-s="' + kv[0] + '">卖</button></td></tr>';
}).join("")
: '<tr><td colspan="5" style="color:#6b7a66">空仓:收获与收取的产出会自动入库</td></tr>');
/* 建筑 */
const builds = (catalog && catalog.buildings) || [];
$("buildings").innerHTML = builds.length ? builds.map((b) => {
const disabled = b.locked || b.maxed || s.gold < b.next_cost;
const label = b.name + " " + (b.maxed ? "已满" : b.next_cost + " 金") +
"(" + b.owned + "/" + b.max_level + ")";
return '<button data-b="' + b.key + '"' + (disabled ? " disabled" : "") +
' title="' + b.effect + '">' + label + '</button>';
}).join("") : '<span style="color:#6b7a66">目录加载中…</span>';
updateMap();
renderLog(null);
renderBoard();
}
function renderLog(entries) {
if (entries) entries.forEach((e) => logAppend(e));
else if (!logAppend._cache) {
api("/api/log?limit=12&farm=" + encodeURIComponent(farmName()), { headers: headers() })
.then((d) => { if (d) d.entries.forEach(logAppend); });
}
}
function logAppend(e) {
if (logAppend._last && e.seq <= logAppend._last) return;
logAppend._last = e.seq;
logAppend._cache = true;
const box = $("log");
const div = document.createElement("div");
div.className = "k-" + e.kind;
div.innerHTML = '<span class="d">D' + e.day + '</span>' + e.text;
box.insertBefore(div, box.firstChild);
while (box.childNodes.length > 60) box.removeChild(box.lastChild);
}
async function renderBoard() {
const d = await api("/api/world", { headers: headers() });
if (!d) return;
$("worldinfo").textContent = d.farms + " 个牧场 · " + d.clients + " 个连接 · 运行 " +
Math.round(d.uptime) + " 秒";
$("board").innerHTML = '<tr><th>#</th><th>牧场</th><th class="num">净资产</th>' +
'<th class="num">天</th><th class="num">在线</th></tr>' +
d.leaderboard.slice(0, 10).map((r) =>
'<tr><td>' + r.rank + '</td><td>' + r.farm + '</td><td class="num">' +
Math.round(r.net_worth) + '</td><td class="num">' + r.day + '</td><td class="num">' +
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;
if (t.tagName !== "BUTTON") return;
if (t.dataset.w) act("water", { plot: +t.dataset.w });
else if (t.dataset.h) act("harvest", { plot: +t.dataset.h });
else if (t.dataset.plant !== undefined) {
$("pickmask").classList.remove("show");
if (t.dataset.plant && pickPlot) act("plant", { plot: pickPlot, crop: t.dataset.plant });
}
else if (t.dataset.p) {
openCropPick(+t.dataset.p);
}
else if (t.dataset.f) act("feed", { animal_id: +t.dataset.f });
else if (t.dataset.c) act("collect", { animal_id: +t.dataset.c });
else if (t.dataset.s) act("sell", { key: t.dataset.s });
else if (t.dataset.b) act("build", { key: t.dataset.b });
else if (t.dataset.act === "buy_animal") act("buy_animal", { kind: $("buyanimal").value });
else if (t.dataset.act === "buy_feed") act("buy_feed", { quantity: 10 });
else if (t.dataset.act === "reset") {
if (t.dataset.armed) {
t.dataset.armed = ""; t.textContent = "重置";
act("reset");
} else {
t.dataset.armed = "1"; t.textContent = "再点一次确认";
setTimeout(() => { t.dataset.armed = ""; t.textContent = "重置"; }, 3000);
}
}
else if (t.dataset.act) act(t.dataset.act);
});
/* 播种选作物:页面内弹层。window.prompt 在 sandbox iframe(内置预览面板)里
会被静默屏蔽——不弹窗、直接返回 null,表现为"点播种没反应"。 */
let pickPlot = 0;
function openCropPick(plot) {
if (!state) { toast("还没连接上服务器"); return; }
const pool = Object.keys(cropNames).filter((k) => {
const c = (catalog || { crops: [] }).crops.find((x) => x.key === k);
return !c || !c.locked;
});
if (!pool.length) { toast("还没有可种的作物"); return; }
pickPlot = plot;
$("picktitle").textContent = plot + " 号地 · 要种什么?";
$("pickcrops").innerHTML = pool.map((k) =>
'<button data-plant="' + k + '">' + cropNames[k] + "</button>").join("") +
'<button class="danger" data-plant="">取消</button>';
$("pickmask").classList.add("show");
}
$("pickmask").addEventListener("click", (ev) => {
if (ev.target.id === "pickmask") $("pickmask").classList.remove("show");
});
/* ---- 登录:用户名即牧场身份,Cookie 由服务器签发 ---- */
async function whoami() {
const d = await api("/api/me");
me = (d && d.user) || null;
applyIdentity();
}
function applyIdentity() {
$("who").textContent = me ? "🌾 " + me : "未登录";
$("logout").classList.toggle("hidden", !me);
$("loginmask").classList.toggle("show", !me);
if (me) {
lsSet("sf.farm", me);
connect();
} else if (es) {
try { es.close(); } catch (e) {}
es = null;
clearInterval(pollTimer);
setStatus("未登录", "");
}
}
async function doLogin() {
const name = ($("loginname").value || "").trim();
if (!name) { toast("先填一个用户名"); return; }
const d = await api("/api/login", {
method: "POST", headers: headers(),
body: JSON.stringify({ username: name })
});
if (d && d.ok) { me = d.user; toast(d.message); applyIdentity(); }
}
$("dologin").addEventListener("click", doLogin);
$("loginname").addEventListener("keydown", (e) => { if (e.key === "Enter") doLogin(); });
$("logout").addEventListener("click", async () => {
await api("/api/logout", { method: "POST", headers: headers() });
me = null;
applyIdentity();
$("loginname").focus();
});
/* ---- 启动 ---- */
$("token").value = lsGet("sf.token") || "";
$("token").addEventListener("change", () => {
lsSet("sf.token", token());
if (me) connect();
});
whoami();
setInterval(renderBoard, 5000);
</script>
</body>
</html>
"""
def dashboard_text(host: str, port: int) -> str:
"""给终端/curl 看的纯文本索引。"""
return (
"SimpleFarm 游戏服务器\n"
"======================\n"
f"网页客户端:http://{host}:{port}/\n"
f"接口文档 :http://{host}:{port}/docs\n"
f"健康检查 :http://{host}:{port}/api/health\n"
f"实时通道 :http://{host}:{port}/api/stream?farm=<牧场名>(SSE 单向推送)\n"
"\n"
"给 AI / 脚本的最短路径\n"
"----------------------\n"
f"curl 'http://{host}:{port}/api/actions?farm=default'\n"
f"curl -X POST 'http://{host}:{port}/api/actions?farm=default' \\\n"
" -H 'Content-Type: application/json' \\\n"
' -d \'{"action":"plant","params":{"plot":1,"crop":"wheat"}}\'\n'
"\n"
"登录(网页用;脚本可以不带 Cookie 继续 ?farm=)\n"
"----------------------------------------------\n"
'POST /api/login {"username":"名字"} → 设置 Cookie,登录后只能操作自己的牧场\n'
"GET /api/me 当前登录用户(未登录为 null)\n"
"POST /api/logout 退出登录\n"
"\n"
"读取类\n"
"------\n"
"GET /api/world 世界概览 + 排行榜\n"
"GET /api/farms 牧场列表\n"
"GET /api/state 完整状态\n"
"GET /api/catalog 作物/牲畜/价格\n"
"GET /api/actions 当前合法动作(AI 推荐入口)\n"
"GET /api/log 最近事件\n"
"GET /api/help 规则 + 动作清单 + 参数说明\n"
"\n"
"写入类\n"
"------\n"
'POST /api/actions {"action":"...","params":{...}} 通用入口\n'
'POST /api/batch {"actions":[{...},{...}]} 批量\n'
"POST /api/plant|water|harvest|harvest_all|buy_animal|feed|\n"
" collect|buy_feed|buy_plot|sell|build|pause|save|reset 便捷入口\n"
'POST /api/farms {"farm":"名字"} 创建/进入牧场\n'
"POST /api/admin/save 全部存档\n"
"POST /api/admin/shutdown 优雅停机\n"
)