Files
snowy-yang 15d290a60a 移除行动点系统,倒计时本地走秒,字体优化
为什么:游戏的时间(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。
2026-09-25 22:44:08 +08:00

1002 lines
40 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""终端用户界面(TUI)。
渲染策略
--------
不做整屏清空,而是把光标归位后逐行重写并用 ``\\x1b[K`` 清理行尾,
这样刷新时几乎没有闪烁。
宽度处理
--------
中文是双宽字符,所有对齐都基于 :func:`display_width` 计算,
保证边框在 Windows Terminal / iTerm / VSCode 终端下都不歪。
"""
from __future__ import annotations
import contextlib
import os
import re
import shutil
import sys
import time
import unicodedata
from typing import Any
from . import config, keys
from .engine import GameError
from .keys import KeyReader
from .models import format_duration
from .storage import default_save_path
# ---------------------------------------------------------------- 颜色
C_RESET = "\x1b[0m"
C_DIM = "\x1b[38;5;242m"
C_GRAY = "\x1b[38;5;246m"
C_WHITE = "\x1b[38;5;253m"
C_GOLD = "\x1b[38;5;221m"
C_GREEN = "\x1b[38;5;114m"
C_RED = "\x1b[38;5;203m"
C_BLUE = "\x1b[38;5;111m"
C_CYAN = "\x1b[38;5;80m"
C_ORANGE = "\x1b[38;5;215m"
C_PURPLE = "\x1b[38;5;176m"
C_BOLD = "\x1b[1m"
ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
CELL_WIDTH = 17 # 单块农田格子的显示宽度
CELL_INNER = CELL_WIDTH - 2
SIDE_WIDTH = 36 # 右侧栏宽度
MIN_TERM_WIDTH = 76
MAX_VISIBLE_ANIMALS = 5 # 侧栏同时最多展示的牲畜数量
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)