478 lines
20 KiB
Python
478 lines
20 KiB
Python
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
|