为什么:游戏的时间(40 分钟一天、只能自然流动)本身就是唯一的行动节奏 阀门,再叠一层"每天 10 点行动点"纯属重复惩罚——浇水还要体力既不拟真 也不好玩。删掉之后,稀缺的只剩时间本身,放置玩法的定位反而更纯粹。 改动范围(引擎到界面连根拔): - config 删 START_AP/AP_COST;models 删 ap 字段与 max_ap,存档 payload 里的旧 ap 键被 from_dict 自然忽略; - engine 删 _spend_ap 与全部 8 个调用点、每日恢复逻辑与日志; available_actions 不再按行动点筛,cost 只剩金币; - storage 建表去掉 ap 列,老库在 connect() 里 ALTER TABLE DROP COLUMN 无痛迁移(实测 saves/farm.db 已自动迁完); - bot 删 AP 分支(无 AP 后 available_actions 恒可用);client/TUI/CLI/ README/帮助文本同步清理。 网页端顺带两件事: - "下一天"倒计时改为客户端本地走秒——SSE 只在有变化时推送,以前倒计时 只在推送瞬间跳变,现在每秒都在动(记基点 + 1s 定时器补齐); - 字体优化:页面字体栈补齐 MiSans/HarmonyOS Sans/PingFang,正文字号 14→15,统计数字 tabular-nums;canvas 小字统一字体栈并加粗提对比度。 测试适配:删 2 个 AP 专属用例(无 AP 可测)、改名/收敛 5 个, 532 项全绿(skip 39),ruff/pyright 干净。真机验证:老库迁移、 统计条无行动点、倒计时逐秒递减(39:04→39:02)、动作不再报 no_ap。
1016 lines
42 KiB
Python
1016 lines
42 KiB
Python
"""内嵌的网页客户端。
|
||
|
||
为什么要内嵌而不是写一堆静态文件
|
||
--------------------------------
|
||
服务端只需要一个 ``/`` 路由就能给出完整的可视化客户端,部署时不用管理
|
||
静态目录、不用配 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);
|
||
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
|
||
};
|
||
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 = '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);
|
||
});
|
||
/* 牧场围栏 */
|
||
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 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();
|
||
});
|
||
/* 倒计时本地走秒:状态只在动作/推送时到达,中间的每一秒自己补 */
|
||
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"
|
||
)
|