PyWeek 36
from pyweek36 import main

if __name__ == "__main__":
    main.main()

pyweek36/__main__.py
letsbuilda/pyweek36
from enum import IntEnum
from pathlib import Path

SCREEN_TITLE = "Blobbing Blob"

# How big are our image tiles?
SPRITE_IMAGE_SIZE = 64

SPRITE_SCALING_PLAYER = 1
SPRITE_SCALING_TILES = 1

# Scaled sprite size for tiles
SPRITE_SIZE = int(SPRITE_IMAGE_SIZE * SPRITE_SCALING_TILES)

SCREEN_GRID_WIDTH = 25
SCREEN_GRID_HEIGHT = 15

SCREEN_WIDTH = SPRITE_SIZE * SCREEN_GRID_WIDTH
SCREEN_HEIGHT = SPRITE_SIZE * SCREEN_GRID_HEIGHT

# Physics
GRAVITY = 2500
DEFAULT_DAMPING = 1.0
WALL_FRICTION = 0.0


# Controls
class InputType(IntEnum):
    UP = 0
    DOWN = 1
    LEFT = 2
    RIGHT = 3


INPUT_BUFFER_DURATION = 0.15
COYOTE_DURATION = 0.15

# Player
PLAYER_MASS = 20
PLAYER_FRICTION = 0
PLAYER_DAMPING = 0.95

PLAYER_WALK_SPEED = 400
PLAYER_ACCEL = 200
PLAYER_DECEL = 250
PLAYER_AIR_ACCEL_FACTOR = 0.4
PLAYER_JUMP_IMPULSE = 17000

# Player animation
DEAD_ZONE = 0.1
RIGHT_FACING = 0
LEFT_FACING = 1
DISTANCE_TO_CHANGE_TEXTURE = 20

# Bullet
BULLET_VELOCITY = 2000
BULLET_MASS = 0.1
BULLET_GRAVITY = 0
BULLET_KILL_TIME = 2

# How fast the dark matter spreads in seconds, give or take a margin percentage
SPREAD_RATE = 2.5
SPREAD_MIN_DELAY = 1
SPREADABLE_BLOCKS = {"darkmatter", "source"}
SPREAD_TARGETS = {"solid"}

# Assets & animations
PLAYER_IDLE_ANIM_RATE = 0.2
PLAYER_WALK_ANIM_RATE = 0.1
PLAYER_JUMP_ANIM_RATE = 0.1
ANIM_DEAD_ZONE = 1
WALK_ANIM_DISTANCE = 20
DEATH_ANIMATION_TIME = 0.5
DEATH_ANIMATION_SCALE = 50

ASSETS_DIR = Path(__file__).parent.parent / "assets"
DARKMATTER_TEXTURE_PATH = (
    ASSETS_DIR / "blocks/dark_matter_blocks/dark_matter_blocks.png"
)
LEVEL_DIR = ASSETS_DIR / "tiled" / "levels"
WALL_TEXTURE_PATH = ASSETS_DIR / "blocks/permanent_blocks/tiles.png"
PLAYER_IDLE_ANIM_PATH = ASSETS_DIR / "player/idle"
PLAYER_JUMP_ANIM_PATH = ASSETS_DIR / "player/jump"
PLAYER_WALK_ANIM_PATH = ASSETS_DIR / "player/walk"
LOOPING_TEXTURES = {"idle", "walk"}

SOUNDS_DIR = ASSETS_DIR / "sounds"
MUSIC_PATH = SOUNDS_DIR / "music.wav"

CAMERA_DAMPING = 0.1
CAMERA_LOOKAHEAD_THRESHOLD = 0.15
CAMERA_LOOKAHEAD = 0.5

pyweek36/constants.py
letsbuilda/pyweek36
"""
Example of Pymunk Physics Engine Platformer
"""
import math
from random import random

import arcade
from arcade import PymunkPhysicsEngine, SpriteList, Camera
from pyglet.math import Vec2

from .constants import *
from .sprites import BulletSprite, PlayerSprite


class GameWindow(arcade.Window):
    """Main Window"""

    textures = {
        "darkmatter": arcade.load_texture(DARKMATTER_TEXTURE_PATH),
        "wall": arcade.load_texture(WALL_TEXTURE_PATH),
        "death_animation": arcade.load_texture(PLAYER_IDLE_ANIM_PATH / "idle01.png"),
    }

    def __init__(self, level_name: str, previous_window):
        """Create the variables"""

        self.previous_window = previous_window

        super().__init__(SCREEN_WIDTH, SCREEN_HEIGHT, SCREEN_TITLE, resizable=False)

        self.player_sprite: PlayerSprite | None = None
        self.block_list: SpriteList = SpriteList()
        self.background_sprite_list: SpriteList = SpriteList()
        self.bullet_list: SpriteList = SpriteList()
        self.spread_queue = []  # heap to store when to spread each sprite
        self.spread_rate = SPREAD_RATE
        self.minimum_spread_delay = SPREAD_MIN_DELAY

        self.map_name = level_name

        # Track the current state of what key is pressed
        self.global_time: float = 0
        self.last_pressed: dict[int, float] = {}
        self.pressed_inputs: set[int] = set()
        k = arcade.key
        self.control_map: dict[int, InputType] = (
            dict.fromkeys([k.UP, k.W, k.SPACE], InputType.UP)
            | dict.fromkeys([k.DOWN, k.S], InputType.DOWN)
            | dict.fromkeys([k.LEFT, k.A], InputType.LEFT)
            | dict.fromkeys([k.RIGHT, k.D], InputType.RIGHT)
        )
        self.physics_engine: PymunkPhysicsEngine | None = None
        self.dead: int = -1

        self.camera: Camera | None = None

    def spread_dark_matter(self, _time):
        spread_blocks = {
            block
            for block in self.block_list
            if block.properties["type"] in SPREADABLE_BLOCKS
        }
        target_locations = {
            (block.center_x + dx, block.center_y + dy)
            for block in spread_blocks
            for dx, dy in [
                (-SPRITE_SIZE, 0),
                (SPRITE_SIZE, 0),
                (0, -SPRITE_SIZE),
                (0, SPRITE_SIZE),
            ]
        }
        self.spread_queue.clear()
        for block in self.block_list:
            if block.properties["type"] not in SPREAD_TARGETS:
                continue
            elif block.position not in target_locations:
                continue
            decay_delay = self.minimum_spread_delay + random() * (
                self.spread_rate - self.minimum_spread_delay
            )
            self.spread_queue.append((self.global_time + decay_delay, block))
        self.spread_queue.sort(reverse=True)  # reverse since we pop from end later

    def load_tilemap(self, map_name):
        self.player_sprite = PlayerSprite(self)

        tile_map = arcade.tilemap.TileMap(
            ASSETS_DIR / "tiled" / "levels" / f"level{map_name}.tmx",
            SPRITE_SCALING_TILES,
            hit_box_algorithm="Detailed",
        )

        # Get spread rate from map
        if (spread_rate := tile_map.properties.get("Spread Rate")) != "":
            self.spread_rate = float(spread_rate)
        if (spread_min_delay := tile_map.properties.get("Minimum Spread Delay")) != "":
            self.minimum_spread_delay = float(spread_min_delay)

        self.physics_engine = PymunkPhysicsEngine(
            damping=DEFAULT_DAMPING,
            gravity=(0, -GRAVITY),
        )

        # Load player position from Player layer of map
        player_layer = tile_map.sprite_lists["Player"]
        self.player_sprite.position = Vec2(*player_layer[0].position)

        self.physics_engine.add_sprite(
            self.player_sprite,
            friction=PLAYER_FRICTION,
            mass=PLAYER_MASS,
            damping=PLAYER_DAMPING,
            moment=arcade.PymunkPhysicsEngine.MOMENT_INF,
            collision_type="player",
        )

        # Walls
        self.block_list = tile_map.sprite_lists["Map"]

        self.physics_engine.add_sprite_list(
            self.block_list,
            friction=WALL_FRICTION,
            collision_type="wall",
            body_type=arcade.PymunkPhysicsEngine.STATIC,
        )

        # Background
        self.background_sprite_list = tile_map.sprite_lists["Background"]

        # Bullets
        self.bullet_list.clear()

        def wall_hit_handler(bullet_sprite, wall_sprite, _arbiter, _space, _data):
            """Called for bullet/wall collision"""
            bullet_sprite.remove_from_sprite_lists()

            if wall_sprite.properties["type"] == "darkmatter":
                wall_sprite.properties["type"] = "solid"
                wall_sprite.properties["health"] = 1
                wall_sprite.texture = self.textures["wall"]

        def player_wall_handler(_player_sprite, wall_sprite, _arbiter, _space, _data):
            return not wall_sprite.properties["type"] in {"darkmatter", "source"}

        self.physics_engine.add_collision_handler(
            "bullet", "wall", post_handler=wall_hit_handler
        )

        self.physics_engine.add_collision_handler(
            "player", "wall", begin_handler=player_wall_handler
        )

        # Reschedule spreading to reset offset
        self.spread_queue.clear()
        arcade.unschedule(self.spread_dark_matter)
        arcade.schedule(self.spread_dark_matter, self.spread_rate)

    def setup(self):
        """Set up everything with the game"""

        self.camera = Camera(self.width, self.height)

        arcade.set_background_color(arcade.color.AMAZON)

        self.load_tilemap(self.map_name)

    def on_key_press(self, key, modifiers):
        """Called whenever a key is pressed."""
        if key in {arcade.key.ESCAPE, arcade.key.Q}:
            # Open previous window
            self.close()
            window = self.previous_window()
            window.setup()
            window.open_level_selector()
            arcade.run()

        if (type_ := self.control_map.get(key)) is None:
            return
        self.last_pressed[type_] = self.global_time
        self.pressed_inputs.add(type_)

    def on_key_release(self, key, modifiers):
        """Called whenever a key is released."""
        if (type_ := self.control_map.get(key)) is None:
            return
        self.pressed_inputs.discard(type_)

    def is_buffered(self, key):
        return self.last_pressed.get(key, -1) + INPUT_BUFFER_DURATION > self.global_time

    def consume_buffer(self, key):
        self.last_pressed[key] = -1

    def on_mouse_press(self, x, y, button, modifiers):
        """Called whenever the mouse button is clicked."""

        bullet = BulletSprite(20, 5, arcade.color.DARK_YELLOW)
        bullet.properties["spawn_time"] = self.global_time
        self.bullet_list.append(bullet)

        # Position the bullet at the player's current location
        start_x, start_y = bullet.position = self.player_sprite.position

        angle = math.atan2(y - start_y, x - start_x + self.camera.position[0])
        bullet.angle = math.degrees(angle)

        # Move the bullet forwards a bit to prevent it from colliding with the player
        bullet.position += Vec2.from_polar(30, angle)

        self.physics_engine.add_sprite(
            bullet,
            mass=BULLET_MASS,
            damping=1.0,
            friction=0.6,
            collision_type="bullet",
            gravity=(0, -BULLET_GRAVITY),
            elasticity=0.9,
        )

        # Add force to bullet
        self.physics_engine.set_velocity(
            bullet,
            (BULLET_VELOCITY * math.cos(angle), BULLET_VELOCITY * math.sin(angle)),
        )

        sound = arcade.Sound(SOUNDS_DIR / "bullet.wav")
        sound.play(volume=0.5)

    def update_tiles(self):
        """Spreads scheduled dark matter"""
        while self.spread_queue and self.spread_queue[-1][0] < self.global_time:
            block: arcade.Sprite
            _, block = self.spread_queue.pop()
            if block.properties.get("health", 0) > 0:
                block.properties["health"] -= 1
                continue
            block.texture = self.textures["darkmatter"]
            block.properties["type"] = "darkmatter"
            block.remove_from_sprite_lists()
            self.block_list.append(block)
            self.physics_engine.add_sprite(
                block,
                friction=WALL_FRICTION,
                collision_type="wall",
                body_type=arcade.PymunkPhysicsEngine.STATIC,
            )

    def on_update(self, delta_time):
        """Movement and game logic"""

        # If player is to right of screen, win
        if self.player_sprite.position[0] > self.width:
            sound = arcade.Sound(SOUNDS_DIR / "level_complete.wav")
            sound.play(volume=2)
            self.player_sprite.stop_movement_sound()
            self.close()
            window = self.previous_window()
            window.setup()
            window.open_level_selector()
            arcade.run()

        self.global_time += delta_time

        self.player_sprite.on_update(delta_time)
        self.update_tiles()

        # Move items in the physics engine
        self.physics_engine.step()

        # camera_x_target = self.player_sprite.center_x - self.camera.viewport_width / 2
        # x_vel = self.player_sprite.velocity[0]
        # abs_normalized_vel = math.log2(
        #     PLAYER_WALK_SPEED - min(abs(x_vel), PLAYER_WALK_SPEED - 1)
        # ) / math.log2(PLAYER_WALK_SPEED)
        # if abs_normalized_vel > CAMERA_LOOKAHEAD_THRESHOLD:
        #     camera_x_target += (
        #         math.copysign(abs_normalized_vel, x_vel) * CAMERA_LOOKAHEAD
        #     )
        # self.camera.move_to(
        #     Vec2(max(camera_x_target, 0), 0),
        #     CAMERA_DAMPING,
        # )

        if self.player_sprite.position[1] < 0:
            self.load_tilemap(self.map_name)
            self.dead = self.global_time

            self.player_sprite.stop_movement_sound()

            sound = arcade.Sound(SOUNDS_DIR / "fall.wav")
            sound.play(volume=0.5)

        for bullet in self.bullet_list:
            if self.global_time - bullet.properties["spawn_time"] > BULLET_KILL_TIME:
                bullet.kill()

    def on_draw(self):
        """Draw everything"""
        self.clear()
        if self.global_time - self.dead > DEATH_ANIMATION_TIME:
            self.camera.use()
            self.background_sprite_list.draw()
            self.block_list.draw()
            self.bullet_list.draw()
            self.player_sprite.draw()
        else:
            try:
                self.textures["death_animation"].draw_scaled(
                    self.width / 2 + self.camera.position.x,
                    self.height / 2 + self.camera.position.y,
                    DEATH_ANIMATION_SCALE
                    * math.sin(
                        (math.pi / 4)
                        * (DEATH_ANIMATION_TIME - (self.global_time - self.dead))
                    ),
                )
            except:
                pass

pyweek36/game.py
letsbuilda/pyweek36
"""
GUI for the game
"""

import arcade
from arcade.gui import UIManager

from .game import GameWindow
from .constants import *


class GameGUI(arcade.Window):
    def __init__(self):
        super().__init__(SCREEN_WIDTH, SCREEN_HEIGHT, SCREEN_TITLE, resizable=False)
        self.title = None
        self.v_box = None
        self.manager = None

    def open_level_selector(self):
        """Open level selector"""
        # Clear the window
        self.manager.clear()
        self.manager.enable()

        # Create a vertical BoxGroup to align buttons
        self.v_box = arcade.gui.UIBoxLayout()

        levels = {
            int(file.stem[-1]): file.stem
            for file in sorted(LEVEL_DIR.iterdir())
            if file.suffix == ".tmx"
            and file.stem not in {"demo", "template", "level10"}
        }
        levels[10] = "level10"

        buttons = []
        for level, name in levels.items():
            button = arcade.gui.UIFlatButton(text=f"Level {level}", width=200)
            self.v_box.add(button.with_space_around(bottom=20))
            buttons.append(button)

            button.on_click = (lambda l: lambda event: self.start_game(l))(level)

        # Create a widget to hold the v_box widget, that will center the buttons
        self.manager.add(
            arcade.gui.UIAnchorWidget(
                anchor_x="center_x", anchor_y="center_y", child=self.v_box
            )
        )

    def start_game(self, level):
        """Start the game"""
        self.close()

        game = GameWindow(level, GameGUI)
        game.setup()
        arcade.run()

    def setup(self):
        """Set up the window"""

        # a UIManager to handle the UI.
        self.manager = arcade.gui.UIManager()
        self.manager.enable()

        # Set background color
        arcade.set_background_color(arcade.color.DARK_BLUE_GRAY)

        # Create a vertical BoxGroup to align buttons
        self.v_box = arcade.gui.UIBoxLayout()

        # Add a title in big black letters
        self.title = arcade.gui.UILabel(
            text=SCREEN_TITLE, color=arcade.color.WHITE, font_size=36
        )
        self.v_box.add(self.title.with_space_around(bottom=20))

        # Create the buttons
        start_button = arcade.gui.UIFlatButton(text="Start", width=200)
        self.v_box.add(start_button.with_space_around(bottom=20))

        @start_button.event("on_click")
        def on_click_start(event):
            """Open level selector"""
            self.open_level_selector()

        quit_button = arcade.gui.UIFlatButton(text="Quit", width=200)
        self.v_box.add(quit_button.with_space_around(bottom=20))

        # use a decorator to handle on_click events
        @quit_button.event("on_click")
        def on_click_settings(event):
            """User closes window"""
            arcade.close_window()

        # Create a widget to hold the v_box widget, that will center the buttons
        self.manager.add(
            arcade.gui.UIAnchorWidget(
                anchor_x="center_x", anchor_y="center_y", child=self.v_box
            )
        )

    def on_draw(self):
        self.clear()
        try:
            self.manager.draw()
        except AttributeError:
            pass

    def on_key_press(self, symbol: int, modifiers: int):
        """Handle key presses"""
        if symbol in (arcade.key.ESCAPE, arcade.key.Q):
            self.setup()

pyweek36/gui.py
letsbuilda/pyweek36
"""RunnerZ for the game."""

import arcade
from .constants import MUSIC_PATH
from .gui import GameGUI

from threading import Thread


background_music = arcade.Sound(MUSIC_PATH)
music_id = background_music.play(volume=0.5)


# Game music theme thread plays infinitely
def play_music():
    # If music is not playing, play it
    global background_music, music_id
    if not background_music.is_playing(music_id):
        music_id = background_music.play(volume=0.5)


def main():
    """Main method"""
    window = GameGUI()
    window.setup()

    # Start the background music
    music_thread = Thread(target=play_music)
    music_thread.start()

    arcade.run()

pyweek36/main.py
letsbuilda/pyweek36
from itertools import cycle
from typing import TYPE_CHECKING

import arcade

from .constants import *

if TYPE_CHECKING:
    from .game import GameWindow


def load_texture_pairs(dir_path: str) -> list[list[arcade.Texture]]:
    dir_ = ASSETS_DIR / dir_path
    textures = []
    for file in sorted(dir_.iterdir()):
        textures.append(arcade.load_texture_pair(file))
    return textures


class PlayerSprite(arcade.Sprite):
    """Player Sprite"""

    anim_textures = {
        "idle": (PLAYER_IDLE_ANIM_RATE, load_texture_pairs(PLAYER_IDLE_ANIM_PATH)),
        "walk": (PLAYER_WALK_ANIM_RATE, load_texture_pairs(PLAYER_WALK_ANIM_PATH)),
        "jump": (PLAYER_JUMP_ANIM_RATE, load_texture_pairs(PLAYER_JUMP_ANIM_PATH)),
        "fall": (0, load_texture_pairs(PLAYER_JUMP_ANIM_PATH)[1:]),
    }

    def __init__(self, game: "GameWindow"):
        """Init"""
        # Let parent initialize
        super().__init__(
            scale=SPRITE_SCALING_PLAYER,
        )

        self.game = game
        self.facing_direction = RIGHT_FACING
        self.last_on_ground = -1

        self.movement_sound = arcade.Sound(SOUNDS_DIR / "movement.wav")
        self.movement_sound_id = None

        self.current_texture = None
        self.anim_texture_iter = None
        self.anim_rate = None
        self.last_changed_texture = None
        self.set_texture_type("idle")

        self.hit_box = self.anim_textures["idle"][1][0][0].hit_box_points

    def set_texture_type(self, type_: str):
        if self.current_texture == type_:
            return
        self.current_texture = type_
        self.anim_rate, textures = self.anim_textures[type_]
        if type_ in LOOPING_TEXTURES:
            self.anim_texture_iter = cycle(textures)
        else:
            self.anim_texture_iter = iter(textures)
        self.last_changed_texture = -1

    def stop_movement_sound(self):
        if self.movement_sound_id is not None:
            self.movement_sound.stop(self.movement_sound_id)
            self.movement_sound_id = None

    def play_movement_sound(self):
        if self.movement_sound_id is None:
            self.movement_sound_id = self.movement_sound.play(volume=0.5, loop=True)

    def on_update(self, delta_time: float = 1 / 60):
        # Update attributes
        engine = self.game.physics_engine
        on_ground = engine.is_on_ground(self)
        if on_ground:
            self.last_on_ground = self.game.global_time

        # Horizontal movement
        right_pressed = InputType.RIGHT in self.game.pressed_inputs
        left_pressed = InputType.LEFT in self.game.pressed_inputs
        target_vel = (right_pressed - left_pressed) * PLAYER_WALK_SPEED
        accel = PLAYER_ACCEL if target_vel else PLAYER_DECEL
        if not on_ground:
            accel *= PLAYER_AIR_ACCEL_FACTOR
            self.stop_movement_sound()
        else:
            # Play movement sound
            self.play_movement_sound()
        # Stop movement sound if not moving
        if target_vel == 0:
            self.stop_movement_sound()

        vel_diff = target_vel - self.velocity[0]
        engine.apply_force(self, (vel_diff * accel, 0))

        # Jump
        if (
            self.game.is_buffered(InputType.UP)
            and self.last_on_ground + COYOTE_DURATION > self.game.global_time
        ):
            self.game.consume_buffer(InputType.UP)
            impulse_amount = PLAYER_JUMP_IMPULSE
            if not on_ground:
                impulse_amount -= self.velocity[1] * PLAYER_MASS
            engine.apply_impulse(self, (0, impulse_amount))
            self.last_on_ground = -1

            # Play jump sound
            sound = arcade.Sound(SOUNDS_DIR / "jump.wav")
            sound.play(volume=0.5)

        # Update texture
        if self.velocity[0] / 60 < -ANIM_DEAD_ZONE:
            self.facing_direction = LEFT_FACING
        elif self.velocity[0] / 60 > ANIM_DEAD_ZONE:
            self.facing_direction = RIGHT_FACING
        if on_ground:
            if abs(self.velocity[0]) / 60 < ANIM_DEAD_ZONE:
                texture_type = "idle"
            else:
                texture_type = "walk"
        else:
            if self.velocity[1] > 0:
                texture_type = "jump"
            else:
                texture_type = "fall"
        self.set_texture_type(texture_type)
        if self.game.global_time > self.last_changed_texture + self.anim_rate:
            try:
                next_texture = next(self.anim_texture_iter)
            except StopIteration:
                self.last_changed_texture = float("inf")
            else:
                self.texture = next_texture[self.facing_direction]
                self.last_changed_texture = self.game.global_time

    def pymunk_moved(self, physics_engine, dx, dy, d_angle):
        """Handle being moved by the pymunk engine"""
        self.velocity = [dx * 60, dy * 60]


class BulletSprite(arcade.SpriteSolidColor):
    """Bullet Sprite"""

    def pymunk_moved(self, physics_engine, dx, dy, d_angle):
        """Handle when the sprite is moved by the physics engine."""
        # If the bullet falls below the screen, remove it
        if self.center_y < -100:
            self.kill()

pyweek36/sprites.py
letsbuilda/pyweek36
import sys

MIN_VER = (3, 10)

if sys.version_info[:2] >= MIN_VER:
    from pyweek36.main import main

    main()
else:
    sys.exit("This game requires Python {}.{}.".format(*MIN_VER))

run_game.py
letsbuilda/pyweek36