import datetime as dt import sys import time from typing import Callable class GameLoop: """Help animate `TextCanvas`. The main functions of interest are `GameLoop.loop_fixed()`, and `GameLoop.loop_variable()`. Note: The other functions constitute the lower-level machinery. They are made available in case one wanted to be more hands on. But for normal use, use the `loop_*` functions. """ @staticmethod def loop_fixed( time_step: dt.timedelta, render_frame: Callable[[], str | None], ) -> None: """Run a game loop with fixed time step. In this mode, the time step is fixed to a given duration. This means the frame rate is constant. This is simple to implement, and better for physics.
The given time step must always be greater than the time it takes to render a frame. If rendering a frame takes longer than the time step, the frame rate will obviously be affected, and the "fixed" nature of it will not hold.
Returns: The `render_frame` closure must return an `str | None`. If it returns `str`, then the string will be rendered, and on to the next iteration. If it returns `None`, the game loop stops right there.
The screen is never cleared between frames; the new frame only overwrites the old one in-place. This is to reduce the risk of flickering. Thus, the string should never get smaller from one iteration to the next, else it would not completely erase the old frame.
""" game_loop = GameLoop() game_loop.set_up() try: while True: start: int = time.perf_counter_ns() if (frame := render_frame()) is None: break game_loop.update(frame) time_elapsed: float = (time.perf_counter_ns() - start) / 1000 # μs time_to_next_frame = time_step - dt.timedelta(microseconds=time_elapsed) # If not, rendering took longer than time step. In which # case, we want to render the next frame immediately. if time_to_next_frame > dt.timedelta(0): game_loop.sleep(time_to_next_frame) except KeyboardInterrupt: pass game_loop.tear_down() @staticmethod def loop_variable(render_frame: Callable[[float], str | None]) -> None: """Run a game loop with variable time step. In this mode, the time step is whatever time it takes to render a frame. There is no artificial delay between frames, it's just one after the other as fast as possible (although it _is_ possible for the user to `sleep()` manually inside the loop, to save on some CPU cycles). The render duration of the last frame is passed to the loop as `delta_time`. `delta_time` should be used to modulate values to make the speed of the animations independent of the frame rate. Returns: The `render_frame` closure must return an `str | None`. If it returns `str`, then the string will be rendered, and on to the next iteration. If it returns `None`, the game loop stops right there.
The screen is never cleared between frames; the new frame only overwrites the old one in-place. This is to reduce the risk of flickering. Thus, the string should never get smaller from one iteration to the next, else it would not completely erase the old frame.
""" game_loop = GameLoop() game_loop.set_up() # The first one is a bit... arbitrary. Close to 60fps. delta_time = dt.timedelta(milliseconds=17) try: while True: start: int = time.perf_counter_ns() if (frame := render_frame(delta_time.total_seconds())) is None: break game_loop.update(frame) time_elapsed: float = (time.perf_counter_ns() - start) / 1000 # μs delta_time = dt.timedelta(microseconds=time_elapsed) except KeyboardInterrupt: pass game_loop.tear_down() # Lower-level machinery, made available if one wants to be more # hands on. For normal use, use the loop functions. def set_up(self) -> None: """Set the stage for the render loop. This function should be called once before the loop starts. This effectively hides the blinking text cursor and clears the screen. """ self.hide_text_cursor() self.clear_screen() def update(self, frame: str) -> None: """Update the screen buffer with a new render. This function should be called on every iteration of the loop. This overwrites the current frame in-place, without clearing the screen (to prevent flickering). Any terminating newline character is stripped to ensure the output exactly matches the canvas' height. """ # Always do extra operations _before_ drawing, to help keep # drawing time to a minimum and help reduce flickering. frame = frame.removesuffix("\n") self.move_cursor_top_left() print(f"{frame}", end="") # Flush because output may not contain newline. self.flush() def tear_down(self) -> None: """Clean up after the render loop. This function should be called once after the loop ends. This restores the blinking text cursor, prints an end-of-line (`\n`) character not to mess with the system prompt, and ensures the output buffer is flushed. """ self.show_text_cursor() # The newline we've been omitting in the rendered frames. print(flush=True) # Even-lower-level machinery. @staticmethod def clear() -> None: """Clear the screen (soft). This is a "soft" clear. Since it does not flush, it may not be applied immediately (because of output buffering). It also won't reset the cursor to the top-left. For something more analogous to the system "clear" command, see `clear_screen()`. """ print("\x1b[2J", end="", flush=False) def clear_screen(self) -> None: """Clear the screen (hard). This is a "hard" clear. Since it flushes, it will force the screen to be empty.
This is meant to be used during setup, before the render loop. To clear between frames, you're better off _overwriting_ instead of clearing. See `GameLoop.move_cursor_top_left()`.
""" self.clear() self.move_cursor_top_left() self.flush() @staticmethod def hide_text_cursor() -> None: """Hide the blinking text cursor.""" print("\x1b[?25l", end="", flush=False) @staticmethod def show_text_cursor() -> None: """Restore the blinking text cursor.""" print("\x1b[?25h", end="", flush=False) @staticmethod def move_cursor_top_left() -> None: """Move the cursor to the top-left corner of the screen. That is, first row, first column. This function is meant to be used as a screen-wide carriage return (`\r`). With a regular `\r`, you move the cursor to the start of the line, and anything you write will overwrite the existing line. Here it is the same, but at the level of the entire screen. Since the render always has the same dimension, instead of clearing the screen, you can simply move the caret to the start and overwrite the exising characters. The benefit over a full-fledged `clear()` is that you never have an intermediate state where the screen is empty. Not only does this reduces the number of screen updates, it also prevents flickering (no blank screen between two frames). """ print("\x1b[1;1H", end="", flush=False) @staticmethod def flush() -> None: """Flush stdout. Most often stdout is line-buffered. This means, what you write to stdout will be kept in memory and not be displayed until it sees and end-of-line `\n` character. Flushing stdout forces the immediate display of what's in the buffer, even if there is no `\n`. """ sys.stdout.flush() @staticmethod def sleep(duration: dt.timedelta) -> None: """Sleep for some duration.""" time.sleep(duration.total_seconds())