Files
SimpleFarm/farm/tui.py
T
snowy-yang f8e093c98f feat: 实时通道改 SSE、服务器换 uvicorn+zttp、节奏改 40 分钟一天,并重建测试与优化热路径
三件事凑成一次不兼容变更:WebSocket 通道、Hypercorn 服务器、「手动推进一天」都消失了。

实时通道:WebSocket -> SSE
- hub 改为 SSE 订阅池,GET /api/stream 单向推送 welcome/state/day/notice/bye,
  250ms 节流 + 15s 心跳注释;写操作一律走 HTTP POST /api/actions,不再有 WS 回执。
- 停机三处一起修才干净:close_all() 发 bye + timeout_graceful_shutdown 兜底 +
  客户端 stop() 直接 shutdown 底层 socket(光 close() 会等读线程的锁,最长卡到下次心跳)。
- 网页版改 EventSource + fetch;tools/ws_demo.py 换成 tools/sse_demo.py。

服务器:Hypercorn -> uvicorn + zttp 的 HTTP/2
- 同一端口同时说 HTTP/1.1 与 HTTP/2。uvicorn 0.53 不再内置 h2,必须装 zttp 并显式
  http="zttp",否则 Config.load() 直接 raise。
- 信号接管改成子类覆盖 Server.handle_exit(0.53 没有 install_signal_handlers 可覆盖),
  不调 super()、不写 _captured_signals,退出时不重放信号;第二次信号强制退出。
- 入口启动前用 daemon.port_busy() 自检端口:Windows 上 SO_REUSEADDR 会让第二个实例
  「绑上」已监听端口并抢走新连接,所以判断「能不能连上」而不是「能不能 bind」。
- 修掉 clear_pid_file 的 Windows 真 bug:文件还开着就 os.remove 会失败且被
  except OSError 吞掉,导致每次正常退出都留 stale pid 文件。

节奏:一天 15 秒 -> 40 分钟,并整体移除「休息」
- DEFAULT_TICK_SECONDS=2400,MAX_TICK_SECONDS 放宽到 86400(旧上限会把 40 分钟夹回去)。
- engine.sleep / SLEEP_COOLDOWN / /api/sleep / TUI 的 [N]休息 / 网页版按钮全部删掉,
  时间只能自然流动;机器人改为 wait_for_next_day(timeout),CLI 加 bot --wait。
- 时长显示统一走 models.format_duration(),不再出现 2399.6s 这种写法。

键盘:修复方向键/空格/Tab/ESC 全部静默失效
- keys.py 的具名键统一小写并成为唯一来源,tui.py 一律引用 keys.XXX 而不手写字符串
  (历史 bug 就是 Windows 侧产大写 UP、TUI 里写小写 up,字母键正常所以很难发现)。
- 顺带修 POSIX 路径一次 os.read 只取首字节、其余按键静默丢弃的老毛病。

测试:重建 500 项
- test_engine 110 / test_actions 35 / test_server 131(skip 35) / test_tui_keys 75 /
  test_bot 36 / test_client 113。未安装的第三方依赖用 skipUnless 自动跳过,其余照跑,
  所以是全绿而不是靠跳过伪装。
- 被测试逼出两处真隐患并修掉:RemoteFarm._normalize() 必须「先补 scheme 再削尾斜杠」
  (顺序反了畸形端点会溜到系统代理、报一个和真实原因无关的 502);StateView 空视图要
  预置 plots/animals,否则连不上服务器时读 state.plots 直接 AttributeError。

优化:先量后改,依据与前后数字都在 tools/bench.py
- 新增 engine.brief_source():brief/summary/动作回执只要十几个标量,不再为它们序列化
  整局;to_dict 里 net_worth 只算一次。brief -76%、summary -77%、leaderboard(32) -78%、
  GET /api/world -31%、POST /api/actions?full=1 -16%。
- 拆掉三个巨型函数:create_app 475->32 行(_Services + _register_*)、
  build_parser 与 available_actions 分组、__main__.main 160->7 行。
- 消除重复:等级门槛 5 处 -> _is_locked/_require_level/_newly_unlocked;hub 的
  broadcast/broadcast_all/close_all -> _push_many;daemon.probe/world -> _get_json;
  main.py 的 _add_endpoint_args/_leaderboard_lines。
- tools/bench.py 作为长期性能守卫(含调用计数器),谁把轻量路径改回 snapshot() 会立刻报警。

门禁:ruff format/check 全绿、pyright 0 错误、500 项测试 OK(skip 35)。
2026-09-19 12:55:45 +08:00

851 lines
33 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.
"""终端用户界面(TUI)。
渲染策略
--------
不做整屏清空,而是把光标归位后逐行重写并用 ``\\x1b[K`` 清理行尾,
这样刷新时几乎没有闪烁。
宽度处理
--------
中文是双宽字符,所有对齐都基于 :func:`display_width` 计算,
保证边框在 Windows Terminal / iTerm / VSCode 终端下都不歪。
"""
from __future__ import annotations
import contextlib
import os
import re
import shutil
import sys
import time
import unicodedata
from typing import Any
from . import config, keys
from .engine import GameError
from .keys import KeyReader
from .models import format_duration
from .storage import default_save_path
# ---------------------------------------------------------------- 颜色
C_RESET = "\x1b[0m"
C_DIM = "\x1b[38;5;242m"
C_GRAY = "\x1b[38;5;246m"
C_WHITE = "\x1b[38;5;253m"
C_GOLD = "\x1b[38;5;221m"
C_GREEN = "\x1b[38;5;114m"
C_RED = "\x1b[38;5;203m"
C_BLUE = "\x1b[38;5;111m"
C_CYAN = "\x1b[38;5;80m"
C_ORANGE = "\x1b[38;5;215m"
C_PURPLE = "\x1b[38;5;176m"
C_BOLD = "\x1b[1m"
ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
CELL_WIDTH = 17 # 单块农田格子的显示宽度
CELL_INNER = CELL_WIDTH - 2
SIDE_WIDTH = 36 # 右侧栏宽度
MIN_TERM_WIDTH = 76
MAX_VISIBLE_ANIMALS = 5 # 侧栏同时最多展示的牲畜数量
def strip_ansi(text: str) -> str:
return ANSI_RE.sub("", text)
def display_width(text: str) -> int:
"""计算字符串在终端中占用的列数(中文算 2 列)。"""
width = 0
for char in strip_ansi(text):
if unicodedata.combining(char):
continue
width += 2 if unicodedata.east_asian_width(char) in ("W", "F") else 1
return width
def pad(text: str, width: int, align: str = "left", fill: str = " ") -> str:
"""按显示宽度补空格。``align`` 可为 left / right / center。
内容比 ``width`` 还宽时就地截断,保证任何一行都不会挤破边框。
"""
gap = width - display_width(text)
if gap < 0:
text = truncate(text, width)
gap = max(0, width - display_width(text))
if align == "right":
return fill * gap + text
if align == "center":
left = gap // 2
return fill * left + text + fill * (gap - left)
return text + fill * gap
def truncate(text: str, width: int, ellipsis: str = "…") -> str:
"""按显示宽度截断(不破坏 ANSI 色彩)。"""
if display_width(text) <= width:
return text
out = []
used = 0
limit = max(0, width - 1)
for char in strip_ansi(text):
char_width = 2 if unicodedata.east_asian_width(char) in ("W", "F") else 1
if used + char_width > limit:
break
out.append(char)
used += char_width
return "".join(out) + ellipsis
def color(text: str, code: str, enabled: bool = True) -> str:
return f"{code}{text}{C_RESET}" if enabled else text
def bar(filled: int, total: int, width: int, on: str = "█", off: str = "░") -> str:
"""生成进度条字符串(不含颜色)。"""
if total <= 0:
total = 1
filled = max(0, min(width, round(width * filled / total)))
return on * filled + off * (width - filled)
# ---------------------------------------------------------------- 终端控制
def enable_ansi() -> bool:
"""在 Windows 上开启 VT 转义序列与 UTF-8 输出,返回是否成功。"""
if os.name != "nt":
return True
try:
import ctypes
kernel32 = ctypes.windll.kernel32
# 输出代码页切到 UTF-8,避免中文/制表符乱码
kernel32.SetConsoleOutputCP(65001)
handle = kernel32.GetStdHandle(-11)
mode = ctypes.c_uint32()
if kernel32.GetConsoleMode(handle, ctypes.byref(mode)):
kernel32.SetConsoleMode(handle, mode.value | 0x0004)
return True
except Exception:
return False
def terminal_size() -> tuple[int, int]:
size = shutil.get_terminal_size(fallback=(110, 30))
return max(MIN_TERM_WIDTH, size.columns), max(18, size.lines)
# ---------------------------------------------------------------- 绘制工具
def draw_box(
title: str, body: list[str], width: int, accent: str | None = None, use_color: bool = True
) -> list[str]:
"""画一个带标题的方框,返回行列表(宽度精确等于 ``width``)。"""
inner = max(0, width - 2)
head = f" {title} "
if use_color and accent:
head = color(head, accent)
fill_len = max(0, inner - 1 - display_width(head))
top = "┌─" + head + "─" * fill_len + "┐"
bottom = "└" + "─" * inner + "┘"
lines = [top]
lines.extend("│" + pad(truncate(row, inner), inner) + "│" for row in body)
lines.append(bottom)
return lines
def hstack(left: list[str], right: list[str], gap: int = 1) -> list[str]:
"""把两列行按行并排,短的一侧用空格补齐。"""
height = max(len(left), len(right))
left_width = max((display_width(line) for line in left), default=0)
out = []
for i in range(height):
left_line = left[i] if i < len(left) else ""
right_line = right[i] if i < len(right) else ""
out.append(pad(left_line, left_width) + " " * gap + right_line)
return out
class TuiApp:
"""交互式界面主程序。"""
def __init__(
self,
engine: Any,
save_path: str | None = None,
use_color: bool = True,
autosave: bool = True,
server_label: str = "",
) -> None:
# engine 既可以是本地 FarmEngine,也可以是远程 RemoteFarm——只要接口一致
self.engine = engine
self.save_path = save_path or default_save_path()
self.use_color = use_color
self.autosave = autosave
self.server_label = server_label
self.reader = KeyReader()
# 读不到键盘时是否在界面上提示:预览工具画的是定格画面,会关掉它
self.show_keyboard_notice = True
self.running = True
self.mode = "main" # main / help / select_crop / select_animal / confirm_reset
self.plot_cursor = 0
self.animal_cursor = 0
self.grid_cols = 3
self.menu: list[dict[str, Any]] = []
self.menu_title = ""
self.pending_action = ""
self.message = ""
self.message_color = C_WHITE
self.message_until = 0.0
self._last_render = 0.0
# ================================================================ 生命周期
def _enter(self) -> None:
if enable_ansi() and os.name == "nt":
# 只有 TextIOWrapper 才有 reconfigure;被重定向时可能拿到别的流。
# 取不到就按原编码继续,不影响后续渲染。
reconfigure = getattr(sys.stdout, "reconfigure", None)
if reconfigure is not None:
with contextlib.suppress(Exception):
reconfigure(encoding="utf-8", errors="replace")
sys.stdout.write("\x1b[?25l\x1b[2J\x1b[H")
sys.stdout.write(C_RESET)
sys.stdout.flush()
def _exit(self) -> None:
if self.autosave:
# 本地引擎写文件;远程引擎让服务器落盘
with contextlib.suppress(Exception):
self.engine.save(self.save_path)
self.reader.close()
sys.stdout.write(C_RESET + "\x1b[?25h\n")
sys.stdout.flush()
def run(self) -> None:
self._enter()
try:
while self.running:
key = self.reader.poll()
if key:
try:
self._handle_key(key)
except GameError as exc:
self._flash(exc.message, C_RED)
except Exception as exc: # 任何意外都不能让界面崩溃
self._flash(f"内部错误:{exc}", C_RED)
now = time.time()
if now - self._last_render >= 0.1:
self._render()
self._last_render = now
time.sleep(0.03)
except KeyboardInterrupt:
pass
finally:
self._exit()
# ================================================================ 提示
def _flash(self, text: str, tone: str = C_GREEN, seconds: float = 4.0) -> None:
self.message = text
self.message_color = tone
self.message_until = time.time() + seconds
def _act(self, func, *args) -> None:
"""统一执行引擎动作并显示结果。"""
try:
result = func(*args)
self._flash(result.get("message", "完成"), C_GREEN)
except GameError as exc:
self._flash(exc.message, C_RED)
# ================================================================ 输入
def _handle_key(self, key: str) -> None:
if key == keys.CTRL_C:
self.running = False
return
if self.mode == "help":
self.mode = "main"
return
if self.mode == "confirm_reset":
if key == "y":
self.engine.reset()
self.plot_cursor = 0
self.animal_cursor = 0
self._flash("农场已重置,重新开始吧", C_GOLD)
else:
self._flash("已取消重置", C_GRAY)
self.mode = "main"
return
if self.mode in ("select_crop", "select_animal"):
self._handle_menu_key(key)
return
# ---- 主模式
# 键名一律取 farm.keys 的规范化常量:写成 keys.XXX 后 pyright 能直接
# 查出拼写错误,不会再出现"两端命名不一致、按键静默失效"这种事。
handler = {
"q": self._quit,
"?": self._open_help,
keys.UP: lambda: self._move_plot(-self.grid_cols),
keys.DOWN: lambda: self._move_plot(self.grid_cols),
keys.LEFT: lambda: self._move_plot(-1),
keys.RIGHT: lambda: self._move_plot(1),
"w": lambda: self._act(self.engine.water, self.plot_cursor + 1),
"h": lambda: self._act(self.engine.harvest, self.plot_cursor + 1),
"e": self._harvest_all,
"p": self._open_crop_menu,
"b": self._open_animal_menu,
"f": self._feed,
"c": self._collect,
"l": lambda: self._act(self.engine.buy_plot),
"k": lambda: self._act(self.engine.buy_feed, config.FEED_BULK),
"s": self._save,
"r": self._ask_reset,
keys.SPACE: self._toggle_pause,
keys.TAB: self._toggle_focus,
}
action = handler.get(key)
if action:
action()
def _quit(self) -> None:
self.running = False
def _open_help(self) -> None:
self.mode = "help"
def _move_plot(self, delta: int) -> None:
count = len(self.engine.state.plots)
if count == 0:
return
self.plot_cursor = max(0, min(count - 1, self.plot_cursor + delta))
def _toggle_pause(self) -> None:
result = self.engine.pause()
self._flash("时间已暂停" if result["paused"] else "时间恢复流动", C_ORANGE)
def _toggle_focus(self) -> None:
animals = self.engine.state.animals
if not animals:
self._flash("牧场里还没有动物", C_GRAY)
return
self.animal_cursor = (self.animal_cursor + 1) % len(animals)
def _harvest_all(self) -> None:
"""一键收获所有成熟作物。"""
engine = self.engine
targets = [p.index + 1 for p in engine.state.plots if p.is_ripe]
if not targets:
self._flash("暂时没有成熟的作物", C_GRAY)
return
total = 0
count = 0
for number in targets:
try:
result = engine.harvest(number)
total += result["earned"]
count += result["quantity"]
except GameError:
continue
self._flash(f"收获 {count} 份作物,共 +{total} 金币", C_GREEN)
# ---------------------------------------------------------- 菜单交互
def _open_crop_menu(self) -> None:
plot = self.engine.state.plots[self.plot_cursor] if self.engine.state.plots else None
if plot is None:
return
if plot.crop is not None:
self._flash(f"{plot.index + 1} 号地已种了作物,先收获再说", C_RED)
return
items: list[dict[str, Any]] = []
catalog = {c["key"]: c for c in self.engine.catalog()["crops"]}
for key, info in config.CROPS.items():
entry = catalog.get(key, {})
price = entry.get("price", info["base_price"])
profit = price - info["seed_cost"]
items.append(
{
"key": key,
"label": info["name"],
"cost": info["seed_cost"],
"detail": (
f"种子{info['seed_cost']:>3} {info['grow_days']:>2}天 "
f"售价{price:>5.0f} 净赚 {profit:+.0f}"
),
"locked": self.engine.state.level < info["level_req"],
"lock_hint": f"需 Lv.{info['level_req']}",
"affordable": self.engine.state.gold >= info["seed_cost"],
}
)
self.menu = items
self.menu_title = f"选择要种在 {plot.index + 1} 号地的作物"
self.mode = "select_crop"
def _open_animal_menu(self) -> None:
if len(self.engine.state.animals) >= config.MAX_ANIMALS:
self._flash("牧场已满,无法再养", C_RED)
return
catalog = {a["key"]: a for a in self.engine.catalog()["animals"]}
items = []
for key, info in config.ANIMALS.items():
entry = catalog.get(key, {})
items.append(
{
"key": key,
"label": info["name"],
"cost": info["cost"],
"detail": (
f"{info['cost']:>4}金币 产{info['product']}"
f" 每{info['cycle']}天 日净收益{entry.get('profit_per_day', 0):.0f}"
),
"locked": self.engine.state.level < info["level_req"],
"lock_hint": f"需 Lv.{info['level_req']}",
"affordable": self.engine.state.gold >= info["cost"],
}
)
self.menu = items
self.menu_title = "选择要购买的动物"
self.mode = "select_animal"
def _handle_menu_key(self, key: str) -> None:
if key in (keys.ESC, "q"):
self.mode = "main"
self._flash("已取消", C_GRAY)
return
if not key.isdigit():
return
index = int(key)
if index == 0 or index > len(self.menu):
self._flash("没有这个选项", C_RED)
return
item = self.menu[index - 1]
if item["locked"]:
self._flash(f"尚未解锁({item['lock_hint']})", C_RED)
return
if self.mode == "select_crop":
self._act(self.engine.plant, self.plot_cursor + 1, item["key"])
else:
self._act(self.engine.buy_animal, item["key"])
self.mode = "main"
def _feed(self) -> None:
animals = self.engine.state.animals
if not animals:
self._flash("牧场里还没有动物,按 B 购买", C_GRAY)
return
self.animal_cursor = min(self.animal_cursor, len(animals) - 1)
self._act(self.engine.feed, animals[self.animal_cursor].aid)
def _collect(self) -> None:
animals = self.engine.state.animals
if not animals:
self._flash("牧场里还没有动物", C_GRAY)
return
self.animal_cursor = min(self.animal_cursor, len(animals) - 1)
self._act(self.engine.collect, animals[self.animal_cursor].aid)
def _save(self) -> None:
try:
result = self.engine.save(self.save_path)
message = result.get("message", "已存档")
self._flash(message if len(message) < 46 else "已存档", C_GREEN)
except OSError as exc:
self._flash(f"存档失败:{exc}", C_RED)
except GameError as exc:
self._flash(f"存档失败:{exc.message}", C_RED)
def _ask_reset(self) -> None:
self.mode = "confirm_reset"
# ================================================================ 渲染
def _render(self) -> None:
width, height = terminal_size()
state = self.engine.state
snapshot = self.engine.snapshot()
# 地块光标可能因为增删而越界
if state.plots:
self.plot_cursor = max(0, min(self.plot_cursor, len(state.plots) - 1))
# 先把两栏宽度定死,保证 hstack 之后总宽正好等于终端宽度
side_width = min(SIDE_WIDTH, max(28, width // 3))
left_width = max(20, width - side_width - 1)
side_col = self._render_side(snapshot, side_width)
farm_col = self._render_farm(snapshot, left_width)
middle = hstack(farm_col, side_col, gap=1)
bottom = self._render_bottom(snapshot, width)
lines: list[str] = []
lines.append(self._render_header(snapshot, width))
lines.extend(self._render_status(snapshot, width))
lines.extend(middle)
lines.extend(bottom)
if len(lines) > height - 1:
lines = lines[: height - 1]
out = ["\x1b[H"]
out.extend(truncate(line, width) + "\x1b[K\r\n" for line in lines)
# 清掉本轮剩余行
out.append("\x1b[J")
sys.stdout.write("".join(out))
sys.stdout.flush()
# ---------------------------------------------------------- header
def _render_header(self, snap: dict[str, Any], width: int) -> str:
title = color(" SimpleFarm", C_BOLD + C_GREEN, self.use_color)
if self.server_label:
title += color(f" {self.server_label} ", C_BOLD + C_CYAN, self.use_color)
else:
title += color(" 农场经营 ", C_GRAY, self.use_color)
left = "╭─" + title
online = snap.get("online")
if online is not None and online > 1:
tag = color(f"👥{online} ", C_ORANGE, self.use_color)
else:
tag = ""
if snap["paused"]:
right = tag + color("⏸ 已暂停 ", C_ORANGE, self.use_color)
else:
countdown = snap.get("seconds_to_tick")
if countdown is None:
right = tag + color("手动模式 ", C_GRAY, self.use_color)
else:
right = tag + color(
f"第 {snap['day']} 天 · 下一日 {format_duration(countdown)} ",
C_CYAN,
self.use_color,
)
gap = max(1, width - display_width(left) - display_width(right) - 1)
return left + "─" * gap + right + "╮"
# ---------------------------------------------------------- 状态区
def _render_status(self, snap: dict[str, Any], width: int) -> list[str]:
net_worth = snap["net_worth"]
goal = snap["next_goal"]
ap_ratio = snap["ap"] / max(1, snap["max_ap"])
ap_color = C_GREEN if ap_ratio > 0.4 else (C_ORANGE if ap_ratio > 0 else C_RED)
ap_bar = bar(snap["ap"], snap["max_ap"], 10)
line1 = (
color(f" {snap['gold']:>5}", C_BOLD + C_GOLD, self.use_color)
+ color(" 金币", C_GRAY, self.use_color)
+ " "
+ color(f"Lv.{snap['level']}", C_BOLD + C_PURPLE, self.use_color)
+ color(f" {snap['exp']}/{snap['exp_needed']} exp", C_GRAY, self.use_color)
+ " "
+ color("行动点 ", C_GRAY, self.use_color)
+ color(ap_bar, ap_color, self.use_color)
+ color(f" {snap['ap']}/{snap['max_ap']}", C_WHITE, self.use_color)
+ " "
+ color(f"饲料 {snap['feed']}", C_ORANGE, self.use_color)
)
if goal:
progress = bar(int(net_worth), int(goal["amount"]), 12)
pct = min(100, int(100 * net_worth / max(1, goal["amount"])))
goal_text = (
color(" 目标 ", C_GRAY, self.use_color)
+ color(goal["text"], C_CYAN, self.use_color)
+ " "
+ color(progress, C_BLUE, self.use_color)
+ color(f" {pct}%", C_WHITE, self.use_color)
)
else:
goal_text = color(" 目标 ", C_GRAY, self.use_color) + color(
"全部达成!你是真正的农业大亨", C_GOLD, self.use_color
)
line2 = (
color(f" 净资产 {net_worth:>7.0f}", C_GRAY, self.use_color)
+ " "
+ goal_text
+ " "
+ color(f"地块 {snap['plot_count']}/{snap['max_plots']}", C_GRAY, self.use_color)
+ " "
+ color(f"牲畜 {snap['animal_count']}/{snap['max_animals']}", C_GRAY, self.use_color)
)
return [pad(line1, width), pad(line2, width)]
# ---------------------------------------------------------- 农田
def _render_farm(self, snap: dict[str, Any], width: int) -> list[str]:
inner = width - 2
# 格子之间留 1 列间隙,避免相邻边框粘成 "││"
cols = max(1, (inner + 1) // (CELL_WIDTH + 1))
self.grid_cols = cols
plots = snap["plots"]
rows: list[str] = []
for start in range(0, len(plots), cols):
chunk = plots[start : start + cols]
parts = [self._render_cell(cell, width).split("\n") for cell in chunk]
for line_index in range(3):
row = " ".join(part[line_index] for part in parts if line_index < len(part))
rows.append(pad(row, inner))
rows.append("") # 行间距
if rows and rows[-1] == "":
rows.pop()
title = f"农田 {snap['plot_count']}/{snap['max_plots']} 块"
return draw_box(title, rows, width, accent=C_GREEN, use_color=self.use_color)
def _render_cell(self, plot: dict[str, Any], farm_width: int) -> str:
selected = plot["index"] == self.plot_cursor
border = C_BOLD + C_GREEN if selected else C_DIM
if plot["is_empty"]:
name_text = color(" 空地", C_GRAY, self.use_color)
state_text = color("空闲", C_DIM, self.use_color)
progress = color("·" * 10, C_DIM, self.use_color)
water = color("░" * config.WATER_CAPACITY, C_DIM, self.use_color)
else:
info = config.CROPS.get(plot["crop"], {"name": "?"})
if plot["is_ripe"]:
state_text = color("可收获", C_BOLD + C_GREEN, self.use_color)
elif plot["moisture"] == 0:
state_text = color("缺水!", C_RED, self.use_color)
else:
state_text = color(f"剩{plot['days_left']}天", C_WHITE, self.use_color)
name_text = color(f" {plot['position']} {info['name']}", C_WHITE, self.use_color)
ratio = plot["growth"] / max(1, plot["grow_days"])
filled_count = max(0, min(10, round(ratio * 10)))
progress = color(
"█" * filled_count + "░" * (10 - filled_count),
C_GREEN if plot["is_ripe"] else C_CYAN,
self.use_color,
)
water_level = plot["moisture"]
water = color(
"▓" * water_level + "░" * (config.WATER_CAPACITY - water_level),
C_BLUE if water_level else C_RED,
self.use_color,
)
spacer = max(0, CELL_INNER - display_width(name_text) - display_width(state_text))
line1 = name_text + " " * spacer + state_text
line2 = progress + " " + water
top = color("╭" + "─" * CELL_INNER + "╮", border, self.use_color)
bottom = color("╰" + "─" * CELL_INNER + "╯", border, self.use_color)
body = [
color("│", border, self.use_color)
+ pad(line1, CELL_INNER)
+ color("│", border, self.use_color),
color("│", border, self.use_color)
+ pad(line2, CELL_INNER)
+ color("│", border, self.use_color),
]
return "\n".join([top, *body, bottom])
# ---------------------------------------------------------- 侧栏
def _render_side(self, snap: dict[str, Any], side_width: int = SIDE_WIDTH) -> list[str]:
animals = snap["animals"]
rows: list[str] = []
if not animals:
rows.append(color(" 牧场空空的,按 B 购买", C_DIM, self.use_color))
rows.append(color(" 鸡/羊/牛需每日喂食才产出", C_DIM, self.use_color))
else:
# 只渲染一个跟随光标的窗口,避免牲畜变多后把界面撑爆
start = max(
0,
min(
max(0, len(animals) - MAX_VISIBLE_ANIMALS),
self.animal_cursor - MAX_VISIBLE_ANIMALS // 2,
),
)
for index in range(start, min(len(animals), start + MAX_VISIBLE_ANIMALS)):
animal = animals[index]
focused = index == self.animal_cursor
marker = color("▸", C_BOLD + C_GREEN, self.use_color) if focused else " "
if animal["pending"] > 0:
status = color(
f"{animal['product']}x{animal['pending']}", C_BOLD + C_GOLD, self.use_color
)
elif animal["is_sick"]:
status = color("虚弱!", C_RED, self.use_color)
elif not animal["fed"]:
status = color("未喂食", C_ORANGE, self.use_color)
elif animal["days_to_product"] <= 1:
status = color("即将产出", C_GREEN, self.use_color)
else:
status = color(f"再{animal['days_to_product']}天", C_GRAY, self.use_color)
head = marker + color(f" #{animal['id']} {animal['name']}", C_WHITE, self.use_color)
rows.append(pad(head, 13) + status)
hidden = len(animals) - start - MAX_VISIBLE_ANIMALS
if hidden > 0:
rows.append(color(f" …还有 {hidden} 只(Tab 切换)", C_DIM, self.use_color))
pasture = draw_box(
f"牧场 {snap['animal_count']}/{snap['max_animals']}",
rows,
side_width,
accent=C_ORANGE,
use_color=self.use_color,
)
# ---- 市场行情(中国习惯:涨红跌绿)
market_rows: list[str] = []
name_by_key = {k: v["name"] for k, v in config.CROPS.items()}
ranked = sorted(
((k, v) for k, v in snap["market"].items() if not k.startswith("animal:")),
key=lambda item: -item[1],
)[:4]
for key, price in ranked:
base = config.CROPS[key]["base_price"]
if price > base * 1.06:
trend, tone = "涨", C_RED
elif price < base * 0.94:
trend, tone = "跌", C_GREEN
else:
trend, tone = "平", C_GRAY
label = color(f" {name_by_key.get(key, key)}", C_WHITE, self.use_color)
value = color(f"{price:>6.0f} {trend}", tone, self.use_color)
spacer = max(1, side_width - 4 - display_width(label) - display_width(value))
market_rows.append(label + " " * spacer + value)
market_rows.append(
color(f" 饲料 {config.FEED_UNIT_PRICE} 金币/份(按 K 购买)", C_DIM, self.use_color)
)
market = draw_box(
"市场行情", market_rows, side_width, accent=C_CYAN, use_color=self.use_color
)
return pasture + market
# ---------------------------------------------------------- 底部
def _render_bottom(self, snap: dict[str, Any], width: int) -> list[str]:
if self.mode == "help":
return self._render_help(width)
if self.mode in ("select_crop", "select_animal"):
return self._render_menu(width)
rows: list[str] = []
now = time.time()
if self.message and now < self.message_until:
rows.append(color(" " + self.message, self.message_color, self.use_color))
else:
rows.append(color(" 按 ? 查看全部快捷键与玩法说明", C_DIM, self.use_color))
for entry in self.engine.recent_log(limit=5):
tone = {
"good": C_GREEN,
"bad": C_RED,
"warn": C_ORANGE,
"action": C_CYAN,
}.get(entry["kind"], C_GRAY)
rows.append(
color(f" [D{entry['day']}] ", C_DIM, self.use_color)
+ color(entry["text"], tone, self.use_color)
)
log_box = draw_box("农事日志", rows, width, accent=C_PURPLE, use_color=self.use_color)
hint = [
color(" [↑↓←→]选地 [P]播种 [W]浇水 [H]收获 [E]全部收获", C_GRAY, self.use_color),
color(
" [F]喂食 [C]收取 [Tab]换牲畜 [B]买动物 [L]买地 [K]买饲料",
C_GRAY,
self.use_color,
),
color(" [空格]暂停 [S]存档 [R]重置 [Q]退出", C_GRAY, self.use_color),
]
if self.show_keyboard_notice and not self.reader.available:
# 读不到键盘时把原因摆在最显眼处,别让玩家对着界面干等
hint = [
color(
" ⚠ 当前终端读不到按键,只能观看:" + (self.reader.reason or "非交互式环境"),
C_ORANGE,
self.use_color,
)
]
return log_box + [pad(h, width) for h in hint]
def _render_menu(self, width: int) -> list[str]:
rows: list[str] = []
rows.append(color(" " + self.menu_title + "(按 Esc 取消)", C_CYAN, self.use_color))
for i, item in enumerate(self.menu, start=1):
if item["locked"]:
label = color(f" [{i}] {item['label']}", C_DIM, self.use_color)
detail = color(f"{item['detail']} ✗ {item['lock_hint']}", C_DIM, self.use_color)
else:
label = color(f" [{i}] {item['label']}", C_BOLD + C_WHITE, self.use_color)
tone = C_GREEN if item["affordable"] else C_RED
note = "" if item["affordable"] else " 金币不足"
detail = color(f"{item['detail']}{note}", tone, self.use_color)
rows.append(label + pad("", max(1, 20 - display_width(label))) + detail)
return draw_box("菜单", rows, width, accent=C_GOLD, use_color=self.use_color)
def _render_help(self, width: int) -> list[str]:
rows = [
color(
" 玩法目标:靠种地和养殖把净资产做到 10000 金币,解锁全部 5 个里程碑。",
C_WHITE,
self.use_color,
),
"",
color(" 时间", C_BOLD + C_GOLD, self.use_color)
+ color(
f" 每 {config.DEFAULT_TICK_SECONDS / 60:.0f} 分钟自动推进一天,"
"作物成长、牲畜产奶、市场价都在这一天结算;只能等,没有快进。",
C_GRAY,
self.use_color,
),
color(" 水分", C_BOLD + C_GOLD, self.use_color)
+ color(
" 作物每天消耗 1 点水分,水为 0 就停止生长。按 W 浇水恢复到 3 点。",
C_GRAY,
self.use_color,
),
color(" 行动点", C_BOLD + C_GOLD, self.use_color)
+ color(
" 每天 10 点,播种/浇水/喂食各消耗 1 点,收获和收取免费。", C_GRAY, self.use_color
),
color(" 牲畜", C_BOLD + C_GOLD, self.use_color)
+ color(
" 必须每天喂食才会产出;连续 3 天没吃东西会离开农场。", C_GRAY, self.use_color
),
color(" 市场", C_BOLD + C_GOLD, self.use_color)
+ color(" 作物和畜产品价格每天波动 ±18%,高点卖出更赚。", C_GRAY, self.use_color),
"",
color(" 快捷键", C_BOLD + C_GOLD, self.use_color)
+ color(
" P 播种 / W 浇水 / H 收获 / E 一键收获 / F 喂食 / C 收取产出",
C_GRAY,
self.use_color,
),
color(" ", C_GRAY, self.use_color)
+ color(
" B 买动物 / L 开垦土地 / K 买饲料 / 空格 暂停 / S 存档 / R 重置",
C_GRAY,
self.use_color,
),
"",
color(" AI 接入", C_BOLD + C_GOLD, self.use_color)
+ color(
" 游戏跑在服务器进程里,HTTP 接口给 AI、SSE 长连接给实时界面,",
C_GRAY,
self.use_color,
),
color(" ", C_GRAY, self.use_color)
+ color(
" AI 与你在同一个农场里操作(见 README「给 AI 的接口」)。", C_GRAY, self.use_color
),
"",
color(" 按任意键返回", C_DIM, self.use_color),
]
return draw_box("帮助 · 玩法说明", rows, width, accent=C_BLUE, use_color=self.use_color)