Python 025 - Score?
We should probably keep score. Shouldn’t be terribly difficult. Just some text … how hard could it be?
Now that I have a sort of idea how to display text, it might be a good time to do the score. It goes up near top left, is five digits long, with leading zeros. Or is it zeroes? the simplest thing that could possibly work might go like this:
- Have a well-known value Score;
- Every time through the loop, display_score.
How might display_score
work? Something like this:
- If Score is different from the last time we displayed it;
- … Convert Score to text;
- … Render the text to a surface;
- Display the surface.
Close enough to simple, even reasonably efficient. Let’s just do it. In fact, let’s do less.
def draw_everything():
global ship
screen.fill("midnightblue")
for ship in ships:
ship.draw(screen)
for asteroid in asteroids:
asteroid.draw(screen)
for missile in missiles:
missile.draw(screen)
draw_score()
Not quite done yet …
def draw_score():
score_surface, score_rect = render_score()
screen.blit(score_surface, score_rect)
Still not done …
def render_score():
score_text = f"0000{score}"[-5:]
score_surface = score_font.render(score_text, True, "green")
score_rect = score_surface.get_rect(topleft=(10,10))
return score_surface, score_rect
One more thing, in init:
def game_init():
global screen, clock, running
pygame.init()
screen = pygame.display.set_mode((u.SCREEN_SIZE, u.SCREEN_SIZE))
pygame.display.set_caption("Asteroids")
clock = pygame.time.Clock()
define_game_over()
define_score()
running = True
insert_quarter(0)
def define_score():
global score, old_score, score_font
score = 0
old_score = -1
score_font = pygame.font.SysFont("arial", 48)
Somewhat to my surprise, I get this:
So that’s pretty fine.
I’ve not put in the “optimization” of using the old_score to decide whether to render. Let’s hold off on that and instead, rack up some points. Where do asteroids go to die? Well, here:
def split_or_die(self, asteroids):
if self not in asteroids: return # already dead
asteroids.remove(self)
if self.size > 0:
a1 = Asteroid(self.size - 1, self.position)
asteroids.append(a1)
a2 = Asteroid(self.size - 1, self.position)
asteroids.append(a2)
I had thought, first, just to increment main.score
. But I can’t import main into Asteroid, because main imports asteroid. This problem is related to the fact that I have a mass of globals in main and other modules reference them. I think we’ll resolve this by slowly moving shared items to a new module. One possibility is to use a shared module that we already have, “u”. Let’s try that.
# U - Universal Constants
import pygame
SCREEN_SIZE = 768
SPEED_OF_LIGHT = 500
ASTEROID_DELAY = 4
...
score = 0
Now in main:
def define_score():
global score_font
u.score = 0
score_font = pygame.font.SysFont("arial", 48)
def draw_score():
score_surface, score_rect = render_score()
screen.blit(score_surface, score_rect)
def render_score():
score_text = f"0000{u.score}"[-5:]
score_surface = score_font.render(score_text, True, "green")
score_rect = score_surface.get_rect(topleft=(10,10))
return score_surface, score_rect
And Asteroid:
def split_or_die(self, asteroids):
if self not in asteroids: return # already dead
u.score += [100, 50, 20][self.size]
asteroids.remove(self)
if self.size > 0:
a1 = Asteroid(self.size - 1, self.position)
asteroids.append(a1)
a2 = Asteroid(self.size - 1, self.position)
asteroids.append(a2)
This works as advertised, with one exception: when you crash the ship into an asteroid, you get the score. This is not so good.
Not the best idea …
Let’s do this: Instead of treating the score list as a literal, let’s ask the attacker for its score list and pass it in to the split. Like this:
def collide_with_attacker(self, attacker, attackers, asteroids):
if self.withinRange(attacker.position, attacker.radius):
if attacker in attackers: attackers.remove(attacker)
self.split_or_die(attacker.score_list, asteroids)
def split_or_die(self, score_list, asteroids):
if self not in asteroids: return # already dead
if not score_list: score_list = [0, 0, 0]
u.score += score_list[self.size]
asteroids.remove(self)
if self.size > 0:
a1 = Asteroid(self.size - 1, self.position)
asteroids.append(a1)
a2 = Asteroid(self.size - 1, self.position)
asteroids.append(a2)
Now in missile:
class Missile:
def __init__(self, position, velocity):
self.position = position.copy()
self.velocity = velocity.copy()
self.score_list = [100, 50, 20]
self.radius = 2
self.time = 0
I am interested to learn while testing that Python won’t return a None for an access to score_list when the object doesn’t have one. So all the objects I might encounter need a score_list. I think I would like to have a test for that.
def test_score_list(self):
ship = Ship(u.CENTER)
assert ship.score_list == [0, 0, 0]
missile = Missile(u.CENTER, Vector2(0,0))
assert missile.score_list == [100, 50, 20]
With that passing, we should be good to go. And we are. I’d like to improve that test to check the Saucer, but we haven’t done Saucer yet. I suppose I could write a starter Saucer class just to get the test in place? But no. But yes, let’s actually do it. You know I’ll forget otherwise.
def test_score_list(self):
ship = Ship(u.CENTER)
assert ship.score_list == [0, 0, 0]
missile = Missile(u.CENTER, Vector2(0,0))
assert missile.score_list == [100, 50, 20]
saucer = Saucer()
assert saucer.score_list == [0, 0, 0]
I’ll just put a trivial Saucer class into the test file. We should get an error when I try to create one elsewhere.
class Saucer:
def __init__(self):
self.score_list = [0, 0, 0]
OK. Score’s done. Let’s review the code and see what we think:
Review
In “u”, we define the score variable. It’s our first variable in “u”, which has up until now been constants:
u ...
score = 0
In main, we define, render, and draw the score:
def game_init():
global screen, clock, running
pygame.init()
screen = pygame.display.set_mode((u.SCREEN_SIZE, u.SCREEN_SIZE))
pygame.display.set_caption("Asteroids")
clock = pygame.time.Clock()
define_game_over()
define_score()
running = True
insert_quarter(0)
def define_score():
global score_font
u.score = 0
score_font = pygame.font.SysFont("arial", 48)
def draw_score():
score_surface, score_rect = render_score()
screen.blit(score_surface, score_rect)
def render_score():
score_text = f"0000{u.score}"[-5:]
score_surface = score_font.render(score_text, True, "green")
score_rect = score_surface.get_rect(topleft=(10,10))
return score_surface, score_rect
We’re rendering it on every draw cycle. We could optimize that if it seemed desirable.
We have no Saucer yet, so only asteroids add to the score, only when they are killed by a missile. We handle that by having each possible collider provide a score_list of the scores to be added if the collider kills an asteroid of size 0, 1, or 2:
class Ship ...
self.score_list = [0, 0, 0]
class Missile ...
self.score_list = [100, 50, 20]
And in the Asteroid collision code we fetch the array and use it. I think we’ll want to refactor this:
def collide_with_attacker(self, attacker, attackers, asteroids):
if self.withinRange(attacker.position, attacker.radius):
if attacker in attackers: attackers.remove(attacker)
self.split_or_die(attacker.score_list, asteroids)
def split_or_die(self, score_list, asteroids):
if self not in asteroids: return # already dead
if not score_list: score_list = [0, 0, 0]
u.score += score_list[self.size]
asteroids.remove(self)
if self.size > 0:
a1 = Asteroid(self.size - 1, self.position)
asteroids.append(a1)
a2 = Asteroid(self.size - 1, self.position)
asteroids.append(a2)
No real point passing the array down, let’s just do it in collide
:
def collide_with_attacker(self, attacker, attackers, asteroids):
if self.withinRange(attacker.position, attacker.radius):
if attacker in attackers: attackers.remove(attacker)
u.score += attacker.score_list[self.size]
self.split_or_die(asteroids)
def split_or_die(self, asteroids):
if self not in asteroids: return # already dead
asteroids.remove(self)
if self.size > 0:
a1 = Asteroid(self.size - 1, self.position)
asteroids.append(a1)
a2 = Asteroid(self.size - 1, self.position)
asteroids.append(a2)
That seems to make perfect sense. Even better, it works. Let’s commit: Game shows proper score when missiles hit asteroids.
That was a fairly substantial commit, 7 files. Let’s reflect and sum up:
Reflection
While I did add some trivial testing, I’ve not really tested scoring. That should be done, I think, and there are perhaps even some tests where it could be done easily. That said, I’m not going to do it this morning. I feel a bit off for some reason, and I want to be done. I’ve made a sticky note to do more tests, and I’ve tested carefully in the game. In addition, the code is so simple that it couldn’t possibly be wrong … could it?
The fact that seven files changed makes me think that there were probably some incremental commits along the way. Since I did draw the zero score as you saw in the picture above, I certainly could have committed at that point. I’m not sure that would have left any files out of the final list, but it would have been a small safe save point in the event of an error.
Moving score
over to “u” was a small change but a big design decision. I think that the addition to score is the first storing reference from the game’s space objects back toward the top of the game, although we do have references to the constants. There are some references to main in the tests, to access some of its values for testing, and, of course, to call its functions.
I’ll try to bring this change to the front of my mind and think about whether this is a decent way to share information such as the score. It’s always an issue when a lower-level object wants to have influence at the higher level. In a game of this size, some shared variables might be OK. In a larger more complex program, that could, and probably would, lead to trouble.
A small-scale program like this one exemplifies some issues that arise in larger programs, but not all issues. And the solutions that are acceptable in the small will not necessarily serve in the large. Here, for now, I think we’re OK.
Next time, we’ll review what’s left to be done. Saucer, hyperspace, and whatever else is left on the list.
So far so good. See you next time!