add flexible front end

This commit is contained in:
bridge
2025-11-11 18:42:38 +08:00
parent ba536978e7
commit ed4174d5ed
7 changed files with 218 additions and 49 deletions

View File

@@ -1,5 +1,6 @@
from .app import Front
from .layout import LayoutConfig, calculate_layout, get_fullscreen_resolution
__all__ = ["Front"]
__all__ = ["Front", "LayoutConfig", "calculate_layout", "get_fullscreen_resolution"]

View File

@@ -17,12 +17,12 @@ from .rendering import (
draw_tooltip_for_avatar,
draw_tooltip_for_region,
draw_status_bar,
STATUS_BAR_HEIGHT,
draw_small_regions,
draw_sect_headquarters,
)
from .events_panel import draw_sidebar
from .menu import PauseMenu
from .layout import calculate_layout, get_fullscreen_resolution
class Front:
@@ -30,21 +30,15 @@ class Front:
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._last_step_ms = 0
self.events: List[Event] = []
@@ -54,31 +48,61 @@ class Front:
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 + STATUS_BAR_HEIGHT
self.screen = pygame.display.set_mode((width_px, height_px))
# 获取可用屏幕分辨率(排除任务栏)并计算动态布局
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, 16, self.font_path)
self.tooltip_font = create_font(self.pygame, 14, self.font_path)
self.sidebar_font = create_font(self.pygame, 14, self.font_path)
self.status_font = create_font(self.pygame, 18, self.font_path)
self.name_font = create_font(self.pygame, 16, self.font_path)
# 使用动态字体大小
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.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()
@@ -175,6 +199,7 @@ class Front:
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,
@@ -183,11 +208,11 @@ class Front:
self.tile_images,
self.tile_size,
self.margin,
STATUS_BAR_HEIGHT,
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)
draw_small_regions(pygame, self.screen, self.world, self.region_images, self.tile_images, self.tile_size, self.margin, status_bar_height, self.tile_originals)
draw_sect_headquarters(pygame, self.screen, self.world, self.sect_images, self.tile_size, self.margin, status_bar_height)
hovered_region = draw_region_labels(
pygame,
self.screen,
@@ -196,7 +221,7 @@ class Front:
self._get_region_font,
self.tile_size,
self.margin,
STATUS_BAR_HEIGHT,
status_bar_height,
)
self._assign_avatar_images()
hovered_default, hover_candidates = draw_avatars_and_pick_hover(
@@ -208,13 +233,13 @@ class Front:
self.tile_size,
self.margin,
self._get_display_center,
STATUS_BAR_HEIGHT,
status_bar_height,
self.name_font,
self._sidebar_filter_avatar_id,
)
hovered_avatar = self._pick_hover_with_scroll(hovered_default, hover_candidates)
# 先绘制状态栏和侧边栏,再绘制 tooltip 保证 tooltip 在最上层
draw_status_bar(pygame, self.screen, self.colors, self.status_font, self.margin, self.world)
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:
@@ -233,7 +258,7 @@ class Front:
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,
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,
@@ -241,7 +266,7 @@ class Front:
# 保存供点击检测
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)
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
@@ -253,10 +278,10 @@ class Front:
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)
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)
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)

View File

@@ -33,7 +33,18 @@ def load_tile_originals(pygame_mod) -> Dict[TileType, object]:
return originals
def load_avatar_images(pygame_mod, tile_size: int):
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):
@@ -41,7 +52,6 @@ def load_avatar_images(pygame_mod, tile_size: int):
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)
avatar_size = max(26, int((tile_size * 4 // 3) * 1.8))
scaled = pygame_mod.transform.scale(image, (avatar_size, avatar_size))
results.append(scaled)
return results

View File

@@ -1,5 +1,5 @@
from typing import List, Optional, Tuple, Dict
from .rendering import STATUS_BAR_HEIGHT, map_pixel_size
from .rendering import map_pixel_size
def _wrap_text_by_pixels(font, text: str, max_width_px: int) -> List[str]:
@@ -35,6 +35,7 @@ def draw_sidebar(
tile_size: int,
margin: int,
sidebar_width: int,
status_bar_height: int,
*,
filter_selected_label: str,
filter_is_open: bool,
@@ -42,7 +43,7 @@ def draw_sidebar(
) -> 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_y = margin + status_bar_height
sidebar_rect = pygame_mod.Rect(
sidebar_x,

137
src/front/layout.py Normal file
View File

@@ -0,0 +1,137 @@
"""动态布局计算模块
根据屏幕分辨率动态计算所有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"]

View File

@@ -3,9 +3,7 @@ from typing import List, Optional, Tuple, Callable
from src.classes.avatar import Avatar
from src.utils.text_wrap import wrap_text
# 顶部状态栏高度(像素)
STATUS_BAR_HEIGHT = 32
TOOLTIP_MIN_WIDTH = 260
def wrap_lines_for_tooltip(lines: List[str], max_chars_per_line: int = 28) -> List[str]:
"""
将一组 tooltip 行进行字符级换行:
@@ -335,13 +333,12 @@ def draw_avatars_and_pick_hover(
return hovered, candidate_avatars
def draw_tooltip(pygame_mod, screen, colors, lines: List[str], mouse_x: int, mouse_y: int, font, min_width: Optional[int] = None, top_limit: int = 0):
def draw_tooltip(pygame_mod, screen, colors, lines: List[str], mouse_x: int, mouse_y: int, font, min_width: int = 260, top_limit: int = 0):
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
if min_width is not None:
width = max(width, min_width)
width = max(width, min_width)
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
@@ -362,19 +359,19 @@ def draw_tooltip(pygame_mod, screen, colors, lines: List[str], mouse_x: int, mou
cursor_y += s.get_height() + spacing
def draw_tooltip_for_avatar(pygame_mod, screen, colors, font, avatar: Avatar):
def draw_tooltip_for_avatar(pygame_mod, screen, colors, font, avatar: Avatar, tooltip_min_width: int = 260, status_bar_height: int = 32):
# 改为从 Avatar.get_hover_info 获取信息行,避免前端重复拼接
lines = avatar.get_hover_info()
draw_tooltip(pygame_mod, screen, colors, lines, *pygame_mod.mouse.get_pos(), font, min_width=TOOLTIP_MIN_WIDTH, top_limit=STATUS_BAR_HEIGHT)
draw_tooltip(pygame_mod, screen, colors, lines, *pygame_mod.mouse.get_pos(), font, min_width=tooltip_min_width, top_limit=status_bar_height)
def draw_tooltip_for_region(pygame_mod, screen, colors, font, region, mouse_x: int, mouse_y: int):
def draw_tooltip_for_region(pygame_mod, screen, colors, font, region, mouse_x: int, mouse_y: int, tooltip_min_width: int = 260, 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(lines, 28)
draw_tooltip(pygame_mod, screen, colors, wrapped_lines, mouse_x, mouse_y, font, min_width=TOOLTIP_MIN_WIDTH, top_limit=STATUS_BAR_HEIGHT)
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):
@@ -394,13 +391,12 @@ def draw_year_month_info(pygame_mod, screen, colors, font, margin: int, guide_wi
screen.blit(ym_surf, (x_pos, 8))
def draw_status_bar(pygame_mod, screen, colors, font, margin: int, world):
def draw_status_bar(pygame_mod, screen, colors, font, margin: int, world, status_bar_height: int = 32):
status_y = 8
status_height = STATUS_BAR_HEIGHT
status_rect = pygame_mod.Rect(0, 0, screen.get_width(), status_height)
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_height), (screen.get_width(), status_height), 2)
(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)
@@ -412,8 +408,10 @@ __all__ = [
"draw_tooltip_for_avatar",
"draw_tooltip_for_region",
"draw_status_bar",
"STATUS_BAR_HEIGHT",
"map_pixel_size",
"draw_hover_badge",
"draw_small_regions",
"draw_sect_headquarters",
]

View File

@@ -101,11 +101,8 @@ async def main():
front = Front(
simulator=sim,
tile_size=24,
margin=8,
step_interval_ms=750,
window_title="Cultivation World — Front Demo",
sidebar_width=350,
)
await front.run_async()