Python 172 - Two Players!!
If things go roughly as I expect, we’ll have the two-player game today. Sometimes things do go as I expect. It could happen. It DOES happen!
- Where are we?
-
We have changed ScoreKeeper to have a player number member. Player zero displays on the left, and Player one on the right. ScoreKeepers interact with a new object, Signal, which also contains a player number. If the number matches the keeper’s, that keeper pays attention to any incoming Score items. If it does not, the keeper ignores Scores.
- What’s next?
-
We need to change ShipMaker to know whether we are in a one-player or two-player game. It will have a list of one or two counts of the number of
ships_remaining
, and a state variable indicating whether player zero or player one is next up. We need to change access toships_remaining
to take a parameter indicating which player is of interest, so that ScoreKeeper can display the correct count.
I think all this will be straightforward. Let’s start by changing access to ships_remaining
which is presently just accessed directly by interested parties.
There are 14 usages of that member, mostly in tests. I’ll break everything and then fix it.
First, change the init to make the variable private. I won’t use rename, because I’m intentionally disconnecting everyone.
class ShipMaker(Flyer):
def __init__(self):
self.ships_remaining = u.SHIPS_PER_QUARTER
self._timer = Timer(u.SHIP_EMERGENCE_TIME)
self._game_over = False
self._need_ship = True
self._safe_to_emerge = False
That becomes:
def __init__(self):
self._ships_remaining = u.SHIPS_PER_QUARTER
self._timer = Timer(u.SHIP_EMERGENCE_TIME)
self._game_over = False
self._need_ship = True
self._safe_to_emerge = False
I changed all the local references to the private variable. Now to build a method for outsiders to call:
def ships_remaining(self, player_number):
return self._ships_remaining
We’ll fill in the player number in a later phase.
Now to fix all the tests, which I’ll just change by adding (0) to all the references.
I also needed to add a method to allow tests to change the number of ships remaining:
def testing_set_ships_remaining(self, ships):
self._ships_remaining = ships
We are green. Commit: Ships remaining is now accessed with player number parameter.
So far so good, lots of tiny steps.
Next, let’s give ShipMaker a creation parameter specifying number of players to create. In the spirit of tiny tests, we’ll just add it, ignore it, and commit. Let’s do make it required.
class ShipMaker(Flyer):
def __init__(self, number_of_players):
self._ships_remaining = u.SHIPS_PER_QUARTER
self._timer = Timer(u.SHIP_EMERGENCE_TIME)
self._game_over = False
self._need_ship = True
self._safe_to_emerge = False
Commit: ShipMaker requires number_of_players construction parameter, unused.
Now let’s add the table and the next_player index. I think we need a test for this.
def test_two_players(self):
fleets = Fleets()
ship_maker = ShipMaker(2)
fleets.append(ship_maker)
interactor = Interactor(fleets)
fi = FI(fleets)
avail = ship_maker._ships_remaining
assert len(avail) == 2
This specifies my intention to make _ships_remaining
into a list.
def __init__(self, number_of_players):
self._ships_remaining = []
for _ in range(0, number_of_players):
self._ships_remaining.append(u.SHIPS_PER_QUARTER)
self._timer = Timer(u.SHIP_EMERGENCE_TIME)
self._game_over = False
self._need_ship = True
self._safe_to_emerge = False
def ships_remaining(self, player_number):
return self._ships_remaining[player_number]
Hm, things breaking. This was a bigger change than I had thought, because there is code setting _ships_remaining
. We’ll cause them all to refer to [0].
No, that’s going to cause me trouble. Roll back and do again, differently.
First add a member for _current_player_number
:
def __init__(self, number_of_players):
self._ships_remaining = u.SHIPS_PER_QUARTER
self._current_player_number = 0
self._timer = Timer(u.SHIP_EMERGENCE_TIME)
self._game_over = False
self._need_ship = True
self._safe_to_emerge = False
And the table, I need that to exist.
def __init__(self, number_of_players):
self._ships_remaining = []
for _ in range(0, number_of_players):
self._ships_remaining.append(u.SHIPS_PER_QUARTER)
self._current_player_number = 0
self._timer = Timer(u.SHIP_EMERGENCE_TIME)
self._game_over = False
self._need_ship = True
self._safe_to_emerge = False
Now adjust all my internal references to use the current:
def create_ship(self, fleets):
if not self._safe_to_emerge:
return False
if self._ships_remaining[self._current_player_number] > 0:
self._ships_remaining[self._current_player_number] -= 1
fleets.append(Ship(u.CENTER))
else:
fleets.append(GameOver())
self._game_over = True
return True
def ships_remaining(self, player_number):
return self._ships_remaining[player_number]
def testing_set_ships_remaining(self, ships):
self._ships_remaining[0] = ships
def add_ship(self):
self._ships_remaining[self._current_player_number] += 1
player.play("extra_ship")
We are green. Commit: ships remaining is a table, internally indexed by _current_player_number
.
Now for something completely different. When we rez a ship, we should send a Signal and we should set up so that next time we use the other player if there is one.
I don’t quite see how to do this. Let’s think. When we start up, there will be no ship, so the first one we create should be for player zero.
def create_ship(self, fleets):
if not self._safe_to_emerge:
return False
if self._ships_remaining[self._current_player_number] > 0:
self._ships_remaining[self._current_player_number] -= 1
fleets.append(Ship(u.CENTER))
else:
fleets.append(GameOver())
self._game_over = True
return True
It seems to me that it might work to increment current right after signaling.
We’re on a fresh commit, let’s try it.
This isn’t quite right but I think it might work well enough to see it in the game.
def create_ship(self, fleets):
if not self._safe_to_emerge:
return False
if self._ships_remaining[self._current_player_number] > 0:
self._ships_remaining[self._current_player_number] -= 1
fleets.append(Ship(u.CENTER))
fleets.append(Signal(self._current_player_number))
self._current_player_number = (self._current_player_number + 1) % len(self._ships_remaining)
else:
fleets.append(GameOver())
self._game_over = True
return True
With a tweak to my fake ship creator, NoShips, this works in the game, alternating between players and scoring as I would expect.
I’m sure it will trigger GameOver too soon, as soon as either player is out of ships.
That isn’t quite what happens. Since I have won no free ships, I get my full eight ships worth of futile play. But, if I did get an extra ship, I don’t think it would work right. I’ll fudge it, giving player zero one ship and player one three. Sure enough, as soon as player zero’s second turn comes around, we get game over:
I can still commit this, because the single player game works correctly. We are green. Commit: ships remaining is a table. two-player game quits early. one-player is good.
We should talk about this series of commits, I think.
Reflection
For quite some time, the code has contained parts of the two-player feature, which has not been, and still is not, quite working. But the single-player game has been kept working. There’s no way in the game to trigger the two-player mode: my code for doing that is in my local copy of coin
, which I have not committed.
So we have done upwards of ten commits toward this new feature, with none of them visible in the game. Small steps, released because they are safely hidden behind the scenes.
This is a good thing. In another situation, we might use a “feature flag” to keep the feature hidden, but in this case it will require a special “coin” to turn it on.
This is quite nice. I can work for days on two-player mode, and because my code is always committed, I don’t need to worry about complicated merges. I will have to merge coin
but no one is going to change that anyway.
Let’s get back to it. How can we manage an unbalanced number of ships?
Back To It
def create_ship(self, fleets):
if not self._safe_to_emerge:
return False
if self._ships_remaining[self._current_player_number] > 0:
self._ships_remaining[self._current_player_number] -= 1
fleets.append(Ship(u.CENTER))
fleets.append(Signal(self._current_player_number))
self._current_player_number = (self._current_player_number + 1) % len(self._ships_remaining)
else:
fleets.append(GameOver())
self._game_over = True
return True
It would be nice if we could do it all in here. How might this work?
The condition for rezzing a ship isn’t that the current player has one, it is that either player has one.
Then, if the current player has one, we should rez it. And if not, we should rez on behalf of the other player. Note that we always set current player to the player who will get the next ship if there is one. That name probably should be changed.
Let’s do that. Should it be next_player
? Let’s try it and see if we like it.
def create_ship(self, fleets):
if not self._safe_to_emerge:
return False
if self._ships_remaining[self._next_player] > 0:
self._ships_remaining[self._next_player] -= 1
fleets.append(Ship(u.CENTER))
fleets.append(Signal(self._next_player))
self._next_player = (self._next_player + 1) % len(self._ships_remaining)
else:
fleets.append(GameOver())
self._game_over = True
return True
We have methods for getting and setting. Perhaps we should use them. I’ll hold off on that.
Let’s change the second if:
def create_ship(self, fleets):
if not self._safe_to_emerge:
return False
if self.ships_remain():
self._ships_remaining[self._next_player] -= 1
fleets.append(Ship(u.CENTER))
fleets.append(Signal(self._next_player))
self._next_player = (self._next_player + 1) % len(self._ships_remaining)
else:
fleets.append(GameOver())
self._game_over = True
return True
def ships_remain(self):
return self._ships_remaining[self._next_player] > 0
Now extract the body:
def create_ship(self, fleets):
if not self._safe_to_emerge:
return False
if self.ships_remain():
self.rez_available_ship(fleets)
else:
fleets.append(GameOver())
self._game_over = True
return True
def rez_available_ship(self, fleets):
self._ships_remaining[self._next_player] -= 1
fleets.append(Ship(u.CENTER))
fleets.append(Signal(self._next_player))
self._next_player = (self._next_player + 1) % len(self._ships_remaining)
In this code, we know that there is at least one ship. We want to check next player first.
def rez_available_ship(self, fleets):
if self.ships_remaining(self._next_player) > 0:
self._ships_remaining[self._next_player] -= 1
fleets.append(Ship(u.CENTER))
fleets.append(Signal(self._next_player))
self._next_player = (self._next_player + 1) % len(self._ships_remaining)
else:
self._next_player = (self._next_player + 1) % len(self._ships_remaining)
self.rez_available_ship(fleets)
If next player has a ship, we rez it. If not, we update next player and call ourselves recursively. We are certain that we’ll get a ship, because there is one somewhere.
We need to change the check:
def ships_remain(self):
return sum(self._ships_remaining) > 0
I think this works and I know it needs a test. But I can’t resist trying it. And it works perfectly.
Let’s do write a test.
def test_unequal_ship_count(self):
fleets = Fleets()
fi = FI(fleets)
maker = ShipMaker(2)
maker._ships_remaining = [1, 3]
maker.create_ship(fleets)
signals = fi.signals
assert signals[0].signal == 0
This test, so far, will check that the creation has said to score player zero. Since player zero has one ship, that’s good. I need to add the signals
method to FI.
class FleetInspector:
@property
def signals(self):
return self.select_class(Signal)
Test is not running. I got no Signals. Oh, create has lots of conditions. Let’s check at a lower level.
def test_unequal_ship_count(self):
fleets = Fleets()
fi = FI(fleets)
maker = ShipMaker(2)
maker._ships_remaining = [1, 3]
assert maker.ships_remain()
maker.rez_available_ship(fleets)
signal = fi.signals[0]
assert signal.signal == 0
That’s green. We can now rez four ships, one for player zero and three for player one, but not five.
def test_unequal_ship_count(self):
fleets = Fleets()
fi = FI(fleets)
maker = ShipMaker(2)
maker._ships_remaining = [1, 3]
self.make_ship_for_player(0, fi, fleets, maker)
self.make_ship_for_player(1, fi, fleets, maker)
self.make_ship_for_player(1, fi, fleets, maker)
self.make_ship_for_player(1, fi, fleets, maker)
assert not maker.ships_remain()
@staticmethod
def make_ship_for_player(player, fi, fleets, maker):
assert maker.ships_remain()
maker.rez_available_ship(fleets)
signal = fi.signals[0]
assert signal.signal == player
fleets.remove(signal)
We check the signal to see which kind of ship was made, and we see one for player zero and three for player one, as intended.
Commit: two-player game works, does not yet flash score.
I honestly think that this is done, except that we want the score to flash or otherwise indicate whose turn it is. How about this:
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_rect = score_surface.get_rect(topleft=(x_position, 10))
return score_surface, score_rect
Now we get a bright green score and a dull gray one:
Now we just need a way to make it all happen. That will be in coin
.
coin.py
def two_player(fleets):
fleets.clear()
fleets.append(WaveMaker())
fleets.append(ShipMaker(2))
fleets.append(ScoreKeeper(1))
_append_common_elements(fleets)
And to call it, a new command:
class Game:
def accept_coins(self):
keys = pygame.key.get_pressed()
if keys[pygame.K_q]:
coin.quarter(self.fleets)
elif keys[pygame.K_a]:
coin.no_asteroids(self.fleets)
elif keys[pygame.K_2]:
coin.two_player(self.fleets)
Try that out. Works perfectly. Improve the help:
class GameOver(Flyer):
def init_game_over(self):
if not pygame.get_init():
return
big_font = pygame.font.SysFont("arial", 64)
small_font = pygame.font.SysFont("arial", 48)
self.game_over_surface = big_font.render("GAME OVER", True, "white")
self.game_over_pos = self.game_over_surface.get_rect(centerx=u.CENTER.x, centery=u.CENTER.y / 2)
pos_left = u.CENTER.x - 150
pos_top = self.game_over_pos.centery
self.help_lines = []
messages = ["d - turn left", "f - turn right", "j - accelerate", "k - fire missile", "q - insert quarter", "2 - two players"]
for message in messages:
pos_top += 60
text = small_font.render(message, True, "white")
text_rect = text.get_rect(topleft=(pos_left, pos_top))
pair = (text, text_rect)
self.help_lines.append(pair)
And:
And that should do it. Final commit including the new coin code: Two player game released, type 2 for two player game.
We’ll do a full review in a separate article but:
Summary
Without ever breaking the game, in a series o a dozen commits, we have implemented a two player game, which was not contemplated nor prepared for in any way. Our steps, roughly, were:
- Adjust ScoreKeeper to be able to display on both sides of the screen;
- Create a small Signal object holding a player number;
- Adjust ScoreKeeper to enable/disable based on Signal;
- Adjust ShipMaker to expect a player number when accessing ships remaining info;
- Add number of players parameter to ShipMaker;
- Provide a small list of ships available, one for each player;
- Use the table to decide which player gets next ship;
- Change color of ScoreKeeper to indicate active player.
One tiny new object, small changes to two others, and some new tests.
I think that speaks well for the decentralized design. It’s hard to imagine how it could have been easier no matter what the design was.
No, let me be more emphatic: This went delightfully easily and well!
We are pleased.
See you next time!