-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathdynamic_loader.py
More file actions
1406 lines (1160 loc) · 53.8 KB
/
dynamic_loader.py
File metadata and controls
1406 lines (1160 loc) · 53.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# dynamic_loader.py - 更新版
import pygame
import os
import json
import time
from typing import List, Tuple, Dict, Optional
from enum import Enum
from collections import OrderedDict
# 在dynamic_loader.py中修改InlineFragment类
class InlineFragment:
"""行内片段 - 增强版,支持图片标记"""
def __init__(self, text, color=(255, 255, 255), click_value=None,
is_image_mark=False, img_info=None, clip_pos=None, size=None):
self.text = str(text)
self.color = color
self.click_value = click_value
self.is_image_mark = is_image_mark # 是否是图片标记
self.img_info = img_info # 图片信息(如果是图片标记)
self.clip_pos = clip_pos # 裁剪位置
self.size = size # 调整大小
self.width = 0 # 将在绘制时计算(图片需要特殊处理)
self.height = 0 # 图片高度
def calculate_width(self, font):
"""计算片段宽度"""
if self.is_image_mark and self.img_info:
# 如果是图片标记,宽度为图片宽度(或默认占位符宽度)
if self.size:
self.width = self.size[0]
else:
self.width = self.img_info.get('original_width', 270)
else:
# 普通文本
self.width = font.size(self.text)[0]
return self.width
def render_image(self):
"""渲染图片(如果是图片标记)"""
if not self.is_image_mark or not self.img_info:
return None
try:
img_path = os.path.join(self.img_info.get('base_dir', './'),
self.img_info.get('filename', ''))
if not os.path.exists(img_path):
return None
image = pygame.image.load(img_path).convert_alpha()
# 裁剪
clip_x, clip_y = self.clip_pos if self.clip_pos else (0, 0)
clip_width = self.img_info.get('width', 270)
clip_height = self.img_info.get('height', 270)
# 确保裁剪区域有效
img_width, img_height = image.get_size()
if clip_x + clip_width > img_width:
clip_width = img_width - clip_x
if clip_y + clip_height > img_height:
clip_height = img_height - clip_y
if clip_width > 0 and clip_height > 0:
clip_rect = pygame.Rect(
clip_x, clip_y, clip_width, clip_height)
clipped_image = image.subsurface(clip_rect)
else:
return None
# 调整大小
if self.size:
target_width, target_height = self.size
clipped_image = pygame.transform.scale(
clipped_image, (target_width, target_height))
return clipped_image
except Exception as e:
print(f"渲染行内图片失败: {e}")
return None
class ContentType(Enum):
TEXT = "text"
IMAGE = "image"
IMAGE_MARK = "image_mark" # 图片标记类型
IMAGE_STACK = "image_stack" # 新增:图片叠加类型
DIVIDER = "divider"
MENU = "menu"
class ConsoleContent:
"""控制台内容项 - 支持行内片段"""
def __init__(self, content_type: ContentType, data, color=(255, 255, 255), height=30,
metadata=None, fragments=None):
self.type = content_type
self.data = data # 主文本内容(用于向后兼容)
self.color = color
self.height = height
self.metadata = metadata or {}
self.timestamp = time.time()
# 行内片段(用于支持同一行内的多个部分)
self.fragments = fragments or []
if not self.fragments and self.data:
# 如果没有片段但有数据,创建一个默认片段
self.fragments = [InlineFragment(self.data, color)]
def add_fragment(self, fragment: InlineFragment):
"""添加行内片段"""
self.fragments.append(fragment)
def get_full_text(self):
"""获取完整文本"""
if self.fragments:
return ''.join(f.text for f in self.fragments)
return self.data
def __repr__(self):
return f"ConsoleContent(type={self.type}, text={self.get_full_text()[:50]})"
class DynamicLoader:
"""动态加载器 - 支持滚动和日志记录"""
def __init__(self, screen_width: int, screen_height: int, font,
input_area_height: int = 40, log_file: str = "log.txt"):
"""
初始化动态加载器
Args:
screen_width: 屏幕宽度
screen_height: 屏幕高度
font: PyGame字体对象
input_area_height: 输入区域高度
log_file: 日志文件路径
"""
self.screen_width = screen_width
self.screen_height = screen_height
self.font = font
self.input_area_height = input_area_height
# 图片信息注册表
self.image_registry = {} # url -> 图片信息
self.image_surface_cache = {} # 缓存已渲染的图片Surface
self.placeholder_color = (100, 100, 150) # 图片加载前的占位符颜色
# 内容管理
self.history: List[ConsoleContent] = [] # 完整的历史记录
self.max_history_length = 10000 # 最大历史记录数
self.current_display: List[ConsoleContent] = [] # 当前显示的内容
self.max_visible_items = 10000 # 最大可见项目数
# 滚动控制
self.scroll_offset = 0 # 滚动偏移(项目数)
self.line_height = 30 # 每行高度
self.content_area_height = screen_height - input_area_height - 20 # 内容区域高度
# 滚动条
self.scrollbar_width = 10
self.scrollbar_visible = False
self.scrollbar_color = (100, 100, 100)
self.scrollbar_active_color = (150, 150, 150)
# 日志文件
self.log_file = log_file
self._init_log_file()
# 缓存
self.text_surface_cache = {} # 文本surface缓存
self.image_cache = OrderedDict() # 图片缓存
self.CACHE_LIMIT = 50 # 限制内存中最多只保留 50 张最近使用的图片
self.clickable_regions = [] # 存储所有可点击区域
self.clickable_region_counter = 0 # 可点击区域计数器
self.active_clickable_regions = [] # 当前显示的可点击区域
def _get_image_from_cache(self, img_path):
"""
LRU 缓存获取图片:
1. 如果在缓存里,移动到末尾(标记为最近使用),直接返回。
2. 如果不在缓存里,从硬盘加载。
3. 如果缓存满了,删除最久未使用的图片(第一个)。
"""
if not img_path or not os.path.exists(img_path):
return None
# 情况 1: 图片已在缓存中
if img_path in self.image_cache:
# 将其移动到字典末尾,表示"最近刚刚用过"
self.image_cache.move_to_end(img_path)
return self.image_cache[img_path]
# 情况 2: 图片不在缓存中,需要加载
try:
image = pygame.image.load(img_path).convert_alpha()
# 检查缓存是否已满
if len(self.image_cache) >= self.CACHE_LIMIT:
# [核心逻辑] 弹出第一个元素(即最久没被用过的图片),释放内存
removed_path, _ = self.image_cache.popitem(last=False)
# print(f"释放图片内存: {removed_path}") # 调试用
# 加入缓存
self.image_cache[img_path] = image
return image
except Exception as e:
print(f"加载图片失败: {img_path} - {e}")
return None
# 在DynamicLoader类中添加add_inline_fragments方法
def add_inline_fragments(self, fragments):
"""
添加行内片段到历史记录 - 支持自动换行
Args:
fragments: InlineFragment列表
"""
if not fragments:
return []
# 计算最大允许宽度 (屏幕宽 - 边距 - 滚动条预留)
max_width = self.screen_width - 40 - 15
added_items = []
current_line_fragments = []
current_line_width = 0
# 辅助函数:提交当前行
def commit_line():
nonlocal current_line_fragments, current_line_width
if not current_line_fragments:
return
# 创建内容项
item = ConsoleContent(
ContentType.TEXT,
"",
fragments=current_line_fragments,
height=self.line_height
)
# 注册点击区域
for i, fragment in enumerate(current_line_fragments):
if fragment.click_value:
item.metadata['clickable'] = True
region_id = self.clickable_region_counter
self.clickable_regions.append({
'id': region_id,
'content_item': item,
'fragment_index': i, # 标记是第几个片段
'click_value': fragment.click_value,
'text': fragment.text,
'type': 'inline_fragment'
})
self.clickable_region_counter += 1
self.history.append(item)
added_items.append(item)
# 重置当前行
current_line_fragments = []
current_line_width = 0
# --- 遍历所有片段 ---
for frag in fragments:
# 1. 计算片段总宽
frag_width = frag.calculate_width(self.font)
# 2. 如果是图片 (不可分割)
if frag.is_image_mark:
# 如果当前行放不下这张图,且当前行不是空的,先换行
if current_line_width + frag_width > max_width and current_line_fragments:
commit_line()
# 加入当前行
current_line_fragments.append(frag)
current_line_width += frag_width
# 如果这张图本身就比屏幕宽(极少见),那也没办法,只能让它超出去或者强制再换行
# 这里选择让它独占一行后,如果还超宽就不管了,因为图片很难切分
if current_line_width > max_width:
commit_line()
# 3. 如果是文本 (可以分割)
else:
# 如果整段文本能放下,直接加
if current_line_width + frag_width <= max_width:
current_line_fragments.append(frag)
current_line_width += frag_width
else:
# 放不下,需要切分文本
remaining_text = frag.text
while remaining_text:
# 尝试找出能在当前行放下的最大子字符串
# 这是一个字符级循环,确保中文也能正确换行
fit_text = ""
fit_width = 0
# 预计算剩余空间
space_left = max_width - current_line_width
# 快速检查:如果一个字都放不下,且当前行有东西,先换行
if space_left <= 0 and current_line_fragments:
commit_line()
space_left = max_width
# 逐字扫描 (优化:可以先估算,但逐字最准确)
# 为了性能,这里可以优化,但对于VN来说逐字扫描通常够快
split_index = 0
for char in remaining_text:
char_w = self.font.size(char)[0]
if fit_width + char_w <= space_left:
fit_width += char_w
split_index += 1
else:
break
# 如果是空的(连一个字都放不下),强制换行
if split_index == 0:
if current_line_fragments:
commit_line()
continue # 换行后重试
else:
# 极端情况:这一行是空的,但第一个字就比屏幕宽(不太可能发生)
# 强制放入一个字防止死循环
split_index = 1
fit_width = self.font.size(
remaining_text[0])[0]
# 切割文本
fit_text = remaining_text[:split_index]
remaining_text = remaining_text[split_index:]
# 创建新片段(继承颜色和点击属性)
new_frag = InlineFragment(
fit_text,
frag.color,
frag.click_value,
is_image_mark=False
)
# 只有当它是图片时才需要传 info,文本不需要,避免 calculate_width 报错
new_frag.width = fit_width
current_line_fragments.append(new_frag)
current_line_width += fit_width
# 如果还有剩余文本,说明这行满了,提交换行
if remaining_text:
commit_line()
# 提交最后一行
commit_line()
# 记录日志
full_text = ''.join(f.text for f in fragments)
self._write_to_log(f"[TEXT] {full_text}")
self._update_current_display()
# 自动滚动
if self.scroll_offset <= 5:
self.scroll_to_bottom()
return added_items
# 在DynamicLoader类中添加_parse_params方法
def _parse_params(self, param_str):
"""解析参数字符串为字典"""
params = {}
if not param_str:
return params
param_pairs = param_str.split(';')
for pair in param_pairs:
if ':' in pair:
key, value = pair.split(':', 1)
params[key.strip()] = value.strip()
return params
def parse_image_stack_mark(self, text):
import re
"""解析图片叠加标记字符串 - 全面修复版"""
# 1. 基础检查
if not text.startswith("[IMG_STACK:") or not text.endswith("]"):
return [], {}
# 2. 提取内容 (注意索引是11,跳过冒号)
content = text[11:-1]
if not content:
return [], {}
img_elements = []
img_infos = {}
# 3. 分割图片元素 (使用 | )
elements = [elem.strip()
for elem in content.split('|') if elem.strip()]
for element in elements:
# 提取 图片名 和 参数块
# 格式: 图片名{参数1:值1;参数2:值2}
pattern = r'([^{]+)(?:\{([^}]+)\})?'
match = re.match(pattern, element)
if match:
img_name = match.group(1).strip()
param_str = match.group(2).strip() if match.group(2) else ""
# 4. 解析参数 (使用 ; 分割)
params = {}
if param_str:
param_pairs = param_str.split(';') # 关键:用分号分割
for pair in param_pairs:
if ':' in pair:
key, value = pair.split(':', 1)
params[key.strip()] = value.strip()
# 5. 获取并合并信息
img_info = self.get_registered_image_info(img_name)
if img_info:
merged_info = img_info.copy()
# --- 解析 Offset (偏移) ---
if 'offset' in params:
try:
# 移除括号,按逗号分割
# 支持负数,如 (-10, 20)
clean_val = params['offset'].replace(
'(', '').replace(')', '')
ox, oy = map(int, clean_val.split(','))
merged_info['offset_x'] = ox
merged_info['offset_y'] = oy
# print(f"DEBUG: {img_name} offset set to {ox}, {oy}")
except ValueError:
print(f"解析offset失败: {params['offset']}")
# --- 解析 Clip (裁剪) ---
if 'clip' in params:
try:
clean_val = params['clip'].replace(
'(', '').replace(')', '')
cx, cy = map(int, clean_val.split(','))
merged_info['clip_x'] = cx
merged_info['clip_y'] = cy
except ValueError:
pass
# --- 解析 Size (缩放) ---
if 'size' in params:
try:
clean_val = params['size'].replace(
'(', '').replace(')', '')
tw, th = map(int, clean_val.split(','))
merged_info['target_width'] = tw
merged_info['target_height'] = th
except ValueError:
pass
# 其他简单参数
if 'click' in params:
merged_info['click_value'] = params['click']
if 'chara' in params:
merged_info['chara_id'] = params['chara']
if 'type' in params:
merged_info['draw_type'] = params['type']
img_elements.append({
'name': img_name,
'params': params,
'info': merged_info
})
img_infos[img_name] = merged_info
else:
# 只有找不到图片信息时才打印错误,避免刷屏
print(f"警告: 注册表中找不到图片 '{img_name}'")
return img_elements, img_infos
def _parse_image_mark(self, text):
"""解析图片标记字符串"""
if not text.startswith("[IMG:") or not text.endswith("]"):
return None, {}
content = text[5:-1] # 移除 [IMG: 和 ]
parts = content.split("|")
if not parts:
return None, {}
img_url = parts[0]
params = {}
for param in parts[1:]:
if "=" in param:
key, value = param.split("=", 1)
params[key] = value
return img_url, params
def register_image_info(self, url, img_info):
"""注册图片信息"""
if not hasattr(self, 'image_registry'):
self.image_registry = {}
self.image_registry[url] = img_info
def get_registered_image_info(self, url):
"""获取已注册的图片信息"""
if hasattr(self, 'image_registry') and url in self.image_registry:
return self.image_registry[url]
return None
def add_image_mark(self, img_mark, click_value=None, template_width=None, template_height=None):
"""
添加图片标记到历史记录 - 增强版,支持图片叠加
Args:
img_mark: 图片标记字符串,格式 [IMG:图片ID|参数] 或 [IMG_STACK:图片1|参数]
click_value: 点击时输入的文本
"""
# 检查是否是图片叠加标记
if img_mark.startswith("[IMG_STACK:"):
return self._add_image_stack_mark(img_mark, click_value)
# 原有的单张图片处理逻辑
img_url, params = self._parse_image_mark(img_mark)
if not img_url:
return self.add_text(img_mark)
img_info = self.get_registered_image_info(img_url)
if not img_info:
item = ConsoleContent(
ContentType.TEXT,
f"[图片未找到: {img_url}]",
color=(255, 100, 100),
height=self.line_height
)
self.history.append(item)
self._update_current_display()
return item
# 解析参数
clip_pos = None
size = None
if 'clip' in params:
clip_x, clip_y = map(int, params['clip'].split(','))
clip_pos = (clip_x, clip_y)
if 'size' in params:
width, height = map(int, params['size'].split(','))
size = (width, height)
# 创建图片内容项
item = ConsoleContent(
ContentType.IMAGE_MARK,
img_mark,
height=size[1] +
10 if size else img_info.get('original_height', 270) + 10,
metadata={
'img_url': img_url,
'img_info': img_info,
'clip_pos': clip_pos,
'size': size,
'template_width': template_width,
'template_height': template_height,
'click_value': click_value,
'cached_surface': None,
'needs_rendering': True
}
)
if click_value:
item.metadata['clickable'] = True
item.metadata['region_id'] = self.clickable_region_counter
self.clickable_regions.append({
'id': self.clickable_region_counter,
'content_item': item,
'click_value': click_value,
'text': f"[图片] {img_url}",
'type': 'image'
})
self.clickable_region_counter += 1
self.history.append(item)
self._write_to_log(f"[IMAGE] {img_url}")
self._update_current_display()
if self.scroll_offset <= 5:
self.scroll_to_bottom()
return item
def _add_image_stack_mark(self, img_mark, click_value=None, template_width=None, template_height=None):
"""
添加图片叠加标记 - 新格式版本
"""
# 1. 解析新格式的标记字符串
img_elements, img_infos = self.parse_image_stack_mark(img_mark)
if not img_elements:
# 解析失败,显示错误文本
return self.add_text(f"[图片叠加解析失败: {img_mark[:50]}...]", (255, 100, 100))
# 2. 计算模板尺寸
if template_width is None:
template_width = self.screen_width - 20 # 默认屏幕宽减边距
if template_height is None:
# 自动计算高度:取所有图片中的最大高度
template_height = 0
for element in img_elements:
info = element['info']
# 优先使用target_height,否则使用original_height
height = info.get(
'target_height', info.get('original_height', 270))
template_height = max(template_height, height)
# 3. 计算总高度(模板高度 + 边距)
total_height = template_height + 10 # 上下各留5px边距
# 4. 创建ConsoleContent对象
item = ConsoleContent(
ContentType.IMAGE_STACK,
img_mark,
height=total_height,
metadata={
'img_elements': img_elements, # 图片元素列表(保持顺序)
'img_infos': img_infos, # 图片信息字典(快速查找)
'template_width': template_width,
'template_height': template_height,
'global_click': click_value, # 全局点击值(整个叠加区域)
'cached_surface': None,
'needs_rendering': True,
# 这些字段在新的渲染器中可能不需要,但保留以兼容旧代码
'img_list': [elem['name'] for elem in img_elements], # 图片名列表
'clip_pos': None, # 不再使用全局clip_pos,每个图片独立
'size': None, # 不再使用全局size,每个图片独立
'chara_id': None, # 不再使用全局chara_id,每个图片独立
'draw_type': None # 不再使用全局draw_type,每个图片独立
}
)
# 5. 处理点击区域
if click_value:
item.metadata['clickable'] = True
item.metadata['region_id'] = self.clickable_region_counter
self.clickable_regions.append({
'id': self.clickable_region_counter,
'content_item': item,
'click_value': click_value,
'text': f"[图片叠加] {len(img_elements)}张",
'type': 'image_stack'
})
self.clickable_region_counter += 1
# 6. 添加到历史记录
self.history.append(item)
self._write_to_log(f"[IMAGE_STACK] {len(img_elements)}张图片")
self._update_current_display()
# 7. 滚动到底部
if self.scroll_offset <= 5:
self.scroll_to_bottom()
return item
def set_font(self, font):
self.font = font
def handle_mouse_click(self, mouse_pos: Tuple[int, int]) -> Optional[str]:
"""
处理鼠标点击事件
Args:
mouse_pos: 鼠标位置 (x, y)
Returns:
点击的文本值,如果没有点击可点击区域则返回None
"""
# 更新活动点击区域
# self._update_active_clickable_regions()
# 检查是否点击了任何活动区域
for region in self.active_clickable_regions:
if region['rect'].collidepoint(mouse_pos):
return region['click_value']
return None
def _update_active_clickable_regions(self):
"""更新当前显示的可点击区域"""
self.active_clickable_regions = []
current_y = 10
# 遍历当前显示的内容项
for item in self.current_display:
# 检查普通文本点击区域
if 'clickable' in item.metadata and item.metadata['clickable']:
# 根据项目类型计算区域
region_rect = None
if item.type == ContentType.TEXT:
# 文本区域
text_width = self.font.size(
item.data)[0] if item.data else 0
region_rect = pygame.Rect(
10, current_y, text_width, item.height)
elif item.type == ContentType.IMAGE:
# 图片区域
if item.data in self.image_cache:
image = self.image_cache[item.data]
img_x = 10 # 图片从左侧开始
region_rect = pygame.Rect(
img_x, current_y, image.get_width(), item.height)
if region_rect:
# 查找对应的点击区域记录
for region in self.clickable_regions:
if region.get('content_item') == item and region.get('type') in ['text', 'image']:
region['rect'] = region_rect
self.active_clickable_regions.append({
'id': region['id'],
'rect': region_rect,
'click_value': region['click_value'],
'text': region['text'],
'type': region['type']
})
# 检查行内片段点击区域
elif item.fragments:
current_x = 10
for i, fragment in enumerate(item.fragments):
# 计算片段宽度
fragment.calculate_width(self.font)
# 检查是否需要换行
available_width = self.screen_width - current_x - 20
if fragment.width > available_width and current_x > 10:
current_x = 10
current_y += item.height
# 如果是可点击片段
if fragment.click_value:
region_rect = pygame.Rect(
current_x, current_y, fragment.width, item.height)
# 查找对应的点击区域记录
for region in self.clickable_regions:
if (region.get('content_item') == item and
region.get('fragment_index') == i and
region.get('type') == 'inline_fragment'):
region['rect'] = region_rect
self.active_clickable_regions.append({
'id': region['id'],
'rect': region_rect,
'click_value': region['click_value'],
'text': region['text'],
'type': region['type']
})
current_x += fragment.width
# 一行绘制完毕,换行
if item.fragments:
current_y += item.height
else:
# 更新Y位置
current_y += item.height
def clear_clickable_regions(self):
"""清空所有可点击区域"""
self.clickable_regions = []
self.active_clickable_regions = []
self.clickable_region_counter = 0
# 清空历史记录中所有内容的点击元数据
def _init_log_file(self):
"""初始化日志文件"""
try:
# 创建日志文件目录(如果不存在)
log_dir = os.path.dirname(self.log_file)
if log_dir and not os.path.exists(log_dir):
os.makedirs(log_dir)
# 写入初始信息
with open(self.log_file, 'w', encoding='utf-8') as f:
f.write(f"\n{'='*60}\n")
f.write(f"会话开始时间: {time.strftime('%Y-%m-%d %H:%M:%S')}\n")
f.write(f"{'='*60}\n\n")
except Exception as e:
print(f"初始化日志文件失败: {e}")
def add_text(self, text: str, color: Tuple[int, int, int] = (255, 255, 255)) -> List[ConsoleContent]:
"""
添加文本到历史记录
Args:
text: 要添加的文本
color: 文本颜色
Returns:
添加的内容项列表
"""
added_items = []
# 处理空文本
if text is None or text == "":
item = ConsoleContent(ContentType.TEXT, "",
color, self.line_height)
self.history.append(item)
self._write_to_log("")
added_items.append(item)
self._update_current_display()
# 自动滚动到底部(如果已经在底部附近)
if self.scroll_offset <= 5:
self.scroll_to_bottom()
return added_items
# 处理制表符
text = text.replace('\t', ' ')
# 分割文本为多行
lines = []
current_line = ""
for char in text:
# 处理换行符
if char == '\n':
if current_line:
lines.append(current_line)
current_line = ""
lines.append("") # 空行
continue
# 测试添加当前字符后的宽度
test_line = current_line + char
text_width = self.font.size(test_line)[0]
# 如果超出屏幕宽度(减去滚动条和边距)
max_width = self.screen_width - 40 - \
(self.scrollbar_width if self.scrollbar_visible else 0)
if text_width > max_width:
if current_line:
lines.append(current_line)
current_line = char
else:
current_line = test_line
# 添加最后一行
if current_line:
lines.append(current_line)
# 将新行添加到历史记录
for line in lines:
item = ConsoleContent(ContentType.TEXT, line,
color, self.line_height)
self.history.append(item)
self._write_to_log(line)
added_items.append(item)
# 限制历史记录长度
if len(self.history) > self.max_history_length:
self.history = self.history[-self.max_history_length:]
# 更新当前显示
self._update_current_display()
# 自动滚动到底部(如果已经在底部附近)
if self.scroll_offset <= 5:
self.scroll_to_bottom()
return added_items
def add_divider(self, char: str = "─", length: int = 40, color: Tuple[int, int, int] = (150, 150, 150)):
"""
添加分割线
Args:
char: 分割线字符
length: 分割线长度
color: 颜色
"""
divider_text = char * length
item = ConsoleContent(ContentType.DIVIDER,
divider_text, color, self.line_height)
self.history.append(item)
self._write_to_log(divider_text)
self._update_current_display()
return item
def add_menu(self, items: List[str], color: Tuple[int, int, int] = (200, 200, 255)):
"""
添加菜单
Args:
items: 菜单项列表
color: 颜色
"""
added_items = []
for item in items:
content_item = ConsoleContent(
ContentType.MENU, item, color, self.line_height)
self.history.append(content_item)
self._write_to_log(item)
added_items.append(content_item)
self._update_current_display()
return added_items
def _write_to_log(self, text: str):
"""写入日志文件"""
try:
with open(self.log_file, 'a', encoding='utf-8') as f:
timestamp = time.strftime("[%H:%M:%S] ")
f.write(timestamp + text + "\n")
except Exception as e:
print(f"写入日志失败: {e}")
def _update_current_display(self):
"""更新当前显示的内容(根据滚动偏移)"""
# 清空当前显示
self.current_display = []
# 如果历史记录为空,直接返回
if not self.history:
return
# 计算起始索引
# scroll_offset 表示跳过的最新项目数
start_index = max(0, len(self.history) - 1 - self.scroll_offset)
# 从起始索引开始向前(向历史方向)显示
available_height = self.content_area_height
current_height = 0
for i in range(start_index, -1, -1):
item = self.history[i]
if current_height + item.height <= available_height:
self.current_display.insert(0, item) # 保持顺序
current_height += item.height
else:
break
# 如果显示区域还有空间,尝试向后(向最新方向)显示
# 这样可以确保显示区域总是填满
if current_height < available_height and start_index < len(self.history) - 1:
for i in range(start_index + 1, len(self.history)):
item = self.history[i]
if current_height + item.height <= available_height:
self.current_display.append(item) # 添加到末尾
current_height += item.height
else:
break
# 更新滚动条可见性
total_height = sum(item.height for item in self.history)
self.scrollbar_visible = total_height > self.content_area_height
def scroll_up(self, amount: int = 1):
"""向上滚动(查看更旧的内容)"""
# 最大滚动偏移是历史记录总数减1
max_scroll = max(0, len(self.history) - 1)
self.scroll_offset = min(max_scroll, self.scroll_offset + amount)
self._update_current_display()
def scroll_down(self, amount: int = 1):
"""向下滚动(查看更新的内容)"""
self.scroll_offset = max(0, self.scroll_offset - amount)
self._update_current_display()
def scroll_to_bottom(self):
"""滚动到底部 - 显示最新的内容"""
self.scroll_offset = 0
self._update_current_display()
def scroll_to_top(self):
"""滚动到顶部 - 显示最旧的内容"""
# 设置滚动偏移为最大值
self.scroll_offset = max(0, len(self.history) - 1)
self._update_current_display()
def clear_history(self):