Files
cultivation-world-simulator/src/front/app.py
2025-11-02 20:44:05 +08:00

446 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import asyncio
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,
STATUS_BAR_HEIGHT,
draw_small_regions,
draw_sect_headquarters,
)
from .events_panel import draw_sidebar
from .menu import PauseMenu
class Front:
def __init__(
self,
simulator: Simulator,
*,
tile_size: int = 32,
margin: int = 8,
step_interval_ms: int = 400,
window_title: str = "Cultivation World Simulator",
font_path: Optional[str] = None,
sidebar_width: int = 300,
):
self.world = simulator.world
self.simulator = simulator
self.tile_size = tile_size
self.margin = margin
self.step_interval_ms = step_interval_ms
self.window_title = window_title
self.font_path = font_path
self.sidebar_width = sidebar_width
self._last_step_ms = 0
self.events: List[Event] = []
import pygame
self.pygame = pygame
pygame.init()
pygame.font.init()
width_px = self.world.map.width * tile_size + margin * 2 + sidebar_width
height_px = self.world.map.height * tile_size + margin * 2 + STATUS_BAR_HEIGHT
self.screen = pygame.display.set_mode((width_px, height_px))
pygame.display.set_caption(window_title)
self.font = create_font(self.pygame, 16, self.font_path)
self.tooltip_font = create_font(self.pygame, 14, self.font_path)
self.sidebar_font = create_font(self.pygame, 14, self.font_path)
self.status_font = create_font(self.pygame, 18, self.font_path)
self.name_font = create_font(self.pygame, 16, self.font_path)
self._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.avatar_images: Dict[str, object] = {}
self._assign_avatar_images()
self.clock = pygame.time.Clock()
# 暂停菜单
self.pause_menu = PauseMenu(pygame)
# 渲染插值状态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):
events = await self.simulator.step()
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"])
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)
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)
# 先绘制状态栏和侧边栏,再绘制 tooltip 保证 tooltip 在最上层
draw_status_bar(pygame, self.screen, self.colors, self.status_font, self.margin, self.world)
# 计算筛选后的事件
if self._sidebar_filter_avatar_id is None:
events_to_draw: List[Event] = self.events
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 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
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,
filter_selected_label=sel_label,
filter_is_open=self._sidebar_filter_open,
filter_options=options,
)
# 保存供点击检测
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)
# 绘制候选徽标(仅当存在多个候选)
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._menu_option_rects = self.pause_menu.draw(self.screen, self.colors, self.status_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", [])
return self.pause_menu.handle_click(mouse_pos, option_rects)
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)]
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"]