Skip to content

Commit

Permalink
Create a threading example (#2480)
Browse files Browse the repository at this point in the history
* completed draft threading example

* add colour for currently loading level

* lint and black pass

* Use color constants for clarity

* Fix typos

* Add missing whitespace

* Add note explaining missing map

* Explain that test_map_5 is blank

* Fix typos + rephrase _load_levels comment

* smal typo fixes and cursor change

* Formatting fix for line length

* Improve order and clarity of level loading constants

* Numerous readability and clarity changes

* Simplify math by doing local unpacks in some methods

* Expand top-level docstring

* Expand docstrings and comments to explain code further

* Stop hiding the file extension afterward

* small opinionated touch-ups

---------

Co-authored-by: pushfoo <36696816+pushfoo@users.noreply.github.com>
  • Loading branch information
DragonMoffon and pushfoo authored Jan 7, 2025
1 parent 8e0e1e7 commit 54b2cee
Showing 1 changed file with 322 additions and 0 deletions.
322 changes: 322 additions & 0 deletions arcade/examples/threaded_loading.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,322 @@
"""
Load level data in the background with interactive previews.
Level preview borders will turn green when their data loads:
1. Pan the camera by clicking and dragging
2. Zoom in and out by scrolling up or down
Loading data during gameplay always risks slowdowns. These risks grow
grow with number, size, and loading complexity of files. Some games
avoid the problem by using non-interactive loading screens. This example
uses a different approach.
Background loading works if a game has enough RAM and CPU cycles to
run light features while loading. These can be the UI or menus, or even
gameplay light enough to avoid interfering with the loading thread. For
example, players can handle inventory or communication while data loads.
Although Python's threading module has many pitfalls, we'll avoid them
by keeping things simple:
1. There is only one background loader thread
2. The loader thread will load each map in order, one after another
3. If a map loads successfully, the UI an interactive preview
If Python and Arcade are installed, this example can be run from the command line with:
python -m arcade.examples.threaded_loading
"""
from __future__ import annotations

import sys
import time

# Python's threading module has proven tools for working with threads, and
# veteran developers may want to explore 3.13's new 'No-GIL' concurrency.
import threading

import arcade
from arcade.color import RED, GREEN, BLUE, WHITE
from arcade.math import clamp

# Window size and title
WINDOW_WIDTH = 1280
WINDOW_HEIGHT = 720
WINDOW_TITLE = 'Threaded Tilemap Loading'

# We'll simulate loading large files by adding a loading delay to
# each map we load. You can omit the delay in your own projects.
ARTIFICIAL_DELAY = 1


# The resource handle prefix to load map files from
LEVEL_LOCATION = ':assets:tiled_maps/'
# Level filenames in the resource folder
LEVELS = (
'test_map_1.json',
'test_map_2.json',
'test_map_3.json',
'test_map_4.json', # Doesn't exist so we can simulate failed reads
'test_map_5.json', # Intentionally empty file
'test_map_6.json',
'test_map_7.json',
)

# Rendering layout controls
COLUMN_COUNT = 4
LEVEL_RENDERER_SIZE = WINDOW_WIDTH // 5 - 10, WINDOW_HEIGHT // 5 - 10


class LevelLoader:
"""Wrap a loader thread which runs level loading in the background.
IMPORTANT: NEVER call graphics code from threads! They break OpenGL!
It's common to group threading tasks into manager objects which track
and coordinate them. Advanced thread managers often keep idle threads
'alive' to re-use when needed. These complex techniques are beyond the
scope of this tutorial.
"""

def __init__(self, levels: tuple[str, ...], location: str):
self._levels = levels
self._location = location

self._begun: bool = False
self._finished: bool = False

# Threads do not start until their `start` method is called.
self.loading_thread = threading.Thread(target=self._load_levels)

self._loaded_levels: dict[str, arcade.TileMap] = {}
self._failed_levels: set[str] = set()
self._current_level: str = ''

# Avoid the difficulties of coordinating threads without
# freezing by using one loading thread with a one lock.
self._interaction_lock = threading.Lock()

# An underscore at the start of a name is how a developer tells
# others to treat things as private. Here, it means that only
# LevelLoader should ever call `_load_levels` directly.
def _load_levels(self):
for level in self._levels:
with self._interaction_lock:
self._current_level = level

time.sleep(ARTIFICIAL_DELAY) # "Slow" down (delete this line before use)

# Since unhandled exceptions "kill" threads, we catch the only major
# exception we expect. Level 4 is intentionally missing to test cases
# such as this one when building map loading code.
try:
path = f'{self._location}{level}'
tilemap = arcade.load_tilemap(path, lazy=True)
except FileNotFoundError:
print(f"ERROR: {level} doesn't exist, skipping!", file=sys.stderr)
with self._interaction_lock:
self._failed_levels.add(level)
continue

with self._interaction_lock:
self._loaded_levels[level] = tilemap

with self._interaction_lock:
self._finished = True

def start_loading_levels(self):
with self._interaction_lock:
if not self._begun:
self.loading_thread.start()
self._begun = True

@property
def current_level(self) -> str:
with self._interaction_lock:
return self._current_level

@property
def begun(self):
with self._interaction_lock:
return self._begun

@property
def finished(self):
with self._interaction_lock:
return self._finished

def is_level_loaded(self, level: str) -> bool:
with self._interaction_lock:
return level in self._loaded_levels

def did_level_fail(self, level: str) -> bool:
with self._interaction_lock:
return level in self._failed_levels

def get_level(self, level: str) -> arcade.TileMap | None:
with self._interaction_lock:
return self._loaded_levels.get(level, None)


class LevelRenderer:
"""
Draws previews of loaded data and colored borders to show status.
"""

def __init__(
self,
level: str,
level_loader: LevelLoader,
location: arcade.types.Point2,
size: tuple[int, int]
):
self.level_name = level
self.loader = level_loader

self.location = location
self.size = size
x, y = location
self.camera: arcade.Camera2D = arcade.Camera2D(
arcade.XYWH(x, y, size[0], size[1])
)
camera_x, camera_y = self.camera.position
self.level: arcade.TileMap | None = None
self.level_text: arcade.Text = arcade.Text(
level,
camera_x, camera_y,
anchor_x='center',
anchor_y='center'
)

def update(self):
level = self.level
loader = self.loader
if not level and loader.is_level_loaded(self.level_name):
self.level = self.loader.get_level(self.level_name)

def draw(self):
# Activate the camera to render into its viewport rectangle
with self.camera.activate():
if self.level:
for spritelist in self.level.sprite_lists.values():
spritelist.draw()
self.level_text.draw()

# Choose a color based on the load status
if self.level is not None:
color = GREEN
elif self.loader.did_level_fail(self.level_name):
color = RED
elif self.loader.current_level == self.level_name:
color = BLUE
else:
color = WHITE

# Draw the outline over any thumbnail
arcade.draw_rect_outline(self.camera.viewport, color, 3)

def point_in_area(self, x, y):
return self.camera.viewport.point_in_rect((x, y))

def drag(self, dx, dy):
# Store a few values locally to make the math easier to read
camera = self.camera
x, y = camera.position
zoom = camera.zoom

# Move the camera while accounting for zoom
camera.position = x - dx / zoom, y - dy / zoom

def scroll(self, scroll):
camera = self.camera
zoom = camera.zoom
camera.zoom = clamp(zoom + scroll / 10, 0.1, 10)


class GameView(arcade.View):

def __init__(self, window = None, background_color = None):
super().__init__(window, background_color)
self.level_loader = LevelLoader(LEVELS, LEVEL_LOCATION)
self.level_renderers: list[LevelRenderer] = []

for idx, level in enumerate(LEVELS):
row = idx // COLUMN_COUNT
column = idx % COLUMN_COUNT
pos = (1 + column) / 5 * self.width, (3 - row) / 4 * self.height
self.level_renderers.append(
LevelRenderer(level, self.level_loader, pos, LEVEL_RENDERER_SIZE)
)

self.loading_sprite = arcade.SpriteSolidColor(
64,
64,
self.center_x,
200,
WHITE
)

self.dragging = None

def on_show_view(self):
self.level_loader.start_loading_levels()

def on_update(self, delta_time):
# This sprite will spin one revolution per second. Even when loading levels this
# won't freeze thanks to the threaded loading
self.loading_sprite.angle = (360 * self.window.time) % 360
for renderer in self.level_renderers:
renderer.update()

if self.dragging is not None:
self.window.set_mouse_cursor(
self.window.get_system_mouse_cursor(self.window.CURSOR_SIZE))
else:
self.window.set_mouse_cursor(None)

def on_draw(self):
self.clear()
arcade.draw_sprite(self.loading_sprite)
for renderer in self.level_renderers:
renderer.draw()

def on_mouse_press(self, x, y, button, modifiers):
for renderer in self.level_renderers:
if renderer.point_in_area(x, y):
self.dragging = renderer
break

def on_mouse_release(self, x, y, button, modifiers):
self.dragging = None

def on_mouse_drag(self, x, y, dx, dy, _buttons, _modifiers):
if self.dragging is not None:
self.dragging.drag(dx, dy)

def on_mouse_scroll(self, x, y, scroll_x, scroll_y):
if self.dragging is not None:
self.dragging.scroll(scroll_y)
return
for renderer in self.level_renderers:
if renderer.point_in_area(x, y):
renderer.scroll(scroll_y)
break


def main():
""" Main function """
# Create a window class. This is what actually shows up on screen
window = arcade.Window(WINDOW_WIDTH, WINDOW_HEIGHT, WINDOW_TITLE)

# Create the GameView
game = GameView()

# Show GameView on screen
window.show_view(game)

# Start the arcade game loop
arcade.run()


if __name__ == "__main__":
main()

0 comments on commit 54b2cee

Please sign in to comment.