Python 171 - Two Players
Yesterday, an idea. Today, an experiment. Your intrepid author is optimistic. Things go very nicely.
- The Story
- Modify the game so that two players can play, alternating turns, with separate scores on screen for each.
- The Tentative Plan
- In two-player mode, rez1 two ScoreKeeper instances, giving them a member value that tells them whether they should display on the left or right.
-
Enhance ShipMaker to maintain two ship counts instead of its current one. When ShipMaker creates a ship, it will alternate decremeting the first count, then the second, and so on. And, when it creates a ship, it will also create a new object (ScoreKeeperSignal?) indicating which player number owns the ship.
-
ScoreKeepers will interact with the Signal. If the Signal matches the Keeper’s saved value, that Keeper will accumulate subsequent Score instances and if not, it will not. This will turn on whichever ScoreKeeper matches the current Ship, and turn off the other.
- Implementation Concerns
- Our practice here chez Ron2 is always to work at Head, always to commit to Head, never to branch the repo … and to proceed in small steps. But it is possible that this idea may not work. I think it will; I’m really rather certain that it will; but it’s possible that it won’t.
-
I don’t want to work uncommitted for a long period of time. So what I think I have to do is to work first on the bits that are more or less guaranteed to be OK, such as making ScoreKeeper able to display on both sides. New objects, like the Signal, will be safe to implement, because so long as we never rez one in the game, they will be harmless.
-
Changes to existing objects, especially to ShipMaker, will need to be made very carefully, so that until the feature can be turned on, everything is still working in single player mode.
Let’s get started.
Spike to display score on right
First, I’ll just fudge the ScoreKeeper locally, moving the display over to the right unti it’s where I think it should go. I’m basically just trying to get the x coordinate.
It’s easy to see what has to be fixed:
class ScoreKeeper(Flyer):
def render_score(self):
score_text = f"0000{self.score}"[-5:]
score_surface = self.score_font.render(score_text, True, "green")
score_rect = score_surface.get_rect(topleft=(10, 10))
return score_surface, score_rect
When we change the topleft
x coordinate, the score moves over. There is also the matter of the ship display:
def draw_available_ships(self, screen):
for i in range(0, self._ship_maker.ships_remaining):
self.draw_available_ship(self.available_ship, i, screen)
def draw_available_ship(self, ship, i, screen):
position = i * Vector2(35, 0)
ship.move_to(Vector2(55, 100) + position)
ship.draw(screen)
We draw the ships left to right. If Player One were to earn lots of ships, they would tail off to the right and there’d be no problem. Ideally, with Player Two, we’d draw them right to left so that her ships will never tail off screen.
ScoreKeeper has no construction parameters. Let’s give it one parameter, a player number. And, despite my desire to call them Player One and Player Two, I think we’ll have them be zero and one.
def __init__(self, player_number=0):
self.score = 0
self._fence = u.FREE_SHIP_SCORE
self._player_number = player_number
self._ship_maker = NoShips()
if pygame.get_init():
self.score_font = pygame.font.SysFont("arial", 48)
In order to adjust the positioning, I want to display the score on both sides at once, to judge the spacing. So I’ll be adding two ScoreKeepers for a while here, until I get the numbers worked out.
First the text position:
def render_score(self):
x_position = [10, 850][self._player_number]
score_text = f"0000{self.score}"[-5:]
score_surface = self.score_font.render(score_text, True, "green")
score_rect = score_surface.get_rect(topleft=(x_position, 10))
return score_surface, score_rect
I’m guessing at 850. It should be close.
Now in the coins:
def _append_common_elements(fleets):
fleets.append(SaucerMaker())
fleets.append(ScoreKeeper(0))
fleets.append(ScoreKeeper(1))
fleets.append(Thumper())
I expect to see two scores on the screen. And I do:
With a little tweaking, I accept 875 as a decent right coordinate. We’ll have another picture soon. Let’s do the available ships.
def draw_available_ship(self, ship, i, screen):
position = i * Vector2(35, 0)
ship.move_to(Vector2(55, 100) + position)
ship.draw(screen)
We’ll want to set position to be negative if we are ship #1. And where should x start? Again, I’ll just adjust until I like it.
def draw_available_ship(self, ship, i, screen):
x_position = [55, u.SCREEN_SIZE - 55][self._player_number]
position = i * Vector2(35, 0)
if self._player_number == 1:
position = - position
ship.move_to(Vector2(x_position, 100) + position)
ship.draw(screen)
Got it right the first time!
We could certainly improve those magic numbers, but for now, I think this is good. We can commit ScoreKeeper: can display on left or right of screen depending on player_number construction parameter.
Reflection
Note that we have committed the right-side display feature to HEAD. Origin, Main, whatever you kids call it. It’s released. It’s just that no released object ever sets player_number
to one.
Back to it
Now let’s do our Signal object. I don’t even see much point to TDDing it: it will have no behavior. It’s a flyer, so we let PyCharm implement the necessary methods. And I observe that it does have a little behavior: it removes itself. But it has no conditional behavior, so I’m still not going to test it.
I have two broken tests and I think I’m pleased.
I have to add Signal to the list of known classes:
@staticmethod
def all_known_flyer_subclasses():
return {
Asteroid, BeginChecker, EndChecker,
Fragment, GameOver, Missile, Saucer, SaucerMaker,
Score, ScoreKeeper, Ship, ShipMaker, Signal, Thumper, WaveMaker}
And the other test tells me that I didn’t implement interact_with
. That would be bad. And that’s going to tell me that I need to provide interact_with_signal
up in Flyer.
class Signal(Flyer):
def interact_with(self, other, fleets):
other.interact_with_signal(self, fleets)
class Flyer(ABC):
def interact_with_signal(self, signal, fleets):
pass
We are green. Commit: Signal(Flyer) object created. Unused.
Now then. If we were to create a Signal(1) and put it in the mix, we would like the right-hand ScoreKeeper to start keeping score and the left hand one to stop.
I think we’ll try it. Let’s first upgrade ScoreKeeper to check and honor the signal.
class ScoreKeeper(Flyer):
def __init__(self, player_number=0):
self.score = 0
self._fence = u.FREE_SHIP_SCORE
self._player_number = player_number
self._scoring = True
self._ship_maker = NoShips()
if pygame.get_init():
self.score_font = pygame.font.SysFont("arial", 48)
def interact_with_signal(self, signal, fleets):
self._scoring = signal.signal == self._player_number
def interact_with_score(self, score, fleets):
if self._scoring:
self.score += score.score
if self.score >= self._fence:
self._ship_maker.add_ship()
self._fence += u.FREE_SHIP_SCORE
I don’t like to init _scoring
to True but it breaks tests if I don’t. Let’s try adding two keepers and a signal to the standard mix, to test this out.
I’m starting to want some programmed tests for this.
With this in place:
def _append_common_elements(fleets):
fleets.append(SaucerMaker())
fleets.append(ScoreKeeper(0))
fleets.append(ScoreKeeper(1))
fleets.append(Signal(1))
fleets.append(Thumper())
The ship’s score appears only on the right. However, the ship count goes down on both sides. How does that get decremented? Ah. ScoreKeeper observes the ShipMaker and does this:
def draw_available_ships(self, screen):
for i in range(0, self._ship_maker.ships_remaining):
self.draw_available_ship(self.available_ship, i, screen)
We haven’t changed ShipMaker to deal with two players yet. Since ScoreKeeper will know its player_number, we can change it to ask for ships_remaining(self.player_number)
and ShipMaker will certainly know that.
We can commit our change to ScoreKeeper, but I think we’d do better to init _scoring
properly.
Wait, some tests are breaking. Why? Oh, just that the coin tests aren’t expecting a Signal in the setup. Remove that line and they’re happy. But let’s do the init this way:
def __init__(self, player_number=0):
self.score = 0
self._fence = u.FREE_SHIP_SCORE
self._player_number = player_number
self._scoring = player_number == 0
self._ship_maker = NoShips()
if pygame.get_init():
self.score_font = pygame.font.SysFont("arial", 48)
Now the zero ScoreKeeper, the left one, starts out scoring and the right one does not. Let’s commit: ScoreKeeper honors Signal, only scores if signal matches player_number.
Let’s take a break and reflect.
Reflection
So far so good. We have a new Signal object, and we have ScoreKeepers listening only when they get the Signal that matches their player number. The Player Zero keeper scores on the left side of the screen, and the Player One on the right.
We do not have any tests for this and I think we should.
Let’s build it and see if they come.
class TestMultiPlayer():
def test_signal(self):
assert Signal(0).signal == 0
assert Signal(1).signal == 1
Not too clever, just getting the ball rolling.
Let’s do some interaction tests.
def test_keeper_scoring_inits_properly(self):
assert ScoreKeeper(0)._scoring
assert not ScoreKeeper(1)._scoring
And see if it switches:
def test_keeper_switches_scoring(self):
k0 = ScoreKeeper(0)
k1 = ScoreKeeper(1)
assert k0._scoring
assert not k1._scoring
s1 = Signal(1)
k0.interact_with_signal(s1, [])
k1.interact_with_signal(s1, [])
assert not k0._scoring
assert k1._scoring
s0 = Signal(0)
k0.interact_with_signal(s0, [])
k1.interact_with_signal(s0, [])
assert k0._scoring
assert not k1._scoring
I rather like those tests, not least because they all pass.
- Nota Bene:
- There is absolutely no reason why I could not have written those first. Would it have been better if I had done? Difficult to say. Certainly wouldn’t have been worse.
Let’s commit those tests: tests for ScoreKeeper-Signal interaction.
The good news is that we have a place to put tests now, and with any luck it will attract more as we move on.
Shall we move on? I’ve been working 90 minutes, so a break would be OK, and the article is up to 300 lines of input. Let’s think about what we need to do next: it might be wise to stop.
Next Steps
With ScoreKeeper and Signal apparently ready, it remains to enhance ShipMaker. The changes are fairly substantial, something like this:
- Can be created to manage one, or two, players.
- Keep available_ships count for both players.
- New method available_ships[player_number].
- When ship is to be created, alternate player_number, emitting Signal(player_number) as well as Ship.
Possible Small Steps
- make
ships_remaining
private in ShipMaker, provide ships_remaining(player_number). - make
_ships_remaining
a list of two values, link upships_remaining
. - init a member
_player_number
to zero, increment it after rezzing a ship and Signal, looping it back to zero of course. - make another
coin
that sets us up for two players.
Hmm, if I did that, I could release coin, which is presently held back. But not now, I am tired and dangerous.
I think that with a clear head, these steps can be done safely and without breaking the game. And I think I’d be wise to stop now, because I am tired enough that I will start making mistakes.
We’ll work on this further soon. Maybe today, maybe not.
It’s going well. Everything is committed except my experimental coin
, and the game continues to work just as it should.
See you soon!
-
The word “rez” first appeared in Tron, as far as I know, referring to creating an object. It is commonly used in Second Life with the same meaning. And here. ↩
-
Although “rez” is pronounced as in “says”, “chez”, as you surely know, is pronounced like “shay”, which I used to have a beautiful one-horse version of, until one day it collapsed. ↩