"""终端用户界面(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 from .engine import GameError from .keys import KeyReader 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.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 in ("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 # ---- 主模式 handler = { "q": self._quit, "?": self._open_help, "up": lambda: self._move_plot(-self.grid_cols), "down": lambda: self._move_plot(self.grid_cols), "left": lambda: self._move_plot(-1), "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), "n": lambda: self._act(self.engine.sleep), "s": self._save, "r": self._ask_reset, " ": self._toggle_pause, "tab": self._toggle_focus, "tab\t": 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 ("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']} 天 · 下一日 {countdown:.1f}s ", 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) keys = " [↑↓←→]选地 [P]播种 [W]浇水 [H]收获 [E]全部收获" keys2 = " [F]喂食 [C]收取 [Tab]换牲畜 [B]买动物 [L]买地 [K]买饲料" keys3 = " [N]休息 [空格]暂停 [S]存档 [R]重置 [Q]退出" hint = [ color(keys, C_GRAY, self.use_color), color(keys2, C_GRAY, self.use_color), color(keys3, C_GRAY, 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( " 每 15 秒自动推进一天,作物成长、牲畜产奶、市场价格都在这一天结算。", 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 买饲料 / N 休息 / 空格 暂停 / S 存档 / R 重置", C_GRAY, self.use_color, ), "", color(" AI 接入", C_BOLD + C_GOLD, self.use_color) + color( " 游戏跑在服务器进程里,HTTP 接口与 WebSocket 通道同时开放,", 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)