282 lines
9.2 KiB
Python
282 lines
9.2 KiB
Python
import math
|
||
from typing import Dict, List, Optional, Tuple
|
||
|
||
# Front 只依赖项目内部类型定义与 pygame
|
||
from src.sim.simulator import Simulator
|
||
from src.classes.world import World
|
||
from src.classes.tile import TileType
|
||
from src.classes.avatar import Avatar, Gender
|
||
|
||
|
||
class Front:
|
||
"""
|
||
基于 pygame 的前端展示。
|
||
|
||
- 渲染地图 `World.map` 与其中的 `Avatar`
|
||
- 以固定节奏调用 `simulator.step()`,画面随之更新
|
||
- 鼠标悬停在 avatar 上时显示信息
|
||
|
||
按键:
|
||
- A:切换自动步进(默认开启)
|
||
- 空格:手动执行一步(在自动关闭时有用)
|
||
- ESC / 关闭窗口:退出
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
world: World,
|
||
simulator: Simulator,
|
||
*,
|
||
tile_size: int = 32,
|
||
margin: int = 8,
|
||
step_interval_ms: int = 400,
|
||
window_title: str = "Cultivation World Simulator",
|
||
font_path: Optional[str] = None,
|
||
):
|
||
self.world = world
|
||
self.simulator = simulator
|
||
self.tile_size = tile_size
|
||
self.margin = margin
|
||
self.step_interval_ms = step_interval_ms
|
||
self.window_title = window_title
|
||
self.font_path = font_path
|
||
|
||
# 运行时状态
|
||
self._auto_step = True
|
||
self._last_step_ms = 0
|
||
|
||
# 延迟导入 pygame:避免未安装 pygame 时影响非可视化运行/测试
|
||
import pygame # type: ignore
|
||
|
||
self.pygame = pygame
|
||
pygame.init()
|
||
pygame.font.init()
|
||
|
||
# 计算窗口大小
|
||
width_px = world.map.width * tile_size + margin * 2
|
||
height_px = world.map.height * tile_size + margin * 2
|
||
self.screen = pygame.display.set_mode((width_px, height_px))
|
||
pygame.display.set_caption(window_title)
|
||
|
||
# 字体(优先中文友好字体;可显式传入 TTF 路径)
|
||
self.font = self._create_font(16)
|
||
self.tooltip_font = self._create_font(14)
|
||
|
||
# 配色
|
||
self.colors: Dict[str, Tuple[int, int, int]] = {
|
||
"bg": (18, 18, 18),
|
||
"grid": (40, 40, 40),
|
||
"text": (230, 230, 230),
|
||
"tooltip_bg": (32, 32, 32),
|
||
"tooltip_bd": (90, 90, 90),
|
||
"avatar": (240, 220, 90),
|
||
}
|
||
|
||
self.tile_colors: Dict[TileType, Tuple[int, int, int]] = {
|
||
TileType.PLAIN: (64, 120, 64),
|
||
TileType.FOREST: (24, 96, 48),
|
||
TileType.MOUNTAIN: (108, 108, 108),
|
||
TileType.WATER: (60, 120, 180),
|
||
TileType.SEA: (30, 90, 150),
|
||
TileType.CITY: (140, 120, 90),
|
||
TileType.DESERT: (210, 180, 60),
|
||
TileType.RAINFOREST: (12, 80, 36),
|
||
TileType.GLACIER: (210, 230, 240),
|
||
TileType.SNOW_MOUNTAIN: (200, 200, 200),
|
||
}
|
||
|
||
self.clock = pygame.time.Clock()
|
||
|
||
# --------------------------- 主循环 ---------------------------
|
||
def run(self):
|
||
pygame = self.pygame
|
||
running = True
|
||
while running:
|
||
dt_ms = self.clock.tick(60)
|
||
self._last_step_ms += dt_ms
|
||
|
||
for event in pygame.event.get():
|
||
if event.type == pygame.QUIT:
|
||
running = False
|
||
elif event.type == pygame.KEYDOWN:
|
||
if event.key in (pygame.K_ESCAPE,):
|
||
running = False
|
||
elif event.key == pygame.K_a:
|
||
self._auto_step = not self._auto_step
|
||
elif event.key == pygame.K_SPACE:
|
||
self._step_once()
|
||
|
||
if self._auto_step and self._last_step_ms >= self.step_interval_ms:
|
||
self._step_once()
|
||
|
||
self._render()
|
||
|
||
pygame.quit()
|
||
|
||
def _step_once(self):
|
||
self.simulator.step()
|
||
self._last_step_ms = 0
|
||
|
||
# --------------------------- 渲染 ---------------------------
|
||
def _render(self):
|
||
pygame = self.pygame
|
||
self.screen.fill(self.colors["bg"])
|
||
|
||
self._draw_map()
|
||
hovered = self._draw_avatars_and_pick_hover()
|
||
if hovered is not None:
|
||
self._draw_tooltip_for_avatar(hovered)
|
||
|
||
# 状态条
|
||
hint = f"A:自动步进({'开' if self._auto_step else '关'}) SPACE:单步 ESC:退出"
|
||
text_surf = self.font.render(hint, True, self.colors["text"])
|
||
self.screen.blit(text_surf, (self.margin, 4))
|
||
|
||
pygame.display.flip()
|
||
|
||
def _draw_map(self):
|
||
pygame = self.pygame
|
||
map_obj = self.world.map
|
||
ts = self.tile_size
|
||
m = self.margin
|
||
|
||
# 先画网格背景块
|
||
for y in range(map_obj.height):
|
||
for x in range(map_obj.width):
|
||
tile = map_obj.get_tile(x, y)
|
||
color = self.tile_colors.get(tile.type, (80, 80, 80))
|
||
rect = pygame.Rect(m + x * ts, m + y * ts, ts, ts)
|
||
pygame.draw.rect(self.screen, color, rect)
|
||
|
||
# 画网格线
|
||
grid_color = self.colors["grid"]
|
||
for gx in range(map_obj.width + 1):
|
||
start_pos = (m + gx * ts, m)
|
||
end_pos = (m + gx * ts, m + map_obj.height * ts)
|
||
pygame.draw.line(self.screen, grid_color, start_pos, end_pos, 1)
|
||
for gy in range(map_obj.height + 1):
|
||
start_pos = (m, m + gy * ts)
|
||
end_pos = (m + map_obj.width * ts, m + gy * ts)
|
||
pygame.draw.line(self.screen, grid_color, start_pos, end_pos, 1)
|
||
|
||
def _draw_avatars_and_pick_hover(self) -> Optional[Avatar]:
|
||
pygame = self.pygame
|
||
mouse_x, mouse_y = pygame.mouse.get_pos()
|
||
|
||
hovered: Optional[Avatar] = None
|
||
min_dist = float("inf")
|
||
|
||
for avatar in self.simulator.avatars:
|
||
cx, cy = self._avatar_center_pixel(avatar)
|
||
radius = max(8, self.tile_size // 3)
|
||
pygame.draw.circle(self.screen, self.colors["avatar"], (cx, cy), radius)
|
||
|
||
# 简单的 hover:鼠标与圆心距离
|
||
dist = math.hypot(mouse_x - cx, mouse_y - cy)
|
||
if dist <= radius and dist < min_dist:
|
||
hovered = avatar
|
||
min_dist = dist
|
||
|
||
return hovered
|
||
|
||
# --------------------------- 工具/辅助 ---------------------------
|
||
def _avatar_center_pixel(self, avatar: Avatar) -> Tuple[int, int]:
|
||
ts = self.tile_size
|
||
m = self.margin
|
||
px = m + avatar.pos_x * ts + ts // 2
|
||
py = m + avatar.pos_y * ts + ts // 2
|
||
return px, py
|
||
|
||
def _avatar_tooltip_lines(self, avatar: Avatar) -> List[str]:
|
||
gender = str(avatar.gender)
|
||
|
||
pos = f"({avatar.pos_x}, {avatar.pos_y})"
|
||
lines = [
|
||
f"{avatar.name}#{avatar.id}",
|
||
f"性别: {gender}",
|
||
f"年龄: {avatar.age}",
|
||
f"位置: {pos}",
|
||
]
|
||
return lines
|
||
|
||
def _draw_tooltip_for_avatar(self, avatar: Avatar):
|
||
pygame = self.pygame
|
||
lines = self._avatar_tooltip_lines(avatar)
|
||
|
||
# 计算尺寸
|
||
padding = 6
|
||
spacing = 2
|
||
surf_lines = [self.tooltip_font.render(t, True, self.colors["text"]) for t in lines]
|
||
width = max(s.get_width() for s in surf_lines) + padding * 2
|
||
height = sum(s.get_height() for s in surf_lines) + padding * 2 + spacing * (len(surf_lines) - 1)
|
||
|
||
mx, my = pygame.mouse.get_pos()
|
||
x = mx + 12
|
||
y = my + 12
|
||
|
||
# 边界修正:尽量不出屏幕
|
||
screen_w, screen_h = self.screen.get_size()
|
||
if x + width > screen_w:
|
||
x = mx - width - 12
|
||
if y + height > screen_h:
|
||
y = my - height - 12
|
||
|
||
bg_rect = pygame.Rect(x, y, width, height)
|
||
pygame.draw.rect(self.screen, self.colors["tooltip_bg"], bg_rect, border_radius=6)
|
||
pygame.draw.rect(self.screen, self.colors["tooltip_bd"], bg_rect, 1, border_radius=6)
|
||
|
||
# 绘制文字
|
||
cursor_y = y + padding
|
||
for s in surf_lines:
|
||
self.screen.blit(s, (x + padding, cursor_y))
|
||
cursor_y += s.get_height() + spacing
|
||
|
||
|
||
def _create_font(self, size: int):
|
||
pygame = self.pygame
|
||
if self.font_path:
|
||
try:
|
||
return pygame.font.Font(self.font_path, size)
|
||
except Exception:
|
||
# 回退到自动匹配
|
||
pass
|
||
return self._load_font_with_fallback(size)
|
||
|
||
def _load_font_with_fallback(self, size: int):
|
||
"""
|
||
在不同平台上尝试加载常见等宽或中文字体,避免中文渲染为方块。
|
||
"""
|
||
pygame = self.pygame
|
||
candidates = [
|
||
# Windows 常见中文字体
|
||
"Microsoft YaHei UI",
|
||
"Microsoft YaHei",
|
||
"SimHei",
|
||
"SimSun",
|
||
# 常见等宽/通用字体
|
||
"Consolas",
|
||
"DejaVu Sans",
|
||
"DejaVu Sans Mono",
|
||
"Arial Unicode MS",
|
||
"Noto Sans CJK SC",
|
||
"Noto Sans CJK",
|
||
]
|
||
|
||
for name in candidates:
|
||
try:
|
||
f = pygame.font.SysFont(name, size)
|
||
# 简单验证一下是否能渲染中文(有些字体返回成功但渲染为空)
|
||
test = f.render("测试中文AaBb123", True, (255, 255, 255))
|
||
if test.get_width() > 0:
|
||
return f
|
||
except Exception:
|
||
pass
|
||
|
||
# 退回默认字体
|
||
return pygame.font.SysFont(None, size)
|
||
|
||
|
||
__all__ = ["Front"]
|
||
|
||
|