chore: 工程化改造前的基线快照

在引入 uv 依赖管理、ruff 静态检查与格式化、pyright 类型检查之前,
先把当前可正常运行的状态固定下来,作为后续大规模自动改动的回滚点。

此时状态:97 项测试全绿,HTTP + WebSocket 多牧场服务器可用。
This commit is contained in:
snowy-yang committed 2026-09-18 21:12:40 +08:00
commit a7725f8812
35 files changed
+11871

No files matched your search

+784
View File
@@ -0,0 +1,784 @@
"""终端用户界面(TUI)。
渲染策略
--------
不做整屏清空,而是把光标归位后逐行重写并用 ``\\x1b[K`` 清理行尾,
这样刷新时几乎没有闪烁。
宽度处理
--------
中文是双宽字符,所有对齐都基于 :func:`display_width` 计算,
保证边框在 Windows Terminal / iTerm / VSCode 终端下都不歪。
"""
from __future__ import annotations
import os
import re
import shutil
import sys
import time
import unicodedata
from typing import Any, Dict, List, Optional, Tuple
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, int(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: Optional[str] = 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]
for row in body:
lines.append("│" + pad(truncate(row, inner), inner) + "│")
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):
l = left[i] if i < len(left) else ""
r = right[i] if i < len(right) else ""
out.append(pad(l, left_width) + " " * gap + r)
return out
class TuiApp:
"""交互式界面主程序。"""
def __init__(self, engine: Any, save_path: Optional[str] = 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":
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
sys.stdout.write("\x1b[?25l\x1b[2J\x1b[H")
sys.stdout.write(C_RESET)
sys.stdout.flush()
def _exit(self) -> None:
if self.autosave:
try:
self.engine.save(self.save_path) # 本地引擎写文件;远程引擎让服务器落盘
except Exception:
pass
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"]
for line in lines:
out.append(truncate(line, width) + "\x1b[K\r\n")
# 清掉本轮剩余行
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)
line = left + "─" * gap + right + "╮"
return line
# ---------------------------------------------------------- 状态区
def _render_status(self, snap: Dict[str, Any], width: int) -> List[str]:
net_worth = snap["net_worth"]
goal = snap["next_goal"]
reached = snap["goal_index"]
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, int(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)