frontengine 1.0.26__tar.gz → 1.0.28__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. {frontengine-1.0.26 → frontengine-1.0.28}/PKG-INFO +33 -1
  2. {frontengine-1.0.26 → frontengine-1.0.28}/README.md +32 -0
  3. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/show/pet/desktop_pet.py +267 -11
  4. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/ui/page/pet/pet_setting_ui.py +79 -13
  5. frontengine-1.0.28/frontengine/utils/audio_meter/audio_envelope.py +56 -0
  6. frontengine-1.0.28/frontengine/utils/audio_meter/screen_audio.py +130 -0
  7. frontengine-1.0.28/frontengine/utils/audio_meter/system_audio_meter.py +298 -0
  8. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/utils/multi_language/english.py +9 -0
  9. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/utils/multi_language/traditional_chinese.py +8 -0
  10. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine.egg-info/PKG-INFO +33 -1
  11. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine.egg-info/SOURCES.txt +2 -0
  12. {frontengine-1.0.26 → frontengine-1.0.28}/pyproject.toml +1 -1
  13. frontengine-1.0.26/frontengine/utils/audio_meter/system_audio_meter.py +0 -136
  14. {frontengine-1.0.26 → frontengine-1.0.28}/LICENSE +0 -0
  15. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/__init__.py +0 -0
  16. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/__main__.py +0 -0
  17. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/show/__init__.py +0 -0
  18. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/show/base_widget.py +0 -0
  19. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/show/gif/__init__.py +0 -0
  20. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/show/gif/paint_gif.py +0 -0
  21. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/show/image/__init__.py +0 -0
  22. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/show/image/paint_image.py +0 -0
  23. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/show/load/__init__.py +0 -0
  24. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/show/load/load_someone_make_ui.py +0 -0
  25. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/show/overlay_factory.py +0 -0
  26. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/show/particle/__init__.py +0 -0
  27. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/show/particle/particle_ui.py +0 -0
  28. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/show/pet/__init__.py +0 -0
  29. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/show/scene/__init__.py +0 -0
  30. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/show/scene/extend_graphic_scene.py +0 -0
  31. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/show/scene/extend_graphic_view.py +0 -0
  32. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/show/scene/scene.py +0 -0
  33. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/show/sound_player/__init__.py +0 -0
  34. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/show/sound_player/sound_effect.py +0 -0
  35. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/show/sound_player/sound_player.py +0 -0
  36. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/show/text/__init__.py +0 -0
  37. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/show/text/draw_text.py +0 -0
  38. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/show/video/__init__.py +0 -0
  39. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/show/video/video_player.py +0 -0
  40. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/show/web/__init__.py +0 -0
  41. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/show/web/webview.py +0 -0
  42. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/show/window_helpers.py +0 -0
  43. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/system_tray/__init__.py +0 -0
  44. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/system_tray/extend_system_tray.py +0 -0
  45. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/ui/__init__.py +0 -0
  46. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/ui/color/__init__.py +0 -0
  47. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/ui/color/global_color.py +0 -0
  48. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/ui/dialog/__init__.py +0 -0
  49. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/ui/dialog/choose_file_dialog.py +0 -0
  50. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/ui/dialog/hotkey_settings_dialog.py +0 -0
  51. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/ui/dialog/save_file_dialog.py +0 -0
  52. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/ui/main_ui.py +0 -0
  53. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/ui/menu/__init__.py +0 -0
  54. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/ui/menu/help_menu.py +0 -0
  55. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/ui/menu/how_to_menu.py +0 -0
  56. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/ui/menu/language_menu.py +0 -0
  57. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/ui/menu/preset_menu.py +0 -0
  58. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/ui/menu/settings_menu.py +0 -0
  59. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/ui/page/__init__.py +0 -0
  60. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/ui/page/control_center/__init__.py +0 -0
  61. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/ui/page/control_center/control_center_ui.py +0 -0
  62. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/ui/page/gif/__init__.py +0 -0
  63. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/ui/page/gif/gif_setting_ui.py +0 -0
  64. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/ui/page/image/__init__.py +0 -0
  65. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/ui/page/image/image_setting_ui.py +0 -0
  66. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/ui/page/particle/__init__.py +0 -0
  67. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/ui/page/particle/particle_setting_ui.py +0 -0
  68. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/ui/page/pet/__init__.py +0 -0
  69. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/ui/page/scene_setting/__init__.py +0 -0
  70. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/ui/page/scene_setting/scene_manager.py +0 -0
  71. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/ui/page/scene_setting/scene_page/__init__.py +0 -0
  72. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/ui/page/scene_setting/scene_page/base_scene_page.py +0 -0
  73. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/ui/page/scene_setting/scene_page/gif.py +0 -0
  74. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/ui/page/scene_setting/scene_page/image.py +0 -0
  75. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/ui/page/scene_setting/scene_page/registry.py +0 -0
  76. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/ui/page/scene_setting/scene_page/sound.py +0 -0
  77. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/ui/page/scene_setting/scene_page/text.py +0 -0
  78. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/ui/page/scene_setting/scene_page/video.py +0 -0
  79. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/ui/page/scene_setting/scene_page/web.py +0 -0
  80. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/ui/page/scene_setting/scene_setting_ui.py +0 -0
  81. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/ui/page/sound_player/__init__.py +0 -0
  82. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/ui/page/sound_player/sound_player_setting_ui.py +0 -0
  83. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/ui/page/text/__init__.py +0 -0
  84. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/ui/page/text/text_setting_ui.py +0 -0
  85. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/ui/page/utils.py +0 -0
  86. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/ui/page/video/__init__.py +0 -0
  87. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/ui/page/video/video_setting_ui.py +0 -0
  88. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/ui/page/web/__init__.py +0 -0
  89. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/ui/page/web/web_setting_ui.py +0 -0
  90. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/user_setting/__init__.py +0 -0
  91. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/user_setting/preset_repository.py +0 -0
  92. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/user_setting/scene_setting.py +0 -0
  93. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/user_setting/user_setting_file.py +0 -0
  94. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/utils/__init__.py +0 -0
  95. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/utils/audio_meter/__init__.py +0 -0
  96. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/utils/browser/__init__.py +0 -0
  97. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/utils/browser/browser.py +0 -0
  98. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/utils/critical_exit/__init__.py +0 -0
  99. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/utils/critical_exit/check_key_is_press.py +0 -0
  100. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/utils/critical_exit/critical_exit.py +0 -0
  101. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/utils/critical_exit/win32_vk.py +0 -0
  102. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/utils/exception/__init__.py +0 -0
  103. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/utils/exception/exception_tags.py +0 -0
  104. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/utils/exception/exceptions.py +0 -0
  105. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/utils/file/__init__.py +0 -0
  106. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/utils/file/open/__init__.py +0 -0
  107. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/utils/file/open/open_file.py +0 -0
  108. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/utils/file/save/__init__.py +0 -0
  109. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/utils/file/save/save_file.py +0 -0
  110. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/utils/hotkey/__init__.py +0 -0
  111. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/utils/hotkey/hotkey_service.py +0 -0
  112. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/utils/json/__init__.py +0 -0
  113. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/utils/json/json_file.py +0 -0
  114. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/utils/json/json_repository.py +0 -0
  115. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/utils/json_format/__init__.py +0 -0
  116. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/utils/json_format/json_process.py +0 -0
  117. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/utils/logging/__init__.py +0 -0
  118. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/utils/logging/loggin_instance.py +0 -0
  119. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/utils/multi_language/__init__.py +0 -0
  120. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/utils/multi_language/france.py +0 -0
  121. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/utils/multi_language/germany.py +0 -0
  122. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/utils/multi_language/italy.py +0 -0
  123. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/utils/multi_language/language_wrapper.py +0 -0
  124. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/utils/multi_language/russian.py +0 -0
  125. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/utils/multi_language/simplified_chinese.py +0 -0
  126. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/utils/preset_schedule/__init__.py +0 -0
  127. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/utils/preset_schedule/preset_schedule_service.py +0 -0
  128. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/utils/redirect_manager/__init__.py +0 -0
  129. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/utils/redirect_manager/redirect_manager_class.py +0 -0
  130. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/utils/smart_pause/__init__.py +0 -0
  131. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/utils/smart_pause/smart_pause_service.py +0 -0
  132. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/utils/theme_schedule/__init__.py +0 -0
  133. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/utils/theme_schedule/theme_schedule_service.py +0 -0
  134. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/utils/web_url.py +0 -0
  135. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/worker/__init__.py +0 -0
  136. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine/worker/qthread_worker.py +0 -0
  137. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine.egg-info/dependency_links.txt +0 -0
  138. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine.egg-info/entry_points.txt +0 -0
  139. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine.egg-info/requires.txt +0 -0
  140. {frontengine-1.0.26 → frontengine-1.0.28}/frontengine.egg-info/top_level.txt +0 -0
  141. {frontengine-1.0.26 → frontengine-1.0.28}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: frontengine
3
- Version: 1.0.26
3
+ Version: 1.0.28
4
4
  Summary: FrontEngine is use-define frontview or only use like screen saver
5
5
  Author-email: JE-Chen <jechenmailman@gmail.com>
6
6
  Project-URL: Homepage, https://github.com/Intergration-Automation-Testing/FrontEngine
@@ -53,6 +53,38 @@ It provides an intuitive interface and supports multiple media formats for inter
53
53
 
54
54
  ---
55
55
 
56
+ ## Desktop pet
57
+
58
+ Spawn an animated sprite that lives on your desktop, from the **Pet** tab.
59
+
60
+ - **Sprites** — pick a single GIF/WebP/PNG, or a *pet pack* folder whose file
61
+ names map to states: `walk`, `idle`, `sleep`, `climb`, `fall`, `drag`
62
+ (missing states fall back to `walk`).
63
+ - **Behaviour** — walk on the floor with gravity (throw it and it bounces),
64
+ wander freely, or chase the cursor. Floor pets can climb screen edges and
65
+ stand on the top edge of other windows.
66
+ - **Life** — mood, fullness and an affection level that persist between runs;
67
+ the pet grows as it levels up, chats in speech bubbles, naps while you are
68
+ away, and warns you about a low battery.
69
+ - **Interaction** — drag it around, right-click to clone/feed/set a reminder,
70
+ and **drop a file onto it**: an image or pet pack becomes its new look,
71
+ anything else is eaten. What it eats matters — an archive is a feast, music
72
+ cheers it up more than it fills, a document is a modest meal, and a binary is
73
+ too hard to chew.
74
+ - **Tag** — with two or more pets on screen, tick *Play tag with each other* and
75
+ one becomes "it": it walks toward its nearest neighbour while the others run
76
+ the other way, and catching someone passes the tag on.
77
+ - **React to audio** — the pet pulses to your speakers' output level. It reads
78
+ only the Windows output *meter* (WASAPI `IAudioMeterInformation`); no audio
79
+ is captured or recorded. Peaks are smoothed with an RMS window and a
80
+ fast-attack/slow-decay envelope so the pulse breathes instead of flickering.
81
+ On multi-monitor setups each pet follows the audio endpoint that matches its
82
+ own screen, falling back to the default output device.
83
+
84
+ Audio features are Windows-only and degrade to "no pulse" elsewhere.
85
+
86
+ ---
87
+
56
88
  ## Install
57
89
 
58
90
  - **System Requirements**
@@ -29,6 +29,38 @@ It provides an intuitive interface and supports multiple media formats for inter
29
29
 
30
30
  ---
31
31
 
32
+ ## Desktop pet
33
+
34
+ Spawn an animated sprite that lives on your desktop, from the **Pet** tab.
35
+
36
+ - **Sprites** — pick a single GIF/WebP/PNG, or a *pet pack* folder whose file
37
+ names map to states: `walk`, `idle`, `sleep`, `climb`, `fall`, `drag`
38
+ (missing states fall back to `walk`).
39
+ - **Behaviour** — walk on the floor with gravity (throw it and it bounces),
40
+ wander freely, or chase the cursor. Floor pets can climb screen edges and
41
+ stand on the top edge of other windows.
42
+ - **Life** — mood, fullness and an affection level that persist between runs;
43
+ the pet grows as it levels up, chats in speech bubbles, naps while you are
44
+ away, and warns you about a low battery.
45
+ - **Interaction** — drag it around, right-click to clone/feed/set a reminder,
46
+ and **drop a file onto it**: an image or pet pack becomes its new look,
47
+ anything else is eaten. What it eats matters — an archive is a feast, music
48
+ cheers it up more than it fills, a document is a modest meal, and a binary is
49
+ too hard to chew.
50
+ - **Tag** — with two or more pets on screen, tick *Play tag with each other* and
51
+ one becomes "it": it walks toward its nearest neighbour while the others run
52
+ the other way, and catching someone passes the tag on.
53
+ - **React to audio** — the pet pulses to your speakers' output level. It reads
54
+ only the Windows output *meter* (WASAPI `IAudioMeterInformation`); no audio
55
+ is captured or recorded. Peaks are smoothed with an RMS window and a
56
+ fast-attack/slow-decay envelope so the pulse breathes instead of flickering.
57
+ On multi-monitor setups each pet follows the audio endpoint that matches its
58
+ own screen, falling back to the default output device.
59
+
60
+ Audio features are Windows-only and degrade to "no pulse" elsewhere.
61
+
62
+ ---
63
+
32
64
  ## Install
33
65
 
34
66
  - **System Requirements**
@@ -12,6 +12,7 @@ from PySide6.QtWidgets import QMenu, QMessageBox, QWidget
12
12
  from frontengine.show.base_widget import BaseWidget
13
13
  from frontengine.show.window_helpers import apply_overlay_window_flags
14
14
  from frontengine.user_setting.user_setting_file import user_setting_dict
15
+ from frontengine.utils.audio_meter.audio_envelope import AudioEnvelope
15
16
  from frontengine.utils.logging.loggin_instance import front_engine_logger
16
17
  from frontengine.utils.multi_language.language_wrapper import language_wrapper
17
18
 
@@ -82,6 +83,65 @@ def scan_pet_pack(folder: str) -> dict:
82
83
  return result
83
84
 
84
85
 
86
+ # 拖進寵物的檔案分類 / What a file dropped onto the pet means
87
+ DROP_SPRITE = "sprite" # 圖片或動作包 -> 換一套外觀
88
+ DROP_FOOD = "food" # 其他檔案 -> 當成食物餵給寵物
89
+
90
+
91
+ def classify_drop(path) -> Optional[str]:
92
+ """
93
+ 判斷拖到寵物身上的檔案要換裝還是當食物;路徑無效或空資料夾回傳 None。
94
+ Classify a file dropped onto the pet: a sprite/pack to wear, food to eat,
95
+ or None when the path is unusable.
96
+ """
97
+ try:
98
+ target = Path(path)
99
+ if not target.exists():
100
+ return None
101
+ except (OSError, TypeError, ValueError):
102
+ return None
103
+ if target.is_dir():
104
+ return DROP_SPRITE if scan_pet_pack(str(target)) else None
105
+ return DROP_SPRITE if target.suffix.lower() in _PACK_EXTS else DROP_FOOD
106
+
107
+
108
+ # 食物種類(依副檔名猜)與各自的效果 / Food kinds guessed from the suffix
109
+ FOOD_SNACK = "snack" # 一般點心 / an ordinary snack
110
+ FOOD_FEAST = "feast" # 壓縮檔=大餐 / an archive is a hearty meal
111
+ FOOD_MUSIC = "music" # 音樂檔=聽了很開心 / music cheers it up more than it fills
112
+ FOOD_BOOK = "book" # 文件檔=細嚼慢嚥 / a document is chewed slowly
113
+ FOOD_HARD = "hard" # 可執行檔=咬不動 / a binary is too hard to chew
114
+
115
+ _FOOD_SUFFIXES = {
116
+ FOOD_FEAST: (".zip", ".7z", ".rar", ".tar", ".gz", ".iso"),
117
+ FOOD_MUSIC: (".mp3", ".wav", ".ogg", ".flac", ".m4a", ".aac"),
118
+ FOOD_BOOK: (".txt", ".md", ".pdf", ".doc", ".docx", ".rtf", ".epub"),
119
+ FOOD_HARD: (".exe", ".dll", ".msi", ".sys", ".bat"),
120
+ }
121
+
122
+ # 每種食物的飽足/心情/親密度變化(心情為負代表不喜歡)
123
+ # Fullness / mood / affection change per food kind (negative mood = dislikes it).
124
+ FOOD_EFFECTS = {
125
+ FOOD_SNACK: {"fullness": 30, "mood": 12, "affection": 5},
126
+ FOOD_FEAST: {"fullness": 45, "mood": 8, "affection": 6},
127
+ FOOD_MUSIC: {"fullness": 15, "mood": 20, "affection": 5},
128
+ FOOD_BOOK: {"fullness": 20, "mood": 6, "affection": 4},
129
+ FOOD_HARD: {"fullness": 2, "mood": -6, "affection": 1},
130
+ }
131
+
132
+
133
+ def classify_food(path) -> str:
134
+ """依副檔名判斷食物種類;認不出來就當一般點心 / Guess the food kind from the suffix."""
135
+ try:
136
+ suffix = Path(path).suffix.lower()
137
+ except (OSError, TypeError, ValueError):
138
+ return FOOD_SNACK
139
+ for kind, suffixes in _FOOD_SUFFIXES.items():
140
+ if suffix in suffixes:
141
+ return kind
142
+ return FOOD_SNACK
143
+
144
+
85
145
  def derive_visual_state(dragging: bool, airborne: bool, surface: str, motion_state: str) -> str:
86
146
  """依目前情形推導應顯示的視覺狀態 / Derive which sprite state to show."""
87
147
  if dragging:
@@ -268,6 +328,92 @@ def nearest_peer(center, peers):
268
328
  return best, best_distance
269
329
 
270
330
 
331
+ # 鬼抓人角色 / Roles in the tag game
332
+ TAG_CHASE = "chase"
333
+ TAG_FLEE = "flee"
334
+
335
+
336
+ class PetTagGame:
337
+ """
338
+ 多隻寵物的鬼抓人:一隻當鬼追最近的同伴,其他往反方向逃;抓到就換人當鬼,
339
+ 並有短暫冷卻避免立刻抓回去。純邏輯、不依賴 Qt,由外部每一拍餵入座標。
340
+
341
+ Tag between pets: one is "it" and chases its nearest peer while the others
342
+ run the other way; catching someone passes "it" on, with a short cooldown so
343
+ the tag cannot bounce straight back. Pure logic — positions are fed in each
344
+ tick from outside.
345
+ """
346
+
347
+ CATCH_DISTANCE = 60.0
348
+ FLEE_DISTANCE = 220.0
349
+ COOLDOWN_TICKS = 20
350
+
351
+ def __init__(self, catch_distance: float = CATCH_DISTANCE, flee_distance: float = FLEE_DISTANCE,
352
+ cooldown_ticks: int = COOLDOWN_TICKS) -> None:
353
+ self.catch_distance = float(catch_distance)
354
+ self.flee_distance = float(flee_distance)
355
+ self.cooldown_ticks = max(0, int(cooldown_ticks))
356
+ self.it = None
357
+ self.just_tagged = None
358
+ self._cooldown = 0
359
+
360
+ def reset(self) -> None:
361
+ """重置遊戲(參與者換人或停玩時呼叫)。"""
362
+ self.it = None
363
+ self.just_tagged = None
364
+ self._cooldown = 0
365
+
366
+ @staticmethod
367
+ def _nearest(positions: dict, source):
368
+ """回傳離 source 最近的其他參與者 (id, 距離);只有一人時回傳 (None, inf)。"""
369
+ source_x, source_y = positions[source]
370
+ best, best_distance = None, float("inf")
371
+ for participant, (x, y) in positions.items():
372
+ if participant == source:
373
+ continue
374
+ distance = ((x - source_x) ** 2 + (y - source_y) ** 2) ** 0.5
375
+ if distance < best_distance:
376
+ best, best_distance = participant, distance
377
+ return best, best_distance
378
+
379
+ def update(self, positions: dict) -> dict:
380
+ """
381
+ 餵入 {參與者: (x, y)},回傳 {參與者: (角色, 目標 x)}。少於兩人時不開局。
382
+ Feed in {participant: (x, y)} and get {participant: (role, target_x)}.
383
+ """
384
+ self.just_tagged = None
385
+ if not positions or len(positions) < 2:
386
+ self.reset()
387
+ return {}
388
+ if self.it not in positions:
389
+ self.it = sorted(positions)[0]
390
+ self._cooldown = self.cooldown_ticks
391
+ # 冷卻期間只是跑給對方追,不換鬼;先判斷再遞減,冷卻 N 拍就真的保護 N 拍。
392
+ # While cooling down nobody can be tagged; check before decrementing so a
393
+ # cooldown of N protects exactly N updates.
394
+ can_tag = self._cooldown <= 0
395
+ if self._cooldown > 0:
396
+ self._cooldown -= 1
397
+ prey, distance = self._nearest(positions, self.it)
398
+ if prey is not None and can_tag and distance <= self.catch_distance:
399
+ self.it = prey
400
+ self.just_tagged = prey
401
+ self._cooldown = self.cooldown_ticks
402
+ prey, _distance = self._nearest(positions, self.it)
403
+ return self._roles(positions, prey)
404
+
405
+ def _roles(self, positions: dict, prey) -> dict:
406
+ """依目前的鬼與獵物組出每個人的角色與要走向的 x。"""
407
+ it_x, _it_y = positions[self.it]
408
+ roles = {self.it: (TAG_CHASE, positions[prey][0] if prey is not None else it_x)}
409
+ for participant, (x, _y) in positions.items():
410
+ if participant == self.it:
411
+ continue
412
+ away = self.flee_distance if x >= it_x else -self.flee_distance
413
+ roles[participant] = (TAG_FLEE, x + away)
414
+ return roles
415
+
416
+
271
417
  def message_bucket(hour: int) -> str:
272
418
  """依小時回傳時段 / Time-of-day bucket for the given hour."""
273
419
  hour = int(hour) % 24
@@ -876,6 +1022,7 @@ class DesktopPetWidget(BaseWidget):
876
1022
  message_box.show()
877
1023
 
878
1024
  self.resize(self.pet_size, self.pet_size)
1025
+ self.setAcceptDrops(True) # drop an image to dress it up, anything else to feed it
879
1026
  self.motion = PetMotion(
880
1027
  0, 0, self.pet_size, self.pet_size, (0, 0, 1920, 1080), speed, behaviour, climb=climb
881
1028
  )
@@ -901,10 +1048,15 @@ class DesktopPetWidget(BaseWidget):
901
1048
  self._audio_react = bool(audio_react)
902
1049
  self._audio_level_provider = None # callable -> level 0..1 or None
903
1050
  self._audio_scale = 1.0
1051
+ # 峰值抖動大,先過 RMS + 快起慢降包絡再驅動脈動
1052
+ # Raw peaks jitter, so smooth them through an RMS + attack/decay envelope.
1053
+ self._audio_envelope = AudioEnvelope()
904
1054
  # 多寵物互動 / Multi-pet interaction
905
1055
  self._peers_provider = None
906
1056
  self._peer_greeted = False
907
1057
  self._peer_tick = 0
1058
+ # 鬼抓人角色(由外部的 PetTagGame 指派)/ Tag role assigned by a PetTagGame
1059
+ self._tag_role = None
908
1060
  # 電量提醒 / Low-battery reaction
909
1061
  self._battery_provider = read_battery
910
1062
  self._battery_warned = False
@@ -957,8 +1109,31 @@ class DesktopPetWidget(BaseWidget):
957
1109
  """設定回傳其他寵物中心座標清單的函式 / Provider of other pets' centres."""
958
1110
  self._peers_provider = provider
959
1111
 
1112
+ def apply_tag_role(self, role) -> None:
1113
+ """
1114
+ 套用鬼抓人角色 (角色, 目標 x);None 代表沒在玩,恢復平常的同伴互動。
1115
+ Apply a tag role; None means the game is off and normal peer play resumes.
1116
+ """
1117
+ if role is None:
1118
+ if self._tag_role is not None:
1119
+ self._tag_role = None
1120
+ self.motion.clear_follow()
1121
+ return
1122
+ self._tag_role, target_x = role
1123
+ self.motion.wake()
1124
+ self.motion.set_follow(target_x)
1125
+
1126
+ def on_tagged(self) -> None:
1127
+ """換自己當鬼時喊一句 / Shout when the tag lands on this pet."""
1128
+ self._gain_affection(1)
1129
+ pool = self._messages.get("tag") or []
1130
+ if pool:
1131
+ self.say(pool[self._chatter_rng.randrange(len(pool))])
1132
+
960
1133
  def _check_peers(self) -> None:
961
1134
  """靠近其他寵物時走過去玩耍、抵達後轉向打招呼(帶遲滯,避免重複)。"""
1135
+ if self._tag_role is not None:
1136
+ return # the tag game owns where this pet is heading
962
1137
  if self._peers_provider is None:
963
1138
  return
964
1139
  try:
@@ -992,19 +1167,50 @@ class DesktopPetWidget(BaseWidget):
992
1167
  self._peer_greeted = False
993
1168
 
994
1169
  def _load_sprite(self, path: str):
1170
+ """載入一張精靈;檔案損毀或格式不支援時回傳 None。"""
995
1171
  if Path(path).suffix.lower() in (".gif", ".webp"):
996
1172
  movie = QMovie(str(path))
1173
+ if not movie.isValid():
1174
+ return None
997
1175
  movie.frameChanged.connect(self.update)
998
1176
  return ("movie", movie)
999
- return ("pixmap", QPixmap(str(path)))
1177
+ pixmap = QPixmap(str(path))
1178
+ return None if pixmap.isNull() else ("pixmap", pixmap)
1000
1179
 
1001
1180
  def _load_sprites(self, image_path: Path) -> None:
1002
- """單一檔 -> 所有狀態共用;資料夾 -> 依檔名對應各狀態。"""
1181
+ """單一檔 -> 所有狀態共用;資料夾 -> 依檔名對應各狀態;載不起來的略過。"""
1003
1182
  if image_path.is_dir():
1004
1183
  for state, path in scan_pet_pack(str(image_path)).items():
1005
- self._sprites[state] = self._load_sprite(path)
1184
+ sprite = self._load_sprite(path)
1185
+ if sprite is not None:
1186
+ self._sprites[state] = sprite
1006
1187
  elif image_path.is_file():
1007
- self._sprites["default"] = self._load_sprite(str(image_path))
1188
+ sprite = self._load_sprite(str(image_path))
1189
+ if sprite is not None:
1190
+ self._sprites["default"] = sprite
1191
+
1192
+ def change_sprite(self, path) -> None:
1193
+ """換一套外觀(拖圖片或動作包到寵物身上時觸發);載不到就維持原樣。"""
1194
+ image_path = Path(path)
1195
+ previous = self._sprites
1196
+ self._sprites = {}
1197
+ self._load_sprites(image_path)
1198
+ if not self._sprites:
1199
+ front_engine_logger.warning(f"[DesktopPetWidget] change_sprite ignored: {image_path}")
1200
+ self._sprites = previous
1201
+ return
1202
+ front_engine_logger.info(f"[DesktopPetWidget] change_sprite | path={image_path}")
1203
+ for kind, obj in previous.values():
1204
+ if kind == "movie":
1205
+ obj.stop()
1206
+ self.image_path = image_path
1207
+ self._active = None
1208
+ self._active_state = None
1209
+ self._set_active_sprite(self._visual_state())
1210
+ self._gain_affection(2)
1211
+ pool = self._messages.get("costume") or []
1212
+ if pool:
1213
+ self.say(pool[self._chatter_rng.randrange(len(pool))])
1008
1214
 
1009
1215
  def _sprite_for(self, state: str):
1010
1216
  if "default" in self._sprites:
@@ -1042,16 +1248,23 @@ class DesktopPetWidget(BaseWidget):
1042
1248
  def set_audio_level_provider(self, provider) -> None:
1043
1249
  """設定回傳目前音量 (0~1 或 None) 的函式 / Provider of the current audio level."""
1044
1250
  self._audio_level_provider = provider
1251
+ self._audio_envelope.reset()
1045
1252
 
1046
1253
  def _update_audio_scale(self) -> None:
1254
+ """取一次音量取樣,經包絡平滑後換算成脈動比例(無音源則不脈動)。"""
1047
1255
  if not self._audio_react or self._audio_level_provider is None:
1048
1256
  self._audio_scale = 1.0
1257
+ self._audio_envelope.reset()
1049
1258
  return
1050
1259
  try:
1051
1260
  level = self._audio_level_provider()
1052
1261
  except Exception: # pragma: no cover - defensive
1053
1262
  level = None
1054
- self._audio_scale = 1.0 if level is None else audio_pulse_scale(level)
1263
+ if level is None:
1264
+ self._audio_scale = 1.0
1265
+ self._audio_envelope.reset()
1266
+ return
1267
+ self._audio_scale = audio_pulse_scale(self._audio_envelope.push(level))
1055
1268
 
1056
1269
  def draw_content(self, painter: QPainter) -> None:
1057
1270
  pixmap = self._current_pixmap()
@@ -1099,8 +1312,14 @@ class DesktopPetWidget(BaseWidget):
1099
1312
  "battery": lines("pet_chatter_battery", "Battery's low - plug me in?|Low power!|Find a charger?"),
1100
1313
  "hungry": lines("pet_chatter_hungry", "I'm hungry...|Got a snack?|Feed me?"),
1101
1314
  "fed": lines("pet_chatter_fed", "Yum!|Thank you!|Delicious!"),
1315
+ "fed_feast": lines("pet_chatter_fed_feast", "What a feast!|So full!|That was huge!"),
1316
+ "fed_music": lines("pet_chatter_fed_music", "Tasty tune!|That one sings!|Encore!"),
1317
+ "fed_book": lines("pet_chatter_fed_book", "Food for thought.|Chewy words!|Mmm, a story."),
1318
+ "fed_hard": lines("pet_chatter_fed_hard", "Too hard to chew!|Ouch, my teeth!|Not food!"),
1102
1319
  "away": lines("pet_chatter_away", "Still there?|I'll nap till you're back~|Zzz..."),
1103
1320
  "levelup": lines("pet_chatter_levelup", "Level up!|I'm growing!|We're getting closer!"),
1321
+ "costume": lines("pet_chatter_costume", "New look!|How do I look?|Nice fit!"),
1322
+ "tag": lines("pet_chatter_tag", "You're it!|Caught you!|My turn to chase!"),
1104
1323
  "any": lines("pet_chatter_any", "Hi there!|(^_^)|Keep going!"),
1105
1324
  }
1106
1325
 
@@ -1267,14 +1486,19 @@ class DesktopPetWidget(BaseWidget):
1267
1486
  def _persist_hunger(self) -> None:
1268
1487
  user_setting_dict["pet_hunger"] = self._hunger.value
1269
1488
 
1270
- def feed(self) -> None:
1271
- """餵食:補足飽足並讓心情變好,說一句道謝。"""
1272
- self._hunger.feed()
1273
- self._mood.pet()
1489
+ def feed(self, kind: str = FOOD_SNACK) -> None:
1490
+ """餵食:依食物種類調整飽足與心情,並說一句對應的話。"""
1491
+ effect = FOOD_EFFECTS.get(kind, FOOD_EFFECTS[FOOD_SNACK])
1492
+ self._hunger.feed(effect["fullness"])
1493
+ mood_change = effect["mood"]
1494
+ if mood_change >= 0:
1495
+ self._mood.pet(mood_change)
1496
+ else:
1497
+ self._mood.decay(-mood_change)
1274
1498
  self._persist_hunger()
1275
1499
  self._persist_mood()
1276
- self._gain_affection(5)
1277
- pool = self._messages.get("fed") or []
1500
+ self._gain_affection(effect["affection"])
1501
+ pool = self._messages.get(f"fed_{kind}") or self._messages.get("fed") or []
1278
1502
  if pool:
1279
1503
  self.say(pool[self._chatter_rng.randrange(len(pool))])
1280
1504
 
@@ -1389,6 +1613,38 @@ class DesktopPetWidget(BaseWidget):
1389
1613
  self.say() # a click (no drag) makes the pet talk
1390
1614
  super().mouseReleaseEvent(event)
1391
1615
 
1616
+ # --- drop a file onto the pet: wear it (image / pack) or eat it ---
1617
+ @staticmethod
1618
+ def dropped_path(mime_data) -> Optional[str]:
1619
+ """從拖曳資料取出第一個可用的本機路徑;沒有可用者回傳 None。"""
1620
+ if mime_data is None or not mime_data.hasUrls():
1621
+ return None
1622
+ for url in mime_data.urls():
1623
+ local_path = url.toLocalFile()
1624
+ if local_path and classify_drop(local_path) is not None:
1625
+ return local_path
1626
+ return None
1627
+
1628
+ def dragEnterEvent(self, event) -> None:
1629
+ if self.dropped_path(event.mimeData()) is not None:
1630
+ event.acceptProposedAction()
1631
+
1632
+ def dragMoveEvent(self, event) -> None:
1633
+ if self.dropped_path(event.mimeData()) is not None:
1634
+ event.acceptProposedAction()
1635
+
1636
+ def dropEvent(self, event) -> None:
1637
+ path = self.dropped_path(event.mimeData())
1638
+ if path is None:
1639
+ return
1640
+ front_engine_logger.info(f"[DesktopPetWidget] dropEvent | path={path}")
1641
+ self.motion.wake()
1642
+ if classify_drop(path) == DROP_SPRITE:
1643
+ self.change_sprite(path)
1644
+ else:
1645
+ self.feed(classify_food(path))
1646
+ event.acceptProposedAction()
1647
+
1392
1648
  def contextMenuEvent(self, event) -> None:
1393
1649
  self.menu.popup(event.globalPos())
1394
1650
 
@@ -1,12 +1,13 @@
1
1
  from typing import Optional
2
2
 
3
+ from PySide6.QtCore import QTimer
3
4
  from PySide6.QtGui import QGuiApplication
4
5
  from PySide6.QtWidgets import (
5
6
  QWidget, QGridLayout, QLabel, QPushButton, QMessageBox, QComboBox, QCheckBox, QFileDialog,
6
7
  )
7
8
 
8
9
  from frontengine.show.pet.desktop_pet import (
9
- DesktopPetWidget, BEHAVIOUR_FLOOR, BEHAVIOUR_WANDER, BEHAVIOUR_CHASE, scan_pet_pack,
10
+ DesktopPetWidget, BEHAVIOUR_FLOOR, BEHAVIOUR_WANDER, BEHAVIOUR_CHASE, PetTagGame, scan_pet_pack,
10
11
  )
11
12
  from frontengine.ui.dialog.choose_file_dialog import choose_pet, choose_wav_sound
12
13
  from frontengine.ui.page.utils import (
@@ -17,7 +18,7 @@ from frontengine.ui.page.utils import (
17
18
  resolve_preferred_monitor,
18
19
  )
19
20
  from frontengine.user_setting.user_setting_file import add_recent_file
20
- from frontengine.utils.audio_meter.system_audio_meter import system_audio_level
21
+ from frontengine.utils.audio_meter.screen_audio import audio_level_provider_for_screen
21
22
  from frontengine.utils.logging.loggin_instance import front_engine_logger
22
23
  from frontengine.utils.multi_language.language_wrapper import language_wrapper
23
24
 
@@ -36,6 +37,11 @@ class PetSettingUI(QWidget):
36
37
  self.ready_to_play = False
37
38
  self.pet_image_path: Optional[str] = None
38
39
  self.pet_sound_path: Optional[str] = None
40
+ # 鬼抓人:由本頁統一驅動,寵物只負責套用被指派的角色
41
+ # Tag game: driven from this page, pets only apply the role they are given.
42
+ self.tag_game = PetTagGame()
43
+ self.tag_timer = QTimer(self)
44
+ self.tag_timer.timeout.connect(self._tick_tag_game)
39
45
 
40
46
  # Choose file
41
47
  self.choose_file_button = QPushButton(
@@ -81,6 +87,9 @@ class PetSettingUI(QWidget):
81
87
  self.talk_checkbox.setChecked(True)
82
88
  self.sit_checkbox = QCheckBox(language_wrapper.language_word_dict.get("pet_sit_label", "Sit on windows"))
83
89
  self.sit_checkbox.setChecked(True)
90
+ self.tag_checkbox = QCheckBox(
91
+ language_wrapper.language_word_dict.get("pet_tag_label", "Play tag with each other"))
92
+ self.tag_checkbox.toggled.connect(self._on_tag_toggled)
84
93
 
85
94
  # Target monitor + sound volume
86
95
  self.target_monitor_label = QLabel(
@@ -93,6 +102,12 @@ class PetSettingUI(QWidget):
93
102
  self.volume_combobox.setCurrentText("100%")
94
103
  self.audio_react_checkbox = QCheckBox(
95
104
  language_wrapper.language_word_dict.get("pet_audio_label", "React to audio"))
105
+ self.drop_hint_label = QLabel(
106
+ language_wrapper.language_word_dict.get(
107
+ "pet_drop_hint",
108
+ "Tip: drop an image or pet pack onto the pet to change its look, "
109
+ "or any other file to feed it."))
110
+ self.drop_hint_label.setWordWrap(True)
96
111
 
97
112
  # Start
98
113
  self.start_button = QPushButton(language_wrapper.language_word_dict.get("pet_start", "Spawn pet"))
@@ -128,6 +143,8 @@ class PetSettingUI(QWidget):
128
143
  self.grid_layout.addWidget(self.volume_label, 6, 2)
129
144
  self.grid_layout.addWidget(self.volume_combobox, 7, 2)
130
145
  self.grid_layout.addWidget(self.audio_react_checkbox, 7, 0)
146
+ self.grid_layout.addWidget(self.tag_checkbox, 7, 1)
147
+ self.grid_layout.addWidget(self.drop_hint_label, 8, 0, 1, 3)
131
148
 
132
149
  def _spawn_pet(self) -> None:
133
150
  """建立、顯示並開始移動一隻寵物(供 Start 與右鍵複製共用)。"""
@@ -147,12 +164,16 @@ class PetSettingUI(QWidget):
147
164
  )
148
165
  pet.clone_requested.connect(self._spawn_pet)
149
166
  pet.set_peers_provider(lambda me=pet: self._peer_centers(me))
167
+ geometry = self._target_geometry()
150
168
  if self.audio_react_checkbox.isChecked():
151
- pet.set_audio_level_provider(system_audio_level) # real system output peak meter
169
+ # 跟隨寵物所在螢幕的音源(比對不到就用系統預設輸出裝置)
170
+ # Follow the audio of the screen the pet lives on; default endpoint otherwise.
171
+ pet.set_audio_level_provider(
172
+ audio_level_provider_for_screen(geometry[1] if geometry is not None else None))
152
173
  pet.set_pet_window_flag()
153
174
  self.pet_list.append(pet)
154
175
  pet.show()
155
- geometry = self._target_geometry()
176
+ self._start_tag_game()
156
177
  if geometry is not None:
157
178
  pet.setScreen(geometry[1])
158
179
  bounds = geometry[0]
@@ -170,17 +191,59 @@ class PetSettingUI(QWidget):
170
191
  return None
171
192
  return (screen.availableGeometry(), screen)
172
193
 
173
- def _peer_centers(self, me) -> list:
174
- """回傳其他仍存活寵物的中心座標(略過已被銷毀者)。"""
175
- centers = []
176
- for other in list(self.pet_list):
177
- if other is me:
178
- continue
194
+ def _alive_pets(self) -> list:
195
+ """回傳仍存活的寵物,並把已被銷毀者從清單移除。"""
196
+ alive = []
197
+ for pet in list(self.pet_list):
179
198
  try:
180
- centers.append(other.center())
199
+ pet.center()
181
200
  except RuntimeError:
182
- pass # underlying C++ object already deleted
183
- return centers
201
+ self.pet_list.remove(pet) # underlying C++ object already deleted
202
+ continue
203
+ alive.append(pet)
204
+ return alive
205
+
206
+ def _peer_centers(self, me) -> list:
207
+ """回傳其他仍存活寵物的中心座標(略過已被銷毀者)。"""
208
+ return [pet.center() for pet in self._alive_pets() if pet is not me]
209
+
210
+ TAG_INTERVAL_MS = 100
211
+
212
+ def _on_tag_toggled(self, checked: bool) -> None:
213
+ """切換鬼抓人:關掉時停錶並把所有寵物放回平常的行為。"""
214
+ front_engine_logger.info(f"[PetSettingUI] tag game | enabled={checked}")
215
+ if checked:
216
+ self._start_tag_game()
217
+ return
218
+ self.tag_timer.stop()
219
+ self.tag_game.reset()
220
+ for pet in self._alive_pets():
221
+ pet.apply_tag_role(None)
222
+
223
+ def _start_tag_game(self) -> None:
224
+ """有兩隻以上寵物且已勾選時才需要開錶 / Only run the game with 2+ pets."""
225
+ if self.tag_checkbox.isChecked() and len(self._alive_pets()) >= 2:
226
+ if not self.tag_timer.isActive():
227
+ self.tag_timer.start(self.TAG_INTERVAL_MS)
228
+
229
+ def _tick_tag_game(self) -> None:
230
+ """每一拍把座標餵給遊戲,再把角色發回各隻寵物。"""
231
+ pets = self._alive_pets()
232
+ if len(pets) < 2:
233
+ self.tag_timer.stop()
234
+ self.tag_game.reset()
235
+ for pet in pets:
236
+ pet.apply_tag_role(None)
237
+ return
238
+ # 用每隻寵物的物件識別碼當鍵,避免有寵物關閉後索引位移導致「鬼」換人
239
+ # Key by object identity so closing a pet cannot shift who is "it".
240
+ by_key = {id(pet): pet for pet in pets}
241
+ roles = self.tag_game.update({key: pet.center() for key, pet in by_key.items()})
242
+ tagged = self.tag_game.just_tagged
243
+ for key, pet in by_key.items():
244
+ pet.apply_tag_role(roles.get(key))
245
+ if key == tagged:
246
+ pet.on_tagged()
184
247
 
185
248
  def start_play_pet(self) -> None:
186
249
  front_engine_logger.info("[PetSettingUI] start_play_pet")
@@ -261,6 +324,7 @@ class PetSettingUI(QWidget):
261
324
  "target_monitor": self.target_monitor_combobox.currentText(),
262
325
  "volume": self.volume_combobox.currentText(),
263
326
  "audio_react": self.audio_react_checkbox.isChecked(),
327
+ "tag": self.tag_checkbox.isChecked(),
264
328
  }
265
329
 
266
330
  def set_state(self, state: dict) -> None:
@@ -298,3 +362,5 @@ class PetSettingUI(QWidget):
298
362
  self.volume_combobox.setCurrentIndex(index)
299
363
  if "audio_react" in state:
300
364
  self.audio_react_checkbox.setChecked(bool(state["audio_react"]))
365
+ if "tag" in state:
366
+ self.tag_checkbox.setChecked(bool(state["tag"]))