<filename>renderloop.py
# The game looop
import time
import gc
from math import ceil

if not hasattr(time, 'sleep_ms') or not hasattr(time, 'ticks_ms'):
	# Emulate https://docs.pycom.io/firmwareapi/micropython/utime.html
	time.ticks_ms = lambda: int(time.time() * 1000)
	time.sleep_ms = lambda x: time.sleep(x/1000.0)


class RenderLoop:
	def __init__(self, display, config=None):
		self.display = display
		self.debug = False
		self.fps = display.fps
		self.t_next_frame = None
		self.prev_frame = 0
		self.frame = 1
		self.t_init = time.ticks_ms()
		self.scenes = []
		self.scene_index = 0
		self.scene_switch_effect = 0
		self.scene_switch_countdown = self.fps * 40
		self.display.clear()
		if not config:
			return
		if 'debug' in config:
			self.debug = config['debug']
		if 'sceneTimeout' in config:
			self.scene_switch_countdown = self.fps * config['sceneTimeout']

	def add_scene(self, scene):
		"""
		Add new scene to the render loop.
		Called by main.py.
		"""
		self.scenes.append(scene)

	def next_frame(self, button_state=0):
		"""
		Display next frame, possibly after a delay to ensure we meet the FPS target
		Called by main.py.
		"""

		scene = self.scenes[self.scene_index]
		# Process input
		if button_state:
			# Let the scene handle input
			handled_bit = scene.input(button_state)
			button_state &= ~handled_bit
			# Use long-pressed buttons to handle intensity changes
			if button_state & 0x22:
				clear = 0
				for s in self.scenes:
					if hasattr(s, 'set_intensity'):
						i = s.set_intensity()
						if button_state & 0x02:
							i -= 2
							clear = 0x02
						elif button_state & 0x20:
							i += 2
							clear = 0x20
						i = (i + 32) % 32
						s.set_intensity(i)
				button_state &= ~clear
				if self.debug:
					print('RenderLoop: updated intensity to {} on scenes, remaining state: {}'.format(i, button_state))

		# Calculate how much we need to wait before rendering the next frame
		t_now = time.ticks_ms() - self.t_init
		if not self.t_next_frame:
			self.t_next_frame = t_now

		delay = self.t_next_frame - t_now
		if delay >= 0:
			# Wait until we can display next frame
			time.sleep_ms(delay)
		else:
			# Resynchronize
			num_dropped_frames = ceil(-delay*self.fps/1000)
			if self.debug:
				print('RenderLoop: FPS {} too high, should\'ve rendered frame {} at {}ms but was {}ms late and dropped {} frames'.format(self.fps, self.frame, self.t_next_frame, -delay, num_dropped_frames))
			self.frame += num_dropped_frames
			self.t_next_frame += ceil(1000*num_dropped_frames/self.fps)
			if self.debug:
				print('RenderLoop: Updated frame counters to frame {} with current next at {}'.format(self.frame, self.t_next_frame))

		# Let the scene render its frame
		t = time.ticks_ms()
		loop_again = scene.render(self.frame, self.frame - self.prev_frame - 1, self.fps)
		t = time.ticks_ms() - t
		if t > 1000/self.fps and self.debug:
			print('RenderLoop: WARN: Spent {}ms rendering'.format(t))


		# Consider switching scenes and update frame counters
		self.scene_switch_countdown -= 1
		scene_increment = 1
		if not loop_again:
			self.scene_switch_countdown = 0
		elif button_state:
			self.scene_switch_countdown = 0
			if button_state & 0x1:
				scene_increment = -1

		if not self.scene_switch_countdown:
			self.reset_scene_switch_counter()
			# Transition to next scene
			self.next_scene(scene_increment, button_state)
			# Account for time wasted above
			t_new = time.ticks_ms() - self.t_init
			t_diff = t_new - t_now
			frames_wasted = ceil(t_diff*self.fps/1000.0)
			if self.debug:
				print('RenderLoop: setup: scene switch took {}ms, original t {}ms, new t {}ms, spent {} frames'.format(t_diff, t_now,t_new, self.fps*t_diff/1000.0))
			self.frame += int(frames_wasted)
			self.t_next_frame += int(1000.0 * frames_wasted / self.fps)

		self.prev_frame = self.frame
		self.frame += 1
		self.t_next_frame += int(1000/self.fps)

	def reset_scene_switch_counter(self):
		"""
		Reset counter used to automatically switch scenes.
		The counter is decreased in .next_frame()
		"""
		self.scene_switch_countdown = 45 * self.fps

	def next_scene(self, increment=1, button_state=0):
		"""
		Transition to a new scene and re-initialize the scene
		"""
		if len(self.scenes) < 2:
			return

		print('RenderLoop: next_scene: transitioning scene')
		# Fade out current scene
		t0 = time.ticks_ms()
		if button_state & 0x01:
			self.display.hscroll(-4)
			button_state &= ~0x01
		elif button_state & 0x10:
			self.display.hscroll(4)
			button_state &= ~0x10
		else:
			effect = self.scene_switch_effect
			self.scene_switch_effect = (effect + 1) % 4
			if effect == 0:
				self.display.vscroll()
			elif effect == 1:
				self.display.hscroll()
			elif effect == 2:
				self.display.fade()
			else:
				self.display.dissolve()

		t2 = time.ticks_ms()
		t1 = t2 - t0
		gc.collect()

		t3 = time.ticks_ms()
		t2 = t3 - t1
		num_scenes = len(self.scenes)
		i = self.scene_index = (num_scenes + self.scene_index + increment) % num_scenes
		# (Re-)initialize scene
		self.scenes[i].reset()
		t4 = time.ticks_ms()
		t3 = t4 - t3
		if self.debug:
			print('RenderLoop: next_scene: selected {}, effect {}ms, gc {}ms, scene reset {}ms, total {}ms'.format(self.scenes[i].__class__.__name__, t1, t2, t3, t4-t0))
		return button_state
