remove front
This commit is contained in:
@@ -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"]
|
||||
|
||||
|
||||
637
src/front/app.py
637
src/front/app.py
@@ -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"]
|
||||
|
||||
|
||||
@@ -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/<name>.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",
|
||||
]
|
||||
|
||||
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
from .app import Front
|
||||
|
||||
__all__ = ["Front"]
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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,支持颜色标记格式:<color:R,G,B>text</color>
|
||||
"""
|
||||
import re
|
||||
padding = 6
|
||||
spacing = 2
|
||||
|
||||
# 解析每行文本,生成渲染表面
|
||||
surf_lines = []
|
||||
for line in lines:
|
||||
# 检查是否包含颜色标记
|
||||
if "<color:" in line:
|
||||
# 使用正则表达式解析颜色标记
|
||||
surf_lines.append(_render_colored_text(pygame_mod, font, line, colors["text"]))
|
||||
else:
|
||||
surf_lines.append(font.render(line, True, colors["text"]))
|
||||
|
||||
width = max(s.get_width() for s in surf_lines) + padding * 2
|
||||
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
|
||||
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
|
||||
# 进一步夹紧,避免位于窗口上边或左边之外
|
||||
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:
|
||||
"""
|
||||
渲染带颜色标记的文本,格式:<color:R,G,B>text</color>
|
||||
返回一个合成的Surface
|
||||
"""
|
||||
import re
|
||||
|
||||
# 解析颜色标记
|
||||
pattern = r'<color:(\d+),(\d+),(\d+)>(.*?)</color>'
|
||||
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
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user