diff --git a/src/classes/avatar.py b/src/classes/avatar.py index 3bfc23e..5d850ca 100644 --- a/src/classes/avatar.py +++ b/src/classes/avatar.py @@ -640,14 +640,11 @@ class Avatar(AvatarSaveMixin, AvatarLoadMixin): # 思考与目标 if self.thinking: - from src.utils.text_wrap import wrap_text - add_section(lines, "思考", wrap_text(self.thinking, 28)) + add_section(lines, "思考", [self.thinking]) if self.long_term_objective: - from src.utils.text_wrap import wrap_text - add_section(lines, "长期目标", wrap_text(self.long_term_objective.content, 28)) + add_section(lines, "长期目标", [self.long_term_objective.content]) if self.short_term_objective: - from src.utils.text_wrap import wrap_text - add_section(lines, "短期目标", wrap_text(self.short_term_objective, 28)) + add_section(lines, "短期目标", [self.short_term_objective]) # 兵器(必有,使用颜色标记等级) if self.weapon is not None: diff --git a/src/front/events_panel.py b/src/front/events_panel.py index 04343a6..6f6c4d6 100644 --- a/src/front/events_panel.py +++ b/src/front/events_panel.py @@ -1,39 +1,8 @@ from typing import List, Optional, Tuple, Dict +from src.utils.text_wrap import wrap_text_by_pixels from .rendering import map_pixel_size -def _wrap_text_by_pixels(font, text: str, max_width_px: int) -> List[str]: - """ - 按像素宽度对单行文本进行硬换行,适配中英文混排(逐字符测量)。 - """ - if not text: - return [""] - - # 先处理显式换行符 \n,避免被当作乱码字符渲染 - logical_lines = str(text).split('\n') - result: List[str] = [] - - # 对每个逻辑行分别进行像素宽度换行 - for logical_line in logical_lines: - if not logical_line: - result.append("") - continue - current = "" - for ch in logical_line: - test = current + ch - w, _ = font.size(test) - if w <= max_width_px: - current = test - else: - if current: - result.append(current) - current = ch - if current: - result.append(current) - - return result if result else [""] - - def draw_sidebar( pygame_mod, screen, @@ -86,7 +55,8 @@ def draw_sidebar( # 描述文字(自动换行) usable_width = phenomenon_width - desc_lines = _wrap_text_by_pixels(font, current_phenomenon.desc, usable_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)) @@ -163,7 +133,8 @@ def draw_sidebar( # 从最新事件开始,逐条向下渲染,超出底部则停止 for event in reversed(events): event_text = str(event) - wrapped_lines = _wrap_text_by_pixels(font, event_text, usable_width) + # 使用统一的 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)) @@ -184,5 +155,3 @@ def draw_sidebar( __all__ = ["draw_sidebar"] - - diff --git a/src/front/rendering.py b/src/front/rendering.py index c69aea2..bf0afe9 100644 --- a/src/front/rendering.py +++ b/src/front/rendering.py @@ -1,14 +1,14 @@ import math from typing import List, Optional, Tuple, Callable from src.classes.avatar import Avatar -from src.utils.text_wrap import wrap_text +from src.utils.text_wrap import wrap_text_by_pixels -def wrap_lines_for_tooltip(lines: List[str], max_chars_per_line: int = 28) -> List[str]: +def wrap_lines_for_tooltip(font, lines: List[str], max_width_px: int = 320) -> List[str]: """ - 将一组 tooltip 行进行字符级换行: + 将一组 tooltip 行进行像素级自动换行: - 对形如 "前缀: 内容" 的行,仅对内容部分换行,并在续行添加两个空格缩进 - - 其他行超过宽度则直接按宽度切分 + - 其他行超过宽度则直接按像素宽度切分 """ wrapped: List[str] = [] for line in lines: @@ -17,23 +17,38 @@ def wrap_lines_for_tooltip(lines: List[str], max_chars_per_line: int = 28) -> Li if split_idx != -1: prefix = line[: split_idx + 2] content = line[split_idx + 2 :] - segs = wrap_text(content, max_chars_per_line) + + 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(" " + seg) + wrapped.append(indent_str + 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) + + # 无前缀情形:直接换行 + 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): @@ -333,7 +348,7 @@ def draw_avatars_and_pick_hover( return hovered, candidate_avatars -def draw_tooltip(pygame_mod, screen, colors, lines: List[str], mouse_x: int, mouse_y: int, font, min_width: int = 260, top_limit: int = 0): +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,支持颜色标记格式:text """ @@ -433,18 +448,20 @@ def _render_colored_text(pygame_mod, font, text: str, default_color) -> object: return combined -def draw_tooltip_for_avatar(pygame_mod, screen, colors, font, avatar: Avatar, tooltip_min_width: int = 260, status_bar_height: int = 32): - # 改为从 Avatar.get_hover_info 获取信息行,避免前端重复拼接 +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() - draw_tooltip(pygame_mod, screen, colors, lines, *pygame_mod.mouse.get_pos(), font, min_width=tooltip_min_width, top_limit=status_bar_height) + # 使用 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 = 260, status_bar_height: int = 32): +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 进行换行 + # 调用 region.get_hover_info(),并统一用 wrap_lines_for_tooltip 进行换行 lines = region.get_hover_info() - wrapped_lines = wrap_lines_for_tooltip(lines, 28) + 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) diff --git a/src/utils/text_wrap.py b/src/utils/text_wrap.py index 6cb3a5e..9b5d7c6 100644 --- a/src/utils/text_wrap.py +++ b/src/utils/text_wrap.py @@ -1,53 +1,154 @@ -def wrap_text(text: str, max_width: int = 20) -> list[str]: +import re +from typing import Optional + +def wrap_text_by_pixels(font, text: str, max_width_px: int, first_line_max_width_px: Optional[int] = None) -> list[str]: """ - 将长文本按指定宽度换行 + 使用像素宽度对文本进行自动换行(Word Wrap)。 + 支持 ... 标签,确保换行时不破坏标签结构。 Args: - text: 要换行的文本 - max_width: 每行的最大字符数,默认20 - + font: 具有 .size(text) -> (w, h) 方法的对象 (如 pygame.font.Font) + text: 输入文本 + max_width_px: 标准行的最大像素宽度 + first_line_max_width_px: (可选) 第一行的最大像素宽度。用于处理首行缩进或前缀的情况。 + 如果未提供,默认为 max_width_px。 + Returns: - 换行后的文本行列表 + list[str]: 换行后的文本列表,每行都是独立的富文本字符串 """ if not text: return [] + + normalized_text = text.replace('\\n', '\n') + paragraphs = normalized_text.split('\n') - lines = [] - current_line = "" - - # 按换行符分割,处理已有的换行 - paragraphs = text.split('\n') + wrapped_lines = [] + # 标记是否处于整个文本的第一行(用于 first_line_max_width_px) + is_absolute_first_line = True + + # 匹配颜色标签的正则: 开头标签,或结束标签 + tag_pattern = re.compile(r'()|()') + for paragraph in paragraphs: - if not paragraph.strip(): - lines.append("") + if not paragraph: + wrapped_lines.append("") + # 空行也算作一行,之后的行不再是 absolute_first_line + is_absolute_first_line = False continue - - words = paragraph.split() - - for word in words: - # 如果当前行加上新词会超过限制 - if len(current_line) + len(word) + 1 > max_width: - if current_line: - lines.append(current_line.strip()) - current_line = word - else: - # 如果单个词就超过限制,强制换行 - if len(word) > max_width: - # 长词强制切分 - for i in range(0, len(word), max_width): - lines.append(word[i:i + max_width]) - else: - lines.append(word) + + # Tokenize logic including tags + parts = tag_pattern.split(paragraph) + raw_tokens = [p for p in parts if p] + + tokens = [] + for rt in raw_tokens: + if tag_pattern.match(rt): + tokens.append({'type': 'tag', 'content': rt}) else: - if current_line: - current_line += " " + word + current_word = "" + for char in rt: + if char.isascii() and not char.isspace(): + current_word += char + else: + if current_word: + tokens.append({'type': 'text', 'content': current_word}) + current_word = "" + tokens.append({'type': 'text', 'content': char}) + if current_word: + tokens.append({'type': 'text', 'content': current_word}) + + # Layout + current_line_str = "" + current_width = 0 + active_color_tag = None + + for token in tokens: + if token['type'] == 'tag': + tag_content = token['content'] + current_line_str += tag_content + if tag_content.startswith(' line_limit: + # Super long word handling + temp_word = word + while True: + w_temp, _ = font.size(temp_word) + # Ensure we use the correct limit for the chunk + # If we just wrapped, we are on a new line, so use max_width_px + current_chunk_limit = max_width_px + + if w_temp <= current_chunk_limit: + # Remaining part fits + current_line_str += temp_word + current_width += w_temp + break + + # Find cut index + cut_idx = 1 + while cut_idx <= len(temp_word): + sub = temp_word[:cut_idx] + sw, _ = font.size(sub) + if sw > current_chunk_limit: + break + cut_idx += 1 + cut_idx -= 1 + if cut_idx == 0: cut_idx = 1 + + chunk = temp_word[:cut_idx] + current_line_str += chunk + if active_color_tag: + current_line_str += "" + wrapped_lines.append(current_line_str) + is_absolute_first_line = False + + temp_word = temp_word[cut_idx:] + current_line_str = "" + if active_color_tag: + current_line_str += active_color_tag + current_width = 0 + else: + if word.isspace(): + pass + else: + current_line_str += word + current_width += w + + if current_line_str: + wrapped_lines.append(current_line_str) + is_absolute_first_line = False + + return wrapped_lines