pyright(basic 模式)从 15 errors / 10 warnings 降到 0/0: - pyproject: pythonPlatform="All"(termios/msvcrt 双平台分支不再误报), reportUnsupportedDunderAll=false(__init__ 惰性导出属正常写法) - keys.py: 捕获 sys.stdin 局部变量 + 提前返回,消除 fileno() 的 None 收窄告警 - server/app.py: lifespan 改用 @asynccontextmanager 并标注 AsyncIterator[None] - server/actions.py: 显式标注 result: dict[str, Any],消除 isinstance 冗余报错 - server/__main__.py: 用 importlib.util.find_spec 探测 websockets - tests/test_server.py: 同上,uvicorn 探测改为 find_spec - tools/preview_tui.py: TYPE_CHECKING 下引入 RemoteFarm,修正返回类型标注 - farm/tui.py: 取消未使用变量,统一 round/取整写法 其他: - README: 新增第 11 节「工程化:uv + ruff + pyright」,快速开始全部改用 uv sync / uv run;原第 11 节起顺延为 12 - requirements.txt: 由 uv export --no-dev --no-hashes 重新生成(锁定版本) - 清理临时日志文件 验收:ruff check 全通过 / ruff format 26 文件无改动 / pyright 0 错误 / unittest 97 项全部通过
835 lines
32 KiB
Python
835 lines
32 KiB
Python
"""终端用户界面(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)
|