update readme and front

This commit is contained in:
bridge
2025-09-12 00:00:33 +08:00
parent ccdcc590f4
commit e2f7afd6e3
10 changed files with 530 additions and 656 deletions

View File

@@ -1,4 +1,4 @@
from .front import Front
from .app import Front
__all__ = ["Front"]

157
src/front/app.py Normal file
View File

@@ -0,0 +1,157 @@
import asyncio
from typing import Dict, List, Optional
from src.sim.simulator import Simulator
from src.classes.event import Event
from src.classes.avatar import Avatar, Gender
from .theme import COLORS
from .fonts import create_font, get_region_font as _get_region_font_cached
from .assets import load_tile_images, load_avatar_images
from .rendering import (
draw_map,
draw_region_labels,
draw_avatars_and_pick_hover,
draw_tooltip_for_avatar,
draw_tooltip_for_region,
draw_status_bar,
)
from .events_panel import draw_sidebar
class Front:
def __init__(
self,
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,
sidebar_width: int = 300,
):
self.world = simulator.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.sidebar_width = sidebar_width
self._auto_step = True
self._last_step_ms = 0
self.events: List[Event] = []
import pygame
self.pygame = pygame
pygame.init()
pygame.font.init()
width_px = self.world.map.width * tile_size + margin * 2 + sidebar_width
height_px = self.world.map.height * tile_size + margin * 2
self.screen = pygame.display.set_mode((width_px, height_px))
pygame.display.set_caption(window_title)
self.font = create_font(self.pygame, 16, self.font_path)
self.tooltip_font = create_font(self.pygame, 14, self.font_path)
self.sidebar_font = create_font(self.pygame, 12, self.font_path)
self.status_font = create_font(self.pygame, 18, self.font_path)
self._region_font_cache: Dict[int, object] = {}
self.colors = COLORS
self.tile_images = load_tile_images(self.pygame, self.tile_size)
self.male_avatars, self.female_avatars = load_avatar_images(self.pygame, self.tile_size)
self.avatar_images: Dict[str, object] = {}
self._assign_avatar_images()
self.clock = pygame.time.Clock()
def add_events(self, new_events: List[Event]):
self.events.extend(new_events)
if len(self.events) > 1000:
self.events = self.events[-1000:]
async def _step_once_async(self):
events = await self.simulator.step()
if events:
self.add_events(events)
self._last_step_ms = 0
async def run_async(self):
pygame = self.pygame
running = True
current_step_task = None
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 == pygame.K_ESCAPE:
running = False
elif event.key == pygame.K_a:
self._auto_step = not self._auto_step
elif event.key == pygame.K_SPACE:
if current_step_task is None or current_step_task.done():
current_step_task = asyncio.create_task(self._step_once_async())
if self._auto_step and self._last_step_ms >= self.step_interval_ms:
if current_step_task is None or current_step_task.done():
current_step_task = asyncio.create_task(self._step_once_async())
self._last_step_ms = 0
if current_step_task and current_step_task.done():
await current_step_task
current_step_task = None
self._render()
await asyncio.sleep(0.016)
pygame.quit()
def _render(self):
pygame = self.pygame
self.screen.fill(self.colors["bg"])
draw_map(pygame, self.screen, self.colors, self.world, self.tile_images, self.tile_size, self.margin)
hovered_region = draw_region_labels(
pygame,
self.screen,
self.colors,
self.world,
self._get_region_font,
self.tile_size,
self.margin,
)
self._assign_avatar_images()
hovered_avatar = draw_avatars_and_pick_hover(
pygame, self.screen, self.colors, self.simulator, self.avatar_images, self.tile_size, self.margin
)
# 先绘制状态栏和侧边栏,再绘制 tooltip 保证 tooltip 在最上层
draw_status_bar(pygame, self.screen, self.colors, self.status_font, self.margin, self.world, self._auto_step)
draw_sidebar(
pygame, self.screen, self.colors, self.sidebar_font, self.events,
self.world.map, self.tile_size, self.margin, self.sidebar_width,
)
if hovered_avatar is not None:
draw_tooltip_for_avatar(pygame, self.screen, self.colors, self.tooltip_font, hovered_avatar)
elif hovered_region is not None:
mouse_x, mouse_y = pygame.mouse.get_pos()
draw_tooltip_for_region(pygame, self.screen, self.colors, self.tooltip_font, hovered_region, mouse_x, mouse_y)
pygame.display.flip()
def _get_region_font(self, size: int):
return _get_region_font_cached(self.pygame, self._region_font_cache, size, self.font_path)
def _assign_avatar_images(self):
import random
for avatar_id, avatar in self.simulator.avatars.items():
if avatar_id not in self.avatar_images:
if avatar.gender == Gender.MALE and self.male_avatars:
self.avatar_images[avatar_id] = random.choice(self.male_avatars)
elif avatar.gender == Gender.FEMALE and self.female_avatars:
self.avatar_images[avatar_id] = random.choice(self.female_avatars)
__all__ = ["Front"]

58
src/front/assets.py Normal file
View File

@@ -0,0 +1,58 @@
import os
from typing import Dict, List
from src.classes.tile import TileType
def load_tile_images(pygame_mod, tile_size: int) -> Dict[TileType, object]:
images: Dict[TileType, object] = {}
tile_types = [
TileType.PLAIN, TileType.WATER, TileType.SEA, TileType.MOUNTAIN,
TileType.FOREST, TileType.CITY, TileType.DESERT, TileType.RAINFOREST,
TileType.GLACIER, TileType.SNOW_MOUNTAIN, TileType.VOLCANO,
TileType.GRASSLAND, TileType.SWAMP, TileType.CAVE, TileType.RUINS, TileType.FARM
]
for tile_type in tile_types:
image_path = f"assets/tiles/{tile_type.value}.png"
if os.path.exists(image_path):
image = pygame_mod.image.load(image_path)
scaled = pygame_mod.transform.scale(image, (tile_size, tile_size))
images[tile_type] = scaled
return images
def load_avatar_images(pygame_mod, tile_size: int):
male_avatars: List[object] = []
female_avatars: List[object] = []
male_dir = "assets/males"
if os.path.exists(male_dir):
for filename in os.listdir(male_dir):
if filename.endswith('.png') and filename != 'original.png' and filename.replace('.png', '').isdigit():
image_path = os.path.join(male_dir, filename)
try:
image = pygame_mod.image.load(image_path)
avatar_size = max(26, int(tile_size * 4 // 3))
scaled = pygame_mod.transform.scale(image, (avatar_size, avatar_size))
male_avatars.append(scaled)
except pygame_mod.error:
continue
female_dir = "assets/females"
if os.path.exists(female_dir):
for filename in os.listdir(female_dir):
if filename.endswith('.png') and filename != 'original.png' and filename.replace('.png', '').isdigit():
image_path = os.path.join(female_dir, filename)
try:
image = pygame_mod.image.load(image_path)
avatar_size = max(26, int(tile_size * 4 // 3 * 0.8 * 1.2))
scaled = pygame_mod.transform.scale(image, (avatar_size, avatar_size))
female_avatars.append(scaled)
except pygame_mod.error:
continue
return male_avatars, female_avatars
__all__ = ["load_tile_images", "load_avatar_images"]

46
src/front/events_panel.py Normal file
View File

@@ -0,0 +1,46 @@
from typing import List
def draw_sidebar(pygame_mod, screen, colors, font, events: List[object],
world_map, tile_size: int, margin: int, sidebar_width: int):
sidebar_x = world_map.width * tile_size + margin * 2
sidebar_y = margin
sidebar_rect = pygame_mod.Rect(sidebar_x, sidebar_y, sidebar_width,
screen.get_height() - margin * 2)
pygame_mod.draw.rect(screen, colors["sidebar_bg"], sidebar_rect)
pygame_mod.draw.rect(screen, colors["sidebar_border"], sidebar_rect, 2)
title_text = "事件历史"
title_surf = font.render(title_text, True, colors["text"])
title_x = sidebar_x + 10
title_y = sidebar_y + 10
screen.blit(title_surf, (title_x, title_y))
line_y = title_y + title_surf.get_height() + 10
pygame_mod.draw.line(screen, colors["sidebar_border"],
(sidebar_x + 10, line_y),
(sidebar_x + sidebar_width - 10, line_y), 1)
event_y = line_y + 15
max_events = (screen.get_height() - event_y - margin) // 20
recent_events = events[-max_events:] if len(events) > max_events else events
for event in reversed(recent_events):
event_text = str(event)
if len(event_text) > 35:
event_text = event_text[:32] + "..."
event_surf = font.render(event_text, True, colors["event_text"])
screen.blit(event_surf, (title_x, event_y))
event_y += 20
if event_y > screen.get_height() - margin:
break
if not events:
no_event_text = "暂无事件"
no_event_surf = font.render(no_event_text, True, colors["event_text"])
screen.blit(no_event_surf, (title_x, event_y))
__all__ = ["draw_sidebar"]

38
src/front/fonts.py Normal file
View File

@@ -0,0 +1,38 @@
from typing import Optional, Dict
def create_font(pygame_mod, size: int, font_path: Optional[str]):
if font_path:
try:
return pygame_mod.font.Font(font_path, size)
except Exception:
pass
return _load_font_with_fallback(pygame_mod, size)
def _load_font_with_fallback(pygame_mod, size: int):
candidates = [
"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:
font = pygame_mod.font.SysFont(name, size)
test = font.render("测试中文AaBb123", True, (255, 255, 255))
if test.get_width() > 0:
return font
except Exception:
continue
return pygame_mod.font.SysFont(None, size)
def get_region_font(pygame_mod, cache: Dict[int, object], size: int, font_path: Optional[str]):
if size not in cache:
cache[size] = create_font(pygame_mod, size, font_path)
return cache[size]
__all__ = ["create_font", "get_region_font"]

View File

@@ -1,658 +1,4 @@
import math
from typing import Dict, List, Optional, Tuple
import asyncio # 新增导入asyncio
# 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
from src.classes.event import Event
from src.utils.text_wrap import wrap_text
class Front:
"""
基于 pygame 的前端展示。
功能:
- 渲染地图与Avatar
- 自动/手动步进模拟
- 鼠标悬停显示信息
按键:
- A切换自动步进
- 空格:手动执行一步
- ESC退出
"""
def __init__(
self,
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,
sidebar_width: int = 300, # 新增:侧边栏宽度
):
self.world = simulator.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.sidebar_width = sidebar_width # 新增:侧边栏宽度
# 运行时状态
self._auto_step = True
self._last_step_ms = 0
self.events: List[Event] = [] # 新增:存储事件历史
# 初始化pygame
import pygame
self.pygame = pygame
pygame.init()
pygame.font.init()
# 计算窗口大小(包含侧边栏)
width_px = self.world.map.width * tile_size + margin * 2 + sidebar_width
height_px = self.world.map.height * tile_size + margin * 2
self.screen = pygame.display.set_mode((width_px, height_px))
pygame.display.set_caption(window_title)
# 字体和缓存
self.font = self._create_font(16)
self.tooltip_font = self._create_font(14)
self.sidebar_font = self._create_font(12) # 新增:侧边栏字体
self.status_font = self._create_font(18) # 新增:状态栏字体(更大更清晰)
self._region_font_cache: Dict[int, object] = {}
# 配色方案
self.colors = {
"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),
"sidebar_bg": (25, 25, 25), # 新增:侧边栏背景色
"sidebar_border": (60, 60, 60), # 新增:侧边栏边框色
"event_text": (200, 200, 200), # 新增:事件文字色
"status_bg": (15, 15, 15), # 新增:状态栏背景色(深色)
"status_border": (50, 50, 50), # 新增:状态栏边框色
"status_text": (220, 220, 220), # 新增:状态栏文字色(亮色)
}
# 加载tile图像
self.tile_images = {}
self._load_tile_images()
# 加载avatar头像图像
self.male_avatars = []
self.female_avatars = []
self.avatar_images = {} # avatar_id -> 图像surface
self._load_avatar_images()
self.clock = pygame.time.Clock()
def add_events(self, new_events: List[Event]):
"""新增:添加新事件到事件历史"""
self.events.extend(new_events)
# 保持最多1000个事件避免内存占用过大
if len(self.events) > 1000:
self.events = self.events[-1000:]
async def _step_once_async(self):
"""异步执行一步模拟"""
events = await self.simulator.step() # 获取返回的事件
if events: # 新增:将事件添加到事件历史
self.add_events(events)
self._last_step_ms = 0
async def run_async(self):
"""异步主循环"""
pygame = self.pygame
running = True
# 用于存储正在进行的step任务
current_step_task = None
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 == pygame.K_ESCAPE:
running = False
elif event.key == pygame.K_a:
self._auto_step = not self._auto_step
elif event.key == pygame.K_SPACE:
# 手动步进:创建新任务
if current_step_task is None or current_step_task.done():
current_step_task = asyncio.create_task(self._step_once_async())
# 自动步进
if self._auto_step and self._last_step_ms >= self.step_interval_ms:
# 自动步进:创建新任务
if current_step_task is None or current_step_task.done():
current_step_task = asyncio.create_task(self._step_once_async())
self._last_step_ms = 0
# 检查step任务是否完成
if current_step_task and current_step_task.done():
await current_step_task
current_step_task = None
self._render()
# 使用asyncio.sleep而不是pygame的时钟避免阻塞
await asyncio.sleep(0.016) # 约60fps
pygame.quit()
def _render(self):
"""渲染主画面"""
pygame = self.pygame
# 清屏
self.screen.fill(self.colors["bg"])
# 绘制地图和标签
self._draw_map()
hovered_region = self._draw_region_labels()
hovered_avatar = self._draw_avatars_and_pick_hover()
# 显示tooltip (人物优先级高于region)
if hovered_avatar is not None:
self._draw_tooltip_for_avatar(hovered_avatar)
elif hovered_region is not None:
mouse_x, mouse_y = pygame.mouse.get_pos()
self._draw_tooltip_for_region(hovered_region, mouse_x, mouse_y)
# 状态信息
self._draw_status_bar()
# 新增:绘制侧边栏
self._draw_sidebar()
pygame.display.flip()
def _draw_status_bar(self):
"""绘制状态栏 - 包含操作指南和年月信息"""
pygame = self.pygame
# 状态栏配置
status_y = 8
status_height = 32
padding = 8
# 绘制状态栏背景
status_rect = pygame.Rect(0, 0, self.screen.get_width(), status_height)
pygame.draw.rect(self.screen, self.colors["status_bg"], status_rect)
pygame.draw.line(self.screen, self.colors["status_border"],
(0, status_height), (self.screen.get_width(), status_height), 2)
# 1. 绘制操作指南
self._draw_operation_guide(status_y, padding)
# 2. 绘制年月信息
self._draw_year_month_info(status_y, padding)
def _draw_operation_guide(self, y_pos: int, padding: int):
"""绘制操作指南"""
# 构建操作指南文本
auto_status = "" if self._auto_step else ""
guide_text = f"A:自动步进({auto_status}) SPACE:单步 ESC:退出"
# 渲染文本
guide_surf = self.status_font.render(guide_text, True, self.colors["status_text"])
# 绘制文本
x_pos = self.margin + padding
self.screen.blit(guide_surf, (x_pos, y_pos))
# 保存操作指南的宽度,供年月信息定位使用
self._guide_width = guide_surf.get_width()
def _draw_year_month_info(self, y_pos: int, padding: int):
"""绘制年月信息"""
# 获取年月数据
year = int(self.simulator.world.month_stamp.get_year())
month_num = self.simulator.world.month_stamp.get_month().value
# 构建年月文本
ym_text = f"{year}{month_num:02d}"
# 渲染文本
ym_surf = self.status_font.render(ym_text, True, self.colors["status_text"])
# 计算位置:放在操作指南右边,留适当间距
x_pos = self.margin + self._guide_width + padding * 3
self.screen.blit(ym_surf, (x_pos, y_pos))
def _draw_map(self):
"""绘制地图"""
pygame = self.pygame
map_obj = self.world.map
ts = self.tile_size
m = self.margin
# 绘制tile图像
for y in range(map_obj.height):
for x in range(map_obj.width):
tile = map_obj.get_tile(x, y)
tile_image = self.tile_images.get(tile.type)
if tile_image:
pos = (m + x * ts, m + y * ts)
self.screen.blit(tile_image, pos)
else:
# 默认颜色块
color = (80, 80, 80)
rect = pygame.Rect(m + x * ts, m + y * ts, ts, ts)
pygame.draw.rect(self.screen, color, rect)
# 绘制网格线
self._draw_grid(map_obj, ts, m)
def _draw_grid(self, map_obj, ts, m):
"""绘制网格线"""
pygame = self.pygame
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_region_labels(self):
"""绘制区域标签"""
pygame = self.pygame
ts = self.tile_size
m = self.margin
mouse_x, mouse_y = pygame.mouse.get_pos()
# 绘制每个region的标签
from src.classes.region import regions_by_id
hovered_region = None
for region in regions_by_id.values():
name = getattr(region, "name", None)
if not name:
continue
# 使用region的center_loc计算屏幕位置
center_x, center_y = region.center_loc
screen_x = m + center_x * ts + ts // 2
screen_y = m + center_y * ts + ts // 2
# 计算字体大小基于region面积
font_size = self._calculate_font_size_by_area(region.area)
region_font = self._get_region_font(font_size)
# 渲染文字
text_surface = region_font.render(str(name), True, self.colors["text"])
shadow_surface = region_font.render(str(name), True, (0, 0, 0))
# 计算位置(居中显示)
text_w = text_surface.get_width()
text_h = text_surface.get_height()
x = int(screen_x - text_w / 2)
y = int(screen_y - text_h / 2)
# 检测鼠标悬停
if (x <= mouse_x <= x + text_w and y <= mouse_y <= y + text_h):
hovered_region = region
# 绘制文字(先阴影后主文字)
self.screen.blit(shadow_surface, (x + 1, y + 1))
self.screen.blit(text_surface, (x, y))
return hovered_region
def _calculate_font_size_by_area(self, area):
"""根据区域面积计算字体大小"""
base = int(self.tile_size * 1.1)
growth = int(max(0, min(24, (area ** 0.5))))
return max(16, min(40, base + growth))
def _get_region_font(self, size: int):
"""获取指定大小的字体(带缓存)"""
if size not in self._region_font_cache:
self._region_font_cache[size] = self._create_font(size)
return self._region_font_cache[size]
def _draw_avatars_and_pick_hover(self) -> Optional[Avatar]:
"""绘制Avatar并检测悬停"""
pygame = self.pygame
mouse_x, mouse_y = pygame.mouse.get_pos()
hovered = None
min_dist = float("inf")
# 确保新的avatar也有头像分配
self._assign_avatar_images()
for avatar_id, avatar in self.simulator.avatars.items():
cx, cy = self._avatar_center_pixel(avatar)
# 尝试使用头像图片
avatar_image = self.avatar_images.get(avatar_id)
if avatar_image:
# 计算头像图片的位置(居中显示)
image_rect = avatar_image.get_rect()
image_x = cx - image_rect.width // 2
image_y = cy - image_rect.height // 2
# 绘制头像图片
self.screen.blit(avatar_image, (image_x, image_y))
# 检测悬停(使用图片的矩形区域)
if image_rect.collidepoint(mouse_x - image_x, mouse_y - image_y):
hovered = avatar
min_dist = 0 # 如果鼠标在图片内,设为最优先
else:
# 回退到圆点显示
radius = max(8, self.tile_size // 3)
pygame.draw.circle(self.screen, self.colors["avatar"], (cx, cy), radius)
# 检测悬停(使用圆形区域)
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]:
"""计算Avatar的像素中心位置"""
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 _draw_tooltip(self, lines: List[str], mouse_x: int, mouse_y: int, font):
"""绘制通用tooltip"""
pygame = self.pygame
# 计算尺寸
padding = 6
spacing = 2
surf_lines = [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)
# 计算位置
x = mouse_x + 12
y = mouse_y + 12
# 边界修正
screen_w, screen_h = self.screen.get_size()
if x + width > screen_w:
x = mouse_x - width - 12
if y + height > screen_h:
y = mouse_y - 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 _draw_tooltip_for_avatar(self, avatar: Avatar):
"""绘制Avatar的tooltip"""
lines = [
f"{avatar.name}",
f"性别: {avatar.gender}",
f"年龄: {avatar.age}",
f"境界: {str(avatar.cultivation_progress)}",
f"灵根: {avatar.root.value}",
f"个性: {avatar.persona.name}",
f"位置: ({avatar.pos_x}, {avatar.pos_y})",
]
# 添加灵石信息使用MagicStone的__str__方法显示详细信息
lines.append(f"灵石: {str(avatar.magic_stone)}")
# 添加物品信息
if avatar.items:
lines.append("物品:")
for item, quantity in avatar.items.items():
lines.append(f" {item.name} x{quantity}")
else:
lines.append("") # 空行分隔
lines.append("物品: 无")
# 添加thinking信息
if avatar.thinking:
lines.append("") # 空行分隔
lines.append("思考:")
# 使用wrap_text函数将thinking信息按20字符换行
thinking_lines = wrap_text(avatar.thinking, 20)
lines.extend(thinking_lines)
self._draw_tooltip(lines, *self.pygame.mouse.get_pos(), self.tooltip_font)
def _draw_tooltip_for_region(self, region, mouse_x: int, mouse_y: int):
"""绘制Region的tooltip"""
# 如果region为None不显示tooltip
if region is None:
return
lines = [
f"区域: {region.name}",
f"描述: {region.desc}",
]
# 根据region类型添加特殊信息
from src.classes.region import CultivateRegion, NormalRegion
if isinstance(region, CultivateRegion):
# 修炼区域:显示灵气信息
stars = "" * region.essence_density + "" * (10 - region.essence_density)
lines.append(f"主要灵气: {region.essence_type} {stars}")
elif isinstance(region, NormalRegion):
# 普通区域:显示物种信息
species_info = region.get_species_info()
if species_info and species_info != "暂无特色物种":
lines.append("物种分布:")
# 将详细的物种信息按分号分割,每个物种信息作为单独一行
for species in species_info.split("; "):
lines.append(f" {species}")
else:
lines.append("物种分布: 暂无特色物种")
# 城市区域不显示额外信息
self._draw_tooltip(lines, mouse_x, mouse_y, self.tooltip_font)
def _load_tile_images(self):
"""加载所有tile类型的图像"""
import os
pygame = self.pygame
# 定义所有tile类型
tile_types = [
TileType.PLAIN, TileType.WATER, TileType.SEA, TileType.MOUNTAIN,
TileType.FOREST, TileType.CITY, TileType.DESERT, TileType.RAINFOREST,
TileType.GLACIER, TileType.SNOW_MOUNTAIN, TileType.VOLCANO,
TileType.GRASSLAND, TileType.SWAMP, TileType.CAVE, TileType.RUINS, TileType.FARM
]
for tile_type in tile_types:
image_path = f"assets/tiles/{tile_type.value}.png"
if os.path.exists(image_path):
image = pygame.image.load(image_path)
scaled_image = pygame.transform.scale(image, (self.tile_size, self.tile_size))
self.tile_images[tile_type] = scaled_image
def _load_avatar_images(self):
"""加载avatar头像图像"""
import os
import random
pygame = self.pygame
# 加载男性头像
male_dir = "assets/males"
if os.path.exists(male_dir):
for filename in os.listdir(male_dir):
# 只加载数字序号的png文件跳过original.png
if filename.endswith('.png') and filename != 'original.png' and filename.replace('.png', '').isdigit():
image_path = os.path.join(male_dir, filename)
try:
image = pygame.image.load(image_path)
# 调整头像大小减小20%后再放大1.2倍
avatar_size = max(26, int(self.tile_size * 4 // 3))
scaled_image = pygame.transform.scale(image, (avatar_size, avatar_size))
self.male_avatars.append(scaled_image)
except pygame.error:
continue # 跳过无法加载的图片
# 加载女性头像
female_dir = "assets/females"
if os.path.exists(female_dir):
for filename in os.listdir(female_dir):
# 只加载数字序号的png文件跳过original.png
if filename.endswith('.png') and filename != 'original.png' and filename.replace('.png', '').isdigit():
image_path = os.path.join(female_dir, filename)
try:
image = pygame.image.load(image_path)
# 调整头像大小减小20%后再放大1.2倍
avatar_size = max(26, int(self.tile_size * 4 // 3 * 0.8 * 1.2))
scaled_image = pygame.transform.scale(image, (avatar_size, avatar_size))
self.female_avatars.append(scaled_image)
except pygame.error:
continue # 跳过无法加载的图片
# 为每个现有的avatar分配头像
self._assign_avatar_images()
def _assign_avatar_images(self):
"""为每个avatar分配头像图片"""
import random
for avatar_id, avatar in self.simulator.avatars.items():
if avatar_id not in self.avatar_images:
if avatar.gender == Gender.MALE and self.male_avatars:
self.avatar_images[avatar_id] = random.choice(self.male_avatars)
elif avatar.gender == Gender.FEMALE and self.female_avatars:
self.avatar_images[avatar_id] = random.choice(self.female_avatars)
# 如果没有可用的头像则使用None后续会画圆点作为fallback
def _create_font(self, size: int):
"""创建字体"""
if self.font_path:
try:
return self.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):
"""加载字体带fallback机制"""
pygame = self.pygame
# 字体候选列表
candidates = [
"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:
font = pygame.font.SysFont(name, size)
# 验证字体是否能渲染中文
test = font.render("测试中文AaBb123", True, (255, 255, 255))
if test.get_width() > 0:
return font
except Exception:
continue
# 退回默认字体
return pygame.font.SysFont(None, size)
def _draw_sidebar(self):
"""新增:绘制侧边栏"""
pygame = self.pygame
# 计算侧边栏位置
sidebar_x = self.world.map.width * self.tile_size + self.margin * 2
sidebar_y = self.margin
# 绘制侧边栏背景
sidebar_rect = pygame.Rect(sidebar_x, sidebar_y, self.sidebar_width,
self.screen.get_height() - self.margin * 2)
pygame.draw.rect(self.screen, self.colors["sidebar_bg"], sidebar_rect)
pygame.draw.rect(self.screen, self.colors["sidebar_border"], sidebar_rect, 2)
# 绘制标题
title_text = "事件历史"
title_surf = self.sidebar_font.render(title_text, True, self.colors["text"])
title_x = sidebar_x + 10
title_y = sidebar_y + 10
self.screen.blit(title_surf, (title_x, title_y))
# 绘制分隔线
line_y = title_y + title_surf.get_height() + 10
pygame.draw.line(self.screen, self.colors["sidebar_border"],
(sidebar_x + 10, line_y),
(sidebar_x + self.sidebar_width - 10, line_y), 1)
# 绘制事件列表
event_y = line_y + 15
max_events = (self.screen.get_height() - event_y - self.margin) // 20 # 每行20像素
# 显示最近的事件(从最新开始)
recent_events = self.events[-max_events:] if len(self.events) > max_events else self.events
for event in reversed(recent_events): # 最新的在顶部
event_text = str(event)
# 如果文本太长,截断它
if len(event_text) > 35: # 大约35个字符
event_text = event_text[:32] + "..."
event_surf = self.sidebar_font.render(event_text, True, self.colors["event_text"])
self.screen.blit(event_surf, (title_x, event_y))
event_y += 20
# 如果超出显示区域,停止绘制
if event_y > self.screen.get_height() - self.margin:
break
# 如果没有事件,显示提示信息
if not self.events:
no_event_text = "暂无事件"
no_event_surf = self.sidebar_font.render(no_event_text, True, self.colors["event_text"])
self.screen.blit(no_event_surf, (title_x, event_y))
from .app import Front
__all__ = ["Front"]

209
src/front/rendering.py Normal file
View File

@@ -0,0 +1,209 @@
import math
from typing import List, Optional, Tuple
from src.classes.avatar import Avatar, Gender
from src.classes.tile import TileType
from src.utils.text_wrap import wrap_text
def draw_grid(pygame_mod, screen, colors, map_obj, ts: int, m: int):
grid_color = 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_mod.draw.line(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_mod.draw.line(screen, grid_color, start_pos, end_pos, 1)
def draw_map(pygame_mod, screen, colors, world, tile_images, ts: int, m: int):
map_obj = world.map
for y in range(map_obj.height):
for x in range(map_obj.width):
tile = map_obj.get_tile(x, y)
tile_image = tile_images.get(tile.type)
if tile_image:
pos = (m + x * ts, m + y * ts)
screen.blit(tile_image, pos)
else:
color = (80, 80, 80)
rect = pygame_mod.Rect(m + x * ts, m + y * ts, ts, ts)
pygame_mod.draw.rect(screen, color, rect)
draw_grid(pygame_mod, screen, colors, map_obj, ts, m)
def calculate_font_size_by_area(tile_size: int, area: int) -> int:
base = int(tile_size * 1.1)
growth = int(max(0, min(24, (area ** 0.5))))
return max(16, min(40, base + growth))
def draw_region_labels(pygame_mod, screen, colors, world, get_region_font, tile_size: int, margin: int):
ts = tile_size
m = margin
mouse_x, mouse_y = pygame_mod.mouse.get_pos()
from src.classes.region import regions_by_id
hovered_region = None
for region in regions_by_id.values():
name = getattr(region, "name", None)
if not name:
continue
center_x, center_y = region.center_loc
screen_x = m + center_x * ts + ts // 2
screen_y = m + center_y * ts + ts // 2
font_size = calculate_font_size_by_area(tile_size, region.area)
region_font = get_region_font(font_size)
text_surface = region_font.render(str(name), True, colors["text"])
shadow_surface = region_font.render(str(name), True, (0, 0, 0))
text_w = text_surface.get_width()
text_h = text_surface.get_height()
x = int(screen_x - text_w / 2)
y = int(screen_y - text_h / 2)
if (x <= mouse_x <= x + text_w and y <= mouse_y <= y + text_h):
hovered_region = region
screen.blit(shadow_surface, (x + 1, y + 1))
screen.blit(text_surface, (x, y))
return hovered_region
def avatar_center_pixel(avatar: Avatar, tile_size: int, margin: int) -> Tuple[int, int]:
px = margin + avatar.pos_x * tile_size + tile_size // 2
py = margin + avatar.pos_y * tile_size + tile_size // 2
return px, py
def draw_avatars_and_pick_hover(pygame_mod, screen, colors, simulator, avatar_images, tile_size: int, margin: int) -> Optional[Avatar]:
mouse_x, mouse_y = pygame_mod.mouse.get_pos()
hovered = None
min_dist = float("inf")
for avatar_id, avatar in simulator.avatars.items():
cx, cy = avatar_center_pixel(avatar, tile_size, margin)
avatar_image = avatar_images.get(avatar_id)
if avatar_image:
image_rect = avatar_image.get_rect()
image_x = cx - image_rect.width // 2
image_y = cy - image_rect.height // 2
screen.blit(avatar_image, (image_x, image_y))
if image_rect.collidepoint(mouse_x - image_x, mouse_y - image_y):
hovered = avatar
min_dist = 0
else:
radius = max(8, tile_size // 3)
pygame_mod.draw.circle(screen, colors["avatar"], (cx, cy), radius)
dist = math.hypot(mouse_x - cx, mouse_y - cy)
if dist <= radius and dist < min_dist:
hovered = avatar
min_dist = dist
return hovered
def draw_tooltip(pygame_mod, screen, colors, lines: List[str], mouse_x: int, mouse_y: int, font):
padding = 6
spacing = 2
surf_lines = [font.render(t, True, 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)
x = mouse_x + 12
y = mouse_y + 12
screen_w, screen_h = screen.get_size()
if x + width > screen_w:
x = mouse_x - width - 12
if y + height > screen_h:
y = mouse_y - height - 12
bg_rect = pygame_mod.Rect(x, y, width, height)
pygame_mod.draw.rect(screen, colors["tooltip_bg"], bg_rect, border_radius=6)
pygame_mod.draw.rect(screen, colors["tooltip_bd"], bg_rect, 1, border_radius=6)
cursor_y = y + padding
for s in surf_lines:
screen.blit(s, (x + padding, cursor_y))
cursor_y += s.get_height() + spacing
def draw_tooltip_for_avatar(pygame_mod, screen, colors, font, avatar: Avatar):
lines = [
f"{avatar.name}",
f"性别: {avatar.gender}",
f"年龄: {avatar.age}",
f"境界: {str(avatar.cultivation_progress)}",
f"灵根: {avatar.root.value}",
f"个性: {avatar.persona.name}",
f"位置: ({avatar.pos_x}, {avatar.pos_y})",
]
lines.append(f"灵石: {str(avatar.magic_stone)}")
if avatar.items:
lines.append("物品:")
for item, quantity in avatar.items.items():
lines.append(f" {item.name} x{quantity}")
else:
lines.append("")
lines.append("物品: 无")
if avatar.thinking:
lines.append("")
lines.append("思考:")
thinking_lines = wrap_text(avatar.thinking, 20)
lines.extend(thinking_lines)
draw_tooltip(pygame_mod, screen, colors, lines, *pygame_mod.mouse.get_pos(), font)
def draw_tooltip_for_region(pygame_mod, screen, colors, font, region, mouse_x: int, mouse_y: int):
if region is None:
return
lines = [
f"区域: {region.name}",
f"描述: {region.desc}",
]
from src.classes.region import CultivateRegion, NormalRegion
if isinstance(region, CultivateRegion):
stars = "" * region.essence_density + "" * (10 - region.essence_density)
lines.append(f"主要灵气: {region.essence_type} {stars}")
elif isinstance(region, NormalRegion):
species_info = region.get_species_info()
if species_info and species_info != "暂无特色物种":
lines.append("物种分布:")
for species in species_info.split("; "):
lines.append(f" {species}")
else:
lines.append("物种分布: 暂无特色物种")
draw_tooltip(pygame_mod, screen, colors, lines, mouse_x, mouse_y, font)
def draw_operation_guide(pygame_mod, screen, colors, font, margin: int, auto_step: bool):
auto_status = "" if auto_step else ""
guide_text = f"A:自动步进({auto_status}) SPACE:单步 ESC:退出"
guide_surf = font.render(guide_text, True, colors["status_text"])
x_pos = margin + 8
screen.blit(guide_surf, (x_pos, 8))
return guide_surf.get_width()
def draw_year_month_info(pygame_mod, screen, colors, font, margin: int, guide_width: int, world):
year = int(world.month_stamp.get_year())
month_num = world.month_stamp.get_month().value
ym_text = f"{year}{month_num:02d}"
ym_surf = font.render(ym_text, True, colors["status_text"])
x_pos = margin + guide_width + 8 * 3
screen.blit(ym_surf, (x_pos, 8))
def draw_status_bar(pygame_mod, screen, colors, font, margin: int, world, auto_step: bool):
status_y = 8
status_height = 32
status_rect = pygame_mod.Rect(0, 0, screen.get_width(), status_height)
pygame_mod.draw.rect(screen, colors["status_bg"], status_rect)
pygame_mod.draw.line(screen, colors["status_border"],
(0, status_height), (screen.get_width(), status_height), 2)
guide_w = draw_operation_guide(pygame_mod, screen, colors, font, margin, auto_step)
draw_year_month_info(pygame_mod, screen, colors, font, margin, guide_w, world)
__all__ = [
"draw_map",
"draw_region_labels",
"draw_avatars_and_pick_hover",
"draw_tooltip_for_avatar",
"draw_tooltip_for_region",
"draw_status_bar",
]

18
src/front/theme.py Normal file
View File

@@ -0,0 +1,18 @@
COLORS = {
"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),
"sidebar_bg": (25, 25, 25),
"sidebar_border": (60, 60, 60),
"event_text": (200, 200, 200),
"status_bg": (15, 15, 15),
"status_border": (50, 50, 50),
"status_text": (220, 220, 220),
}
__all__ = ["COLORS"]