P-268 - Score
Python Asteroids+Invaders on GitHub
I think there are very few things left to do with Invaders. One of them is scoring.
The things we might do include:
- Scoring
- Saucer
- Two Players
- Bottom Line Damage
That last one may need explanation. In the original game, there was a line across the bottom of the screen, and if an invader shot hit the line, it damaged the line. A nice little feature. We don’t even have the line right now, but it’s worth considering.
Let’s discuss “worth”. As far as I can estimate, no one but me will ever play this game. And I won’t play it often. In large part, the point of this exercise was to demonstrate that the decentralized framework could support a completely different game from Asteroids, based only on the initial objects loaded into the mix. It seems to me that that has been demonstrated quite convincingly already.
Another purpose for all my programming work here is to show what it’s like to program in the style I follow. I want to call it “Agile”, but that word has been dragged far away from what it meant when we coined it back at the turn of the century. I suppose I could call it Test-Driven Small Steps Development with Refactoring and Generally Messing About, but the acronym isn’t that compelling. Whatever it is, I try to show you what I do and what happens, and to explain, as best I can, why I do what I do, and to share the lessons that I seem to learn over and over again.
That purpose can be served by just about any program. So maybe it’s coming up on time for something new.
But not yet. Let’s at least get scoring done.
Scoring “Spec”
The scoring in Invaders is rather simple. The bottom two rows of invaders score 10 points, the second two rows score 20 points, and the top row scores 30 points. So the total score per rack of invaders is 220+440+330, or 990 points. And there is the saucer, not yet implemented.
I’ve already implemented Space Invaders, on the iPad, in Codea Lua, and in one of those articles I refer to this analysis from the Computer Archaeology writeup of the game:
The score for shooting the saucer ranges from 50 to 300, and the exact value depends on the number of player shots fired. The table at 0x1D54 contains 16 score values, but a bug in the code at 0x044E treats the table as having 15 values. The saucer data starts out pointing to the first entry. Every time the player’s shot blows up the pointer is incremented and wraps back around. Here is the table. You have to append a trailing “0” to every value to get the three digit score.
; 208D points here to the score given when the saucer is shot. It advances
; every time the player-shot is removed. The code wraps after 15, but there
; are 16 values in this table. This is a bug in the code at 044E (thanks to
; Colin Dooley for finding this).
;
; Thus the one and only 300 comes up every 15 shots (after an initial 8).
1D54: 10 05 05 10 15 10 10 05 30 10 10 10 05 15 10 05
There are five entries of 050, eight entries of 100, two 150s, and only one 300. The 300 score comes up every 15 shots (after an initial eight). It should come up every 16, but again – the code has a bug.
I report that only for completeness in our consideration of scoring this morning. We don’t have a saucer and we’ll not program the score before the need.
Scoring Implementation
We solved scoring in Asteroids with a Score object that goes into the mix carrying a score value to be accumulated, and a ScoreKeeper in the mix, which watches for Score instances, accumulates their values, and displays the current total.
We’ll do something similar. One possibility is to reuse the Score and perhaps even the ScoreKeeper from Asteroids. Or we could produce our own. Let’s review Score.
The Score file, as I look at it now, is 60 lines long. It has a lot of pass
methods in it. Since our practice, newly formed yesterday, is to remove unneeded methods when we pass through, we’ll clear those out.
With that done, it is now 18 lines:
class Score(AsteroidFlyer):
def __init__(self, score):
self.score = score
@classmethod
def should_interact_with(cls):
from scorekeeper import ScoreKeeper
return [ScoreKeeper]
def interact_with(self, other, fleets):
other.interact_with_score(self, fleets)
def interact_with_scorekeeper(self, scorekeeper, fleets):
fleets.remove(self)
Right. 42 lines of useless. I’m not sorry about removing those abstract methods, at least not yet.
I notice the interesting should_interact_with
method. I have no recollection of that. I wonder how it’s used. There is a test that sends that message, and it goes like this:
def test_should_interact_with(self):
# a subclass xyz of Flyer can implement
# should_interact_with to return a list of classes
# each of which will be checked for implementing
# interact_with_xyz
subclasses = get_subclasses(AsteroidFlyer)
for klass in subclasses:
required_method = "interact_with_" + klass.__name__.lower()
if "should_interact_with" in klass.__dict__:
should_interact = klass.should_interact_with()
for interactor in should_interact:
if required_method not in interactor.__dict__:
assert False, interactor.__name__ + " does not implement " + required_method
So at some point in the distant past, in Python 137, I had a way for classes to declare what other classes they should interact with, and a test that checked them. There are exactly two classes that use that feature: Score and ScoreKeeper. Talk about dying on the vine, that idea sure didn’t go anywhere.
But it seems rather useful, doesn’t it … if we would use it. Clearly, we won’t.
The question before us now is whether to use Score in Invaders, or to implement our own. I think our Policy is to implement our own, because surely someday we’ll ship these two products separately.
Let’s TDD up a new InvadersScore object and its use.
class TestInvadersScore:
def test_exists(self):
InvadersScore(100)
class InvadersScore:
def __init__(self, score):
self.score = score
Green. Commit: Initial InvadersScore.
OK, it exists, what does it do? Well, it needs to inherit from InvadersFlyer, for one thing. I don’t have a standard way of testing that, I’ll just do it.
class InvadersScore(InvadersFlyer):
def __init__(self, score):
self.score = score
@property
def mask(self):
return None
@property
def rect(self):
return None
def interact_with(self, other, fleets):
other.interact_with_invadersscore(self, fleets) # corrected, see below
PyCharm insisted on those methods. Now a test fails. The test is telling me that I have to add the new interact with to InvadersFlyer, so let’s do that. Along the way I decide to remove the S from the name for better spelling.
class InvaderScore(InvadersFlyer):
def __init__(self, score):
self.score = score
@property
def mask(self):
return None
@property
def rect(self):
return None
def interact_with(self, other, fleets):
other.interact_with_invaderscore(self, fleets) # corrected, see below
Green. Commit: InvaderScore inherits properly from InvadersFlyer.
Now what? Oh, it should remove itself. When? We could do this in at least two ways. We could remove it on tick
, confident that the new scorekeeper, soon to be implemented, will have seen it. Or, we could be a bit more careful and actually do the remove only after we’ve seen a ScoreKeeper.
Either way, it seems that we need to build our new scorekeeper in concert with the Score. We’ll test accordingly. First, drive out the class:
class TestInvaderScore:
def test_exists(self):
InvaderScore(100)
InvaderScoreKeeper()
class InvaderScoreKeeper(InvadersFlyer):
def __init__(self):
self.total_score = 0
@property
def mask(self):
return None
@property
def rect(self):
return None
def interact_with(self, other, fleets):
other.interact_with_invaderscorekeeper(self)
That, and the addition of the new interact method to InvadersFlyer and we are green. Commit: initial InvaderScoreKeeper.
I’ve decide to keep the two classes together in one file. We’ll see if I regret that, but I predict that I will not.
Now we can interact them. We are quite confident in the overall interaction loop, so we can do them directly, like this:
def test_accumulates(self):
score = InvaderScore(100)
keeper = InvaderScoreKeeper()
assert keeper.total_score == 0
keeper.interact_with_invaderscore(score, [])
assert keeper.total_score == 100
keeper.interact_with_invaderscore(score,[])
assert keeper.total_score == 200
This does not run. We implement:
class InvaderScoreKeeper(InvadersFlyer):
def interact_with_invaderscore(self, score, fleets):
self.total_score += score.score
Green. Commit: InvaderScoreKeeper accumulates score.
We need to assure that the Score removes itself. Let’s test that separately, I guess.
def test_score_removes_self(self):
fleets = []
score = InvaderScore(100)
fleets.append(score)
keeper = InvaderScoreKeeper()
score.interact_with_invaderscorekeeper(keeper, fleets)
assert not fleets
And …
class InvaderScore(InvadersFlyer):
def interact_with_invaderscorekeeper(self, keeper, fleets):
fleets.remove(self)
Green. InvaderScore removes self on interaction with keeper.
Let’s slow down a bit and reflect.
I think that what we have is righteous. Tight, minimal, neat, generally good. What do we need?
Well. We need for our invaders to emit score instances, and we need to draw the score on the screen.
Let’s do this: we’ll emit scores, and we’ll have our score keeper just print the total. Then we’ll work on the display.
So Invaders need to know the score. Ha ha. They do know their row:
class Invader:
def __init__(self, column, row, bitmaps):
self.bitmaps = bitmaps
self.masks = [pygame.mask.from_surface(bitmap) for bitmap in self.bitmaps]
self.column = column
self.relative_position = Vector2(INVADER_SPACING * column, -INVADER_SPACING * row)
self.rect = pygame.Rect(0, 0, 64, 32)
self.image = 0
We can have them know their score based on that. Are we doing to test-drive this? Sure, why not?
def test_invader_scores(self):
maker = BitmapMaker.instance()
maps = maker.invaders
assert Invader(1, 1, maps)._score == 100
assert Invader(1, 2, maps)._score == 100
assert Invader(1, 3, maps)._score == 200
assert Invader(1, 4, maps)._score == 200
assert Invader(1, 5, maps)._score == 300
That’s failing, no surprise. It also fails after I implement it because our rows start at zero.
def test_invader_scores(self):
maker = BitmapMaker.instance()
maps = maker.invaders
assert Invader(1, 0, maps)._score == 100
assert Invader(1, 1, maps)._score == 100
assert Invader(1, 2, maps)._score == 200
assert Invader(1, 3, maps)._score == 200
assert Invader(1, 4, maps)._score == 300
class Invader:
def __init__(self, column, row, bitmaps):
self._score = [100, 100, 200, 200, 300][row]
...
Green. Commit: Invaders know their score.
Now then. Let’s patch in a print in the score keeper and put one in the mix.
Oh, and we need to emit the Score when we’re hit.
class Invader:
def interact_with_group_and_playershot(self, shot, group, fleets):
if self.colliding(shot):
shot.hit_invader(fleets)
group.kill(self)
fleets.append(InvaderScore(self._score))
fleets.append(InvaderExplosion(self.position))
I also made two mistakes in linking them up, leaving the fleets
parameter out of the interact_with
methods. I’ve fixed them above, with comments about “see below” but must duly report my errors.
The game now prints the increasing score as you shoot invaders:
def interact_with_invaderscore(self, score, fleets):
self.total_score += score.score
print("Score:", self.total_score)
Score: 100
Score: 200
Score: 400
Score: 600
Score: 900
Commit: Invaders score correctly, score just prints.
Reflection
Seven commits in an interval of less than 50 minutes, not bad at all. We have tests for everything except the display behavior, which isn’t something we generally test. It seems that it should display something like this, at the top left:
SCORE <1>
0000
And you can guess what displays on the right. High Score displays in the middle, but we’ve never really done a high score feature. Perhaps we should.
Let’s see how the other keeper displays, we’ll steal the idea.
def draw(self, screen):
score_surface, score_destination = self.render_score()
screen.blit(score_surface, score_destination)
self.draw_available_ships(screen
def render_score(self):
x_position = [10, 875][self._player_number]
score_text = f"0000{self.score}"[-5:]
color = "green" if self._scoring else "gray50"
score_surface = self.score_font.render(score_text, True, color)
score_destination = (x_position, 10)
return score_surface, score_destination
I’ll replicate what I need from that, by hand at first:
def draw(self, screen):
header = "SCORE <1>"
header_surface = self.score_font.render(header, True, "white")
screen.blit(header_surface, (75, 10))
score_text = f"0000{self.total_score}"[-5:]
score_surface = self.score_font.render(score_text, True, "white")
screen.blit(score_surface, (135, 60))
And that looks good enough for now:
Commit: Score<1> drawn top left. Let’s sum up.
Summary
Today is one of those days you live for. The decentralized design really shows its strength here. We have implemented scoring by providing two simple classes, InvaderScoreKeeper and InvaderScore, which interact only with each other, and by having the Invader produce a Score instance with the appropriate value when it is hit by a player shot.
Now, it’s probably only fair to point out that in a more centralized game, we’d have had code that was pretty similar, and possibly even shorter. We might also have had a new global object to keep the score, or some complicated hookup between the invaders and the score-keeping. What wee gain with this design is a bit of independence between the objects. And I find it quite enjoyable to think about a few little objects and making them interact to produce some complex behavior.
When we do the Saucer, it’s clear that all it will have to do is toss an InvaderScore into the mix when it is shot down. And what about two players? Well, if we do that, we’ll probably have two ScoreKeepers, one on each side of the screen. That’s what we did in Asteroids, and it was pretty straightforward.
Everything went quite smoothly, except that I forgot to include fleets
on my interaction calls, and that was not discovered by any tests. I think it was probably squiggled by PyCharm but if it was, I didn’t notice until run time. I almost think there must have been a broken test that I didn’t notice. The problem quickly showed up in running the game, but the fact that I hadn’t noticed before is embarrassing.
Ah! I know what it was. It was those short-cut tests where I called interact_with_invaderscore
directly. Had I used interact_with
, they would have failed. I’ve improved them and committed.
Bottom line? New scoring, went in easily. Works as advertised. We are pleased.
- Added in Post
- Did you notice that I scored the invaders 100, 200, 300, not 10, 20, 30? Neither did I, until just now. Fixed. What threw me off? The math that said the whole batch should score 990. It got me thinking in 100s. Probably should have a test for total rack score. Right now, we have no such test. But I did fix the tests we do have.
See you next time!