From 5b4b1b1ff8da23dd032bf74ba299ee17b83ec354 Mon Sep 17 00:00:00 2001 From: bridge Date: Thu, 23 Oct 2025 00:04:04 +0800 Subject: [PATCH] refactor front --- src/front/app.py | 39 +++++++++-- src/front/assets.py | 26 ++++--- src/front/events_panel.py | 5 +- src/front/rendering.py | 139 +++++++++++++++----------------------- 4 files changed, 102 insertions(+), 107 deletions(-) diff --git a/src/front/app.py b/src/front/app.py index d32d789..6286afd 100644 --- a/src/front/app.py +++ b/src/front/app.py @@ -1,4 +1,5 @@ import asyncio +import random from typing import Dict, List, Optional from src.sim.simulator import Simulator @@ -16,6 +17,8 @@ from .rendering import ( draw_tooltip_for_region, draw_status_bar, STATUS_BAR_HEIGHT, + draw_small_regions, + draw_sect_headquarters, ) from .events_panel import draw_sidebar @@ -82,6 +85,10 @@ class Front: self._sidebar_filter_avatar_id: Optional[str] = None self._sidebar_filter_open: bool = False + # 侧栏筛选选项缓存(列表)与脏标记 + self._sidebar_options_cache: Optional[List[tuple[str, Optional[str]]]] = None + self._sidebar_options_dirty: bool = True + # hover 轮换状态(滚轮切换) self._hover_anchor_pos: Optional[tuple[int, int]] = None self._hover_candidates: List[str] = [] # avatar_id 列表(当前锚点下) @@ -100,6 +107,8 @@ class Front: self._last_step_ms = 0 # 步进完成后,更新插值目标 self._update_avatar_display_targets() + # 世界推进后,角色增减或名称改变的可能性上升,置脏侧栏选项 + self._sidebar_options_dirty = True async def run_async(self): pygame = self.pygame @@ -156,7 +165,6 @@ class Front: STATUS_BAR_HEIGHT, ) # 底图后叠加小区域整图(2x2/3x3),再绘制宗门总部,避免被覆盖 - from .rendering import draw_sect_headquarters, draw_small_regions draw_small_regions(pygame, self.screen, self.world, self.region_images, self.tile_images, self.tile_size, self.margin, STATUS_BAR_HEIGHT, self.tile_originals) draw_sect_headquarters(pygame, self.screen, self.world, self.sect_images, self.tile_size, self.margin, STATUS_BAR_HEIGHT) hovered_region = draw_region_labels( @@ -194,10 +202,8 @@ class Front: aid = self._sidebar_filter_avatar_id events_to_draw = [e for e in self.events if getattr(e, "related_avatars", None) and (aid in e.related_avatars)] - # 构造下拉选项(第一个是所有人;其余为当前世界中的角色) - options: List[tuple[str, Optional[str]]] = [("所有人", None)] - for avatar_id, avatar in self.world.avatar_manager.avatars.items(): - options.append((avatar.name, avatar_id)) + # 构造下拉选项(第一个是所有人;其余为当前世界中的角色)- 带缓存 + options = self._get_sidebar_options_cached() sel_label = "所有人" if self._sidebar_filter_avatar_id is not None: sel_avatar = self.world.avatar_manager.avatars.get(self._sidebar_filter_avatar_id) @@ -311,13 +317,20 @@ class Front: self._hover_last_build_ms = self._now_ms() def _assign_avatar_images(self): - import random + # 若在上一次分配后头像集合未发生变化,且数量相等,则跳过 + if not getattr(self, "_avatar_assign_dirty", True) and len(self.avatar_images) == len(self.world.avatar_manager.avatars): + return + assigned_new = False for avatar_id, avatar in self.world.avatar_manager.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) + assigned_new = True + # 分配完成,标记为干净;在后续状态更新时会被置脏 + if assigned_new or len(self.avatar_images) == len(self.world.avatar_manager.avatars): + self._avatar_assign_dirty = False # --- 插值辅助 --- def _now_ms(self) -> int: @@ -344,6 +357,10 @@ class Front: "start_ms": float(now), "duration_ms": float(max(1, self.step_interval_ms)), } + # 任何插值初始化/同步都可能意味着角色集合发生变化,置脏以便头像图像分配在下一帧检查 + self._avatar_assign_dirty = True + # 角色集合变动也会影响侧栏选项 + self._sidebar_options_dirty = True def _update_avatar_display_targets(self): now = self._now_ms() @@ -388,6 +405,16 @@ class Front: y = float(state["start_py"]) + (float(state["target_py"]) - float(state["start_py"])) * te return x, y + def _get_sidebar_options_cached(self) -> List[tuple[str, Optional[str]]]: + if (not self._sidebar_options_dirty) and self._sidebar_options_cache is not None: + return self._sidebar_options_cache + options: List[tuple[str, Optional[str]]] = [("所有人", None)] + for avatar_id, avatar in self.world.avatar_manager.avatars.items(): + options.append((avatar.name, avatar_id)) + self._sidebar_options_cache = options + self._sidebar_options_dirty = False + return options + __all__ = ["Front"] diff --git a/src/front/assets.py b/src/front/assets.py index 3a6ba4a..53b8ad5 100644 --- a/src/front/assets.py +++ b/src/front/assets.py @@ -4,15 +4,18 @@ from pathlib import Path from src.classes.tile import TileType +# 统一的贴图类型集合,供各加载函数复用 +ALL_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 +] + + 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: + for tile_type in ALL_TILE_TYPES: image_path = f"assets/tiles/{tile_type.value}.png" if os.path.exists(image_path): image = pygame_mod.image.load(image_path) @@ -23,13 +26,7 @@ def load_tile_images(pygame_mod, tile_size: int) -> Dict[TileType, object]: def load_tile_originals(pygame_mod) -> Dict[TileType, object]: originals: 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: + for tile_type in ALL_TILE_TYPES: image_path = f"assets/tiles/{tile_type.value}.png" if os.path.exists(image_path): originals[tile_type] = pygame_mod.image.load(image_path) @@ -104,6 +101,7 @@ __all__ = [ "load_avatar_images", "load_sect_images", "load_region_images", + "ALL_TILE_TYPES", ] diff --git a/src/front/events_panel.py b/src/front/events_panel.py index 28d0ec7..d0a381b 100644 --- a/src/front/events_panel.py +++ b/src/front/events_panel.py @@ -1,5 +1,5 @@ from typing import List, Optional, Tuple, Dict -from .rendering import STATUS_BAR_HEIGHT +from .rendering import STATUS_BAR_HEIGHT, map_pixel_size def _wrap_text_by_pixels(font, text: str, max_width_px: int) -> List[str]: @@ -40,7 +40,8 @@ def draw_sidebar( filter_is_open: bool, filter_options: List[Tuple[str, Optional[str]]], ) -> Dict[str, object]: - sidebar_x = world_map.width * tile_size + margin * 2 + map_px_w, _ = map_pixel_size(type("_W", (), {"map": world_map})(), tile_size) + sidebar_x = map_px_w + margin * 2 sidebar_y = margin + STATUS_BAR_HEIGHT sidebar_rect = pygame_mod.Rect( diff --git a/src/front/rendering.py b/src/front/rendering.py index 2c0840c..cef23a6 100644 --- a/src/front/rendering.py +++ b/src/front/rendering.py @@ -1,13 +1,11 @@ import math from typing import List, Optional, Tuple, Callable -from src.classes.avatar import Avatar, Gender -from src.classes.tile import TileType -from src.classes.relation import Relation -from src.classes.root import format_root_cn +from src.classes.avatar import Avatar from src.utils.text_wrap import wrap_text # 顶部状态栏高度(像素) STATUS_BAR_HEIGHT = 32 +TOOLTIP_MIN_WIDTH = 260 def wrap_lines_for_tooltip(lines: List[str], max_chars_per_line: int = 28) -> List[str]: """ 将一组 tooltip 行进行字符级换行: @@ -300,35 +298,16 @@ def draw_avatars_and_pick_hover( screen.blit(avatar_image, (image_x, image_y)) # 名字(置于头像下方居中) if name_font is not None: - name_text = str(getattr(avatar, "name", "")) - if name_text: - is_highlight = bool(highlight_avatar_id and avatar.id == highlight_avatar_id) - text_color = (236, 236, 236) if is_highlight else colors["text"] - text_surf = name_font.render(name_text, True, text_color) - tx = image_x + (image_rect.width - text_surf.get_width()) // 2 - ty = image_y + image_rect.height + 2 - if is_highlight: - pad_x = 6 - pad_y = 2 - w = text_surf.get_width() + pad_x * 2 - h = text_surf.get_height() + pad_y * 2 - bg = pygame_mod.Surface((w, h), pygame_mod.SRCALPHA) - bg.fill((0, 0, 0, 210)) - screen.blit(bg, (tx - pad_x, ty - pad_y)) - # 边框 - rect = pygame_mod.Rect(tx - pad_x, ty - pad_y, w, h) - pygame_mod.draw.rect(screen, colors.get("tooltip_bd", (90, 90, 90)), rect, 1, border_radius=6) - screen.blit(text_surf, (tx, ty)) - else: - # 轻描边 - border_color = colors.get("text_border", (24, 24, 24)) - border_surf = name_font.render(name_text, True, border_color) - for dx in (-1, 1, 0, 0): - for dy in (0, 0, -1, 1): - if dx == 0 and dy == 0: - continue - screen.blit(border_surf, (tx + dx, ty + dy)) - screen.blit(text_surf, (tx, ty)) + _draw_avatar_name_label( + pygame_mod, + screen, + colors, + name_font, + str(getattr(avatar, "name", "")), + is_highlight=bool(highlight_avatar_id and avatar.id == highlight_avatar_id), + anchor_x=image_x + image_rect.width // 2, + anchor_y=image_y + image_rect.height + 2, + ) if image_rect.collidepoint(mouse_x - image_x, mouse_y - image_y): dist = math.hypot(mouse_x - cx, mouse_y - cy) candidates_with_dist.append((dist, avatar)) @@ -337,33 +316,16 @@ def draw_avatars_and_pick_hover( pygame_mod.draw.circle(screen, colors["avatar"], (cx, cy), radius) # 名字(置于圆形下方居中) if name_font is not None: - name_text = str(getattr(avatar, "name", "")) - if name_text: - is_highlight = bool(highlight_avatar_id and avatar.id == highlight_avatar_id) - text_color = (236, 236, 236) if is_highlight else colors["text"] - text_surf = name_font.render(name_text, True, text_color) - tx = int(cx - text_surf.get_width() / 2) - ty = int(cy + radius + 2) - if is_highlight: - pad_x = 6 - pad_y = 2 - w = text_surf.get_width() + pad_x * 2 - h = text_surf.get_height() + pad_y * 2 - bg = pygame_mod.Surface((w, h), pygame_mod.SRCALPHA) - bg.fill((0, 0, 0, 210)) - screen.blit(bg, (tx - pad_x, ty - pad_y)) - rect = pygame_mod.Rect(tx - pad_x, ty - pad_y, w, h) - pygame_mod.draw.rect(screen, colors.get("tooltip_bd", (90, 90, 90)), rect, 1, border_radius=6) - screen.blit(text_surf, (tx, ty)) - else: - border_color = colors.get("text_border", (24, 24, 24)) - border_surf = name_font.render(name_text, True, border_color) - for dx in (-1, 1, 0, 0): - for dy in (0, 0, -1, 1): - if dx == 0 and dy == 0: - continue - screen.blit(border_surf, (tx + dx, ty + dy)) - screen.blit(text_surf, (tx, ty)) + _draw_avatar_name_label( + pygame_mod, + screen, + colors, + name_font, + str(getattr(avatar, "name", "")), + is_highlight=bool(highlight_avatar_id and avatar.id == highlight_avatar_id), + anchor_x=cx, + anchor_y=int(cy + radius + 2), + ) dist = math.hypot(mouse_x - cx, mouse_y - cy) if dist <= radius: candidates_with_dist.append((dist, avatar)) @@ -373,7 +335,7 @@ def draw_avatars_and_pick_hover( return hovered, candidate_avatars -def draw_tooltip(pygame_mod, screen, colors, lines: List[str], mouse_x: int, mouse_y: int, font, min_width: Optional[int] = None): +def draw_tooltip(pygame_mod, screen, colors, lines: List[str], mouse_x: int, mouse_y: int, font, min_width: Optional[int] = None, top_limit: int = 0): padding = 6 spacing = 2 surf_lines = [font.render(t, True, colors["text"]) for t in lines] @@ -390,7 +352,6 @@ def draw_tooltip(pygame_mod, screen, colors, lines: List[str], mouse_x: int, mou y = mouse_y - height - 12 # 进一步夹紧,避免位于窗口上边或左边之外 x = max(0, min(x, screen_w - width)) - top_limit = 0 # 如需避免覆盖状态栏,可改为 STATUS_BAR_HEIGHT y = max(top_limit, min(y, screen_h - height)) bg_rect = pygame_mod.Rect(x, y, width, height) pygame_mod.draw.rect(screen, colors["tooltip_bg"], bg_rect, border_radius=6) @@ -404,35 +365,16 @@ def draw_tooltip(pygame_mod, screen, colors, lines: List[str], mouse_x: int, mou def draw_tooltip_for_avatar(pygame_mod, screen, colors, font, avatar: Avatar): # 改为从 Avatar.get_hover_info 获取信息行,避免前端重复拼接 lines = avatar.get_hover_info() - draw_tooltip(pygame_mod, screen, colors, lines, *pygame_mod.mouse.get_pos(), font, min_width=260) + draw_tooltip(pygame_mod, screen, colors, lines, *pygame_mod.mouse.get_pos(), font, min_width=TOOLTIP_MIN_WIDTH, top_limit=STATUS_BAR_HEIGHT) def draw_tooltip_for_region(pygame_mod, screen, colors, font, region, mouse_x: int, mouse_y: int): if region is None: return - # 改为调用 region.get_hover_info() + # 改为调用 region.get_hover_info(),并统一用 wrap_lines_for_tooltip 进行换行 lines = region.get_hover_info() - # 区域描述较长时做字符级换行,策略与头像思考/目标一致(28 字) - wrapped_lines: list[str] = [] - for line in lines: - # 针对以“描述: ”开头的行,保留前缀并仅对内容换行 - if line.startswith("描述: "): - prefix = "描述: " - content = line[len(prefix):] - segs = wrap_text(content, 28) - if segs: - wrapped_lines.append(prefix + segs[0]) - for seg in segs[1:]: - wrapped_lines.append(" " + seg) - else: - wrapped_lines.append(line) - else: - if len(line) > 28: - wrapped_lines.extend(wrap_text(line, 28)) - else: - wrapped_lines.append(line) - # 与头像一致设置较合理的最小宽度,避免过窄导致难以阅读 - draw_tooltip(pygame_mod, screen, colors, wrapped_lines, mouse_x, mouse_y, font, min_width=260) + wrapped_lines = wrap_lines_for_tooltip(lines, 28) + draw_tooltip(pygame_mod, screen, colors, wrapped_lines, mouse_x, mouse_y, font, min_width=TOOLTIP_MIN_WIDTH, top_limit=STATUS_BAR_HEIGHT) def draw_operation_guide(pygame_mod, screen, colors, font, margin: int, auto_step: bool): @@ -472,6 +414,7 @@ __all__ = [ "draw_tooltip_for_region", "draw_status_bar", "STATUS_BAR_HEIGHT", + "map_pixel_size", ] @@ -499,4 +442,30 @@ def draw_hover_badge(pygame_mod, screen, colors, font, center_x: int, center_y: screen.blit(surf, (rect.x + pad_x, rect.y + pad_y)) +def _draw_avatar_name_label(pygame_mod, screen, colors, font, name_text: str, *, is_highlight: bool, anchor_x: int, anchor_y: int) -> None: + if not name_text: + return + text_color = (236, 236, 236) if is_highlight else colors["text"] + text_surf = font.render(name_text, True, text_color) + tx = int(anchor_x - text_surf.get_width() / 2) + ty = int(anchor_y) + if is_highlight: + pad_x = 6 + pad_y = 2 + w = text_surf.get_width() + pad_x * 2 + h = text_surf.get_height() + pad_y * 2 + bg = pygame_mod.Surface((w, h), pygame_mod.SRCALPHA) + bg.fill((0, 0, 0, 210)) + screen.blit(bg, (tx - pad_x, ty - pad_y)) + rect = pygame_mod.Rect(tx - pad_x, ty - pad_y, w, h) + pygame_mod.draw.rect(screen, colors.get("tooltip_bd", (90, 90, 90)), rect, 1, border_radius=6) + screen.blit(text_surf, (tx, ty)) + +def map_pixel_size(world_or_map, tile_size: int) -> Tuple[int, int]: + """ + 计算地图像素宽高(不含 margin 与顶部偏移)。 + 支持传入 world(含 .map)或 map 对象(含 .width/.height)。 + """ + map_obj = getattr(world_or_map, "map", world_or_map) + return map_obj.width * tile_size, map_obj.height * tile_size