ผลต่างระหว่างรุ่นของ "Prg2/space (applying design patterns)"

จาก Theory Wiki
ไปยังการนำทาง ไปยังการค้นหา
 
(ไม่แสดง 3 รุ่นระหว่างกลางโดยผู้ใช้คนเดียวกัน)
แถว 516: แถว 516:
 
You can adjust the probabilities.  It would be very fun to add more <tt>EnemyGenerateionStrategy</tt>'s.  For example, you may want to add special bullets that move slowing toward the Ship.
 
You can adjust the probabilities.  It would be very fun to add more <tt>EnemyGenerateionStrategy</tt>'s.  For example, you may want to add special bullets that move slowing toward the Ship.
  
== Additional improvements ==
+
== Additional improvements (optional) ==
 +
 
 +
=== Lifes & restarts ===
 +
 
 +
You might not want to die instantly.  You might want to restart the game.
 +
 
 +
=== Rotating ship ===
 +
 
 +
You might want to use a nicer ship image, but you need to rotate the image so that you can see the ship direction.  To do so, you need to install [https://pillow.readthedocs.io/en/stable/ Pillow] (a PIL fork for Python 3), and use <tt>PIL.ImageTk.PhotoImage</tt> instead of <tt>tk.PhotoImage</tt>.
 +
 
 +
You can follow the answer in [https://stackoverflow.com/questions/15736530/python-tkinter-rotate-image-animation] or a tutorial in [https://www.homeandlearn.uk/python-third-party-library-use.html].  You can just replace <tt>PIL</tt> with <tt>Pillow</tt>.
 +
 
 +
=== Interval update ===
 +
 
 +
We still have duplicated code for <tt>update_score</tt> and <tt>update_bomb_power</tt>.  Note you can think of the if in update_bomb_power as to belong to the inner block of the wait condition.
 +
 
 +
<syntaxhighlight lang="python">
 +
    def update_score(self):
 +
        self.score_wait += 1
 +
        if self.score_wait >= SCORE_WAIT:
 +
            # ...
 +
 
 +
    def update_bomb_power(self):
 +
        self.bomb_wait += 1
 +
        if (self.bomb_wait >= BOMB_WAIT) and (self.bomb_power.value != BOMB_FULL_POWER):
 +
            # ...
 +
</syntaxhighlight>
 +
 
 +
Find a way to integrate this wait nicely into the traditional update loop in <tt>update</tt> method.

รุ่นแก้ไขปัจจุบันเมื่อ 03:42, 25 มีนาคม 2564

This is part of Programming 2 2563

Overview

In this assignment, you will apply design patterns to the provided Space Fighter code. In this game you can turn the ship with Left and Right keys. You can fire bullets with Spacebar and applying bombs with the Z key. You have 30 bullets at a time and the bomb needs cool-down period (to recharge its power to 100%).

Prg2-space-fighter.png

Understanding the current code

There are 4 main classes:

  • SpaceGame
  • Ship (in elements.py)
  • Bullet and Enemy (in elements.py)

Changes in gamelib

Since we will have a lot of sprites flying around, it would cause the game huge delays if we keep out-of-scene sprites in the game. We add attribute to_be_deleted so that the sprite or the game app could signify the game loop that a sprite can be discarded and method delete to perform the actual deletion from the canvas. We also provide methods for stoping the game stop_animation and resuming the game resume_animation. Method animate becomes:

class GameApp(ttk.Frame):
    # ...

    def animate(self):
        if not self.is_stopped:
            self.pre_update()

            remaining_elements = []
            for element in self.elements:
                element.update()
                element.render()

                if element.to_be_deleted:
                    element.delete()
                else:
                    remaining_elements.append(element)

            self.elements = remaining_elements

            self.post_update()

        self.after(self.update_delay, self.animate)

We also add a few helper methods for collision detection to Sprite. (Implementations are in utils.py)

class GameCanvasElement(GameElement):
    # ...

    def distance_to(self, element):
        return distance(self.x, self.y, element.x, element.y)

    def is_within_distance(self, element, d):
        return self.distance_to(element) <= d

SpaceGame

SpaceGame is the main class. It maintains a Ship, a list of Enemies, and a list of Bullets.

  • It keeps bullets and enemies in self.bullets and self.enemies. The reason these objects are kept outside the typical self.elements is to improve performance when performing collision detection.
  • There are 2 mechanisms to generate enemies.
    def create_enemy_star(self):
        # ...

    def create_enemy_from_edges(self):
        # ...

    def create_enemies(self):
        if random() < 0.2:
            enemies = self.create_enemy_star()
        else:
            enemies = self.create_enemy_from_edges()

        for e in enemies:
            self.add_enemy(e)
  • It runs collision detection to detect bullet-enemy collisions and enemy-ship collisions.
  • It handles keyboard events (both key-pressed and key-released events).
  • It also deals with bomb capacity of the ship: detonates the bomb, maintains bomb cool-down periods, and shows/hides bomb radius after it is detonated.

Ship

Ship (code in elements.py) maintains ship movement. It cruises forward with constant speed. The user can only change the ship's direction by turning left and right. It also generate bullets with method fire:

    def fire(self):
        if self.app.bullet_count() >= MAX_NUM_BULLETS:
            return

        dx,dy = direction_to_dxdy(self.direction)

        bullet = Bullet(self.app, self.x, self.y, dx * BULLET_BASE_SPEED, dy * BULLET_BASE_SPEED)

        self.app.add_bullet(bullet)

This code leaves rooms for more creativity in creating bullets, e.g., you can let the ship fire many bullets or even having a weapon system in the game.

Bullet and Enemy

Both Bullet and Enemy are FixedDirectionSprites that move at constant speed in a fixed direction.

class FixedDirectionSprite(Sprite):
    def __init__(self, app, image_filename, x, y, vx, vy):
        super().__init__(app, image_filename, x, y)
        self.vx = vx
        self.vy = vy

    def update(self):
        self.x += self.vx
        self.y += self.vy

        if (self.x < 0) or (self.y < 0) or (self.x > CANVAS_WIDTH) or (self.y > CANVAS_HEIGHT):
            self.to_be_deleted = True

However, you can definitely create more complex enemies that follow the ship.

Bombing mechanism

When the user press 'Z', if the bomb power is full, GameApp destroys all enemies within the radius of BOMB_RADIUS from the ship. It briefly displays a circle to show this radius. This functionality is a complete HACK to the code. See the code below where the create_oval is called and its deletion is scheduled directly with after method from ttk.Frame.

    def bomb(self):
        if self.bomb_power == BOMB_FULL_POWER:
            self.bomb_power = 0

            self.bomb_canvas_id = self.canvas.create_oval(
                self.ship.x - BOMB_RADIUS, 
                self.ship.y - BOMB_RADIUS,
                self.ship.x + BOMB_RADIUS, 
                self.ship.y + BOMB_RADIUS
            )

            self.after(200, lambda: self.canvas.delete(self.bomb_canvas_id))

            for e in self.enemies:
                if self.ship.distance_to(e) <= BOMB_RADIUS:
                    e.to_be_deleted = True

            self.update_bomb_power_text()

Statuses

SpaceGame shows 3 status texts: the score, the power level of the bomb, and the currently level. You can see that the code for these 3 statuses are extremely similar.

class SpaceGame(GameApp):
    def init_game(self):
        # ...

        self.level = 1
        self.level_text = Text(self, '', 100, 580)
        self.update_level_text()

        self.score = 0
        self.score_wait = 0
        self.score_text = Text(self, '', 100, 20)
        self.update_score_text()

        self.bomb_power = BOMB_FULL_POWER
        self.bomb_wait = 0
        self.bomb_power_text = Text(self, '', 700, 20)
        self.update_bomb_power_text()

        # ...

    def update_score_text(self):
        self.score_text.set_text('Score: %d' % self.score)

    def update_bomb_power_text(self):
        self.bomb_power_text.set_text('Power: %d%%' % self.bomb_power)

    def update_level_text(self):
        self.level_text.set_text('Level: %d' % self.level)

    def update_score(self):
        self.score_wait += 1
        if self.score_wait >= SCORE_WAIT:
            self.score += 1
            self.score_wait = 0
            self.update_score_text()

    def update_bomb_power(self):
        self.bomb_wait += 1
        if (self.bomb_wait >= BOMB_WAIT) and (self.bomb_power != BOMB_FULL_POWER):
            self.bomb_power += 1
            self.bomb_wait = 0
            self.update_bomb_power_text()

Notes: the previous code use f-format strings for status text, but it might be easier to use old formatting approach if you want to extract this status texts as a new class.

Getting started

This is an individual assignment, but you should still use branches in git for managing your work. You should use the following template as a starter code.

Since this is the second time you work on design patterns, the instructions would be less specific and you can use your judgement more freely to improve the code.

Code modification for simpler testing

It is fairly easy to die in this game. To help you live longer (in the game), so that you can test it a bit more easily, you can comment out the ship collision detection.

    def process_collisions(self):
        self.process_bullet_enemy_collisions()
        # -- comment out this line to prevent ship collision
        # self.process_ship_enemy_collision()

You can also decrease the BOMB_FULL_POWER to 1 in consts.py so that you can always use bombs.

The Strategy Pattern

The first application of design pattern to our code is to use Strategy Pattern to improve enemy creation. While currently there are only two ways to create enemies, the game could be more interesting with more enemy creation patterns.

We first look at the enemy generation methods: create_enemy_star and create_enemy_from_edge. The codes requires the game and the ship, so we create the following interface for the strategy.

class EnemyGenerationStrategy(ABC):
    @abstractmethod
    def generate(self, space_game, ship):
        pass

Note that we use ABC (abstract base class, see [1]) to fully enforce the new implementation of generate method.

The two methods should be extracted into the following classes. Your task: extract the following code. Pay attention to references to self. Don't forget to change them.

class StarEnemyGenerationStrategy(EnemyGenerationStrategy):
    def generate(self, space_game, ship):
        ####
        # TODO: extracted from method create_enemy_star

class EdgeEnemyGenerationStrategy(EnemyGenerationStrategy):
    def generate(self, space_game, ship):
        ####
        # TODO: extracted from method create_enemy_from_edge

With these two strategies in place, we can also clean up our enemy generation code (with probabilities). We create a list of strategies with their associated accumulative probabilities in init_game

class SpaceGame(GameApp):
    def init_game(self):
        # ...

        self.enemy_creation_strategies = [
            (0.2, StarEnemyGenerationStrategy()),
            (1.0, EdgeEnemyGenerationStrategy())
        ]

    # ...

    def create_enemies(self):
        p = random()

        for prob, strategy in self.enemy_creation_strategies:
            if p < prob:
                enemies = strategy.generate(self, self.ship)
                break

        for e in enemies:
            self.add_enemy(e)

    # ...

This way of specifying a list of strategies enables multiple sets of enemy creation strategies in the game. This would be extremely useful when implementing game levels.

The Chain of Responsibilities Pattern

We have already used the Observer pattern to deal with keyboard event handling. Today we will apply the Chain of Responsibilities pattern.

We first work with the framework in gamelib.py

We first add the do-nothing base class for the handler.

File: gamelib.py
class KeyboardHandler:
    def __init__(self, successor=None):
        self.successor = successor

    def handle(self, event):
        if self.successor:
            self.successor.handle(event)

We initialize the key_pressed_handler and key_released_handler to this empty handler in GameApp. Make sure you put this before the call to init_game. (Why?)

File: gamelib.py
class GameApp(ttk.Frame): 
    def __init__(self, parent, canvas_width=800, canvas_height=500, update_delay=33):
        # ...

        self.key_pressed_handler = KeyboardHandler()
        self.key_released_handler = KeyboardHandler()

        # ...

        self.init_game()

        # ...

We then all the handlers in the event handling function, instead of doing nothing.

File: gamelib.py
class GameApp(ttk.Frame): 
    # ...

    def on_key_pressed(self, event):
        self.key_pressed_handler.handle(event)

    def on_key_released(self, event):
        self.key_released_handler.handle(event)

Now let's work on main.py. First import the KeyboardHandler class.

File: main.py
from gamelib import Sprite, GameApp, Text, KeyboardHandler

Our keyboard handlers should know the game and the ship, so we create a base class to remember those objects on initialization.

File: main.py
class GameKeyboardHandler(KeyboardHandler):
    def __init__(self, game_app, ship, successor=None):
        super().__init__(successor)
        self.game_app = game_app
        self.ship = ship

We create the BombKeyPressedHandler by extracting the code for key 'Z' here. Note the super().handle(event) at the end that we forward unhandled requests along the Chain.

File: main.py
class BombKeyPressedHandler(GameKeyboardHandler):
    def handle(self, event):
        print('here')
        if event.char.upper() == 'Z':
            self.game_app.bomb()
        else:                                     #
            super().handle(event)                 # It is very important to forward the request

Now you have to implement the other two handlers. (Basically you just need to move the code around)

File: main.py
class ShipMovementKeyPressedHandler(GameKeyboardHandler):
    def handle(self, event):
        # TODO:
        #   - extract the code from on_key_pressed


class ShipMovementKeyReleasedHandler(GameKeyboardHandler):
    def handle(self, event):
        # TODO:
        #   - extract the code from on_key_released

With these handlers, we should remove the old on_key_pressed and on_key_released methods.

File: main.py
    # --- should be deleted ---
    # def on_key_pressed(self, event):
    #    ...
    #
    # def on_key_released(self, event):
    #    ...

We are left with setting up these handlers. Let's add method init_key_handlers to deal with this mess.

File: main.py
class SpaceGame(GameApp):
    def init_game(self):
        # ...

        self.init_key_handlers()

    def init_key_handlers(self):
        key_pressed_handler = ShipMovementKeyPressedHandler(self, self.ship)
        key_pressed_handler = BombKeyPressedHandler(self, self.ship, key_pressed_handler)
        self.key_pressed_handler = key_pressed_handler

        key_released_handler = ShipMovementKeyReleasedHandler(self, self.ship)
        self.key_released_handler = key_released_handler

Status display

We would encapsulate the text for displaying status with the value of the status itself. We shall create a StatusWithText class. The object StatusWithText keeps the status value and the canvas's text, and update the text automatically when the value is updated. We shall use Python's magic: property to make our code looks nice.

Be careful! Everytime you use some magic, recall that it might bite you at your back!!!

class StatusWithText:
    def __init__(self, app, x, y, text_template, default_value=0):
        self.x = x
        self.y = y
        self.text_template = text_template
        self._value = default_value
        self.label_text = Text(app, '', x, y)
        self.update_label()

    @property
    def value(self):
        return self._value

    @value.setter
    def value(self, v):
        self._value = v
        self.update_label()
    
    def update_label(self):
        self.label_text.set_text(self.text_template % self.value)

This StatusWithText has property value that we can read and write (through its setter). The value is shown on self.label_text using the provided template (e.g., 'Score: %d'). Please try to read the code to see how it works.

The following are the changes needed to apply this to self.score. We basically replace score with this StatusWithText, and every where we would like to access or update the score, we use self.score.value instead. Note that we do not have to call update_score_text, so you can remove that method.

class SpaceGame(GameApp):
    def init_game(self):
        # ...

        self.score_wait = 0
        # --- remove this
        # self.score = 0
        # self.score_text = Text(self, '', 100, 20)
        # self.update_score_text()
        # --- replace with:
        self.score = StatusWithText(self, 100, 20, 'Score: %d', 0)

        # ...

    def update_score(self):
        self.score_wait += 1
        if self.score_wait >= SCORE_WAIT:
            # --- remove this
            # self.score += 1
            # --- replace with
            self.score.value += 1

            self.score_wait = 0

    # --- you should remove this as well
    # def update_score_text(self):
    #     self.score_text.set_text('Score: %d' % self.score)

Your task: Apply StatusWithText with level and bomb_power. Make sure to also update method bomb to use self.bomb_power.value instead of just self.bomb_power.

Bombs

Currently implementation of bomb is essentially insider method bomb. The code below shows the original implementation before the usage of StatusWithText.

    def bomb(self):
        if self.bomb_power == BOMB_FULL_POWER:
            self.bomb_power = 0

            self.bomb_canvas_id = self.canvas.create_oval(
                self.ship.x - BOMB_RADIUS, 
                self.ship.y - BOMB_RADIUS,
                self.ship.x + BOMB_RADIUS, 
                self.ship.y + BOMB_RADIUS
            )

            self.after(200, lambda: self.canvas.delete(self.bomb_canvas_id))

            for e in self.enemies:
                if self.ship.distance_to(e) <= BOMB_RADIUS:
                    e.to_be_deleted = True

            self.update_bomb_power_text()

Your Task: This is a open-ended task. Find a way to improve the code in this part. Possible approaches are:

  • Extract the enemy destroying loop into a function.
  • Extract the circle drawing code
  • Try to hide access to self.after
  • Create special canvas objects that disappear over time.

Levels (optional)

You can change the game levels. You can update self.level in either method update_score or some additional method. To make the game more challenging, you can play with self.enemy_creation_strategies. Currently, we set

        self.enemy_creation_strategies = [
            (0.2, StarEnemyGenerationStrategy()),
            (1.0, EdgeEnemyGenerationStrategy())
        ]

You can adjust the probabilities. It would be very fun to add more EnemyGenerateionStrategy's. For example, you may want to add special bullets that move slowing toward the Ship.

Additional improvements (optional)

Lifes & restarts

You might not want to die instantly. You might want to restart the game.

Rotating ship

You might want to use a nicer ship image, but you need to rotate the image so that you can see the ship direction. To do so, you need to install Pillow (a PIL fork for Python 3), and use PIL.ImageTk.PhotoImage instead of tk.PhotoImage.

You can follow the answer in [2] or a tutorial in [3]. You can just replace PIL with Pillow.

Interval update

We still have duplicated code for update_score and update_bomb_power. Note you can think of the if in update_bomb_power as to belong to the inner block of the wait condition.

    def update_score(self):
        self.score_wait += 1
        if self.score_wait >= SCORE_WAIT:
            # ...

    def update_bomb_power(self):
        self.bomb_wait += 1
        if (self.bomb_wait >= BOMB_WAIT) and (self.bomb_power.value != BOMB_FULL_POWER):
            # ...

Find a way to integrate this wait nicely into the traditional update loop in update method.