-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathanimation_module.py
More file actions
359 lines (303 loc) · 13.3 KB
/
animation_module.py
File metadata and controls
359 lines (303 loc) · 13.3 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
import customtkinter as ctk
from PIL import Image, ImageTk
import threading
import time
import random
import os
import pygame
from dataclasses import dataclass
from typing import List
from enum import Enum
# Import the updated detector
from presence_detector import PresenceDetector
# --- CONSTANTE SI STRUCTURI DE DATE ---
SLEEP_IN_SECONDS = 60
@dataclass
class AnimationFrame:
image_path: str
duration_ms: int
@dataclass
class Animation:
frames: List[AnimationFrame]
is_loop: bool
class State(Enum):
SLEEPING = "sleeping"
LOOKING_AROUND_SLEEP = "looking_around_sleep"
LOOKING_AROUND = "looking_around"
NORMAL = "normal"
CLICK_ME = "click_me"
WINKING = "winking"
# Define animations
ANIMATIONS = {
State.NORMAL: Animation(
frames=[
AnimationFrame("faces/smile.png", 2000),
AnimationFrame("faces/closed_eyes.png", 100),
AnimationFrame("faces/smile.png", 0),
], is_loop=True
),
State.SLEEPING: Animation(
frames=[
AnimationFrame("faces/sleep1.png", 1000),
AnimationFrame("faces/sleep2.png", 1000),
AnimationFrame("faces/sleep1.png", 0),
], is_loop=True
),
State.LOOKING_AROUND_SLEEP: Animation(
frames=[
AnimationFrame("faces/sleep_look_right2.png", 1000),
AnimationFrame("faces/sleep_look_right1.png", 250),
AnimationFrame("faces/sleep_look_front.png", 250),
AnimationFrame("faces/sleep_look_left1.png", 250),
AnimationFrame("faces/sleep_look_left2.png", 1000),
AnimationFrame("faces/sleep_look_left1.png", 250),
AnimationFrame("faces/sleep_look_front.png", 250),
AnimationFrame("faces/sleep_look_right1.png", 250),
AnimationFrame("faces/sleep_look_right2.png", 1000)
], is_loop=False
),
State.LOOKING_AROUND: Animation(
frames=[
AnimationFrame("faces/look_right1.png", 250),
AnimationFrame("faces/look_right2.png", 1000),
AnimationFrame("faces/look_right1.png", 250),
AnimationFrame("faces/smile.png", 250),
AnimationFrame("faces/look_left1.png", 250),
AnimationFrame("faces/look_left2.png", 1000),
AnimationFrame("faces/look_left1.png", 250),
AnimationFrame("faces/smile.png", 250),
AnimationFrame("faces/look_right1.png", 250),
AnimationFrame("faces/look_right2.png", 1000),
AnimationFrame("faces/look_right1.png", 250)
], is_loop=False
),
State.CLICK_ME: Animation(
frames=[
AnimationFrame("faces/here1.png", 400),
AnimationFrame("faces/here2.png", 400),
AnimationFrame("faces/here1.png", 0),
], is_loop=True
),
State.WINKING: Animation(
frames=[
AnimationFrame("faces/wink.png", 1000),
AnimationFrame("faces/smile.png", 1000),
], is_loop=False
),
}
class Character:
def __init__(self, app_view):
self.app = app_view
self.current_state = State.SLEEPING
self.current_frame = 0
self.is_awake = False
self.normal_timer = 0
self.running = True
self.image_cache = {}
self.force_restart = False
# --- AUDIO INIT ---
try:
pygame.mixer.init()
self.audio_enabled = True
except Exception as e:
print(f"Audio init failed: {e}")
self.audio_enabled = False
# Load voice lines paths
self.voice_lines = {
"wakeup": self._load_voice_files("voicelines/wakeup"),
"normal": self._load_voice_files("voicelines/normal"),
"chemare": self._load_voice_files("voicelines/chemare"),
"letsgo": self._load_voice_files("voicelines/letsgo")
}
def _load_voice_files(self, directory):
"""Helper to get all .wav files from a directory."""
files = []
if os.path.exists(directory):
for file in os.listdir(directory):
if file.endswith(".wav"):
files.append(os.path.join(directory, file))
return files
def play_voice(self, category):
"""Plays a random sound from the category on a separate thread to avoid UI freeze."""
if not self.audio_enabled:
return
# Definim sarcina care va rula pe thread
def _audio_task():
# Verificam in thread daca e ocupat, pentru a fi siguri
if pygame.mixer.music.get_busy():
return
if category in self.voice_lines and self.voice_lines[category]:
sound_file = random.choice(self.voice_lines[category])
try:
# Load si Play sunt operatiuni I/O care pot bloca putin, de aceea sunt in thread
pygame.mixer.music.load(sound_file)
pygame.mixer.music.play()
print(f"Playing audio (threaded): {sound_file}")
except Exception as e:
print(f"Error playing sound: {e}")
# Pornim thread-ul daemon (se inchide odata cu aplicatia daca e cazul)
threading.Thread(target=_audio_task, daemon=True).start()
def load_image(self, path):
if path not in self.image_cache:
try:
pil_image = Image.open(path)
pil_image = pil_image.resize((800, 480), Image.Resampling.LANCZOS)
self.image_cache[path] = ImageTk.PhotoImage(pil_image)
except Exception as e:
return None
return self.image_cache[path]
def run_animation(self):
"""Main animation loop - Thread Safe"""
while self.running:
try:
# --- VERIFICARE AUDIO PENTRU "TALKING" ---
is_talking = False
# get_busy este thread-safe in general
if self.audio_enabled and pygame.mixer.music.get_busy():
is_talking = True
if is_talking:
# Daca vorbeste, afisam "talk.png" si ignoram cadrul curent al animatiei
tk_image = self.load_image("faces/talk.png")
if tk_image and hasattr(self.app, 'winfo_exists') and self.app.winfo_exists():
self.app.after(0, lambda img=tk_image: self._safe_gui_update(img))
# Asteptam putin si verificam din nou (bucla scurta cat timp vorbeste)
time.sleep(0.1)
continue
# --- LOGICA NORMALA DE ANIMATIE ---
if self.force_restart:
self.force_restart = False
self.current_frame = 0
animation = ANIMATIONS[self.current_state]
if self.current_frame >= len(animation.frames):
self.current_frame = 0
frame = animation.frames[self.current_frame]
tk_image = self.load_image(frame.image_path)
if tk_image:
if hasattr(self.app, 'winfo_exists') and self.app.winfo_exists():
self.app.after(0, lambda img=tk_image: self._safe_gui_update(img))
else:
break
if frame.duration_ms > 0:
time.sleep(frame.duration_ms / 1000.0)
self.current_frame += 1
if self.current_frame >= len(animation.frames):
if animation.is_loop:
self.current_frame = 0
else:
self.on_animation_complete()
except Exception as e:
print(f"Animation Error: {e}")
time.sleep(0.1)
def _safe_gui_update(self, img):
try:
if hasattr(self.app, 'image_label') and self.app.image_label.winfo_exists():
self.app.image_label.configure(image=img)
self.app.image_label.image = img
except Exception:
pass
def on_animation_complete(self):
if self.current_state == State.LOOKING_AROUND_SLEEP:
self.change_state(State.SLEEPING)
elif self.current_state == State.LOOKING_AROUND:
self.change_state(State.NORMAL)
elif self.current_state == State.WINKING:
print("Wink finished. Going to Home.")
# Aici se face trecerea la Home, dupa ce s-a terminat animatia (si sunetul letsgo)
if hasattr(self.app, 'navigate_to_home'):
self.app.after(0, self.app.navigate_to_home)
else:
self.change_state(State.NORMAL)
def change_state(self, new_state: State):
# Daca trecem in starea CLICK_ME (trezire), redam sunetul wakeup
if new_state == State.CLICK_ME and self.current_state != State.CLICK_ME:
self.play_voice("wakeup")
# Daca trecem in starea WINKING (tranzitia spre home), redam sunetul "letsgo"
if new_state == State.WINKING and self.current_state != State.WINKING:
self.play_voice("letsgo")
self.current_state = new_state
self.current_frame = 0
self.force_restart = True
def behavior_loop(self):
while self.running:
time.sleep(1) # O data pe secunda
if self.current_state == State.NORMAL:
# 5% sansa sa spuna ceva (Normal sau Chemare)
if random.random() < 0.05:
# Alegem random intre normal si chemare
category = random.choice(["normal", "chemare"])
self.play_voice(category)
# Logica existenta de looking around
if random.random() < 0.05:
self.change_state(State.LOOKING_AROUND)
self.normal_timer += 1
if self.normal_timer >= SLEEP_IN_SECONDS:
self.change_state(State.SLEEPING)
self.normal_timer = 0
self.is_awake = False
elif self.current_state == State.SLEEPING and not self.is_awake:
# Cand doarme nu spune nimic
if random.random() < 0.05:
self.change_state(State.LOOKING_AROUND_SLEEP)
def wake_up(self):
if not self.is_awake:
self.is_awake = True
self.change_state(State.CLICK_ME)
def on_click(self):
if self.current_state == State.CLICK_ME:
self.change_state(State.NORMAL)
# voiceline garantat la prima apasare din Here
category = random.choice(["normal", "chemare"])
self.play_voice(category)
self.normal_timer = 0
elif self.current_state == State.NORMAL:
# Cand e normal si dam click, incepe tranzitia (Winking)
# Sunetul "letsgo" se va declansa in change_state cand setam WINKING
self.change_state(State.WINKING)
def stop(self):
self.running = False
try:
pygame.mixer.quit()
except:
pass
class AnimationView(ctk.CTkFrame):
def __init__(self, parent, controller):
super().__init__(parent)
self.controller = controller
self.pack(fill="both", expand=True)
self.character = Character(self)
self.image_label = ctk.CTkLabel(self, text="", width=800, height=480)
self.image_label.place(x=0, y=0)
# BINDINGS
self.image_label.bind("<Button-1>", self.on_image_click)
self.image_label.bind("<Double-Button-1>", self.on_double_click)
self.animation_thread = threading.Thread(target=self.character.run_animation, daemon=True)
self.behavior_thread = threading.Thread(target=self.character.behavior_loop, daemon=True)
self.animation_thread.start()
self.behavior_thread.start()
self.controller.bind("<space>", self.on_space_key)
# --- PRESENCE DETECTOR ---
# Create detector (uses singleton camera - won't restart camera if already running)
self.detector = PresenceDetector(on_detect_callback=self.thread_safe_wakeup)
# Start detection (this also starts camera if not already running)
self.detector.start()
def thread_safe_wakeup(self):
if hasattr(self, 'winfo_exists') and self.winfo_exists():
self.after(0, self.character.wake_up)
def on_image_click(self, event):
self.character.on_click()
def on_double_click(self, event):
if self.character.current_state == State.NORMAL:
self.navigate_to_home()
def navigate_to_home(self):
self.controller.show_home()
def on_space_key(self, event):
self.character.wake_up()
def cleanup(self):
"""Called when switching away from this view"""
# 1. Stop Character threads
self.character.stop()
# 2. PAUSE detection (camera keeps running!)
if hasattr(self, 'detector'):
self.detector.stop() # This only stops detection, not camera
self.controller.unbind("<space>")
print("AnimationView cleanup complete (camera still running)")