Files
cultivation-world-simulator/src/front/rendering.py
2025-10-23 00:30:06 +08:00

478 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 math
from typing import List, Optional, Tuple, Callable
from src.classes.avatar import Avatar
from src.utils.text_wrap import wrap_text
# 顶部状态栏高度(像素)
STATUS_BAR_HEIGHT = 32
TOOLTIP_MIN_WIDTH = 260
def wrap_lines_for_tooltip(lines: List[str], max_chars_per_line: int = 28) -> List[str]:
"""
将一组 tooltip 行进行字符级换行:
- 对形如 "前缀: 内容" 的行,仅对内容部分换行,并在续行添加两个空格缩进
- 其他行超过宽度则直接按宽度切分
"""
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 :]
segs = wrap_text(content, max_chars_per_line)
if segs:
wrapped.append(prefix + segs[0])
for seg in segs[1:]:
wrapped.append(" " + seg)
else:
wrapped.append(line)
continue
# 无前缀情形:必要时整行切分
if len(line) > max_chars_per_line:
wrapped.extend(wrap_text(line, max_chars_per_line))
else:
wrapped.append(line)
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: Optional[int] = None, top_limit: int = 0):
padding = 6
spacing = 2
surf_lines = [font.render(t, True, colors["text"]) for t in lines]
width = max(s.get_width() for s in surf_lines) + padding * 2
if min_width is not None:
width = max(width, min_width)
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 draw_tooltip_for_avatar(pygame_mod, screen, colors, font, avatar: Avatar):
# 改为从 Avatar.get_hover_info 获取信息行,避免前端重复拼接
lines = avatar.get_hover_info()
draw_tooltip(pygame_mod, screen, colors, lines, *pygame_mod.mouse.get_pos(), font, min_width=TOOLTIP_MIN_WIDTH, top_limit=STATUS_BAR_HEIGHT)
def draw_tooltip_for_region(pygame_mod, screen, colors, font, region, mouse_x: int, mouse_y: int):
if region is None:
return
# 改为调用 region.get_hover_info(),并统一用 wrap_lines_for_tooltip 进行换行
lines = region.get_hover_info()
wrapped_lines = wrap_lines_for_tooltip(lines, 28)
draw_tooltip(pygame_mod, screen, colors, wrapped_lines, mouse_x, mouse_y, font, min_width=TOOLTIP_MIN_WIDTH, top_limit=STATUS_BAR_HEIGHT)
def draw_operation_guide(pygame_mod, screen, colors, font, margin: int, auto_step: bool):
auto_status = "" if auto_step else ""
guide_text = f"A:自动步进({auto_status}) SPACE:单步 ESC:退出"
guide_surf = font.render(guide_text, True, colors["status_text"])
x_pos = margin + 8
screen.blit(guide_surf, (x_pos, 8))
return guide_surf.get_width()
def draw_year_month_info(pygame_mod, screen, colors, font, margin: int, guide_width: int, world):
year = int(world.month_stamp.get_year())
month_num = world.month_stamp.get_month().value
ym_text = f"{year}{month_num:02d}"
ym_surf = font.render(ym_text, True, colors["status_text"])
x_pos = margin + guide_width + 8 * 3
screen.blit(ym_surf, (x_pos, 8))
def draw_status_bar(pygame_mod, screen, colors, font, margin: int, world, auto_step: bool):
status_y = 8
status_height = STATUS_BAR_HEIGHT
status_rect = pygame_mod.Rect(0, 0, screen.get_width(), status_height)
pygame_mod.draw.rect(screen, colors["status_bg"], status_rect)
pygame_mod.draw.line(screen, colors["status_border"],
(0, status_height), (screen.get_width(), status_height), 2)
guide_w = draw_operation_guide(pygame_mod, screen, colors, font, margin, auto_step)
draw_year_month_info(pygame_mod, screen, colors, font, margin, guide_w, world)
__all__ = [
"draw_map",
"draw_region_labels",
"draw_avatars_and_pick_hover",
"draw_tooltip_for_avatar",
"draw_tooltip_for_region",
"draw_status_bar",
"STATUS_BAR_HEIGHT",
"map_pixel_size",
]
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 colors["text"]
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, colors.get("text_border", (24, 24, 24)))
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