Python 79 - More Position
We’ll continue plugging in the MovableLocation, but I think it may need a better name.
We have this small class whose job, so far, is to calculate a new location given its current location, a velocity, and an amount of time, delta_time:
class MovableLocation:
def __init__(self, position, velocity, size=u.SCREEN_SIZE):
self.position = position
self.velocity = velocity
self.size = size
def move(self, delta_time):
position = self.position + self.velocity*delta_time
position.x = position.x % self.size
position.y = position.y % self.size
self.position = position
So far, we’ve only used it in Asteroid (and some tests):
class Asteroid(Flyer):
def __init__(self, size=2, position=None):
super().__init__()
self.size = size
if self.size not in [0, 1, 2]:
self.size = 2
self.radius = [16, 32, 64][self.size]
position = position if position is not None else Vector2(0, random.randrange(0, u.SCREEN_SIZE))
angle_of_travel = random.randint(0, 360)
velocity = u.ASTEROID_SPEED.rotate(angle_of_travel)
# v right down there :)
self.location = MovableLocation(position, velocity)
self.offset = Vector2(self.radius, self.radius)
self.surface = SurfaceMaker.asteroid_surface(self.radius * 2)
@property
def position(self):
return self.location.position
@position.setter
def position(self, position):
self.location.position = position
def move(self, delta_time, _asteroids):
self.location.move(delta_time)
There are some places where Asteroid wants to refer to its position, and that is done with two properties, a getter and a setter, shown above.
I don’t really like the thing’s name. It is too long. That’s not a typing issue, it’s more that it’s a simpler idea than its name connotes. I am thinking it should be named Mover or Mote or something. No better idea yet, and renaming isn’t a big deal in PyCharm, if we’re careful.
I think we can use ML, I’ll call it ML, in Missile very directly. Ship will require acceleration, and Saucer will be interesting because the saucer’s position doesn’t wrap around: it wants to disappear when it reaches x-zero or x-max, while it does wrap around on y. We’ll save that for last, you can be sure of that.
So Missile:
class Missile:
def __init__(self, position, velocity, missile_score_list, saucer_score_list):
self.score_list = missile_score_list
self.saucer_score_list = saucer_score_list
self.position = position.copy()
self.velocity = velocity.copy()
self.radius = 2
self.timer = Timer(u.MISSILE_LIFETIME, self.timeout)
I don’t think we need to copy the position and velocity but the concern is that if we were to modify them in place, whoever had passed them in to us might experience change. Python passes the actual object in a case like this. Anyway, we don’t much care here. We’ll just create our new ML, not saving p and v at all.
class Missile:
def __init__(self, position, velocity, missile_score_list, saucer_score_list):
self.score_list = missile_score_list
self.saucer_score_list = saucer_score_list
self.location = MovableLocation(position, velocity)
self.radius = 2
self.timer = Timer(u.MISSILE_LIFETIME, self.timeout)
22 tests fail. I’ll look at them for surprises, but I expect the issues will be around position
and/or velocity
not being accessible.
The first half-dozen fails are all about position
. Make a property:
@property
def position(self):
return self.location.position
17 pass, 5 still failing. What do they want? They want velocity
:
@property
def velocity(self):
return self.location.velocity
Green. I want to see the asteroids fly, run the game. It’s good that I did:
AttributeError: property 'position' of 'Missile' object has no setter
I might also mention that I forgot to plug my location into move:
def move(self, delta_time):
self.location.move(delta_time)
Green and good: Commit: Missile converted to use MovableLocation.
OK, let’s see about Ship.
class Ship(Flyer):
def __init__(self, position):
super().__init__()
self.position = position.copy()
self.velocity = Vector2(0, 0)
self.can_fire = True
self.radius = 25
self.angle = 0
self.acceleration = u.SHIP_ACCELERATION
self.accelerating = False
ship_scale = 4
ship_size = Vector2(14, 8)*ship_scale
self.ship_surface, self.ship_accelerating_surface = SurfaceMaker.ship_surfaces(ship_size)
OK, naively we would just replace the first two lines. Let’s explore a bit more deeply, see how acceleration is done, etc.
def accelerate_by(self, accel):
self.velocity = self.velocity + accel
I figure we can “just” forward that to the ML. We’ll test-drive in the method “of course”. Shall we do that now? Yes, let’s.
def test_acceleration(self):
position = Vector2(990, 990)
velocity = Vector2(100, 200)
mp = MovableLocation(position, velocity, 1000)
mp.accelerate_by(Vector2(15, 37))
assert mp.velocity == Vector2(115, 237)
class MovableLocation:
def accelerate_by(self, acceleration_vector):
self.velocity = self.velocity + acceleration_vector
Test green. Commit: MovableLocation has accelerate_by
.
Let’s go ahead and plug ML into Ship now.
class Ship(Flyer):
def __init__(self, position):
super().__init__()
self.location = MovableLocation(position, Vector2(0, 0))
self.can_fire = True
self.radius = 25
self.angle = 0
self.acceleration = u.SHIP_ACCELERATION
self.accelerating = False
ship_scale = 4
ship_size = Vector2(14, 8)*ship_scale
self.ship_surface, self.ship_accelerating_surface = SurfaceMaker.ship_surfaces(ship_size)
@property
def position(self):
return self.location.position
def accelerate_by(self, accel):
self.velocity = self.velocity + accel
def move(self, delta_time, _ships):
self.location.move(delta_time)
Four tests are still failing. Here’s one:
def test_ship_move(self):
ship = Ship(Vector2(50, 60))
ship.velocity = Vector2(10, 16)
ship.move(0.5, [ship])
assert ship.position == Vector2(55, 68)
Right, that won’t do. We need a setter for velocity, I guess.
@property
def velocity(self):
return self.location.velocity
@velocity.setter
def velocity(self, velocity):
self.location.velocity = velocity
Tests are green. Gotta try the game. Glad I did, I needed the setter for position, to rez the ship. We’re green and good. Commit: ship converted to MovableLocation.
Reflection
I haz a concern. I wish I had tests that drove out the need for these properties. Let me remove them and see what breaks.
It’s the scorekeeper:
File "/Users/ron/PycharmProjects/firstGo/game.py", line 79, in draw_available_ship
ship.position += Vector2(35, 0)
def draw_available_ships(self):
ship = Ship(Vector2(20, 100))
ship.angle = 90
for i in range(0, ShipFleet.ships_remaining):
self.draw_available_ship(ship)
def draw_available_ship(self, ship):
ship.position += Vector2(35, 0)
ship.draw(self.screen)
I could change this to create multiple ships. Seems costly, though we are already creating one. How about giving it a new location? I decide, for now, just to create one for each position:
def draw_available_ships(self):
for i in range(0, ShipFleet.ships_remaining):
self.draw_available_ship(i)
def draw_available_ship(self,i):
position = i*Vector2(35, 0)
ship = Ship(Vector2(55,100) + position)
ship.angle = 90
ship.draw(self.screen)
We can optimize that later if we care to. Now I don’t need the setter for position.
I’ll leave the velocity ones in. Commit: adjust draw_available_ships not to need position setter.
Shall we try to do Saucer? I guess so.
class Saucer:
direction = -1
saucer_surface = None
offset = None
@classmethod
def init_for_new_game(cls):
cls.direction = -1
def __init__(self, _position=None, size=2):
self.size = size
Saucer.direction = -Saucer.direction
x = 0 if Saucer.direction > 0 else u.SCREEN_SIZE
self.position = Vector2(x, random.randrange(0, u.SCREEN_SIZE))
self.velocity = Saucer.direction * u.SAUCER_VELOCITY
self.directions = (self.velocity.rotate(45), self.velocity, self.velocity, self.velocity.rotate(-45))
self.radius = 20
self.create_surface_class_members()
self.set_firing_timer()
self.set_zig_timer()
We see some things to be concerned about already, direction and the zig-zag thing. I believe that we create a new Saucer on every pass. Let’s drill down a bit deeper:
def set_zig_timer(self):
# noinspection PyAttributeOutsideInit
self.zig_timer = Timer(u.SAUCER_ZIG_TIME, self.zig_zag_action)
def zig_zag_action(self):
self.velocity = self.new_direction()
def move(self, delta_time, saucers):
self.position += delta_time * self.velocity
self.position.y = self.position.y % u.SCREEN_SIZE
x = self.position.x
if x < 0 or x > u.SCREEN_SIZE:
if self in saucers:
saucers.remove(self)
I think we can work through this directly. We can set velocity directly into the ML. We’ll think about whether we like that after we do it.
def move(self, delta_time, saucers):
off_x, off_y = self.location.move(delta_time)
if off_x:
if self in saucers:
saucers.remove(self)
I’ve decided that move
will return two booleans, true if the ML has wrapped around on the x or y directions. I have a test looping:
class MovableLocation:
def __init__(self, position, velocity, size=u.SCREEN_SIZE):
self.position = position
self.velocity = velocity
self.size = size
def move(self, delta_time):
position = self.position + self.velocity*delta_time
old_x = position.x
old_y = position.y
position.x = position.x % self.size
position.y = position.y % self.size
self.position = position
return position.x != old_x, position.y != old_y
I have ten tests failing. Nine of them work when I implement the position setter. One remains:
def test_vanish_at_edge(self):
Saucer.init_for_new_game()
saucer = Saucer()
saucers = [saucer]
assert saucer.position.x == 0
saucer.move(1, saucers)
assert saucers
while saucer.position.x < u.SCREEN_SIZE:
assert saucers
saucer.move(delta_time=0.1, saucers=saucers)
assert not saucers
This one was looping before I put in the check. It’s failing now on the assert saucers
, because we never can get a position equal to SCREEN_SIZE now.
What do we want to test? We want to test that when the saucer gets to the edge, it will destroy itself. Let’s see. How about if we iterate for some period of time and then expect saucers to be empty?
def test_vanish_at_edge(self):
Saucer.init_for_new_game()
saucer = Saucer()
saucers = [saucer]
assert saucer.position.x == 0
saucer.move(1, saucers)
assert saucers
time = 0
delta_time = 0.1
while time < 10:
time += delta_time
saucer.move(delta_time=delta_time, saucers=saucers)
assert not saucers
I’ll allow that. We’re green, and the game works as advertised.
Commit: Saucer uses MovableLocation.
Let’s assess.
Assessment
The new ML object has served us well in each of Asteroid, Missile, Ship, and Saucer, and the only difficulties we encountered were that we needed accessors to allow the objects to think in terms of position and velocity while having the work done in ML.
I’m a bit torn about those accessors. They’re perfectly sensible:
@property
def position(self):
return self.location.position
@position.setter
def position(self, position):
self.location.position = position
@property
def velocity(self):
return self.location.velocity
@velocity.setter
def velocity(self, velocity):
self.location.velocity = velocity
However we should look at them to see why they’re being made.
In the case of Saucer, a number of accesses are here:
def missile_at_angle(self, desired_angle, velocity_adjustment):
missile_velocity = Vector2(u.MISSILE_SPEED, 0).rotate(desired_angle) + velocity_adjustment
offset = Vector2(2 * self.radius, 0).rotate(desired_angle)
return Missile.from_saucer(self.position + offset, missile_velocity)
def angle_to(self, ship):
aiming_point = nearest_point(self.position, ship.position, u.SCREEN_SIZE)
angle_point = aiming_point - self.position
return degrees(atan2(angle_point.y, angle_point.x))
In the first case, we should probably change Missile.from_saucer
to accept a location, offset, and velocity. And probably we’d like a factory method on MovableLocation, ML.offset_by
, to provide the new location.
Or possibly, Missile.from_saucer
should be passed a Saucer and work from there.
In the second case, angle_to
, we might want some help for nearest_point
, although that is private to Saucer at the moment:
def nearest(shooter, target, size):
dist = abs(target - shooter)
t_min = target - size
t_min_dist = abs(t_min - shooter)
t_max = target + size
t_max_dist = abs(t_max - shooter)
if t_min_dist < dist:
return t_min
elif t_max_dist < dist:
return t_max
else:
return target
def nearest_point(shooter, target, size):
nearest_x = nearest(shooter.x, target.x, size)
nearest_y = nearest(shooter.y, target.y, size)
return Vector2(nearest_x, nearest_y)
I don’t even want to try to explain that just now. Reviewing Python -48, it appears that that nearest
function just sprang from my forehead in some magical way.
I think the names of the distances could be better and that might help. Something like direct_distance
, wrap_low_distance
, wrap_high_distance
, but we’re not here for that today.
Looking at MovableLocation, it seems that we should explore the various uses of access to its innards, and see how we might limit those by providing useful methods on ML itself. I would guess that Collider could use a distance function on ML rather than fetching position from the objects, reaching down to ML.
But even now, we have gained a small improvement, removing the duplicate code from our four flyer classes and isolating it in MovableLocation.
We do have the interesting fact that it returns two booleans from ‘move
, only one of which is ever used, once. But if it’s going to return info about going off screen across x, it seems it should do the same for y.
Which reminds me, we should do a test for that. I’m glad we had this little chat.
def test_wrap_booleans(self):
position = Vector2(990, 990)
velocity = Vector2(100, 50)
mp = MovableLocation(position, velocity, 1000)
off_x, off_y = mp.move(0.05)
assert not off_x
assert not off_y
off_x, off_y = mp.move(0.05)
assert off_x
assert not off_y
off_x, off_y = mp.move(0.1)
assert not off_x
assert off_y
Commit: Test boolean returns from ‘move’.
That pleases me.
Summary
The little ML object offloads motion and acceleration from the flyer classes, reducing a bit of duplication and centralizing the slightly complex logic of wrapping around and such.
Creating a new ship each time we display the available ones seems a bit messy. I’ve made a sticky note to optimize that. Might be worth it.
I am pleased with the cleverness of allowing it to return those status flags, but would freely grand that it’s a bit odd. I hope it’s not too clever.
I think it will be able to do more for us. We use position in drawing and in collisions. The velocity gets changed in various places. And tests need access. It would be nice if we could get access to those members moved out of the flyers. We’ll see over time whether that’s practical.
For today, a decent result. See you next time!