Python 195 - Features?
Somehow I am enjoying working on, refining, polishing this Asteroids game. I don’t want to stop. I have at least two silly features in mind. And, are there still lilies to gild? We discuss inheritance. Basically done?
- C.W.
- Lots of thinking and speculation in here, no code changes, but some code displayed to show why no code changes were found. Scan freely, or go about your other business. I just write ‘em, I don’t demand that you read ‘em.
-
But there are at least three valuable ideas in here. Probably.
My two feature ideas include:
- A moving star field in the background.
- The asteroids could rotate at random speeds.
- What would a 3D Asteroids game be like?
- Do the Gilded Rose in Python?
- [Your idea here. Toot1 me up!]
As for lily gilding, we’ll take a look at some of the larger classes. But first, a word from our sponsor, Implementation Inheritance. Here’s a snippet of code from ScoreKeeper, which happens to be open on my screen just now:
def interact_with_asteroid(self, asteroid, fleets):
pass
def interact_with_missile(self, missile, fleets):
pass
def interact_with_saucer(self, saucer, fleets):
pass
def interact_with_ship(self, ship, fleets):
pass
There’s a dozen lines that aren’t as interesting as they might be.
I wonder how many occurrences there are of something like that.
There are 92 occurrences of pass
in 18 files. Flyer has a bunch but that may be OK, it’s defining the protocol. Asteroid has 2. Fragment has 4, GameOver 5, Saucer 2, SaucerMaker 4, Score 6, ScoreKeeper 6, Ship 1, ShipMaker 1, ShipProvider 4, Signal 5, and there are a bunch in tests.
Those are not all in interact_with_something
. Some are legitimate implementations in null objects. Some are other “events” that the object ignores, such as draw
or tick
.
Well, since the game’s fundamental notion is that everyone is sent interact_with_whatever
for every kind of object in the mix, a case can be made that, at least for the real Flyers, asteroid, missile, ship, and saucer, every class should be required to say explicitly what it does with those classes. And that’s why they are listed as abstract methods in Flyer, to enforce that rule. The interact
methods for all the non-flying Flyers are non-abstract and thus optional.
There’s a trade-off here. We gain safety, some error prevention, by making those methods abstract, requiring Flyers all to implement them explicitly. And we suffer some loss of code clarity, because those blank methods are in our face when we review a class, taking up screen space and head space.
Rely on Duck Typing
In Python, we have duck typing2, so in principle, we need not have all our objects inheriting from the Flyer interface. We could root them separately, and in their superclass root, implement all those methods as pass
, removing all that null code from the working classes, who just yearn for a living wage and a bit of freedom excuse me I got off track.
Dispatch Table
Another possibility would be to steal a page from our Kotlin Asteroids, where we had a small dispatch table that each object was required to provide, with that table listing all the events that it needed to handle. That notion was created by GeePaw Hill, who had objected to my use of implementation inheritance, and devised the scheme in an attempt to show me a way to write near-minimal code without implementation inheritance.
That scheme actually had the same issue as we’d have here if we made those methods non-abstract: if the programmer forgot to put an event in the table, the object wouldn’t respond to that event, and there would be no warning about it anywhere. Here, if a ship just happened not to respond to interact_with_missile
, the Saucer could not shoot it down. A pleasant outcome but not what the makers intended. With the abstract methods, I am required to explicitly deal with ship-missile interactions.
Delegation
A similar possibility would be to defer the interact_with_thing
handling to a separate object. Suppose that each object had to implement interactor
, which must return an object that handles all the interact_with_widget
methods. We could implement that trivially, as
class Gubbin(Flyer):
@property
def interactor(self):
return self
Then, at our leisure, as if we had any, we could implement an interactor class and anyone who wanted one could return it. And, perhaps, it would have a constructor like this:
class Thingummy(Flyer):
@property
def interactor(self):
return Interactor({
`interact_with_ship`, self.interact_with_ship,
`interact_with_missile, self.interact_with_missile
})
Or something like that, whatever it took to hook it up, and then (a miracle occurs) and the high-level interaction code looks up the interaction in the dictionary and if it is there, calls it and otherwise just skips out.
Essentially, we’d be implementing method dispatching using a dictionary, which would be slower and otherwise not impressive. But it would have the advantage of a compact form for defining interact_with_doodad
methods all in one place.
That would be a sort of middle ground, saving source code space, but at the cost of time and perhaps even a bit of clarity while everyone asks “what the hell is going on here”.
Meh, not interesting.
Enough speculation. None of these ideas seems much better than leaving it alone. Of all of them, the most tempting is to allow more implementation inheritance than we already have, at least for the non-flying Flyers.
That phrase does suggest that there’s a missing notion in our hierarchy. We’ll ignore that, for now, as well.
Reviewing some classes
In a desperate attempt to find something to refactor, I look at a few classes. I find things like Missile. Look at this, with its small methods and not many of them:
class Missile(Flyer):
radius = 2
def __init__(self, transponder_key, position, velocity):
self._transponder = Transponder(transponder_key)
self._timer = Timer(u.MISSILE_LIFETIME)
self._location = MovableLocation(position, velocity)
@property
def position(self):
return self._location.position
@property
def velocity_testing_only(self):
return self._location.velocity
def are_we_colliding(self, position, radius):
kill_range = self.radius + radius
dist = self.position.distance_to(position)
return dist <= kill_range
def interact_with(self, attacker, fleets):
attacker.interact_with_missile(self, fleets)
def interact_with_asteroid(self, asteroid, fleets):
if asteroid.are_we_colliding(self.position, self.radius):
self.die(fleets)
def interact_with_missile(self, missile, fleets):
if missile.are_we_colliding(self.position, self.radius):
self.die(fleets)
def interact_with_saucer(self, saucer, fleets):
if saucer.are_we_colliding(self.position, self.radius):
self.die(fleets)
def interact_with_ship(self, ship, fleets):
if ship.are_we_colliding(self.position, self.radius):
self.die(fleets)
def die(self, fleets):
fleets.remove(self)
def draw(self, screen):
pygame.draw.circle(screen, "white", self.position, 4)
def ping_transponder(self, transponder_key, function, *args):
self._transponder.ping(transponder_key, function, *args)
def tick(self, delta_time, fleets):
self.tick_timer(delta_time, fleets)
def tick_timer(self, delta_time, fleets):
self._timer.tick(delta_time, self.die, fleets)
def update(self, delta_time, _fleets):
self._location.move(delta_time)
There isn’t much to dislike there, is there?
The largest class is Ship, at 188 lines. It’s not bad. I’ve done a bit of tidying and alphabetized the methods, which is interesting in a couple of ways:
First, alphabetizing sometimes breaks up some chunks that might belong together, such as update
:
def update(self, delta_time, fleets):
self.control_motion(delta_time, fleets)
self._location.move(delta_time)
self.control_firing(fleets)
It might be nicer to have those two control
methods near update
. Additionally, that suggests that there may be some chunk of responsibility that could be broken out. We’ll think about that.
Second, the need to alphabetize or otherwise organize things is a hint that the class may be too large. I’ll paste it below now, and you can scan over it down to the next thrilling paragraph.
# Ship
from explosion import Explosion
from flyer import Flyer
from hyperspace_generator import HyperspaceGenerator
from missile import Missile
from movable_location import MovableLocation
from painter import Painter
from pygame import Vector2
from sounds import player
from timer import Timer
import pygame
import random
import u
class Ship(Flyer):
thrust_sound = None
def __init__(self, position, drop_in=2):
self.radius = 25 * u.SCALE_FACTOR
self._accelerating = False
self._acceleration = u.SHIP_ACCELERATION
self._allow_freebie = True
self._angle = 0
self._asteroid_tally = 0
self._can_fire = True
self._drop_in = drop_in
self._hyperspace_generator = HyperspaceGenerator(self)
self._hyperspace_timer = Timer(u.SHIP_HYPERSPACE_RECHARGE_TIME)
self._location = MovableLocation(position, Vector2(0, 0))
self._missile_tally = 0
self._shipmaker = None
self._ship_painter = Painter.ship()
self._accelerating_painter = Painter.ship_accelerating()
@property
def position(self):
return self._location.position
@property
def velocity_testing_only(self):
return self._location.velocity
@property
def velocity(self):
return self._location.velocity
@velocity.setter
def velocity(self, value):
self._location.velocity = value
@velocity_testing_only.setter
def velocity_testing_only(self, velocity):
self._location.velocity = velocity
def accelerate_by(self, accel):
self._location.accelerate_by(accel)
def accelerate_to(self, accel):
self._location.accelerate_to(accel)
def are_we_colliding(self, position, radius):
kill_range = self.radius + radius
dist = self.position.distance_to(position)
return dist <= kill_range
def begin_interactions(self, fleets):
self._asteroid_tally = 0
self._missile_tally = 0
def control_motion(self, delta_time, fleets):
if not pygame.get_init():
return
keys = pygame.key.get_pressed()
if keys[pygame.K_s]:
if self._allow_freebie and self._shipmaker:
self._shipmaker.add_ship(u.PLAYER_ZERO)
self._allow_freebie = False
else:
self._allow_freebie = True
if keys[pygame.K_f]:
self.turn_left(delta_time)
if keys[pygame.K_d]:
self.turn_right(delta_time)
if keys[pygame.K_j]:
self.power_on(delta_time)
else:
self.power_off()
if keys[pygame.K_SPACE]:
self._hyperspace_generator.press_button(self._asteroid_tally, fleets)
else:
self._hyperspace_generator.lift_button()
def control_firing(self, fleets):
if not pygame.get_init():
return
if pygame.key.get_pressed()[pygame.K_k]:
self.fire_if_possible(fleets)
else:
self._can_fire = True
def create_missile(self):
player.play("fire", self._location)
return Missile("ship", self.missile_start(), self.missile_velocity())
def draw(self, screen):
self.select_ship_source.draw(screen, self.position, self._angle, self._drop_in)
def explode(self, fleets):
player.play("bang_large", self._location)
fleets.remove(self)
Explosion.from_ship(self.position, fleets)
def explode_if_hit(self, attacker, fleets):
if attacker.are_we_colliding(self.position, self.radius):
self.explode(fleets)
def fire_if_possible(self, fleets):
if self._can_fire and self._missile_tally < u.MISSILE_LIMIT:
fleets.append(self.create_missile())
self._can_fire = False
def increment_tally(self):
self._missile_tally += 1
def interact_with(self, attacker, fleets):
attacker.interact_with_ship(self, fleets)
def interact_with_asteroid(self, asteroid, fleets):
self._asteroid_tally += 1
self.explode_if_hit(asteroid, fleets)
def interact_with_missile(self, missile, fleets):
missile.ping_transponder("ship", self.increment_tally)
self.explode_if_hit(missile, fleets)
def interact_with_saucer(self, saucer, fleets):
self.explode_if_hit(saucer, fleets)
def interact_with_ship(self, ship, fleets):
pass
def interact_with_shipmaker(self, shipmaker, fleets):
self._shipmaker = shipmaker
def missile_start(self):
start_distance = self.radius + Missile.radius + 1
offset = Vector2(start_distance, 0).rotate(-self._angle)
return self.position + offset
def missile_velocity(self):
return Vector2(u.MISSILE_SPEED, 0).rotate(-self._angle) + self.velocity_testing_only
def move_to(self, vector):
self._location.move_to(vector)
def power_on(self, dt):
self._accelerating = True
player.play("accelerate", self._location, False)
accel = dt * self._acceleration.rotate(-self._angle)
self.accelerate_by(accel)
def power_off(self):
self._accelerating = False
@property
def select_ship_source(self):
if self._accelerating and random.random() >= 0.66:
return self._accelerating_painter
else:
return self._ship_painter
def tick(self, delta_time, fleets):
self._drop_in = self._drop_in - delta_time*2 if self._drop_in > 1 else 1
self._hyperspace_generator.tick(delta_time)
def turn_left(self, dt):
self._angle -= u.SHIP_ROTATION_STEP * dt
def turn_right(self, dt):
self._angle += u.SHIP_ROTATION_STEP * dt
def update(self, delta_time, fleets):
self.control_motion(delta_time, fleets)
self._location.move(delta_time)
self.control_firing(fleets)
Huge, right? But aside from the control
stuff, the methods are all quite small and seem to make sense. I don’t see much to worry about here, although it might be tempting to try to extract something around the notion of controlling the ship.
- Hey!!
- I found something!
thrust_sound
is not used. Whee, down to 186 lines!
There almost seem to be phases in that code, some kind of logical sections: controlling, interacting, firing, … but nothing jumps out at me as really needing a separate object.
Bottom line, unless we’re going to make a design change of some major kind, I honestly feel like we have polished this about as far as any irrational3 person might go.
We could do some little features, like the star field or spinning asteroids, but really? I’m afraid this thing is done.
See you next time!
-
That doesn’t sound obscene at all, does it? Ron Jeffries at mastodon dot social ↩
-
You’ve got your mallard, your canvasback, your spotted, plumed, or fulvous whistling duck … no, really, duck typing is a property of languages like Python where you don’t need to specify interfaces. If the object responds to a method by name, you can send that message to it. This is a very powerful, very sharp tool. Some love it, some hate it. ↩
-
A rational person in a real situation might have stopped long ago. We have kept exploring as an example of what can be discovered in perfectly reasonable code, and how to improve it if we care to, all in small safe steps. ↩