Skip to content

Commit 54b2cee

Browse files
Create a threading example (#2480)
* 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>
1 parent 8e0e1e7 commit 54b2cee

File tree

1 file changed

+322
-0
lines changed

1 file changed

+322
-0
lines changed

arcade/examples/threaded_loading.py

Lines changed: 322 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,322 @@
1+
"""
2+
Load level data in the background with interactive previews.
3+
4+
Level preview borders will turn green when their data loads:
5+
6+
1. Pan the camera by clicking and dragging
7+
2. Zoom in and out by scrolling up or down
8+
9+
Loading data during gameplay always risks slowdowns. These risks grow
10+
grow with number, size, and loading complexity of files. Some games
11+
avoid the problem by using non-interactive loading screens. This example
12+
uses a different approach.
13+
14+
Background loading works if a game has enough RAM and CPU cycles to
15+
run light features while loading. These can be the UI or menus, or even
16+
gameplay light enough to avoid interfering with the loading thread. For
17+
example, players can handle inventory or communication while data loads.
18+
19+
Although Python's threading module has many pitfalls, we'll avoid them
20+
by keeping things simple:
21+
22+
1. There is only one background loader thread
23+
2. The loader thread will load each map in order, one after another
24+
3. If a map loads successfully, the UI an interactive preview
25+
26+
If Python and Arcade are installed, this example can be run from the command line with:
27+
python -m arcade.examples.threaded_loading
28+
"""
29+
from __future__ import annotations
30+
31+
import sys
32+
import time
33+
34+
# Python's threading module has proven tools for working with threads, and
35+
# veteran developers may want to explore 3.13's new 'No-GIL' concurrency.
36+
import threading
37+
38+
import arcade
39+
from arcade.color import RED, GREEN, BLUE, WHITE
40+
from arcade.math import clamp
41+
42+
# Window size and title
43+
WINDOW_WIDTH = 1280
44+
WINDOW_HEIGHT = 720
45+
WINDOW_TITLE = 'Threaded Tilemap Loading'
46+
47+
# We'll simulate loading large files by adding a loading delay to
48+
# each map we load. You can omit the delay in your own projects.
49+
ARTIFICIAL_DELAY = 1
50+
51+
52+
# The resource handle prefix to load map files from
53+
LEVEL_LOCATION = ':assets:tiled_maps/'
54+
# Level filenames in the resource folder
55+
LEVELS = (
56+
'test_map_1.json',
57+
'test_map_2.json',
58+
'test_map_3.json',
59+
'test_map_4.json', # Doesn't exist so we can simulate failed reads
60+
'test_map_5.json', # Intentionally empty file
61+
'test_map_6.json',
62+
'test_map_7.json',
63+
)
64+
65+
# Rendering layout controls
66+
COLUMN_COUNT = 4
67+
LEVEL_RENDERER_SIZE = WINDOW_WIDTH // 5 - 10, WINDOW_HEIGHT // 5 - 10
68+
69+
70+
class LevelLoader:
71+
"""Wrap a loader thread which runs level loading in the background.
72+
73+
IMPORTANT: NEVER call graphics code from threads! They break OpenGL!
74+
75+
It's common to group threading tasks into manager objects which track
76+
and coordinate them. Advanced thread managers often keep idle threads
77+
'alive' to re-use when needed. These complex techniques are beyond the
78+
scope of this tutorial.
79+
"""
80+
81+
def __init__(self, levels: tuple[str, ...], location: str):
82+
self._levels = levels
83+
self._location = location
84+
85+
self._begun: bool = False
86+
self._finished: bool = False
87+
88+
# Threads do not start until their `start` method is called.
89+
self.loading_thread = threading.Thread(target=self._load_levels)
90+
91+
self._loaded_levels: dict[str, arcade.TileMap] = {}
92+
self._failed_levels: set[str] = set()
93+
self._current_level: str = ''
94+
95+
# Avoid the difficulties of coordinating threads without
96+
# freezing by using one loading thread with a one lock.
97+
self._interaction_lock = threading.Lock()
98+
99+
# An underscore at the start of a name is how a developer tells
100+
# others to treat things as private. Here, it means that only
101+
# LevelLoader should ever call `_load_levels` directly.
102+
def _load_levels(self):
103+
for level in self._levels:
104+
with self._interaction_lock:
105+
self._current_level = level
106+
107+
time.sleep(ARTIFICIAL_DELAY) # "Slow" down (delete this line before use)
108+
109+
# Since unhandled exceptions "kill" threads, we catch the only major
110+
# exception we expect. Level 4 is intentionally missing to test cases
111+
# such as this one when building map loading code.
112+
try:
113+
path = f'{self._location}{level}'
114+
tilemap = arcade.load_tilemap(path, lazy=True)
115+
except FileNotFoundError:
116+
print(f"ERROR: {level} doesn't exist, skipping!", file=sys.stderr)
117+
with self._interaction_lock:
118+
self._failed_levels.add(level)
119+
continue
120+
121+
with self._interaction_lock:
122+
self._loaded_levels[level] = tilemap
123+
124+
with self._interaction_lock:
125+
self._finished = True
126+
127+
def start_loading_levels(self):
128+
with self._interaction_lock:
129+
if not self._begun:
130+
self.loading_thread.start()
131+
self._begun = True
132+
133+
@property
134+
def current_level(self) -> str:
135+
with self._interaction_lock:
136+
return self._current_level
137+
138+
@property
139+
def begun(self):
140+
with self._interaction_lock:
141+
return self._begun
142+
143+
@property
144+
def finished(self):
145+
with self._interaction_lock:
146+
return self._finished
147+
148+
def is_level_loaded(self, level: str) -> bool:
149+
with self._interaction_lock:
150+
return level in self._loaded_levels
151+
152+
def did_level_fail(self, level: str) -> bool:
153+
with self._interaction_lock:
154+
return level in self._failed_levels
155+
156+
def get_level(self, level: str) -> arcade.TileMap | None:
157+
with self._interaction_lock:
158+
return self._loaded_levels.get(level, None)
159+
160+
161+
class LevelRenderer:
162+
"""
163+
Draws previews of loaded data and colored borders to show status.
164+
"""
165+
166+
def __init__(
167+
self,
168+
level: str,
169+
level_loader: LevelLoader,
170+
location: arcade.types.Point2,
171+
size: tuple[int, int]
172+
):
173+
self.level_name = level
174+
self.loader = level_loader
175+
176+
self.location = location
177+
self.size = size
178+
x, y = location
179+
self.camera: arcade.Camera2D = arcade.Camera2D(
180+
arcade.XYWH(x, y, size[0], size[1])
181+
)
182+
camera_x, camera_y = self.camera.position
183+
self.level: arcade.TileMap | None = None
184+
self.level_text: arcade.Text = arcade.Text(
185+
level,
186+
camera_x, camera_y,
187+
anchor_x='center',
188+
anchor_y='center'
189+
)
190+
191+
def update(self):
192+
level = self.level
193+
loader = self.loader
194+
if not level and loader.is_level_loaded(self.level_name):
195+
self.level = self.loader.get_level(self.level_name)
196+
197+
def draw(self):
198+
# Activate the camera to render into its viewport rectangle
199+
with self.camera.activate():
200+
if self.level:
201+
for spritelist in self.level.sprite_lists.values():
202+
spritelist.draw()
203+
self.level_text.draw()
204+
205+
# Choose a color based on the load status
206+
if self.level is not None:
207+
color = GREEN
208+
elif self.loader.did_level_fail(self.level_name):
209+
color = RED
210+
elif self.loader.current_level == self.level_name:
211+
color = BLUE
212+
else:
213+
color = WHITE
214+
215+
# Draw the outline over any thumbnail
216+
arcade.draw_rect_outline(self.camera.viewport, color, 3)
217+
218+
def point_in_area(self, x, y):
219+
return self.camera.viewport.point_in_rect((x, y))
220+
221+
def drag(self, dx, dy):
222+
# Store a few values locally to make the math easier to read
223+
camera = self.camera
224+
x, y = camera.position
225+
zoom = camera.zoom
226+
227+
# Move the camera while accounting for zoom
228+
camera.position = x - dx / zoom, y - dy / zoom
229+
230+
def scroll(self, scroll):
231+
camera = self.camera
232+
zoom = camera.zoom
233+
camera.zoom = clamp(zoom + scroll / 10, 0.1, 10)
234+
235+
236+
class GameView(arcade.View):
237+
238+
def __init__(self, window = None, background_color = None):
239+
super().__init__(window, background_color)
240+
self.level_loader = LevelLoader(LEVELS, LEVEL_LOCATION)
241+
self.level_renderers: list[LevelRenderer] = []
242+
243+
for idx, level in enumerate(LEVELS):
244+
row = idx // COLUMN_COUNT
245+
column = idx % COLUMN_COUNT
246+
pos = (1 + column) / 5 * self.width, (3 - row) / 4 * self.height
247+
self.level_renderers.append(
248+
LevelRenderer(level, self.level_loader, pos, LEVEL_RENDERER_SIZE)
249+
)
250+
251+
self.loading_sprite = arcade.SpriteSolidColor(
252+
64,
253+
64,
254+
self.center_x,
255+
200,
256+
WHITE
257+
)
258+
259+
self.dragging = None
260+
261+
def on_show_view(self):
262+
self.level_loader.start_loading_levels()
263+
264+
def on_update(self, delta_time):
265+
# This sprite will spin one revolution per second. Even when loading levels this
266+
# won't freeze thanks to the threaded loading
267+
self.loading_sprite.angle = (360 * self.window.time) % 360
268+
for renderer in self.level_renderers:
269+
renderer.update()
270+
271+
if self.dragging is not None:
272+
self.window.set_mouse_cursor(
273+
self.window.get_system_mouse_cursor(self.window.CURSOR_SIZE))
274+
else:
275+
self.window.set_mouse_cursor(None)
276+
277+
def on_draw(self):
278+
self.clear()
279+
arcade.draw_sprite(self.loading_sprite)
280+
for renderer in self.level_renderers:
281+
renderer.draw()
282+
283+
def on_mouse_press(self, x, y, button, modifiers):
284+
for renderer in self.level_renderers:
285+
if renderer.point_in_area(x, y):
286+
self.dragging = renderer
287+
break
288+
289+
def on_mouse_release(self, x, y, button, modifiers):
290+
self.dragging = None
291+
292+
def on_mouse_drag(self, x, y, dx, dy, _buttons, _modifiers):
293+
if self.dragging is not None:
294+
self.dragging.drag(dx, dy)
295+
296+
def on_mouse_scroll(self, x, y, scroll_x, scroll_y):
297+
if self.dragging is not None:
298+
self.dragging.scroll(scroll_y)
299+
return
300+
for renderer in self.level_renderers:
301+
if renderer.point_in_area(x, y):
302+
renderer.scroll(scroll_y)
303+
break
304+
305+
306+
def main():
307+
""" Main function """
308+
# Create a window class. This is what actually shows up on screen
309+
window = arcade.Window(WINDOW_WIDTH, WINDOW_HEIGHT, WINDOW_TITLE)
310+
311+
# Create the GameView
312+
game = GameView()
313+
314+
# Show GameView on screen
315+
window.show_view(game)
316+
317+
# Start the arcade game loop
318+
arcade.run()
319+
320+
321+
if __name__ == "__main__":
322+
main()

0 commit comments

Comments
 (0)