From e2f7afd6e32da91ce50e1b64f51ed490bbc7b307 Mon Sep 17 00:00:00 2001 From: bridge Date: Fri, 12 Sep 2025 00:00:33 +0800 Subject: [PATCH] update readme and front --- README.md | 1 + src/front/__init__.py | 2 +- src/front/app.py | 157 ++++++++ src/front/assets.py | 58 +++ src/front/events_panel.py | 46 +++ src/front/fonts.py | 38 ++ src/front/front.py | 656 +------------------------------- src/front/rendering.py | 209 ++++++++++ src/front/theme.py | 18 + static/game_configs/persona.csv | 1 + 10 files changed, 530 insertions(+), 656 deletions(-) create mode 100644 src/front/app.py create mode 100644 src/front/assets.py create mode 100644 src/front/events_panel.py create mode 100644 src/front/fonts.py create mode 100644 src/front/rendering.py create mode 100644 src/front/theme.py diff --git a/README.md b/README.md index fce1a88..b7e90dc 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ - ✅ 灵根系统 - ✅ 基础移动动作 - ✅ 角色性格 +- [ ] 灵光一闪 & 缘法 - [ ] 角色突破机制:动态的突破成功概率、不同突破结果和效果 - [ ] 角色关系系统 - [ ] 角色特殊能力 diff --git a/src/front/__init__.py b/src/front/__init__.py index dfe62a0..303af52 100644 --- a/src/front/__init__.py +++ b/src/front/__init__.py @@ -1,4 +1,4 @@ -from .front import Front +from .app import Front __all__ = ["Front"] diff --git a/src/front/app.py b/src/front/app.py new file mode 100644 index 0000000..f03733a --- /dev/null +++ b/src/front/app.py @@ -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"] + + diff --git a/src/front/assets.py b/src/front/assets.py new file mode 100644 index 0000000..359ffa0 --- /dev/null +++ b/src/front/assets.py @@ -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"] + + diff --git a/src/front/events_panel.py b/src/front/events_panel.py new file mode 100644 index 0000000..1971671 --- /dev/null +++ b/src/front/events_panel.py @@ -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"] + + diff --git a/src/front/fonts.py b/src/front/fonts.py new file mode 100644 index 0000000..d9a3f60 --- /dev/null +++ b/src/front/fonts.py @@ -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"] + + diff --git a/src/front/front.py b/src/front/front.py index d1cdc92..303af52 100644 --- a/src/front/front.py +++ b/src/front/front.py @@ -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"] diff --git a/src/front/rendering.py b/src/front/rendering.py new file mode 100644 index 0000000..69cb9cc --- /dev/null +++ b/src/front/rendering.py @@ -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", +] + + diff --git a/src/front/theme.py b/src/front/theme.py new file mode 100644 index 0000000..5252a00 --- /dev/null +++ b/src/front/theme.py @@ -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"] + + diff --git a/static/game_configs/persona.csv b/static/game_configs/persona.csv index 71948df..4c682ad 100644 --- a/static/game_configs/persona.csv +++ b/static/game_configs/persona.csv @@ -8,3 +8,4 @@ id,name,prompt 6,贪财,你是一个贪财的人,你对灵石和财富有着强烈的渴望。 7,采药,你是一个热爱采集的人,喜欢在山林中寻找各种奇花异草和灵药,对植物有着敏锐的直觉和深厚的兴趣。你认为大自然的恩赐需要用心去发现和珍惜。 8,猎者,你是一个热爱狩猎的人,享受在野外追踪猎物的刺激感,对各种动物的习性了如指掌。你相信通过狩猎能够磨练自己的意志和技能,获得更强大的力量。 +9,爱财,你嗜财如命,对灵石和财富有着强烈的渴望。