为什么:游戏的时间(40 分钟一天、只能自然流动)本身就是唯一的行动节奏 阀门,再叠一层"每天 10 点行动点"纯属重复惩罚——浇水还要体力既不拟真 也不好玩。删掉之后,稀缺的只剩时间本身,放置玩法的定位反而更纯粹。 改动范围(引擎到界面连根拔): - config 删 START_AP/AP_COST;models 删 ap 字段与 max_ap,存档 payload 里的旧 ap 键被 from_dict 自然忽略; - engine 删 _spend_ap 与全部 8 个调用点、每日恢复逻辑与日志; available_actions 不再按行动点筛,cost 只剩金币; - storage 建表去掉 ap 列,老库在 connect() 里 ALTER TABLE DROP COLUMN 无痛迁移(实测 saves/farm.db 已自动迁完); - bot 删 AP 分支(无 AP 后 available_actions 恒可用);client/TUI/CLI/ README/帮助文本同步清理。 网页端顺带两件事: - "下一天"倒计时改为客户端本地走秒——SSE 只在有变化时推送,以前倒计时 只在推送瞬间跳变,现在每秒都在动(记基点 + 1s 定时器补齐); - 字体优化:页面字体栈补齐 MiSans/HarmonyOS Sans/PingFang,正文字号 14→15,统计数字 tabular-nums;canvas 小字统一字体栈并加粗提对比度。 测试适配:删 2 个 AP 专属用例(无 AP 可测)、改名/收敛 5 个, 532 项全绿(skip 39),ruff/pyright 干净。真机验证:老库迁移、 统计条无行动点、倒计时逐秒递减(39:04→39:02)、动作不再报 no_ap。
1002 lines
40 KiB
Python
1002 lines
40 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, 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 # 侧栏同时最多展示的牲畜数量
|
||
MAX_VISIBLE_GOODS = 4 # 侧栏谷仓同时展示的货物种类
|
||
# 有菜单界面的模式;这几处判断原本各写了一遍模式列表
|
||
MENU_MODES = ("select_crop", "select_animal", "select_goods", "select_build")
|
||
|
||
|
||
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 MENU_MODES:
|
||
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),
|
||
"m": self._open_goods_menu,
|
||
"g": self._open_build_menu,
|
||
"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.0
|
||
count = 0
|
||
for number in targets:
|
||
try:
|
||
result = engine.harvest(number)
|
||
total += float(result.get("value", 0))
|
||
count += int(result.get("quantity", 0))
|
||
except GameError:
|
||
continue
|
||
if not count:
|
||
self._flash("一块都没收进来——多半是谷仓满了,按 M 卖点存货", C_RED)
|
||
return
|
||
self._flash(f"收获 {count} 份作物入谷仓(约值 {total:.0f} 金币,按 M 卖出)", 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 _open_goods_menu(self) -> None:
|
||
"""卖货菜单:每种存货一行,按仓量从多到少排。
|
||
|
||
"全部卖光"也做成一个正常选项而不是单独的快捷键——它和逐个卖是
|
||
同一件事,只是数量取满,放进同一个菜单里玩家更容易发现。
|
||
"""
|
||
inventory = {k: v for k, v in self.engine.state.inventory.items() if v > 0}
|
||
if not inventory:
|
||
self._flash("谷仓是空的:收获与收取的产出会自动入库", C_GRAY)
|
||
return
|
||
market = self.engine.state.market
|
||
items: list[dict[str, Any]] = []
|
||
for key, count in sorted(inventory.items(), key=lambda kv: -kv[1]):
|
||
price = float(market.get(key, 0.0))
|
||
base = config.base_price(key)
|
||
ratio = (price / base - 1) * 100 if base else 0.0
|
||
items.append(
|
||
{
|
||
"key": key,
|
||
"label": config.goods_name(key),
|
||
"cost": 0,
|
||
"detail": (
|
||
f"存{count:>3} 现价{price:>6.0f} 基准{base:>5.0f} "
|
||
f"{ratio:+.0f}% 共值{price * count:>7.0f}"
|
||
),
|
||
"locked": False,
|
||
"lock_hint": "",
|
||
"affordable": True,
|
||
}
|
||
)
|
||
items.append(
|
||
{
|
||
"key": None,
|
||
"label": "全部卖光",
|
||
"cost": 0,
|
||
"detail": "一键清仓(价格不好时慎用)",
|
||
"locked": False,
|
||
"lock_hint": "",
|
||
"affordable": True,
|
||
}
|
||
)
|
||
self.menu = items
|
||
self.menu_title = "卖出谷仓里的货物"
|
||
self.mode = "select_goods"
|
||
|
||
def _open_build_menu(self) -> None:
|
||
"""建造菜单。报价走服务器给的 catalog,拿不到时退回本地配置表。"""
|
||
catalog = {b.get("key"): b for b in (self.engine.catalog().get("buildings") or [])}
|
||
state = self.engine.state
|
||
items: list[dict[str, Any]] = []
|
||
for key, info in config.BUILDINGS.items():
|
||
entry = catalog.get(key, {})
|
||
name = info["name"]
|
||
effect = info["effect"]
|
||
level_req = info["level_req"]
|
||
max_level = info["max_level"]
|
||
owned = entry.get("owned", state.buildings.get(key, 0))
|
||
next_cost = entry.get("next_cost", config.build_price(key, owned))
|
||
maxed = entry.get("maxed", owned >= max_level)
|
||
locked = entry.get("locked", state.level < level_req)
|
||
if maxed:
|
||
detail = f"已建满 {max_level} 座 {effect}"
|
||
else:
|
||
detail = f"{next_cost:>5}金币 {owned}/{max_level} 座 {effect}"
|
||
items.append(
|
||
{
|
||
"key": key,
|
||
"label": name,
|
||
"cost": next_cost,
|
||
"detail": detail,
|
||
"locked": locked,
|
||
"lock_hint": f"需 Lv.{level_req}",
|
||
"affordable": not maxed and state.gold >= next_cost,
|
||
}
|
||
)
|
||
self.menu = items
|
||
self.menu_title = "建造(同类建筑越造越贵)"
|
||
self.mode = "select_build"
|
||
|
||
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"])
|
||
elif self.mode == "select_animal":
|
||
self._act(self.engine.buy_animal, item["key"])
|
||
elif self.mode == "select_goods":
|
||
self._act(self.engine.sell, item["key"])
|
||
else:
|
||
self._act(self.engine.build, 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 = ""
|
||
# 天气放在最显眼的位置:它是每天唯一会变的"环境参数",
|
||
# 而且"今→明"里的第二个字就是预报,浇水决策要靠它。
|
||
weather_name = snap.get("weather_name")
|
||
if weather_name:
|
||
tag += color(
|
||
f"{weather_name}→{snap.get('forecast_name') or '?'} ", C_PURPLE, self.use_color
|
||
)
|
||
|
||
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"]
|
||
|
||
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(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_box = draw_box(
|
||
"市场行情", market_rows, side_width, accent=C_CYAN, use_color=self.use_color
|
||
)
|
||
|
||
# ---- 谷仓:有存货才占地方,空仓只留一行说明
|
||
inventory = {k: v for k, v in (snap.get("inventory") or {}).items() if v > 0}
|
||
granary_rows: list[str] = []
|
||
market = snap.get("market") or {}
|
||
for key, count in sorted(inventory.items(), key=lambda kv: -kv[1])[:MAX_VISIBLE_GOODS]:
|
||
price = float(market.get(key, 0.0))
|
||
base = config.base_price(key)
|
||
ratio = price / base if base else 1.0
|
||
if ratio > 1.06:
|
||
tone = C_RED
|
||
elif ratio < 0.94:
|
||
tone = C_GREEN
|
||
else:
|
||
tone = C_GRAY
|
||
label = color(f" {config.goods_name(key)}", C_WHITE, self.use_color)
|
||
value = color(f"{count:>3} ×{price:>5.0f}", tone, self.use_color)
|
||
spacer = max(1, side_width - 4 - display_width(label) - display_width(value))
|
||
granary_rows.append(label + " " * spacer + value)
|
||
if not granary_rows:
|
||
granary_rows.append(color(" 空仓:收获会自动入库", C_DIM, self.use_color))
|
||
elif len(inventory) > MAX_VISIBLE_GOODS:
|
||
granary_rows.append(
|
||
color(f" …还有 {len(inventory) - MAX_VISIBLE_GOODS} 种存货", C_DIM, self.use_color)
|
||
)
|
||
used = int(snap.get("storage_used") or 0)
|
||
capacity = int(snap.get("granary_capacity") or 0)
|
||
granary_rows.append(
|
||
color(f" 仓容 {used}/{capacity} ", C_GRAY, self.use_color)
|
||
+ color(bar(used, max(1, capacity), 8), C_RED if used >= capacity else C_GOLD)
|
||
)
|
||
granary_rows.append(color(" 按 M 卖出存货", C_DIM, self.use_color))
|
||
granary = draw_box(
|
||
"谷仓", granary_rows, side_width, accent=C_GOLD, use_color=self.use_color
|
||
)
|
||
|
||
return pasture + market_box + granary
|
||
|
||
# ---------------------------------------------------------- 底部
|
||
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 MENU_MODES:
|
||
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]换牲畜 [M]卖货 [G]建造 [B]买动物",
|
||
C_GRAY,
|
||
self.use_color,
|
||
),
|
||
color(
|
||
" [L]买地 [K]买饲料 [空格]暂停 [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(
|
||
" 每天换一种:晴/多云/雨/干旱/暴风雨。作物按天气蒸发 0~2 点水分,"
|
||
"雨天自动补满,暴风雨还可能毁苗。",
|
||
C_GRAY,
|
||
self.use_color,
|
||
),
|
||
color(" 水分", C_BOLD + C_GOLD, self.use_color)
|
||
+ color(
|
||
f" 水为 0 就停止生长,按 W 浇满({config.WATER_CAPACITY} 点)。"
|
||
"顶部「今→明」的第二个字就是明天的天气预报。",
|
||
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(
|
||
" 收获与收取的产出先进谷仓、不直接换钱;按 M 卖出,装满了就收不进来。",
|
||
C_GRAY,
|
||
self.use_color,
|
||
),
|
||
color(" 建筑", C_BOLD + C_GOLD, self.use_color)
|
||
+ color(
|
||
" 按 G 建造:谷仓加容量、水井自动浇水、喂食器自动喂食、温室免疫天气。",
|
||
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(
|
||
" M 卖货 / G 建造 / B 买动物 / L 开垦土地 / K 买饲料",
|
||
C_GRAY,
|
||
self.use_color,
|
||
),
|
||
color(" ", C_GRAY, self.use_color)
|
||
+ color(" 空格 暂停 / S 存档 / R 重置 / Q 退出", 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)
|