Files
2026-10-31 23:04:47 +03:30

275 lines
7.1 KiB
Python

# main.py
import time
import cv2
import pygame
from camera import Camera
from calibration import Calibration
from detection import Detector
from vision import Vision
from physics import PhysicsWorld
from effects import Effects
from rendering import Renderer
from config import (
CAMERA_URL,
SCREEN_WIDTH,
SCREEN_HEIGHT,
BALL_SPAWN_INTERVAL_SECONDS,
)
# If a spawn attempt fails (too many balls in play, or another ball
# is sitting right at the drop point), try again after this short
# delay instead of waiting a whole spawn interval.
_SPAWN_RETRY_SECONDS = 0.25
_WARNING_INTERVAL_SECONDS = 5.0
class Game:
# ========================================================
# Initialization
# ========================================================
def __init__(self):
# Connecting happens on the camera's own thread, so the
# window below comes up (and stays responsive to the
# keyboard) even if the phone isn't streaming yet.
self.camera = Camera(CAMERA_URL)
self.calibration = Calibration()
self.detector = Detector()
# Camera -> calibration -> sticker detection, all on a
# background thread; the loop below only ever reads its
# latest published result.
self.vision = Vision(
self.camera,
self.calibration,
self.detector,
)
self.physics = PhysicsWorld(
SCREEN_WIDTH,
SCREEN_HEIGHT,
)
self.effects = Effects()
self.renderer = Renderer()
self.running = True
self.was_calibrated = False
# Very negative so the first ball drops the moment
# calibration locks on.
self.last_ball_spawn = -1e9
self.last_warning = 0.0
self.vision.start()
# ========================================================
# Main loop
# ========================================================
def run(self):
try:
while self.running:
self._handle_events()
if not self.running:
break
# The per-frame pipeline is wrapped defensively:
# this runs unattended in a busy exhibition hall,
# and one unexpected error must never take the
# installation down. Worst case we skip a frame.
try:
self._run_one_frame()
except Exception as error:
print(
"[ERROR] unexpected failure in main "
f"loop, continuing: {error!r}"
)
time.sleep(0.05)
finally:
self.close()
def _run_one_frame(self):
(
calibrated,
frozen,
obstacles,
message,
) = self.vision.snapshot()
current_time = time.monotonic()
self._maybe_warn(current_time, frozen, message)
if not calibrated:
if self.was_calibrated:
print(
"[CALIBRATION] lost/reset - "
"waiting for the screen to be recognized..."
)
self.was_calibrated = False
self.renderer.render_calibration()
# Nothing moves while calibrating; no need to spin.
self.renderer.clock.tick(30)
return
if not self.was_calibrated:
print("[CALIBRATION] locked on - starting game.")
self.was_calibrated = True
delta_time = self.renderer.get_delta_time()
self.physics.update(delta_time, obstacles)
self._spawn_ball(current_time)
self.effects.update(
delta_time,
self.physics.balls,
self.physics.events,
)
self.renderer.render(
self.physics.balls,
obstacles,
self.effects,
self.physics.launcher,
)
# ========================================================
# Warnings
# ========================================================
def _maybe_warn(self, current_time, frozen, message):
if (
current_time - self.last_warning
< _WARNING_INTERVAL_SECONDS
):
return
if frozen:
print(
"[WARNING] Camera feed looks FROZEN "
"(no motion detected for a while) - "
"check the phone/app/connection. "
"Calibration and obstacle detection are "
"paused until a live frame is seen again."
)
elif message:
print(f"[WARNING] {message}")
else:
return
self.last_warning = current_time
# ========================================================
# Ball spawning
# ========================================================
def _spawn_ball(self, current_time):
# Balls arrive on their OWN clock, one every
# BALL_SPAWN_INTERVAL_SECONDS, whether or not earlier balls
# have left. A ball wedged between stickers just stays
# (until the physics removes it as stuck) and the next one
# still comes on schedule.
if (
current_time - self.last_ball_spawn
< BALL_SPAWN_INTERVAL_SECONDS
):
return
if self.physics.spawn_ball() is not None:
self.last_ball_spawn = current_time
else:
self.last_ball_spawn = (
current_time
- BALL_SPAWN_INTERVAL_SECONDS
+ _SPAWN_RETRY_SECONDS
)
# ========================================================
# Events
# ========================================================
def _handle_events(self):
for event in pygame.event.get():
if event.type == pygame.QUIT:
self.running = False
elif event.type == pygame.KEYDOWN:
if event.key == pygame.K_q:
self.running = False
elif event.key in (
pygame.K_c,
pygame.K_ESCAPE,
):
self._reset_calibration()
# ========================================================
# Calibration reset
# ========================================================
def _reset_calibration(self):
self.vision.request_reset()
self.physics.clear()
self.effects.clear()
self.renderer.force_full_repaint()
self.last_ball_spawn = -1e9
# ========================================================
# Shutdown
# ========================================================
def close(self):
try:
self.vision.stop()
except Exception:
pass
try:
self.camera.release()
except Exception:
pass
try:
cv2.destroyAllWindows()
except Exception:
pass
try:
self.renderer.close()
except Exception:
pass
def main():
game = Game()
game.run()
if __name__ == "__main__":
main()