diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index d6aa979..5a048cb 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -16,8 +16,43 @@ permissions: contents: read jobs: - deploy: + build-pages: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.10' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install pdoc + + - run: pdoc ./play -o docs + - uses: actions/upload-pages-artifact@v3 + with: + path: docs/ + + # Deploy the artifact to GitHub pages. + # This is a separate job so that only actions/deploy-pages has the necessary permissions. + deploy-pages: + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + needs: build-pages + runs-on: ubuntu-latest + steps: + - id: deployment + uses: actions/deploy-pages@v4 + + deploy: + needs: deploy-pages runs-on: ubuntu-latest steps: diff --git a/play/api/events.py b/play/api/events.py index 5402acc..50dc706 100644 --- a/play/api/events.py +++ b/play/api/events.py @@ -42,16 +42,6 @@ async def wrapper(): return func -def repeat(number_of_times): - """ - Repeat a set of commands a certain number of times. - Equivalent to `range(1, number_of_times+1)`. - :param number_of_times: The number of times to repeat the commands. - :return: A range object that can be iterated over. - """ - return range(1, number_of_times + 1) - - # @decorator def repeat_forever(func): """ diff --git a/play/callback/__init__.py b/play/callback/__init__.py index 905beeb..cfae191 100644 --- a/play/callback/__init__.py +++ b/play/callback/__init__.py @@ -14,9 +14,12 @@ class CallbackType(Enum): WHEN_CLICK_RELEASED = 5 WHEN_CLICKED_SPRITE = 6 WHEN_TOUCHING = 7 - WHEN_CONTROLLER_BUTTON_PRESSED = 8 - WHEN_CONTROLLER_BUTTON_RELEASED = 9 - WHEN_CONTROLLER_AXIS_MOVED = 10 + WHEN_STOPPED_TOUCHING = 8 + WHEN_TOUCHING_WALL = 9 + WHEN_STOPPED_TOUCHING_WALL = 10 + WHEN_CONTROLLER_BUTTON_PRESSED = 11 + WHEN_CONTROLLER_BUTTON_RELEASED = 12 + WHEN_CONTROLLER_AXIS_MOVED = 13 class CallbackManager: diff --git a/play/core/__init__.py b/play/core/__init__.py index 2a5426a..686738e 100644 --- a/play/core/__init__.py +++ b/play/core/__init__.py @@ -1,13 +1,13 @@ """Core game loop and event handling functions.""" -import math as _math - import pygame # pylint: disable=import-error from .game_loop_wrapper import listen_to_failure +from .mouse_loop import _handle_mouse_loop, mouse_state +from .sprites_loop import _update_sprites from ..callback import callback_manager, CallbackType from ..callback.callback_helpers import run_callback -from ..globals import backdrop, FRAME_RATE, sprites_group +from ..globals import backdrop, FRAME_RATE, _walls from ..io import screen, PYGAME_DISPLAY, convert_pos from ..io.keypress import ( key_num_to_name as _pygame_key_to_name, @@ -16,9 +16,7 @@ _pressed_keys, ) # don't pollute user-facing namespace with library internals from ..io.mouse import mouse -from ..objects.line import Line -from ..objects.sprite import point_touching_sprite -from ..physics import simulate_physics +from .physics_loop import simulate_physics from ..utils import color_name_to_rgb as _color_name_to_rgb from ..loop import loop as _loop from .controller_loop import ( @@ -31,15 +29,9 @@ _clock = pygame.time.Clock() -click_happened_this_frame = False # pylint: disable=invalid-name -click_release_happened_this_frame = False # pylint: disable=invalid-name - def _handle_pygame_events(): """Handle pygame events in the game loop.""" - global click_happened_this_frame - global click_release_happened_this_frame - for event in pygame.event.get(): if event.type == pygame.QUIT or ( # pylint: disable=no-member event.type == pygame.KEYDOWN # pylint: disable=no-member @@ -53,10 +45,10 @@ def _handle_pygame_events(): _loop.stop() return False if event.type == pygame.MOUSEBUTTONDOWN: # pylint: disable=no-member - click_happened_this_frame = True + mouse_state.click_happened_this_frame = True mouse._is_clicked = True if event.type == pygame.MOUSEBUTTONUP: # pylint: disable=no-member - click_release_happened_this_frame = True + mouse_state.click_release_happened_this_frame = True mouse._is_clicked = False if event.type == pygame.MOUSEMOTION: # pylint: disable=no-member mouse.x, mouse.y = (event.pos[0] - screen.width / 2.0), ( @@ -147,118 +139,13 @@ def _handle_keyboard(): ) -def _handle_mouse_loop(): - """Handle mouse events in the game loop.""" - #################################### - # @mouse.when_clicked callbacks - #################################### - if ( - click_happened_this_frame - and callback_manager.get_callbacks(CallbackType.WHEN_CLICKED) is not None - ): - for callback in callback_manager.get_callbacks(CallbackType.WHEN_CLICKED): - run_callback( - callback, - [], - [], - ) - - ######################################## - # @mouse.when_click_released callbacks - ######################################## - if ( - click_release_happened_this_frame - and callback_manager.get_callbacks(CallbackType.WHEN_CLICK_RELEASED) is not None - ): - for callback in callback_manager.get_callbacks( - CallbackType.WHEN_CLICK_RELEASED - ): - run_callback( - callback, - [], - [], - ) - - -def _update_sprites(): - # pylint: disable=too-many-nested-blocks - sprites_group.update() - for sprite in sprites_group.sprites(): - sprite._is_clicked = False - if sprite.is_hidden: - continue - - ###################################################### - # update sprites with results of physics simulation - ###################################################### - if sprite.physics and sprite.physics.can_move: - - body = sprite.physics._pymunk_body - angle = _math.degrees(body.angle) - if isinstance(sprite, Line): - sprite._x = body.position.x - (sprite.length / 2) * _math.cos(angle) - sprite._y = body.position.y - (sprite.length / 2) * _math.sin(angle) - sprite._x1 = body.position.x + (sprite.length / 2) * _math.cos(angle) - sprite._y1 = body.position.y + (sprite.length / 2) * _math.sin(angle) - # sprite._length, sprite._angle = sprite._calc_length_angle() - else: - if ( - str(body.position.x) != "nan" - ): # this condition can happen when changing sprite.physics.can_move - sprite._x = body.position.x - if str(body.position.y) != "nan": - sprite._y = body.position.y - - sprite.angle = ( - angle # needs to be .angle, not ._angle so surface gets recalculated - ) - sprite.physics._x_speed, sprite.physics._y_speed = body.velocity - - ################################# - # @sprite.when_clicked events - ################################# - if mouse.is_clicked: - if ( - point_touching_sprite(convert_pos(mouse.x, mouse.y), sprite) - and click_happened_this_frame - ): - # only run sprite clicks on the frame the mouse was clicked - sprite._is_clicked = True - if callback_manager.get_callback( - CallbackType.WHEN_CLICKED_SPRITE, id(sprite) - ): - for callback in callback_manager.get_callback( - CallbackType.WHEN_CLICKED_SPRITE, id(sprite) - ): - if not callback.is_running: - run_callback( - callback, - [], - [], - ) - - ################################# - # @sprite.when_touching events - ################################# - if sprite._active_callbacks: - for cb in sprite._active_callbacks: - run_callback( - cb, - [], - [], - ) - - sprites_group.draw(PYGAME_DISPLAY) - - # pylint: disable=too-many-branches, too-many-statements @listen_to_failure() def game_loop(): """The main game loop.""" _keys_released_this_frame.clear() - global click_happened_this_frame, click_release_happened_this_frame - click_happened_this_frame = False - click_release_happened_this_frame = False + mouse_state.click_happened_this_frame = False + mouse_state.click_release_happened_this_frame = False _clock.tick(FRAME_RATE) @@ -267,7 +154,10 @@ def game_loop(): _handle_keyboard() - if click_happened_this_frame or click_release_happened_this_frame: + if ( + mouse_state.click_happened_this_frame + or mouse_state.click_release_happened_this_frame + ): _handle_mouse_loop() _handle_controller() diff --git a/play/core/mouse_loop.py b/play/core/mouse_loop.py new file mode 100644 index 0000000..0b1e12e --- /dev/null +++ b/play/core/mouse_loop.py @@ -0,0 +1,45 @@ +"""This module contains the mouse loop.""" + +from ..callback.callback_helpers import run_callback +from ..callback import callback_manager, CallbackType + + +class MouseState: # pylint: disable=too-few-public-methods + click_happened_this_frame = False # pylint: disable=invalid-name + click_release_happened_this_frame = False # pylint: disable=invalid-name + + +mouse_state = MouseState() + + +def _handle_mouse_loop(): + """Handle mouse events in the game loop.""" + #################################### + # @mouse.when_clicked callbacks + #################################### + if ( + mouse_state.click_happened_this_frame + and callback_manager.get_callbacks(CallbackType.WHEN_CLICKED) is not None + ): + for callback in callback_manager.get_callbacks(CallbackType.WHEN_CLICKED): + run_callback( + callback, + [], + [], + ) + + ######################################## + # @mouse.when_click_released callbacks + ######################################## + if ( + mouse_state.click_release_happened_this_frame + and callback_manager.get_callbacks(CallbackType.WHEN_CLICK_RELEASED) is not None + ): + for callback in callback_manager.get_callbacks( + CallbackType.WHEN_CLICK_RELEASED + ): + run_callback( + callback, + [], + [], + ) diff --git a/play/core/physics_loop.py b/play/core/physics_loop.py new file mode 100644 index 0000000..ea68e66 --- /dev/null +++ b/play/core/physics_loop.py @@ -0,0 +1,16 @@ +"""This module contains the function that simulates the physics of the game""" + +from ..globals import FRAME_RATE +from ..physics import physics_space, _NUM_SIMULATION_STEPS +from .sprites_loop import _update_sprites + + +def simulate_physics(): + """ + Simulate the physics of the game + """ + # more steps means more accurate simulation, but more processing time + for _ in range(_NUM_SIMULATION_STEPS): + # the smaller the simulation step, the more accurate the simulation + physics_space.step(1 / (FRAME_RATE * _NUM_SIMULATION_STEPS)) + _update_sprites(True) diff --git a/play/core/sprites_loop.py b/play/core/sprites_loop.py new file mode 100644 index 0000000..3659b0d --- /dev/null +++ b/play/core/sprites_loop.py @@ -0,0 +1,87 @@ +"""This module contains the main loop for updating sprites and running their events.""" + +import math as _math + +from play.globals import sprites_group +from .mouse_loop import mouse_state +from ..callback import callback_manager, CallbackType +from ..callback.callback_helpers import run_callback +from ..io import convert_pos, PYGAME_DISPLAY +from ..io.mouse import mouse + +from ..objects.line import Line +from ..objects.sprite import point_touching_sprite + + +def _update_sprites(skip_user_events=False): # pylint: disable=too-many-branches + # pylint: disable=too-many-nested-blocks + sprites_group.update() + + for sprite in sprites_group.sprites(): + + ###################################################### + # update sprites with results of physics simulation + ###################################################### + if sprite.physics and sprite.physics.can_move: + body = sprite.physics._pymunk_body + angle = _math.degrees(body.angle) + if isinstance(sprite, Line): + sprite._x = body.position.x - (sprite.length / 2) * _math.cos(angle) + sprite._y = body.position.y - (sprite.length / 2) * _math.sin(angle) + sprite._x1 = body.position.x + (sprite.length / 2) * _math.cos(angle) + sprite._y1 = body.position.y + (sprite.length / 2) * _math.sin(angle) + # sprite._length, sprite._angle = sprite._calc_length_angle() + else: + if ( + str(body.position.x) != "nan" + ): # this condition can happen when changing sprite.physics.can_move + sprite._x = body.position.x + if str(body.position.y) != "nan": + sprite._y = body.position.y + + sprite.angle = ( + angle # needs to be .angle, not ._angle so surface gets recalculated + ) + sprite.physics._x_speed, sprite.physics._y_speed = body.velocity + if skip_user_events: + continue + + ################################# + # @sprite.when_touching events + ################################# + if sprite._active_callbacks: + for cb in sprite._active_callbacks: + run_callback( + cb, + [], + [], + ) + + sprite._is_clicked = False + if sprite.is_hidden: + continue + + ################################# + # @sprite.when_clicked events + ################################# + if mouse.is_clicked: + if ( + point_touching_sprite(convert_pos(mouse.x, mouse.y), sprite) + and mouse_state.click_happened_this_frame + ): + # only run sprite clicks on the frame the mouse was clicked + sprite._is_clicked = True + if callback_manager.get_callback( + CallbackType.WHEN_CLICKED_SPRITE, id(sprite) + ): + for callback in callback_manager.get_callback( + CallbackType.WHEN_CLICKED_SPRITE, id(sprite) + ): + if not callback.is_running: + run_callback( + callback, + [], + [], + ) + + sprites_group.draw(PYGAME_DISPLAY) diff --git a/play/objects/image.py b/play/objects/image.py index ddaaa1b..8d21ada 100644 --- a/play/objects/image.py +++ b/play/objects/image.py @@ -28,10 +28,26 @@ def __init__( def update(self): """Update the image's position, size, angle, and transparency.""" if self._should_recompute: - self._image = pygame.transform.scale(self._image, (self.width, self.height)) + self._image = pygame.transform.scale( + self._image, + (self.width * self.size // 100, self.height * self.size // 100), + ) self._image = pygame.transform.rotate(self._image, self.angle) - self._image.set_alpha(self.transparency) + self._image.set_alpha(self.transparency * 2.55) self.rect = self._image.get_rect() pos = convert_pos(self.x, self.y) self.rect.center = pos super().update() + + @property + def image(self): + """Return the image.""" + return self._image + + @image.setter + def image(self, image: str): + """Set the image.""" + if not os.path.isfile(image): + raise FileNotFoundError(f"Image file '{image}' not found.") + self._image = pygame.image.load(image) + self.update() diff --git a/play/objects/sprite.py b/play/objects/sprite.py index e11e13e..eb7f387 100644 --- a/play/objects/sprite.py +++ b/play/objects/sprite.py @@ -6,12 +6,12 @@ import pygame from ..callback import callback_manager, CallbackType -from ..globals import sprites_group +from ..globals import sprites_group, _walls from ..physics import physics_space, Physics as _Physics from ..utils import _clamp from ..io import screen from ..utils.async_helpers import _make_async -from ..callback.callback_helpers import run_async_callback +from ..callback.callback_helpers import run_async_callback, run_callback def _sprite_touching_sprite(a, b): @@ -65,10 +65,19 @@ def __setattr__(self, name, value): sprite._should_recompute = True super().__setattr__(name, value) - def update(self): + def is_touching_wall(self) -> bool: + """Check if the sprite is touching the edge of the screen. + :return: Whether the sprite is touching the edge of the screen.""" + for wall in _walls: + if self.physics._pymunk_shape.shapes_collide(wall).points: + return True + return False + + def update(self): # pylint: disable=too-many-nested-blocks, too-many-branches """Update the sprite.""" - if self._should_recompute and callback_manager.get_callback( - CallbackType.WHEN_TOUCHING, id(self) + if ( # pylint: disable=too-many-nested-blocks + self._should_recompute + and callback_manager.get_callback(CallbackType.WHEN_TOUCHING, id(self)) ): # check if we are touching any other sprites for callback, b in callback_manager.get_callback( @@ -78,8 +87,42 @@ def update(self): if callback not in self._active_callbacks: self._active_callbacks.append(callback) else: + if callback_manager.get_callback( + CallbackType.WHEN_STOPPED_TOUCHING, id(self) + ): + for ( + stopped_callback, + stopped_b, + ) in callback_manager.get_callback( + CallbackType.WHEN_STOPPED_TOUCHING, id(self) + ): + if stopped_b == b: + if callback in self._active_callbacks: + run_callback(stopped_callback, [], []) if callback in self._active_callbacks: self._active_callbacks.remove(callback) + + if callback_manager.get_callback( # pylint: disable=too-many-nested-blocks + CallbackType.WHEN_TOUCHING_WALL, id(self) + ): + for callback in callback_manager.get_callback( + CallbackType.WHEN_TOUCHING_WALL, id(self) + ): + if self.is_touching_wall(): + if callback not in self._active_callbacks: + self._active_callbacks.append(callback) + else: + if callback_manager.get_callback( + CallbackType.WHEN_STOPPED_TOUCHING_WALL, id(self) + ): + for stopped_callback in callback_manager.get_callback( + CallbackType.WHEN_STOPPED_TOUCHING_WALL, id(self) + ): + if callback in self._active_callbacks: + run_callback(stopped_callback, [], []) + if callback in self._active_callbacks: + self._active_callbacks.remove(callback) + if self._is_hidden: self._image = pygame.Surface((0, 0), pygame.SRCALPHA) self._should_recompute = False @@ -422,25 +465,89 @@ def decorator(func): async_callback = _make_async(func) async def wrapper(): - wrapper.is_running = True await run_async_callback( async_callback, [], [], ) - wrapper.is_running = False - wrapper.is_running = False + for sprite in sprites: + + async def wrapper_func(): + await wrapper() + + sprite._dependent_sprites.append(self) + callback_manager.add_callback( + CallbackType.WHEN_TOUCHING, (wrapper_func, sprite), id(self) + ) + return wrapper + + return decorator + + def when_stopped_touching(self, *sprites): + """Run a function when the sprite is no longer touching another sprite. + :param sprites: The sprites to check if they're touching. + """ + + def decorator(func): + async_callback = _make_async(func) + + async def wrapper(): + await run_async_callback( + async_callback, + [], + [], + ) for sprite in sprites: + + async def wrapper_func(): + await wrapper() + sprite._dependent_sprites.append(self) callback_manager.add_callback( - CallbackType.WHEN_TOUCHING, (wrapper, sprite), id(self) + CallbackType.WHEN_STOPPED_TOUCHING, (wrapper_func, sprite), id(self) ) return wrapper return decorator + def when_touching_wall(self, callback): + """Run a function when the sprite is touching the edge of the screen. + :param callback: The function to run. + """ + async_callback = _make_async(callback) + + async def wrapper(): + await run_async_callback( + async_callback, + [], + [], + ) + + callback_manager.add_callback( + CallbackType.WHEN_TOUCHING_WALL, wrapper, id(self) + ) + return wrapper + + def when_stopped_touching_wall(self, callback): + """Run a function when the sprite is no longer touching the edge of the screen. + :param callback: The function to run. + """ + async_callback = _make_async(callback) + + async def wrapper(): + await run_async_callback( + async_callback, + [], + [], + ) + + callback_manager.add_callback( + CallbackType.WHEN_STOPPED_TOUCHING_WALL, wrapper, id(self) + ) + return wrapper + def _common_properties(self): # used with inheritance to clone return { diff --git a/play/objects/text.py b/play/objects/text.py index 0eded5c..fb10336 100644 --- a/play/objects/text.py +++ b/play/objects/text.py @@ -14,7 +14,7 @@ def __init__( # pylint: disable=too-many-arguments words="hi :)", x=0, y=0, - font="arial.ttf", + font="default", font_size=50, color="black", angle=0, @@ -111,6 +111,9 @@ def color(self, color_): def _load_font(self, font_name, font_size): """Helper method to load a font, either from a file or system.""" + if font_name == "default": + return pygame.font.Font(pygame.font.get_default_font(), font_size) + if os.path.isfile(font_name): return pygame.font.Font(font_name, font_size) play_logger.warning( diff --git a/play/physics/__init__.py b/play/physics/__init__.py index d00cad4..8333a0d 100644 --- a/play/physics/__init__.py +++ b/play/physics/__init__.py @@ -3,7 +3,6 @@ import math as _math import pymunk as _pymunk -from ..globals import FRAME_RATE from ..utils import _clamp _SPEED_MULTIPLIER = 10 @@ -293,13 +292,3 @@ def set_physics_simulation_steps(num_steps: int) -> None: """ global _NUM_SIMULATION_STEPS _NUM_SIMULATION_STEPS = num_steps - - -def simulate_physics(): - """ - Simulate the physics of the game - """ - # more steps means more accurate simulation, but more processing time - for _ in range(_NUM_SIMULATION_STEPS): - # the smaller the simulation step, the more accurate the simulation - physics_space.step(1 / (FRAME_RATE * _NUM_SIMULATION_STEPS)) diff --git a/setup.py b/setup.py index a0e0781..0884228 100644 --- a/setup.py +++ b/setup.py @@ -10,7 +10,7 @@ # This call to setup() does all the work setup( name="corderius-play", - version="2.2.0", + version="2.3.2", description="The easiest way to make games and media projects in Python.", long_description=README, long_description_content_type="text/markdown",