diff --git a/src/front/__init__.py b/src/front/__init__.py deleted file mode 100644 index ad017f0..0000000 --- a/src/front/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -from .app import Front -from .layout import LayoutConfig, calculate_layout, get_fullscreen_resolution - -__all__ = ["Front", "LayoutConfig", "calculate_layout", "get_fullscreen_resolution"] - - diff --git a/src/front/app.py b/src/front/app.py deleted file mode 100644 index af3e2a6..0000000 --- a/src/front/app.py +++ /dev/null @@ -1,637 +0,0 @@ -import asyncio -import os -import random -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_tile_originals, load_avatar_images, load_sect_images, load_region_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, - draw_small_regions, - draw_sect_headquarters, -) -from .events_panel import draw_sidebar -from .menu import PauseMenu -from .toast import Toast -from .layout import calculate_layout, get_fullscreen_resolution - - -class Front: - def __init__( - self, - simulator: Simulator, - *, - step_interval_ms: int = 400, - window_title: str = "Cultivation World Simulator", - font_path: Optional[str] = None, - existed_sects: Optional[List] = None, - ): - self.world = simulator.world - self.simulator = simulator - self.step_interval_ms = step_interval_ms - self.window_title = window_title - self.font_path = font_path - self.existed_sects = existed_sects or [] # 保存本局启用的宗门列表 - - self._last_step_ms = 0 - self.events: List[Event] = [] - - import pygame - self.pygame = pygame - pygame.init() - pygame.font.init() - - # 获取可用屏幕分辨率(排除任务栏)并计算动态布局 - screen_width, screen_height = get_fullscreen_resolution(pygame) - self.layout = calculate_layout( - screen_width, - screen_height, - self.world.map.width, - self.world.map.height - ) - - # 使用动态布局参数 - self.tile_size = self.layout.tile_size - self.margin = self.layout.margin - self.sidebar_width = self.layout.sidebar_width - - # 创建无边框最大化窗口(底部保留任务栏空间) - self.screen = pygame.display.set_mode((self.layout.screen_width, self.layout.screen_height), pygame.NOFRAME) - pygame.display.set_caption(window_title) - - # 将窗口移动到屏幕左上角 (0, 0),顶部紧贴屏幕边缘 - import os - if os.name == 'nt': # Windows系统 - try: - import ctypes - hwnd = pygame.display.get_wm_info()['window'] - # SWP_NOZORDER = 0x0004, SWP_SHOWWINDOW = 0x0040 - # 设置窗口位置到 (0, 0),不改变Z顺序 - ctypes.windll.user32.SetWindowPos(hwnd, 0, 0, 0, - self.layout.screen_width, - self.layout.screen_height, - 0x0004) - except Exception: - pass # 如果设置失败也不影响使用 - - # 设置窗口图标 - icon_path = "assets/icon.png" - if os.path.exists(icon_path): - icon = pygame.image.load(icon_path) - pygame.display.set_icon(icon) - - # 使用动态字体大小 - self.font = create_font(self.pygame, self.layout.font_size_medium, self.font_path) - self.tooltip_font = create_font(self.pygame, self.layout.font_size_tooltip, self.font_path) - self.sidebar_font = create_font(self.pygame, self.layout.font_size_normal, self.font_path) - self.status_font = create_font(self.pygame, self.layout.font_size_large, self.font_path) - self.name_font = create_font(self.pygame, self.layout.font_size_medium, 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.tile_originals = load_tile_originals(self.pygame) - self.sect_images = load_sect_images(self.pygame, self.tile_size) - self.region_images = load_region_images(self.pygame, self.tile_size) - self.male_avatars, self.female_avatars = load_avatar_images(self.pygame, self.tile_size, self.layout.avatar_size) - self.avatar_images: Dict[str, object] = {} - self._assign_avatar_images() - - self.clock = pygame.time.Clock() - - # 暂停菜单 - self.pause_menu = PauseMenu(pygame) - - # Toast提示 - self.toast = Toast(pygame) - - # 世界ID标记(用于取消过期的异步任务) - self._world_id = 0 - - # 渲染插值状态:avatar_id -> {start_px, start_py, target_px, target_py, start_ms, duration_ms} - self._avatar_display_states: Dict[str, Dict[str, float]] = {} - self._init_avatar_display_states() - - # 侧栏筛选状态:None 表示所有人;否则为 avatar_id - 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 列表(当前锚点下) - self._hover_index: int = 0 - self._hover_last_build_ms: int = 0 - - 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): - # 捕获当前world_id,用于检测是否已经加载了新世界 - current_world_id = self._world_id - - events = await self.simulator.step() - - # 如果world_id已改变,说明加载了新存档,丢弃这次结果 - if self._world_id != current_world_id: - print(f"丢弃过期的异步任务结果(world_id: {current_world_id} -> {self._world_id})") - return - - if events: - self.add_events(events) - self._last_step_ms = 0 - # 步进完成后,更新插值目标 - self._update_avatar_display_targets() - # 世界推进后,角色增减或名称改变的可能性上升,置脏侧栏选项 - self._sidebar_options_dirty = True - - async def run_async(self): - pygame = self.pygame - running = True - current_step_task = None - while running: - dt_ms = self.clock.tick(60) - - # 游戏未暂停时才累积时间 - if not self.pause_menu.is_visible: - 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: - self.pause_menu.toggle() - elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 1: - # 处理菜单点击(菜单可见时阻止其他所有交互) - if self.pause_menu.is_visible: - action = self._handle_menu_click() - if action == "quit": - running = False - else: - # 只有菜单不可见时才处理地图交互 - self._handle_mouse_click() - # 兼容旧版滚轮为 MOUSEBUTTON 4/5 - elif event.type == pygame.MOUSEBUTTONDOWN and event.button in (4, 5): - if not self.pause_menu.is_visible: - delta = 1 if event.button == 4 else -1 - self._on_mouse_wheel(delta) - # pygame 2 的标准滚轮事件 - elif getattr(pygame, "MOUSEWHEEL", None) is not None and event.type == pygame.MOUSEWHEEL: - if not self.pause_menu.is_visible: - # event.y: 上滚为正,下滚为负 - self._on_mouse_wheel(int(getattr(event, "y", 0))) - - # 游戏未暂停时才自动步进 - if not self.pause_menu.is_visible 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._update_avatar_display_targets() - - self._render() - await asyncio.sleep(0.016) - pygame.quit() - - def _render(self): - pygame = self.pygame - self.screen.fill(self.colors["bg"]) - status_bar_height = self.layout.status_bar_height - draw_map( - pygame, - self.screen, - self.colors, - self.world, - self.tile_images, - self.tile_size, - self.margin, - status_bar_height, - ) - # 底图后叠加小区域整图(2x2/3x3),再绘制宗门总部,避免被覆盖 - 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) - # 如果菜单可见,不显示任何hover(避免穿透) - if not self.pause_menu.is_visible: - hovered_region = draw_region_labels( - pygame, - self.screen, - self.colors, - self.world, - self._get_region_font, - self.tile_size, - self.margin, - status_bar_height, - ) - self._assign_avatar_images() - hovered_default, hover_candidates = draw_avatars_and_pick_hover( - pygame, - self.screen, - self.colors, - self.simulator, - self.avatar_images, - self.tile_size, - self.margin, - self._get_display_center, - status_bar_height, - self.name_font, - self._sidebar_filter_avatar_id, - ) - hovered_avatar = self._pick_hover_with_scroll(hovered_default, hover_candidates) - else: - # 菜单可见时,清空所有hover状态 - hovered_region = None - hovered_avatar = None - hover_candidates = [] - # 先绘制状态栏和侧边栏,再绘制 tooltip 保证 tooltip 在最上层 - draw_status_bar(pygame, self.screen, self.colors, self.status_font, self.margin, self.world, status_bar_height) - - # 计算筛选后的事件 - if self._sidebar_filter_avatar_id is None: - events_to_draw: List[Event] = self.events - elif self._sidebar_filter_avatar_id == "__world_events__": - # 特殊筛选:仅显示世界事件(不绑定任何角色) - events_to_draw = [e for e in self.events if not getattr(e, "related_avatars", None)] - else: - 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 = self._get_sidebar_options_cached() - sel_label = "所有" - if self._sidebar_filter_avatar_id == "__world_events__": - sel_label = "世界事件" - elif self._sidebar_filter_avatar_id is not None: - sel_avatar = self.world.avatar_manager.avatars.get(self._sidebar_filter_avatar_id) - if sel_avatar is not None: - sel_label = sel_avatar.name - - # 获取天地灵机相关信息 - current_phenomenon = self.world.current_phenomenon - phenomenon_start_year = self.world.phenomenon_start_year if hasattr(self.world, 'phenomenon_start_year') else 0 - current_year = self.world.month_stamp.get_year() - - sidebar_ui = draw_sidebar( - pygame, self.screen, self.colors, self.sidebar_font, events_to_draw, - self.world.map, self.tile_size, self.margin, self.sidebar_width, status_bar_height, - filter_selected_label=sel_label, - filter_is_open=self._sidebar_filter_open, - filter_options=options, - current_phenomenon=current_phenomenon, - phenomenon_start_year=phenomenon_start_year, - current_year=current_year, - ) - # 保存供点击检测 - self._sidebar_ui = sidebar_ui - if hovered_avatar is not None: - draw_tooltip_for_avatar(pygame, self.screen, self.colors, self.tooltip_font, hovered_avatar, self.layout.tooltip_min_width, status_bar_height) - # 绘制候选徽标(仅当存在多个候选) - if len(hover_candidates) >= 2: - from .rendering import draw_hover_badge - # 取当前 hover 对象的显示中心 - cx_f, cy_f = self._get_display_center(hovered_avatar, self.tile_size, self.margin) - cx, cy = int(cx_f), int(cy_f) - # 计算当前索引(1-based) - try: - idx = self._hover_candidates.index(hovered_avatar.id) - except ValueError: - idx = 0 - draw_hover_badge(pygame, self.screen, self.colors, self.tooltip_font, cx, cy, idx + 1, len(hover_candidates), status_bar_height) - 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, self.layout.tooltip_min_width, status_bar_height) - - # 绘制暂停菜单(在最上层) - self._menu_option_rects = self.pause_menu.draw(self.screen, self.colors, self.status_font) - - # 更新并绘制Toast(在最上层) - self.toast.update() - self.toast.draw(self.screen, self.sidebar_font) - - pygame.display.flip() - - def _handle_mouse_click(self) -> None: - """处理侧栏筛选点击""" - pygame = self.pygame - mouse_pos = pygame.mouse.get_pos() - ui = getattr(self, "_sidebar_ui", {}) or {} - toggle_rect = ui.get("filter_toggle_rect") - option_rects = ui.get("filter_option_rects") or [] - if toggle_rect and toggle_rect.collidepoint(mouse_pos): - self._sidebar_filter_open = not self._sidebar_filter_open - return - if self._sidebar_filter_open: - for oid, rect in option_rects: - if rect.collidepoint(mouse_pos): - self._sidebar_filter_avatar_id = oid if oid is not None else None - self._sidebar_filter_open = False - return - - def _handle_menu_click(self) -> Optional[str]: - """处理菜单点击,返回动作""" - mouse_pos = self.pygame.mouse.get_pos() - option_rects = getattr(self, "_menu_option_rects", []) - action = self.pause_menu.handle_click(mouse_pos, option_rects) - - # 处理保存和加载操作 - if action == "save": - self._save_game() - self.pause_menu.hide() - return None - elif action == "load": - success = self._load_game() - if success: - self.pause_menu.hide() - return None - - return action - - def _save_game(self) -> bool: - """保存游戏""" - try: - from src.sim.save.save_game import save_game - success, filename = save_game(self.world, self.simulator, self.existed_sects) - if success and filename: - self.toast.show(f"保存成功!\n{filename}", Toast.SUCCESS, duration_ms=4000) - print(f"游戏保存成功!文件:{filename}") - else: - self.toast.show("游戏保存失败", Toast.ERROR) - return success - except Exception as e: - self.toast.show(f"保存失败: {str(e)[:30]}", Toast.ERROR) - print(f"保存游戏时出错: {e}") - import traceback - traceback.print_exc() - return False - - def _load_game(self) -> bool: - """加载游戏 - 打开文件选择对话框""" - try: - import tkinter as tk - from tkinter import filedialog - from pathlib import Path - from src.utils.config import CONFIG - from src.sim.load.load_game import load_game - - # 创建临时的tkinter根窗口(隐藏) - root = tk.Tk() - root.withdraw() - root.attributes('-topmost', True) - - # 获取saves目录 - saves_dir = CONFIG.paths.saves - saves_dir.mkdir(parents=True, exist_ok=True) - - # 打开文件选择对话框 - save_path = filedialog.askopenfilename( - title="选择存档文件", - initialdir=str(saves_dir), - filetypes=[("JSON文件", "*.json"), ("所有文件", "*.*")] - ) - - # 销毁tkinter根窗口 - root.destroy() - - # 如果用户取消 - if not save_path: - self.toast.show("取消加载", Toast.INFO, duration_ms=2000) - return False - - save_path = Path(save_path) - if not save_path.exists(): - self.toast.show("存档文件不存在", Toast.ERROR) - return False - - # 显示加载提示 - self.toast.show("正在加载存档...", Toast.INFO, duration_ms=10000) - # 强制刷新一次屏幕,让toast显示出来 - self._render() - - # 加载游戏数据 - world, simulator, existed_sects = load_game(save_path) - - # 增加world_id,使所有正在进行的异步任务失效 - self._world_id += 1 - - # 替换当前的world和simulator - self.world = world - self.simulator = simulator - self.existed_sects = existed_sects - - # 从event_manager恢复事件到侧边栏显示列表 - self.events.clear() - recent_events = world.event_manager.get_recent_events(limit=1000) - self.events.extend(recent_events) - - # 重新初始化头像图像分配 - self.avatar_images.clear() - self._assign_avatar_images() - - # 重新初始化插值状态 - self._avatar_display_states.clear() - self._init_avatar_display_states() - - # 标记侧栏选项为脏(需要重建角色列表) - self._sidebar_options_dirty = True - self._sidebar_filter_avatar_id = None - - # 立即显示成功toast,覆盖"正在加载"的toast - filename = save_path.name - self.toast.show(f"加载成功!\n{filename}", Toast.SUCCESS, duration_ms=3000) - print(f"游戏加载成功!文件:{filename}") - return True - except Exception as e: - self.toast.show(f"加载失败: {str(e)[:30]}", Toast.ERROR) - print(f"加载游戏时出错: {e}") - import traceback - traceback.print_exc() - return False - - def _get_region_font(self, size: int): - return _get_region_font_cached(self.pygame, self._region_font_cache, size, self.font_path) - - # --- Hover 轮换逻辑 --- - def _is_mouse_near_anchor(self, radius_px: int = 20) -> bool: - if self._hover_anchor_pos is None: - return False - mx, my = self.pygame.mouse.get_pos() - ax, ay = self._hover_anchor_pos - dx = mx - ax - dy = my - ay - return (dx * dx + dy * dy) <= (radius_px * radius_px) - - def _rebuild_hover_candidates(self, hovered_default: Optional[Avatar], candidates: List[Avatar]) -> None: - self._hover_anchor_pos = self.pygame.mouse.get_pos() - self._hover_candidates = [a.id for a in candidates] - if hovered_default is not None and hovered_default.id in self._hover_candidates: - self._hover_index = self._hover_candidates.index(hovered_default.id) - else: - self._hover_index = 0 - self._hover_last_build_ms = self._now_ms() - - def _pick_hover_with_scroll(self, hovered_default: Optional[Avatar], candidates: List[Avatar]) -> Optional[Avatar]: - # 无候选时清空状态 - if not candidates: - self._hover_anchor_pos = None - self._hover_candidates = [] - self._hover_index = 0 - return None - # 当前候选ID列表 - current_ids = [a.id for a in candidates] - # 需要重建的情形: - # 1) 没有锚点;2) 鼠标离锚点太远;3) 候选集合变化;4) 距上次构建时间过久 - need_rebuild = False - if self._hover_anchor_pos is None: - need_rebuild = True - elif not self._is_mouse_near_anchor(): - need_rebuild = True - elif current_ids != self._hover_candidates: - need_rebuild = True - elif (self._now_ms() - self._hover_last_build_ms) > 800: - need_rebuild = True - if need_rebuild: - self._rebuild_hover_candidates(hovered_default, candidates) - # 选出当前下标对应的 avatar - if not self._hover_candidates: - return hovered_default - self._hover_index %= max(1, len(self._hover_candidates)) - aid = self._hover_candidates[self._hover_index] - return self.world.avatar_manager.avatars.get(aid, hovered_default) - - def _on_mouse_wheel(self, delta: int) -> None: - # 仅当有至少两个候选且鼠标仍在锚点附近时进行轮换 - if len(self._hover_candidates) >= 2 and self._is_mouse_near_anchor(): - if delta > 0: - self._hover_index = (self._hover_index - 1) % len(self._hover_candidates) - elif delta < 0: - self._hover_index = (self._hover_index + 1) % len(self._hover_candidates) - # 轻微刷新锚点时间,避免过快过期 - self._hover_last_build_ms = self._now_ms() - - def _assign_avatar_images(self): - # 若在上一次分配后头像集合未发生变化,且数量相等,则跳过 - 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: - return self.pygame.time.get_ticks() - - def _init_avatar_display_states(self): - now = self._now_ms() - ts = self.tile_size - m = self.margin - # 清理已不存在的 avatar 状态 - to_del = [aid for aid in self._avatar_display_states.keys() if aid not in self.world.avatar_manager.avatars] - for aid in to_del: - self._avatar_display_states.pop(aid, None) - # 初始化/补全 - for avatar_id, avatar in self.world.avatar_manager.avatars.items(): - if avatar_id not in self._avatar_display_states: - cx = m + avatar.pos_x * ts + ts // 2 - cy = m + avatar.pos_y * ts + ts // 2 - self._avatar_display_states[avatar_id] = { - "start_px": float(cx), - "start_py": float(cy), - "target_px": float(cx), - "target_py": float(cy), - "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() - ts = self.tile_size - m = self.margin - self._init_avatar_display_states() - for avatar_id, avatar in self.world.avatar_manager.avatars.items(): - state = self._avatar_display_states[avatar_id] - # 当前目标像素 - cur_target_x = m + avatar.pos_x * ts + ts // 2 - cur_target_y = m + avatar.pos_y * ts + ts // 2 - if int(state["target_px"]) != cur_target_x or int(state["target_py"]) != cur_target_y: - # 以当前插值位置为新起点,目标设为最新位置 - # 计算当前插值位置 - elapsed = max(0.0, float(now) - float(state["start_ms"])) - duration = max(1.0, float(state["duration_ms"])) - t = min(1.0, elapsed / duration) - cur_x = float(state["start_px"]) + (float(state["target_px"]) - float(state["start_px"])) * t - cur_y = float(state["start_py"]) + (float(state["target_py"]) - float(state["start_py"])) * t - state["start_px"] = cur_x - state["start_py"] = cur_y - state["target_px"] = float(cur_target_x) - state["target_py"] = float(cur_target_y) - state["start_ms"] = float(now) - state["duration_ms"] = float(max(1, self.step_interval_ms)) - - def _get_display_center(self, avatar: Avatar, tile_size: int, margin: int): - # 忽略传入的 tile_size/margin,优先使用 Front 的,以避免不一致 - state = self._avatar_display_states.get(avatar.id) - if not state: - # 回退:未初始化时直接返回逻辑中心 - cx = self.margin + avatar.pos_x * self.tile_size + self.tile_size // 2 - cy = self.margin + avatar.pos_y * self.tile_size + self.tile_size // 2 - return float(cx), float(cy) - now = self._now_ms() - elapsed = max(0.0, float(now) - float(state["start_ms"])) - duration = max(1.0, float(state["duration_ms"])) - t = min(1.0, elapsed / duration) - # 使用轻微的 ease-in-out(近似):t' = 3t^2 - 2t^3 - te = t * t * (3.0 - 2.0 * t) - x = float(state["start_px"]) + (float(state["target_px"]) - float(state["start_px"])) * te - 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), - ("世界事件", "__world_events__") - ] - 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 deleted file mode 100644 index 0143090..0000000 --- a/src/front/assets.py +++ /dev/null @@ -1,117 +0,0 @@ -import os -from typing import Dict, List -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] = {} - 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) - scaled = pygame_mod.transform.scale(image, (tile_size, tile_size)) - images[tile_type] = scaled - return images - - -def load_tile_originals(pygame_mod) -> Dict[TileType, object]: - originals: Dict[TileType, object] = {} - 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) - return originals - - -def load_avatar_images(pygame_mod, tile_size: int, avatar_size: int = None): - """ - 加载avatar图像 - - Args: - pygame_mod: pygame模块 - tile_size: tile大小(用于计算默认avatar大小) - avatar_size: 可选,直接指定avatar大小;如果为None则根据tile_size计算 - """ - if avatar_size is None: - avatar_size = max(26, int((tile_size * 4 // 3) * 1.8)) - - def load_from_dir(base_dir: str) -> List[object]: - results: List[object] = [] - if os.path.exists(base_dir): - for filename in os.listdir(base_dir): - if filename.endswith('.png') and filename != 'original.png' and filename.replace('.png', '').isdigit(): - image_path = os.path.join(base_dir, filename) - image = pygame_mod.image.load(image_path) - scaled = pygame_mod.transform.scale(image, (avatar_size, avatar_size)) - results.append(scaled) - return results - return load_from_dir("assets/males"), load_from_dir("assets/females") - - -def load_sect_images(pygame_mod, tile_size: int): - """ - 加载宗门总部图片,缩放为 2x2 tile 大小,返回按文件名(不含后缀)为键的图像字典。 - 文件名建议与宗门名称一致。 - """ - images: Dict[str, object] = {} - base_dir = Path("assets/sects") - if base_dir.exists(): - for filename in base_dir.iterdir(): - if filename.suffix.lower() == ".png" and filename.name != "original.png": - try: - image = pygame_mod.image.load(str(filename)) - scaled = pygame_mod.transform.scale(image, (tile_size * 2, tile_size * 2)) - images[filename.stem] = scaled - except pygame_mod.error: - continue - return images - - -def load_region_images(pygame_mod, tile_size: int) -> Dict[str, Dict[int, object]]: - """ - 加载小区域整图:按名称加载 assets/regions/.png。 - 为兼容 2x2 和 3x3,分别生成两种缩放版本: - - key 2 -> (tile_size*2, tile_size*2) - - key 3 -> (tile_size*3, tile_size*3) - 返回结构: { name: {2: surf2x2, 3: surf3x3} } - """ - results: Dict[str, Dict[int, object]] = {} - base_dir = Path("assets/regions") - if base_dir.exists(): - for filename in base_dir.iterdir(): - if filename.suffix.lower() != ".png" or filename.name == "original.png": - continue - try: - image = pygame_mod.image.load(str(filename)) - except pygame_mod.error: - continue - name_key = filename.stem - variants: Dict[int, object] = {} - for n in (2, 3): - w = tile_size * n - h = tile_size * n - variants[n] = pygame_mod.transform.scale(image, (w, h)) - results[name_key] = variants - return results - - -__all__ = [ - "load_tile_images", - "load_tile_originals", - "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 deleted file mode 100644 index 6f6c4d6..0000000 --- a/src/front/events_panel.py +++ /dev/null @@ -1,157 +0,0 @@ -from typing import List, Optional, Tuple, Dict -from src.utils.text_wrap import wrap_text_by_pixels -from .rendering import map_pixel_size - - -def draw_sidebar( - pygame_mod, - screen, - colors, - font, - events: List[object], - world_map, - tile_size: int, - margin: int, - sidebar_width: int, - status_bar_height: int, - *, - filter_selected_label: str, - filter_is_open: bool, - filter_options: List[Tuple[str, Optional[str]]], - current_phenomenon = None, - phenomenon_start_year: int = 0, - current_year: int = 0, -) -> Dict[str, object]: - 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( - sidebar_x, - sidebar_y, - sidebar_width, - screen.get_height() - sidebar_y - margin, - ) - pygame_mod.draw.rect(screen, colors["sidebar_bg"], sidebar_rect) - pygame_mod.draw.rect(screen, colors["sidebar_border"], sidebar_rect, 2) - - # 天地灵机显示区域(放在最上方) - content_start_y = sidebar_y + 10 - if current_phenomenon is not None: - phenomenon_margin_x = 10 - phenomenon_width = sidebar_width - 20 - phenomenon_x = sidebar_x + phenomenon_margin_x - phenomenon_y = content_start_y - - # 计算持续时间 - elapsed_years = current_year - phenomenon_start_year - remaining_years = max(0, current_phenomenon.duration_years - elapsed_years) - - # 天象名称(使用稀有度颜色) - rarity_color = current_phenomenon.rarity.color_rgb - name_surf = font.render(f"天象:{current_phenomenon.name}", True, rarity_color) - screen.blit(name_surf, (phenomenon_x, phenomenon_y)) - phenomenon_y += name_surf.get_height() + 4 - - # 描述文字(自动换行) - usable_width = phenomenon_width - # 使用统一的 wrap_text_by_pixels - desc_lines = wrap_text_by_pixels(font, current_phenomenon.desc, usable_width) - for line in desc_lines: - line_surf = font.render(line, True, colors["event_text"]) - screen.blit(line_surf, (phenomenon_x, phenomenon_y)) - phenomenon_y += line_surf.get_height() + 2 - - # 剩余时间 - time_text = f"剩余:{remaining_years}年" - time_surf = font.render(time_text, True, colors["event_text"]) - screen.blit(time_surf, (phenomenon_x, phenomenon_y)) - phenomenon_y += time_surf.get_height() + 8 - - # 分隔线 - pygame_mod.draw.line(screen, colors["sidebar_border"], - (sidebar_x + 10, phenomenon_y), - (sidebar_x + sidebar_width - 10, phenomenon_y), 1) - - content_start_y = phenomenon_y + 10 - - # 下拉选择器:显示"所有人/某人",位于天地灵机下方 - dropdown_margin_x = 10 - dropdown_width = sidebar_width - 20 - # 先用一个基准高度,确保点击区域更易操作 - dropdown_height = 24 - dropdown_x = sidebar_x + dropdown_margin_x - dropdown_y = content_start_y - dropdown_rect = pygame_mod.Rect(dropdown_x, dropdown_y, dropdown_width, dropdown_height) - # 填充底色并描边 - pygame_mod.draw.rect(screen, colors["sidebar_bg"], dropdown_rect) - pygame_mod.draw.rect(screen, colors["sidebar_border"], dropdown_rect, 1) - # 选中项文本 - sel_text = filter_selected_label or "所有" - sel_surf = font.render(f"筛选:{sel_text}", True, colors["event_text"]) - screen.blit(sel_surf, (dropdown_x + 6, dropdown_y + (dropdown_height - sel_surf.get_height()) // 2)) - # 右侧箭头 - arrow_char = "▲" if filter_is_open else "▼" - arrow_surf = font.render(arrow_char, True, colors["event_text"]) - screen.blit(arrow_surf, (dropdown_x + dropdown_width - arrow_surf.get_width() - 6, dropdown_y + (dropdown_height - arrow_surf.get_height()) // 2)) - - option_rects: List[Tuple[Optional[str], object]] = [] - options_total_h = 0 - if filter_is_open and filter_options: - # 整体下拉区域背景,避免与事件文字混在一起 - options_total_h = dropdown_height * len(filter_options) - options_area_rect = pygame_mod.Rect(dropdown_x, dropdown_y + dropdown_height, dropdown_width, options_total_h) - pygame_mod.draw.rect(screen, colors["sidebar_bg"], options_area_rect) - pygame_mod.draw.rect(screen, colors["sidebar_border"], options_area_rect, 1) - # 逐项绘制 - opt_y = dropdown_y + dropdown_height - for label, oid in filter_options: - opt_rect = pygame_mod.Rect(dropdown_x, opt_y, dropdown_width, dropdown_height) - pygame_mod.draw.rect(screen, colors["sidebar_bg"], opt_rect) - pygame_mod.draw.rect(screen, colors["sidebar_border"], opt_rect, 1) - opt_surf = font.render(label, True, colors["event_text"]) - screen.blit(opt_surf, (dropdown_x + 6, opt_y + (dropdown_height - opt_surf.get_height()) // 2)) - option_rects.append((oid, opt_rect)) - opt_y += dropdown_height - - # 标题“事件历史”位于筛选下拉之下 - title_text = "事件历史" - title_surf = font.render(title_text, True, colors["text"]) - title_x = sidebar_x + 10 - title_y = dropdown_y + dropdown_height + (options_total_h if filter_is_open else 0) + 10 - screen.blit(title_surf, (title_x, title_y)) - - # 事件列表起始位置位于标题之后 - line_y = title_y + title_surf.get_height() + 6 - 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 - # 预留左右边距各10px - usable_width = sidebar_width - 20 - # 从最新事件开始,逐条向下渲染,超出底部则停止 - for event in reversed(events): - event_text = str(event) - # 使用统一的 wrap_text_by_pixels - wrapped_lines = wrap_text_by_pixels(font, event_text, usable_width) - for line in wrapped_lines: - event_surf = font.render(line, True, colors["event_text"]) - screen.blit(event_surf, (title_x, event_y)) - event_y += event_surf.get_height() + 2 - if event_y > screen.get_height() - margin: - break - 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)) - return { - "filter_toggle_rect": dropdown_rect, - "filter_option_rects": option_rects, - } - - -__all__ = ["draw_sidebar"] diff --git a/src/front/fonts.py b/src/front/fonts.py deleted file mode 100644 index d9a3f60..0000000 --- a/src/front/fonts.py +++ /dev/null @@ -1,38 +0,0 @@ -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"] - - diff --git a/src/front/front.py b/src/front/front.py deleted file mode 100644 index 303af52..0000000 --- a/src/front/front.py +++ /dev/null @@ -1,5 +0,0 @@ -from .app import Front - -__all__ = ["Front"] - - diff --git a/src/front/layout.py b/src/front/layout.py deleted file mode 100644 index 9a90625..0000000 --- a/src/front/layout.py +++ /dev/null @@ -1,137 +0,0 @@ -"""动态布局计算模块 - -根据屏幕分辨率动态计算所有UI组件的尺寸,实现自适应布局。 -""" -from typing import NamedTuple - - -class LayoutConfig(NamedTuple): - """布局配置类,包含所有动态计算的尺寸参数""" - - # 屏幕尺寸 - screen_width: int - screen_height: int - - # 地图相关 - tile_size: int - margin: int - - # UI组件尺寸 - status_bar_height: int - sidebar_width: int - - # 字体尺寸 - font_size_normal: int # 普通文本(原14) - font_size_medium: int # 中等文本(原16) - font_size_large: int # 大文本(原18) - font_size_tooltip: int # tooltip文本(原14) - - # 其他动态参数 - avatar_size: int # avatar图像大小 - tooltip_min_width: int # tooltip最小宽度 - - -def clamp(value: float, min_val: float, max_val: float) -> int: - """将值限制在指定范围内并返回整数""" - return int(max(min_val, min(max_val, value))) - - -def calculate_layout(screen_width: int, screen_height: int, map_width: int = 56, map_height: int = 40) -> LayoutConfig: - """ - 根据屏幕分辨率计算所有布局参数 - - Args: - screen_width: 屏幕宽度(像素) - screen_height: 屏幕高度(像素) - map_width: 地图宽度(格子数) - map_height: 地图高度(格子数) - - Returns: - LayoutConfig: 包含所有布局参数的配置对象 - """ - - # 1. 计算固定UI组件尺寸(使用混合策略:百分比 + 最小最大值限制) - - # 状态栏高度:屏幕高度的2.5%,限制在24-48px - status_bar_height = clamp(screen_height * 0.025, 24, 48) - - # 侧边栏宽度:屏幕宽度的18%,限制在280-420px - sidebar_width = clamp(screen_width * 0.18, 280, 420) - - # 边距:屏幕较短边的0.8%,限制在6-16px - margin = clamp(min(screen_width, screen_height) * 0.008, 6, 16) - - # 2. 计算地图区域可用空间 - available_width = screen_width - sidebar_width - margin * 2 - available_height = screen_height - status_bar_height - margin * 2 - - # 3. 计算tile_size(保证完整显示地图) - tile_size_by_width = available_width / map_width - tile_size_by_height = available_height / map_height - - # 取较小值确保两个方向都能完整显示,并限制最大值为64px(防止超大屏幕显示异常) - tile_size = clamp(min(tile_size_by_width, tile_size_by_height), 1, 64) - - # 4. 计算字体尺寸(根据tile_size动态缩放) - # 基准:tile_size=32时,字体大小为 14/16/18 - font_scale = tile_size / 32.0 - - font_size_normal = max(14, int(14 * font_scale)) # 最小14px保证可读性 - font_size_medium = max(16, int(16 * font_scale)) - font_size_large = max(18, int(18 * font_scale)) - font_size_tooltip = max(14, int(14 * font_scale)) - - # 5. 计算avatar尺寸(与原来的公式保持一致,但基于动态tile_size) - avatar_size = max(20, int((tile_size * 4 // 3) * 1.8)) - - # 6. 计算tooltip最小宽度(原来是260px,按比例缩放) - tooltip_min_width = max(200, int(260 * font_scale)) - - return LayoutConfig( - screen_width=screen_width, - screen_height=screen_height, - tile_size=tile_size, - margin=margin, - status_bar_height=status_bar_height, - sidebar_width=sidebar_width, - font_size_normal=font_size_normal, - font_size_medium=font_size_medium, - font_size_large=font_size_large, - font_size_tooltip=font_size_tooltip, - avatar_size=avatar_size, - tooltip_min_width=tooltip_min_width, - ) - - -def get_fullscreen_resolution(pygame_mod) -> tuple[int, int]: - """ - 获取当前显示器的可用分辨率(排除任务栏) - - Args: - pygame_mod: pygame模块 - - Returns: - (width, height): 可用屏幕分辨率 - """ - # 初始化video模块(如果还未初始化) - if not pygame_mod.get_init(): - pygame_mod.init() - - # 获取显示器信息 - info = pygame_mod.display.Info() - - # 获取桌面可用区域(排除任务栏等) - # 在Windows上,current_w/h 是全屏分辨率 - # 我们需要预留一些空间给任务栏(通常在底部约40-60像素) - width = info.current_w - height = info.current_h - - # 为任务栏预留空间(约40-50像素,取决于缩放比例) - # 这是一个保守的估计,确保窗口不会覆盖任务栏 - taskbar_height = max(40, int(height * 0.04)) # 约4%的高度 - - return width, height - taskbar_height - - -__all__ = ["LayoutConfig", "calculate_layout", "get_fullscreen_resolution"] - diff --git a/src/front/menu.py b/src/front/menu.py deleted file mode 100644 index b999a21..0000000 --- a/src/front/menu.py +++ /dev/null @@ -1,111 +0,0 @@ -"""游戏暂停菜单""" -from typing import Optional, Tuple - - -class MenuOption: - """菜单选项""" - def __init__(self, label: str, action: str): - self.label = label - self.action = action - - -class PauseMenu: - """暂停菜单""" - def __init__(self, pygame_mod): - self.pygame = pygame_mod - self.is_visible = False - self.options = [ - MenuOption("保存游戏", "save"), - MenuOption("加载游戏", "load"), - MenuOption("退出游戏", "quit") - ] - self.selected_index = 0 - - def toggle(self): - """切换菜单显示状态""" - self.is_visible = not self.is_visible - self.selected_index = 0 - - def show(self): - """显示菜单""" - self.is_visible = True - - def hide(self): - """隐藏菜单""" - self.is_visible = False - - def handle_click(self, mouse_pos: Tuple[int, int], option_rects: list) -> Optional[str]: - """处理鼠标点击,返回被点击的选项动作""" - if not self.is_visible: - return None - - for i, rect in enumerate(option_rects): - if rect.collidepoint(mouse_pos): - return self.options[i].action - return None - - def draw(self, screen, colors, font): - """绘制菜单""" - if not self.is_visible: - return [] - - pygame = self.pygame - screen_w, screen_h = screen.get_size() - - # 绘制全屏半透明黑色背景(作为mask,阻止背后交互) - overlay = pygame.Surface((screen_w, screen_h), pygame.SRCALPHA) - overlay.fill((0, 0, 0, 180)) - screen.blit(overlay, (0, 0)) - - # 计算菜单尺寸 - padding = 40 - option_height = 50 - option_spacing = 20 - menu_width = 300 - menu_height = padding * 2 + len(self.options) * option_height + (len(self.options) - 1) * option_spacing - - # 菜单居中位置 - menu_x = (screen_w - menu_width) // 2 - menu_y = (screen_h - menu_height) // 2 - - # 绘制菜单背景 - menu_rect = pygame.Rect(menu_x, menu_y, menu_width, menu_height) - pygame.draw.rect(screen, (40, 40, 40), menu_rect, border_radius=12) - pygame.draw.rect(screen, (100, 100, 100), menu_rect, 2, border_radius=12) - - # 绘制选项 - option_rects = [] - current_y = menu_y + padding - - for i, option in enumerate(self.options): - option_rect = pygame.Rect( - menu_x + 30, - current_y, - menu_width - 60, - option_height - ) - - # 检测鼠标悬停 - mouse_pos = pygame.mouse.get_pos() - is_hovered = option_rect.collidepoint(mouse_pos) - - # 绘制选项背景 - bg_color = (80, 80, 80) if is_hovered else (50, 50, 50) - pygame.draw.rect(screen, bg_color, option_rect, border_radius=8) - pygame.draw.rect(screen, (120, 120, 120), option_rect, 1, border_radius=8) - - # 绘制选项文本 - text_color = (255, 255, 255) if is_hovered else (200, 200, 200) - text_surf = font.render(option.label, True, text_color) - text_x = option_rect.centerx - text_surf.get_width() // 2 - text_y = option_rect.centery - text_surf.get_height() // 2 - screen.blit(text_surf, (text_x, text_y)) - - option_rects.append(option_rect) - current_y += option_height + option_spacing - - return option_rects - - -__all__ = ["PauseMenu"] - diff --git a/src/front/rendering.py b/src/front/rendering.py deleted file mode 100644 index bf0afe9..0000000 --- a/src/front/rendering.py +++ /dev/null @@ -1,565 +0,0 @@ -import math -from typing import List, Optional, Tuple, Callable -from src.classes.avatar import Avatar -from src.utils.text_wrap import wrap_text_by_pixels - - -def wrap_lines_for_tooltip(font, lines: List[str], max_width_px: int = 320) -> List[str]: - """ - 将一组 tooltip 行进行像素级自动换行: - - 对形如 "前缀: 内容" 的行,仅对内容部分换行,并在续行添加两个空格缩进 - - 其他行超过宽度则直接按像素宽度切分 - """ - wrapped: List[str] = [] - for line in lines: - # 仅处理简单前缀(到第一个": "为界) - split_idx = line.find(": ") - if split_idx != -1: - prefix = line[: split_idx + 2] - content = line[split_idx + 2 :] - - prefix_w, _ = font.size(prefix) - indent_str = " " - indent_w, _ = font.size(indent_str) - - # 内容的第一行允许宽度 = 总宽 - 前缀宽 - # 内容的后续行允许宽度 = 总宽 - 缩进宽 - content_first_w = max_width_px - prefix_w - content_rest_w = max_width_px - indent_w - - # 边界保护:如果前缀特别长导致第一行没空间,就强制让它换行(给一个合理的最小值) - # 或者直接让它等于后续行宽度(这会造成视觉溢出,但比死循环好) - if content_first_w < 20: - content_first_w = content_rest_w - - segs = wrap_text_by_pixels(font, content, content_rest_w, first_line_max_width_px=content_first_w) - - if segs: - wrapped.append(prefix + segs[0]) - for seg in segs[1:]: - wrapped.append(indent_str + seg) - else: - # 内容为空的情况 - wrapped.append(line) - continue - - # 无前缀情形:直接换行 - wrapped.extend(wrap_text_by_pixels(font, line, max_width_px)) - - return wrapped - - -def draw_grid(pygame_mod, screen, colors, map_obj, ts: int, m: int, top_offset: int = 0): - grid_color = colors["grid"] - for gx in range(map_obj.width + 1): - start_pos = (m + gx * ts, m + top_offset) - end_pos = (m + gx * ts, m + top_offset + 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 + top_offset + gy * ts) - end_pos = (m + map_obj.width * ts, m + top_offset + 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, top_offset: int = 0): - 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 + top_offset + y * ts) - screen.blit(tile_image, pos) - else: - color = (80, 80, 80) - rect = pygame_mod.Rect(m + x * ts, m + top_offset + y * ts, ts, ts) - pygame_mod.draw.rect(screen, color, rect) - draw_grid(pygame_mod, screen, colors, map_obj, ts, m, top_offset) - - -def draw_sect_headquarters(pygame_mod, screen, world, sect_images: dict, ts: int, m: int, top_offset: int = 0): - """ - 在底图绘制完成后叠加绘制宗门总部(2x2 tile)。 - 以区域左上角(north_west_cor)为锚点绘制。 - """ - for region in world.map.regions.values(): - if getattr(region, "get_region_type", lambda: "")() != "sect": - continue - img_path: str | None = getattr(region, "image_path", None) - if not img_path: - # 可回退到按名称找图:期望 assets/sects/{region.name}.png - key = str(getattr(region, "name", "")) - image = sect_images.get(key) - else: - key = str(pygame_mod.Path(img_path).stem) if hasattr(pygame_mod, "Path") else img_path.split("/")[-1].split("\\")[-1].split(".")[0] - image = sect_images.get(key) - if not image: - # 未加载到图片则跳过 - continue - try: - nw = tuple(map(int, str(getattr(region, "north_west_cor", "0,0")).split(","))) - except Exception: - continue - x_px = m + nw[0] * ts - y_px = m + top_offset + nw[1] * ts - screen.blit(image, (x_px, y_px)) - - -def _is_small_square_region(region) -> int: - """ - 若为 2x2 或 3x3 的矩形/正方形区域,返回边长(2或3);否则返回0。 - """ - try: - nw = tuple(map(int, str(getattr(region, "north_west_cor", "0,0")).split(","))) - se = tuple(map(int, str(getattr(region, "south_east_cor", "0,0")).split(","))) - except Exception: - return 0 - if getattr(region, "shape", None) is None: - return 0 - shape_name = getattr(region.shape, "name", "") - if shape_name not in ("RECTANGLE", "SQUARE"): - return 0 - width = se[0] - nw[0] + 1 - height = se[1] - nw[1] + 1 - if width == height and width in (2, 3): - return width - return 0 - - -def draw_small_regions(pygame_mod, screen, world, region_images: dict, tile_images: dict, ts: int, m: int, top_offset: int = 0, tile_originals: Optional[dict] = None): - """ - 使用整图绘制 2x2 / 3x3 的小区域: - - 优先按名称从 region_images 中取 n×n 的整图(n 为 2 或 3) - - 若没有整图,则将现有 tile 图裁切/合成为一张,避免重复边框 - """ - for region in world.map.regions.values(): - n = _is_small_square_region(region) - if n == 0: - continue - # 仅对 2x2 生效;3x3 不覆盖(保持每格一张图) - if n != 2: - continue - try: - nw = tuple(map(int, str(getattr(region, "north_west_cor", "0,0")).split(","))) - except Exception: - continue - x_px = m + nw[0] * ts - y_px = m + top_offset + nw[1] * ts - name_key = str(getattr(region, "name", "")) - variants = region_images.get(name_key) - if variants and variants.get(n): - screen.blit(variants[n], (x_px, y_px)) - continue - # 回退:从原始 tile 贴图一次性缩放到 n×n,避免“先缩1×1再放大”的二次缩放 - try: - tile = world.map.get_tile(nw[0], nw[1]) - base_image = None - if tile_originals is not None: - base_image = tile_originals.get(tile.type) - if base_image is None: - base_image = tile_images.get(tile.type) - except Exception: - base_image = None - if base_image is not None: - scaled = pygame_mod.transform.scale(base_image, (ts * n, ts * n)) - screen.blit(scaled, (x_px, y_px)) - else: - # 最后兜底:淡色块 - tmp = pygame_mod.Surface((ts * n, ts * n), pygame_mod.SRCALPHA) - tmp.fill((255, 255, 255, 24)) - screen.blit(tmp, (x_px, y_px)) - - -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)))) - size = base + growth - 7 # 再降低2个字号 - return max(16, min(40, size)) - - -def draw_region_labels(pygame_mod, screen, colors, world, get_region_font, tile_size: int, margin: int, top_offset: int = 0, outline_px: int = 2): - ts = tile_size - m = margin - mouse_x, mouse_y = pygame_mod.mouse.get_pos() - hovered_region = None - - # 以区域面积降序放置,优先保证大区域标签可读性 - regions = sorted(list(world.map.regions.values()), key=lambda r: getattr(r, "area", 0), reverse=True) - - placed_rects = [] # 已放置标签的矩形列表,用于碰撞检测 - - # 可放置范围(地图区域) - map_px_w = world.map.width * ts - map_px_h = world.map.height * ts - min_x_allowed = m - max_x_allowed = m + map_px_w - min_y_allowed = m + top_offset - max_y_allowed = m + top_offset + map_px_h - - def _clamp_rect(x0: int, y0: int, w: int, h: int) -> Tuple[int, int]: - # 将标签限制在地图区域内 - x = max(min_x_allowed, min(x0, max_x_allowed - w)) - y = max(min_y_allowed, min(y0, max_y_allowed - h)) - return x, y - - for region in regions: - name = getattr(region, "name", None) - if not name: - continue - # 小区域(面积<=9,例如2x2/3x3)标签放在底部;大区域放在中心 - use_bottom = getattr(region, "area", 0) <= 9 - if use_bottom and getattr(region, "cors", None): - bottom_y = max(y for _, y in region.cors) - xs_on_bottom = [x for x, y in region.cors if y == bottom_y] - if xs_on_bottom: - left_x = min(xs_on_bottom) - right_x = max(xs_on_bottom) - anchor_cx_tile = (left_x + right_x) / 2.0 - else: - anchor_cx_tile = float(region.center_loc[0]) - screen_cx = int(m + anchor_cx_tile * ts + ts // 2) - screen_cy = int(m + top_offset + (bottom_y + 1) * ts + 2) - else: - # 居中放置 - screen_cx = int(m + float(region.center_loc[0]) * ts + ts // 2) - screen_cy = int(m + top_offset + float(region.center_loc[1]) * ts) - 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"]) - border_surface = region_font.render(str(name), True, colors.get("text_border", (24, 24, 24))) - text_w = text_surface.get_width() - text_h = text_surface.get_height() - - # 候选偏移:优先“区域下方”,若越界或冲突,再尝试左右位移、其上方 - pad = 6 - dxw = max(8, int(0.6 * text_w)) + pad - dyh = text_h + pad - candidates = [ - (0, 0), # 正下方(期望位置) - (-dxw, 0), (dxw, 0), # 下方左右 - (0, -dyh), # 底边上方一行 - (-dxw, -dyh), (dxw, -dyh), - (0, -2 * dyh), # 再上方,尽量避免覆盖区域 - ] - - chosen_rect = None - for (dx, dy) in candidates: - # 以锚点为基准,文本顶部左上角坐标 - x_try = int(screen_cx + dx - text_w / 2) - y_try = int(screen_cy + dy) - x_try, y_try = _clamp_rect(x_try, y_try, text_w, text_h) - rect_try = pygame_mod.Rect(x_try, y_try, text_w, text_h) - if not any(rect_try.colliderect(r) for r in placed_rects): - chosen_rect = rect_try - break - if chosen_rect is None: - # 如果所有候选均冲突,就退回锚点正下方 - x0 = int(screen_cx - text_w / 2) - y0 = int(screen_cy) - x0, y0 = _clamp_rect(x0, y0, text_w, text_h) - chosen_rect = pygame_mod.Rect(x0, y0, text_w, text_h) - - # 悬停检测使用最终位置 - if chosen_rect.collidepoint(mouse_x, mouse_y): - hovered_region = region - - # 多方向描边 - if outline_px > 0: - for dx in (-outline_px, 0, outline_px): - for dy in (-outline_px, 0, outline_px): - if dx == 0 and dy == 0: - continue - screen.blit(border_surface, (chosen_rect.x + dx, chosen_rect.y + dy)) - screen.blit(text_surface, (chosen_rect.x, chosen_rect.y)) - placed_rects.append(chosen_rect) - return hovered_region - - -def avatar_center_pixel(avatar: Avatar, tile_size: int, margin: int, top_offset: int = 0) -> Tuple[int, int]: - px = margin + avatar.pos_x * tile_size + tile_size // 2 - py = margin + top_offset + 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, - get_display_center: Optional[Callable[[Avatar, int, int], Tuple[float, float]]] = None, - top_offset: int = 0, - name_font: Optional[object] = None, - highlight_avatar_id: Optional[str] = None, -) -> Tuple[Optional[Avatar], List[Avatar]]: - mouse_x, mouse_y = pygame_mod.mouse.get_pos() - candidates_with_dist: List[Tuple[float, Avatar]] = [] - for avatar_id, avatar in simulator.world.avatar_manager.avatars.items(): - if get_display_center is not None: - cx_f, cy_f = get_display_center(avatar, tile_size, margin) - cx, cy = int(cx_f), int(cy_f) - else: - cx, cy = avatar_center_pixel(avatar, tile_size, margin) - cy += top_offset - 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 name_font is not None: - _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)) - else: - radius = max(8, tile_size // 3) - pygame_mod.draw.circle(screen, colors["avatar"], (cx, cy), radius) - # 名字(置于圆形下方居中) - if name_font is not None: - _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)) - candidates_with_dist.sort(key=lambda t: t[0]) - hovered = candidates_with_dist[0][1] if candidates_with_dist else None - candidate_avatars: List[Avatar] = [a for _, a in candidates_with_dist] - return hovered, candidate_avatars - - -def draw_tooltip(pygame_mod, screen, colors, lines: List[str], mouse_x: int, mouse_y: int, font, min_width: int = 320, top_limit: int = 0): - """ - 绘制tooltip,支持颜色标记格式:text - """ - import re - padding = 6 - spacing = 2 - - # 解析每行文本,生成渲染表面 - surf_lines = [] - for line in lines: - # 检查是否包含颜色标记 - if " screen_w: - x = mouse_x - width - 12 - if y + height > screen_h: - y = mouse_y - height - 12 - # 进一步夹紧,避免位于窗口上边或左边之外 - x = max(0, min(x, screen_w - width)) - 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) - 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 _render_colored_text(pygame_mod, font, text: str, default_color) -> object: - """ - 渲染带颜色标记的文本,格式:text - 返回一个合成的Surface - """ - import re - - # 解析颜色标记 - pattern = r'(.*?)' - parts = [] - last_end = 0 - - for match in re.finditer(pattern, text): - # 添加标记前的普通文本 - if match.start() > last_end: - plain_text = text[last_end:match.start()] - parts.append((plain_text, default_color)) - - # 添加带颜色的文本 - r, g, b = int(match.group(1)), int(match.group(2)), int(match.group(3)) - colored_text = match.group(4) - parts.append((colored_text, (r, g, b))) - - last_end = match.end() - - # 添加剩余的普通文本 - if last_end < len(text): - parts.append((text[last_end:], default_color)) - - # 如果没有颜色标记,直接返回普通渲染 - if len(parts) == 1 and parts[0][1] == default_color: - return font.render(text, True, default_color) - - # 渲染每个部分并合成 - rendered_parts = [] - total_width = 0 - max_height = 0 - - for txt, color in parts: - if txt: - surf = font.render(txt, True, color) - rendered_parts.append(surf) - total_width += surf.get_width() - max_height = max(max_height, surf.get_height()) - - # 创建合成Surface - if not rendered_parts: - return font.render("", True, default_color) - - combined = pygame_mod.Surface((total_width, max_height), pygame_mod.SRCALPHA) - combined.fill((0, 0, 0, 0)) # 透明背景 - - x_offset = 0 - for surf in rendered_parts: - combined.blit(surf, (x_offset, 0)) - x_offset += surf.get_width() - - return combined - - -def draw_tooltip_for_avatar(pygame_mod, screen, colors, font, avatar: Avatar, tooltip_min_width: int = 320, status_bar_height: int = 32): - # 从 Avatar.get_hover_info 获取信息行 - lines = avatar.get_hover_info() - # 使用 wrap_lines_for_tooltip 进行像素级自动换行 - wrapped_lines = wrap_lines_for_tooltip(font, lines, max_width_px=tooltip_min_width) - draw_tooltip(pygame_mod, screen, colors, wrapped_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, tooltip_min_width: int = 320, status_bar_height: int = 32): - if region is None: - return - # 调用 region.get_hover_info(),并统一用 wrap_lines_for_tooltip 进行换行 - lines = region.get_hover_info() - wrapped_lines = wrap_lines_for_tooltip(font, lines, max_width_px=tooltip_min_width) - 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): - guide_text = "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, status_bar_height: int = 32): - status_y = 8 - status_rect = pygame_mod.Rect(0, 0, screen.get_width(), status_bar_height) - pygame_mod.draw.rect(screen, colors["status_bg"], status_rect) - pygame_mod.draw.line(screen, colors["status_border"], - (0, status_bar_height), (screen.get_width(), status_bar_height), 2) - guide_w = draw_operation_guide(pygame_mod, screen, colors, font, margin) - 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", - "map_pixel_size", - "draw_hover_badge", - "draw_small_regions", - "draw_sect_headquarters", -] - - -def draw_hover_badge(pygame_mod, screen, colors, font, center_x: int, center_y: int, index: int, total: int, top_offset: int = 0): - """ - 在给定中心附近绘制一个小徽标,显示 index/total(索引从1开始)。 - 徽标默认放在头像上方偏右位置。 - """ - label = f"{index}/{total}" - surf = font.render(label, True, colors["text"]) - pad_x = 6 - pad_y = 2 - w = surf.get_width() + pad_x * 2 - h = surf.get_height() + pad_y * 2 - # 徽标位置:头像中心的右上角 - x = int(center_x + 10) - y = int(center_y + top_offset - 24 - h) - rect = pygame_mod.Rect(x, y, w, h) - # 半透明背景与描边 - bg = pygame_mod.Surface((w, h), pygame_mod.SRCALPHA) - bg.fill((20, 20, 20, 180)) - screen.blit(bg, (rect.x, rect.y)) - pygame_mod.draw.rect(screen, colors.get("tooltip_bd", (90, 90, 90)), rect, 1, border_radius=6) - # 文本 - 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 (0, 0, 0) - 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)) - return - # 非高亮:加1px 白色阴影提升可读性(不加底板) - shadow = font.render(name_text, True, (255, 255, 255)) - screen.blit(shadow, (tx + 1, ty + 1)) - 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 diff --git a/src/front/theme.py b/src/front/theme.py deleted file mode 100644 index 10791e6..0000000 --- a/src/front/theme.py +++ /dev/null @@ -1,19 +0,0 @@ -COLORS = { - "bg": (18, 18, 18), - "grid": (40, 40, 40), - "text": (236, 236, 236), - "text_border": (24, 24, 24), - "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"] - - diff --git a/src/front/toast.py b/src/front/toast.py deleted file mode 100644 index b4b25f5..0000000 --- a/src/front/toast.py +++ /dev/null @@ -1,149 +0,0 @@ -"""Toast提示组件 - -用于显示临时的成功/失败/信息提示 -""" -from typing import Optional - - -class Toast: - """Toast提示 - - 显示短暂的提示信息,自动消失 - """ - - # Toast类型 - SUCCESS = "success" - ERROR = "error" - INFO = "info" - - def __init__(self, pygame_mod): - self.pygame = pygame_mod - self.message: Optional[str] = None - self.toast_type: str = Toast.INFO - self.start_time: int = 0 - self.duration_ms: int = 3000 # 默认显示3秒 - self.is_visible: bool = False - - def show(self, message: str, toast_type: str = INFO, duration_ms: int = 3000): - """显示Toast提示 - - Args: - message: 提示信息 - toast_type: 类型(success/error/info) - duration_ms: 显示时长(毫秒) - """ - self.message = message - self.toast_type = toast_type - self.duration_ms = duration_ms - self.start_time = self.pygame.time.get_ticks() - self.is_visible = True - - def update(self): - """更新Toast状态,检查是否应该隐藏""" - if not self.is_visible: - return - - current_time = self.pygame.time.get_ticks() - if current_time - self.start_time >= self.duration_ms: - self.is_visible = False - self.message = None - - def draw(self, screen, font): - """绘制Toast - - Args: - screen: pygame屏幕对象 - font: pygame字体对象 - """ - if not self.is_visible or not self.message: - return - - pygame = self.pygame - screen_w, screen_h = screen.get_size() - - # 根据类型选择颜色 - if self.toast_type == Toast.SUCCESS: - bg_color = (34, 139, 34) # 绿色 - border_color = (46, 184, 46) - elif self.toast_type == Toast.ERROR: - bg_color = (178, 34, 34) # 红色 - border_color = (220, 50, 50) - else: # INFO - bg_color = (70, 130, 180) # 蓝色 - border_color = (100, 150, 200) - - # 计算淡入淡出效果 - elapsed = self.pygame.time.get_ticks() - self.start_time - fade_in_duration = 200 # 淡入200ms - fade_out_duration = 500 # 淡出500ms - - if elapsed < fade_in_duration: - # 淡入阶段 - alpha = int(255 * (elapsed / fade_in_duration)) - elif elapsed > self.duration_ms - fade_out_duration: - # 淡出阶段 - remaining = self.duration_ms - elapsed - alpha = int(255 * (remaining / fade_out_duration)) - else: - # 完全显示 - alpha = 255 - - # 创建更大的字体用于Toast - from .fonts import create_font - toast_font = create_font(pygame, 24, None) # 使用24号字体,更大更清晰 - - # 处理多行文本 - lines = self.message.split('\n') - text_surfaces = [] - max_text_w = 0 - total_text_h = 0 - line_spacing = 5 # 行间距 - - for line in lines: - text_surf = toast_font.render(line, True, (255, 255, 255)) - text_surfaces.append(text_surf) - w, h = text_surf.get_size() - max_text_w = max(max_text_w, w) - total_text_h += h - - # 加上行间距 - total_text_h += line_spacing * (len(lines) - 1) - - # Toast尺寸(增大padding) - padding_x = 40 # 水平padding增大 - padding_y = 25 # 垂直padding增大 - toast_w = max(max_text_w + padding_x * 2, 300) # 最小宽度300 - toast_h = total_text_h + padding_y * 2 - - # 位置:屏幕上方中央 - toast_x = (screen_w - toast_w) // 2 - toast_y = 100 # 稍微下移一点 - - # 创建带透明度的surface - toast_surface = pygame.Surface((toast_w, toast_h), pygame.SRCALPHA) - - # 绘制背景(带圆角和透明度) - bg_with_alpha = (*bg_color, alpha) - pygame.draw.rect(toast_surface, bg_with_alpha, (0, 0, toast_w, toast_h), border_radius=8) - - # 绘制边框 - border_with_alpha = (*border_color, alpha) - pygame.draw.rect(toast_surface, border_with_alpha, (0, 0, toast_w, toast_h), 2, border_radius=8) - - # 绘制多行文本(应用透明度,居中显示) - current_y = (toast_h - total_text_h) // 2 # 垂直居中起点 - for text_surf in text_surfaces: - w, h = text_surf.get_size() - text_with_alpha = pygame.Surface((w, h), pygame.SRCALPHA) - text_with_alpha.blit(text_surf, (0, 0)) - text_with_alpha.set_alpha(alpha) - text_x = (toast_w - w) // 2 # 每行水平居中 - toast_surface.blit(text_with_alpha, (text_x, current_y)) - current_y += h + line_spacing - - # 绘制到屏幕 - screen.blit(toast_surface, (toast_x, toast_y)) - - -__all__ = ["Toast"] -