275 lines
7.1 KiB
Python
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()
|