Files
SimpleFarm/farm/server/webui.py
T
snowy-yang 4bc7729715 地图手感打磨:动作粒子 + 昼夜色温 + 成熟呼吸金光
为什么:地图功能已全通但缺"游戏手感"——点击有反馈(toast)却看不到
画面上的动静,时间流动也只在倒计时数字里。这一轮全部补上。

- 动作粒子特效(webui):act() 成功后在目标位置喷粒子 + 上浮表情小字
  (浇水💧水花、播种🌱扬土、收获/收取✨金点、喂食🍽)。粒子受重力、
  按寿命衰减,失败动作不触发(api() 返回 null 时 mapJuice 不执行)。
- 昼夜色温:一天的进度(1 - seconds_to_tick/tick_seconds)映射成
  清晨淡金 → 正午不叠色 → 黄昏橙 → 入夜靛紫的半透明覆盖层,
  让"40 分钟一天"在画面光色上也流动。tick_seconds 完整快照里有、
  brief 里没有,靠 refreshFromBrief 的合并语义保留。
- 成熟作物金光改呼吸式(shadowBlur 随 sin 起伏),悬停可交互对象时
  指针变手型。

验证:532 项测试全绿、ruff/pyright 干净;真机 headless Chrome 实测
粒子注入 23 枚 2 秒内衰减归零、失败动作不产生特效、dayTint 三时段
数值正确、截图目检清晨色调正常、控制台零报错。
2026-09-25 23:38:40 +08:00

1098 lines
46 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: 15px/1.6 "Segoe UI Variable Text", "Segoe UI", "MiSans",
"HarmonyOS Sans SC", "Microsoft YaHei", "PingFang SC", 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; font-variant-numeric: tabular-nums; }
.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;
let tickLeft = null, tickStamp = 0;
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);
mapJuice(action, params);
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.feed],
["仓储", (s.storage_used || 0) + "/" + (s.granary_capacity || 0)],
["净资产", Math.round(s.net_worth)],
["在线", s.online !== undefined ? s.online : "—"],
["下一天", fmtLeft(s.seconds_to_tick), "tickcell"]
].map((p) => '<div class="stat"><small>' + p[0] + '</small><b' +
(p[2] ? ' id="' + p[2] + '"' : '') + '>' + p[1] + '</b></div>').join("");
/* 倒计时基点:SSE 只在有变化时推送,中间的每一秒由本地定时器补齐 */
tickLeft = (s.seconds_to_tick === undefined || s.seconds_to_tick === null) ? null : s.seconds_to_tick;
tickStamp = Date.now();
/* 农田 */
$("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, fx: []
};
/* 动作粒子:浇水溅水花、收获爆金点、播种扬土,配一枚上浮的表情小字。
act() 成功后调 mapJuice(action, params),坐标由地块号/牲畜 id 反查。 */
function plotXY(position) {
const f = MAP.field;
const i = (state.plots || []).findIndex((p) => p.position === position);
if (!f || i < 0) return null;
return { x: f.x + (i % f.cols) * (f.ts + f.gap) + f.ts / 2,
y: f.y + Math.floor(i / f.cols) * (f.ts + f.gap) + f.ts / 2 };
}
function animalXY(id) {
const wd = (MAP.walkers || []).find((w) => w.a.id === id);
return wd ? { x: wd.x, y: wd.y } : null;
}
function mapJuice(action, params) {
if (!MAP.field || !params) return;
let at = null;
if (params.plot !== undefined) at = plotXY(params.plot);
else if (params.animal_id !== undefined) at = animalXY(params.animal_id);
if (!at) return;
const conf = {
water: { color: "#7cc4f5", n: 14, text: "💧" },
plant: { color: "#a97b4f", n: 12, text: "🌱" },
harvest: { color: "#ffd54d", n: 22, text: "✨" },
feed: { color: "#e8b04a", n: 12, text: "🍽" },
collect: { color: "#ffd54d", n: 18, text: "✨" }
}[action];
if (!conf) return;
for (let i = 0; i < conf.n; i++) {
const a = Math.random() * 6.28, v = 34 + Math.random() * 74;
MAP.fx.push({ x: at.x, y: at.y, vx: Math.cos(a) * v, vy: Math.sin(a) * v - 52,
t: 0, life: 0.55 + Math.random() * 0.45, color: conf.color,
r: 1.6 + Math.random() * 2.4 });
}
MAP.fx.push({ x: at.x, y: at.y - 8, floater: true, t: 0, life: 1.1, text: conf.text });
}
/* 一天的进度 → 画面光色:清晨淡金 → 正午不叠色 → 黄昏橙 → 入夜靛紫。
时间不该只在倒计时数字上流动,田里的光也要跟着走。 */
function dayTint(prog) {
if (prog < 0.12) {
const k = prog / 0.12;
return [255, 190, 110, 0.14 * (1 - k)];
}
if (prog < 0.70) return null;
const k = (prog - 0.70) / 0.30;
return [Math.round(mapLerp(255, 104, k)), Math.round(mapLerp(150, 80, k)),
Math.round(mapLerp(72, 190, k)), 0.05 + 0.15 * k];
}
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; });
/* 特效粒子:受重力下坠、寿命到了消失;浮字匀速上飘 */
for (let i = MAP.fx.length - 1; i >= 0; i--) {
const f = MAP.fx[i];
f.t += dt;
if (f.t >= f.life) { MAP.fx.splice(i, 1); continue; }
if (f.floater) { f.y -= 26 * dt; }
else { f.vy += 190 * dt; f.x += f.vx * dt; f.y += f.vy * dt; }
}
/* 牲畜漫步 + 边界回弹(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, now) {
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 = 10 + Math.sin(now / 320 + p.position * 1.7) * 5;
}
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 = '600 11px "Segoe UI","Microsoft YaHei",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 = '600 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, now);
});
/* 牧场围栏 */
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 = '600 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 = '600 11px "Segoe UI","Microsoft YaHei",sans-serif';
c.fillText(kv[1] > 1 ? info[1] + " ×" + kv[1] : info[1], cx, y0 + 32);
});
}
/* 昼夜光色 + 动作特效:盖在场地上、悬停提示之下 */
const dayTotal = state.tick_seconds || 2400;
const tint = dayTint(1 - Math.min(1, (state.seconds_to_tick || 0) / dayTotal));
if (tint && tint[3] > 0.004) {
c.fillStyle = "rgba(" + tint[0] + "," + tint[1] + "," + tint[2] + "," + tint[3].toFixed(3) + ")";
c.fillRect(0, 0, MAP.w, MAP.h);
}
(MAP.fx || []).forEach((f) => {
const k = 1 - f.t / f.life;
c.globalAlpha = f.floater ? Math.min(1, k * 1.8) : k;
if (f.floater) {
c.font = '700 16px "Segoe UI Emoji","Noto Color Emoji",sans-serif';
c.textAlign = "center"; c.textBaseline = "middle";
c.fillText(f.text, f.x, f.y);
} else {
c.fillStyle = f.color;
c.beginPath(); c.arc(f.x, f.y, f.r, 0, 6.29); c.fill();
}
c.globalAlpha = 1;
});
/* 悬停提示框 */
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.style.cursor = hit ? "pointer" : "default";
});
MAP.cv.addEventListener("pointerleave", () => { MAP.hover = null; MAP.cv.style.cursor = "default"; });
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();
});
/* 倒计时本地走秒:状态只在动作/推送时到达,中间的每一秒自己补 */
setInterval(() => {
if (tickLeft === null) return;
const el = $("tickcell");
if (el) el.textContent = fmtLeft(Math.max(0, tickLeft - (Date.now() - tickStamp) / 1000));
}, 1000);
/* ---- 启动 ---- */
$("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"
)