chore: 工程化改造前的基线快照
在引入 uv 依赖管理、ruff 静态检查与格式化、pyright 类型检查之前, 先把当前可正常运行的状态固定下来,作为后续大规模自动改动的回滚点。 此时状态:97 项测试全绿,HTTP + WebSocket 多牧场服务器可用。
This commit is contained in:
commit
a7725f8812
35 files changed
+11871
No files matched your search
+784
@@ -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)
|
||||
Reference in new issue
Block a user