remove front

This commit is contained in:
bridge
2025-11-22 00:50:10 +08:00
parent 8e0c4beaba
commit 522b522024
11 changed files with 0 additions and 1941 deletions

View File

@@ -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"]

View File

@@ -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"]

View File

@@ -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",
]

View File

@@ -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"]

View File

@@ -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"]

View File

@@ -1,5 +0,0 @@
from .app import Front
__all__ = ["Front"]

View File

@@ -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"]

View File

@@ -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"]

View File

@@ -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

View File

@@ -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"]

View File

@@ -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"]