Ice Hockey
Original Python code
import pgzero, pgzrun, pygame
import math, sys, random
from enum import Enum
from pygame.math import Vector2
# Check Python version number. sys.version_info gives version as a tuple, e.g. if (3,7,2,'final',0) for version 3.7.2.
# Unlike many languages, Python can compare two tuples in the same way that you can compare numbers.
if sys.version_info < (3,5):
print("This game requires at least version 3.5 of Python. Please download it from www.python.org")
sys.exit()
# Check Pygame Zero version. This is a bit trickier because Pygame Zero only lets us get its version number as a string.
# So we have to split the string into a list, using '.' as the character to split on. We convert each element of the
# version number into an integer - but only if the string contains numbers and nothing else, because it's possible for
# a component of the version to contain letters as well as numbers (e.g. '2.0.dev0')
# We're using a Python feature called list comprehension - this is explained in the Bubble Bobble/Cavern chapter.
pgzero_version = [int(s) if s.isnumeric() else s for s in pgzero.__version__.split('.')]
if pgzero_version < [1,2]:
print("This game requires at least version 1.2 of Pygame Zero. You have version {0}. Please upgrade using the command 'pip3 install --upgrade pgzero'".format(pgzero.__version__))
sys.exit()
WIDTH = 800
HEIGHT = 480
TITLE = "Ice Hockey"
HALF_WINDOW_W = WIDTH / 2
# Size of level, including both the pitch and the boundary surrounding it
LEVEL_W = 1000
LEVEL_H = 1400
HALF_LEVEL_W = LEVEL_W // 2
HALF_LEVEL_H = LEVEL_H // 2
HALF_PITCH_W = 280
HALF_PITCH_H = 622
GOAL_WIDTH = 50
GOAL_DEPTH = 20
HALF_GOAL_W = GOAL_WIDTH // 2
PITCH_BOUNDS_X = (HALF_LEVEL_W - HALF_PITCH_W, HALF_LEVEL_W + HALF_PITCH_W)
PITCH_BOUNDS_Y = (HALF_LEVEL_H - HALF_PITCH_H, HALF_LEVEL_H + HALF_PITCH_H)
GOAL_BOUNDS_X = (HALF_LEVEL_W - HALF_GOAL_W, HALF_LEVEL_W + HALF_GOAL_W)
GOAL_BOUNDS_Y = (HALF_LEVEL_H - 520 - GOAL_DEPTH,
HALF_LEVEL_H + 520 + GOAL_DEPTH)
PITCH_RECT = pygame.rect.Rect(PITCH_BOUNDS_X[0], PITCH_BOUNDS_Y[0], HALF_PITCH_W * 2, HALF_PITCH_H * 2)
GOAL_0_RECT = pygame.rect.Rect(GOAL_BOUNDS_X[0], GOAL_BOUNDS_Y[0], GOAL_WIDTH, GOAL_DEPTH)
GOAL_1_RECT = pygame.rect.Rect(GOAL_BOUNDS_X[0], GOAL_BOUNDS_Y[1] - GOAL_DEPTH, GOAL_WIDTH, GOAL_DEPTH)
AI_MIN_X = HALF_LEVEL_W - HALF_PITCH_W + GOAL_DEPTH
AI_MAX_X = LEVEL_W - AI_MIN_X
AI_MIN_Y = HALF_LEVEL_H - HALF_PITCH_H + GOAL_DEPTH
AI_MAX_Y = LEVEL_H - AI_MIN_Y
PLAYER_START_POS = [(350, 550), (650, 450), (250, 850), (500, 750), (750, 950), (350, 1250), (650, 1150)]
LEAD_DISTANCE_1 = 10
LEAD_DISTANCE_2 = 50
DRIBBLE_DIST_X, DRIBBLE_DIST_Y = 18, 16
# Speeds for players in various situations. Speeds including 'BASE' can be boosted by the speed_boost difficulty
# setting (only for players on a computer-controlled team)
PLAYER_DEFAULT_SPEED = 2
CPU_PLAYER_WITH_BALL_BASE_SPEED = 2.6
PLAYER_INTERCEPT_BALL_SPEED = 2.75
LEAD_PLAYER_BASE_SPEED = 2.9
HUMAN_PLAYER_WITH_BALL_SPEED = 3
HUMAN_PLAYER_WITHOUT_BALL_SPEED = 3.3
DEBUG_SHOW_LEADS = False
DEBUG_SHOW_TARGETS = False
DEBUG_SHOW_PEERS = False
DEBUG_SHOW_SHOOT_TARGET = False
DEBUG_SHOW_COSTS = False
class Difficulty:
def __init__(self, goalie_enabled, second_lead_enabled, speed_boost, holdoff_timer):
self.goalie_enabled = goalie_enabled
# When a player has the ball, either one or two players will be chosen from the other team to try to intercept
# the ball owner. Those players will have their 'lead' attributes set to a number indicating how far ahead of the
# ball they should try to run. (If they tried to go to where the ball is currently, they'd always trail behind)
# This attribute determines whether there should be one or two lead players
self.second_lead_enabled = second_lead_enabled
# Speed boost to apply to CPU-team players in certain circumstances
self.speed_boost = speed_boost
# Hold-off timer limits rate at which computer-controlled players can pass the ball
self.holdoff_timer = holdoff_timer
DIFFICULTY = [Difficulty(False, False, 0, 120), Difficulty(False, True, 0.1, 90), Difficulty(True, True, 0.2, 60)]
# Custom sine/cosine functions for angles of 0 to 7, where 0 is up,
# 1 is up+right, 2 is right, etc.
def sin(x):
return math.sin(x*math.pi/4)
def cos(x):
return sin(x+2)
# Convert a vector to an angle in the range 0 to 7
def vec_to_angle(vec):
# todo explain a bit
# https://gamedev.stackexchange.com/questions/14602/what-are-atan-and-atan2-used-for-in-games
return int(4 * math.atan2(vec.x, -vec.y) / math.pi + 8.5) % 8
# Convert an angle in the range 0 to 7 to a direction vector. We use -cos rather than cos as increasing angles move
# in a clockwise rather than the usual anti-clockwise direction.
def angle_to_vec(angle):
return Vector2(sin(angle), -cos(angle))
# Used when calling functions such as sorted and min.
# todo explain more
# p.vpos - pos results in a Vector2 which we can get the length of, giving us
# the distance between pos and p.vpos
def dist_key(pos):
return lambda p: (p.vpos - pos).length()
# Turn a vector into a unit vector - i.e. a vector with length 1
# We also return the original length, before normalisation.
# We check for zero length, as trying to normalise a zero-length vector results in an error
def safe_normalise(vec):
length = vec.length()
if length == 0:
return Vector2(0,0), 0
else:
return vec.normalize(), length
# The MyActor class extends Pygame Zero's Actor class by providing the attribute 'vpos', which stores the object's
# current position using Pygame's Vector2 class. All code should change or read the position via vpos, as opposed to
# Actor's x/y or pos attributes. When the object is drawn, we set self.pos (equivalent to setting both self.x and
# self.y) based on vpos, but taking scrolling into account.
class MyActor(Actor):
def __init__(self, img, x=0, y=0, anchor=None):
super().__init__(img, (0, 0), anchor=anchor)
self.vpos = Vector2(x, y)
# We draw with the supplied offset to enable scrolling
def draw(self, offset_x, offset_y):
# Set Actor's screen pos
self.pos = (self.vpos.x - offset_x, self.vpos.y - offset_y)
super().draw()
# Ball physics model parameters
KICK_STRENGTH = 11.5
DRAG = 0.98
# ball physics for one axis
def ball_physics(pos, vel, bounds):
# Add velocity to position
pos += vel
# Check if ball is out of bounds, and bounce if so
if pos < bounds[0] or pos > bounds[1]:
pos, vel = pos - vel, -vel
# Return new position and velocity, applying drag
return pos, vel * DRAG
# Work out number of physics steps for ball to travel given distance
def steps(distance):
# Initialize step count and initial velocity
steps, vel = 0, KICK_STRENGTH
# Run physics until distance reached or ball is nearly stopped
while distance > 0 and vel > 0.25:
distance, steps, vel = distance - vel, steps + 1, vel * DRAG
return steps
class Goal(MyActor):
def __init__(self, team):
x = HALF_LEVEL_W
y = GOAL_0_RECT.bottom if team == 0 else GOAL_1_RECT.top
anchor = 'bottom' if team == 0 else 'top'
super().__init__("ice_hockey_goal" + str(team), x, y, ('center', anchor))
self.team = team
def active(self):
# Is ball within 500 pixels on the Y axis?
return abs(game.ball.vpos.y - self.vpos.y) < 500
# Calculate if player 'target' is a good target for a pass from player 'source'
# target can also be a goal
def targetable(target, source):
# Find normalised (unit) vector v0 and distance d0 from source to target
v0, d0 = safe_normalise(target.vpos - source.vpos)
# If source player is on a computer-controlled team, avoid passes which are likely to be intercepted
# (If source is player-controlled, that's the player's job)
if not game.teams[source.team].human():
# For each player p
for p in game.players:
# Find normalised vector v1 and distance d1 from source to p
v1, d1 = safe_normalise(p.vpos - source.vpos)
# If p is on the other team, and between source and target, and at a similiar
# angular position, target is not a good target
# Multiplying two vectors together invokes an operation known as dot product. It is calculated by
# multiplying the X components of each vector, then multiplying the Y components, then adding the two
# resulting numbers. When each of the input vectors is a unit vector (i.e. with a length of 1, as returned
# from the safe_normalise function), the result of which is a number between -1 and 1. In this case we use
# the result to determine whether player 'p' (vector v1) is in roughly the same direction as player 'target'
# (vector v0), from the point of view of player 'source'.
if p.team != target.team and d1 > 0 and d1 < d0 and v0*v1 > 0.8:
return False
# If target is on the same team, and ahead of source, and not too far away, and source is facing
# approximately towards target (another dot product operation), then target is a good target.
# The dot product operation (multiplying two unit vectors) is used to determine whether (and to what extent) the
# source player is facing towards the target player. A value of 1 means target is directly ahead of source; -1
# means they are directly behind; 0 means they are directly to the left or right.
# See above for more explanation of dot product
return target.team == source.team and d0 > 0 and d0 < 300 and v0 * angle_to_vec(source.dir) > 0.8
# Get average of two numbers; if the difference between the two is less than 1,
# snap to the second number. Used in Ball.update()
def avg(a, b):
return b if abs(b-a) < 1 else (a+b)/2
def on_pitch(x, y):
# Only used when dribbling
return PITCH_RECT.collidepoint(x,y) \
or GOAL_0_RECT.collidepoint(x,y) \
or GOAL_1_RECT.collidepoint(x,y)
class Ball(MyActor):
def __init__(self):
super().__init__("ice_hockey_puck", HALF_LEVEL_W, HALF_LEVEL_H)
# Velocity
self.vel = Vector2(0, 0)
self.owner = None
self.timer = 0
self.shadow = MyActor("balls")
# Check for collision with player p
def collide(self, p):
# The ball collides with p if p's hold-off timer has expired
# and it is DRIBBLE_DIST_X or fewer pixels away
return p.timer < 0 and (p.vpos - self.vpos).length() <= DRIBBLE_DIST_X
def is_in_goal(self):
"""Return True if this ball instance is in 1 of the 2 goals."""
return (GOAL_0_RECT.collidepoint(self.vpos.x, self.vpos.y) or
GOAL_1_RECT.collidepoint(self.vpos.x, self.vpos.y))
def update(self):
self.timer -= 1
# If the ball has an owner, it's being dribbled, so its position is
# based on its owner's position
if self.owner:
# Calculate new ball position for dribbling
# Our target position will be a point just ahead of our owner. However, we don't want to just snap to that
# position straight away. We want to transition to it over several frames, so we take the average of our
# current position and the target position. We also use slightly different offsets for the X and Y axes,
# to reflect that that the game's perspective is not completely top-down - so the positions the ball can
# take in relation to the player should form an ellipse instead of a circle.
# todo explain maths
new_x = avg(self.vpos.x, self.owner.vpos.x + DRIBBLE_DIST_X * sin(self.owner.dir))
new_y = avg(self.vpos.y, self.owner.vpos.y - DRIBBLE_DIST_Y * cos(self.owner.dir))
if on_pitch(new_x, new_y):
# New position is on the pitch, so update
self.vpos = Vector2(new_x, new_y)
else:
# New position is off the pitch, so player loses the ball
# Set hold-off timer so player can't immediately reacquire the ball
self.owner.timer = 60
# Give ball small velocity in player's direction of travel
self.vel = angle_to_vec(self.owner.dir) * 3
# Un-set owner
self.owner = None
else:
# Run physics, one axis at a time
# If ball is vertically inside the goal, it can only go as far as the
# sides of the goal - otherwise it can go all the way to the sides of
# the pitch
# If ball is horizontally inside the goal, it can go all the way to
# the back of the net - otherwise it can only go up to the end of
# the pitch
if self.is_in_goal():
bounds_x = GOAL_BOUNDS_X
bounds_y = GOAL_BOUNDS_Y
else:
bounds_x = PITCH_BOUNDS_X
bounds_y = PITCH_BOUNDS_Y
self.vpos.x, self.vel.x = ball_physics(self.vpos.x, self.vel.x, bounds_x)
self.vpos.y, self.vel.y = ball_physics(self.vpos.y, self.vel.y, bounds_y)
# Update shadow position to track ball
self.shadow.vpos = Vector2(self.vpos)
# Search for a player that can acquire the ball
for target in game.players:
# A player can acquire the ball if the ball has no owner, or the player is on the other team
# from the owner, and collides with the ball
if (not self.owner or self.owner.team != target.team) and self.collide(target):
if self.owner:
# New player is taking the ball from previous owner
# Set hold-off timer so previous owner can't immediately reacquire the ball
self.owner.timer = 60
# Set hold-off timer (dependent on difficulty) to limit rate at which
# computer-controlled players can pass the ball
self.timer = game.difficulty.holdoff_timer
# Update owner, and controllable player for player's team, to player
game.teams[target.team].active_control_player = self.owner = target
# If the ball has an owner, it's time to decide whether to kick it
if self.owner:
team = game.teams[self.owner.team]
# Find the closest targetable player or goal (could be None)
# First we create a list of all players/goals which can be targeted
targetable_players = [p for p in game.players + game.goals if p.team == self.owner.team and targetable(p, self.owner)]
if len(targetable_players) > 0:
# Choose the nearest one
# dist_key returns a function which gets the distance of the ball owner from whichever player or goal (p)
# the sorted function is currently assessing
target = min(targetable_players, key=dist_key(self.owner.vpos))
game.debug_shoot_target = target.vpos
else:
target = None
if team.human():
# If the owner is player-controlled, we kick if the player hits their kick key
do_shoot = team.controls.shoot()
else:
# If the owner is computer-controlled, we kick if the ball's hold-off timer has expired
# and there is a targetable player or goal, and the targetable player or goal is in a more
# favourable location (according to cost()) than the owner's location
do_shoot = self.timer <= 0 and target and cost(target.vpos, self.owner.team) < cost(self.owner.vpos, self.owner.team)
if do_shoot:
# play a random kick effect
game.play_sound("kick", 4)
if target:
# If there is a targetable player or goal, kick towards it
# If the owner is player-controlled, we assume the player will continue to hold the same direction
# keys down after the pass, so the target will start moving in the same direction as the
# current owner; on this assumption, we will kick the ball slightly ahead of the target player's
# current position, through a process of iterative refinement
# If the owner is computer-controlled, or the target is a goal, we only execute the loop once and
# so do not apply lead, as there are no keys being held down and goals don't move.
r = 0
# Decide how many times we're going to go through the loop - the more times, the more accurate
iterations = 8 if team.human() and isinstance(target, Player) else 1
for i in range(iterations):
# In the first loop, t will simply be the position of the targeted player or goal.
# In subsequent loops (if there are any), it will represent a position which is at the
# target's feet plus a bit further in whichever direction the player is currently pressing.
t = target.vpos + angle_to_vec(self.owner.dir) * r
# Get direction vector and distance between target pos and us
vec, length = safe_normalise(t - self.vpos)
# The steps function works out the number of physics steps the ball will take to travel
# the given distance
# todo r
r = HUMAN_PLAYER_WITHOUT_BALL_SPEED * steps(length)
else:
# We're not targeting a player or goal, so just kick the ball straight ahead
# Get direction vector
vec = angle_to_vec(self.owner.dir)
# Make a rough guess at which player the ball might end up closest to so, we can set them as the new
# active player. Pick a point 250 pixels ahead and find the nearest player to that.
target = min([p for p in game.players if p.team == self.owner.team],
key=dist_key(self.vpos + (vec * 250)))
if isinstance(target, Player):
# If we just kicked the ball towards a player, make that player the new active player for this team
game.teams[self.owner.team].active_control_player = target
self.owner.timer = 10 # Owner can't regain the ball for at least 10 frames
# Set velocity
self.vel = vec * KICK_STRENGTH
# We no longer have an owner
self.owner = None
# Return True if the given position is inside the level area, otherwise False
# Takes the goals into account so you can't run through them
def allow_movement(x, y):
if abs(x - HALF_LEVEL_W) > HALF_LEVEL_W:
# Trying to walk off the left or right side of the level
return False
else:
# Player is outside the bounds of the goals on the X axis, so they can walk off the pitch and to the edge
# of the level
return abs(y - HALF_LEVEL_H) < HALF_LEVEL_H
# Generate a score for a given position, where lower numbers are considered to be better.
# This is called when a computer-controlled player with the ball is working out which direction to run in, or whether
# to pass the ball to another player, or kick it into the goal.
# Several things make up the final score:
# - the distance to our own goal - further away is better
# - the proximity of players on the other team - we want to get the ball away from them as much as possible
# - a quadratic equation (don't panic too much!) causing the player to favour the centre of the pitch and their opponents goal
# - an optional handicap value which can bias the result towards or away from a particular position
def cost(pos, team, handicap=0):
# Get pos of our own goal. We do it this way rather than getting the pos of the actual goal object
# because this way gives us the pos of the goal's entrance, whereas the actual goal sprites are not anchored based
# on the entrances.
own_goal_pos = Vector2(HALF_LEVEL_W, (AI_MIN_Y - GOAL_DEPTH) if team == 1 else (AI_MAX_Y + GOAL_DEPTH))
inverse_own_goal_distance = 3500 / (pos - own_goal_pos).length()
result = inverse_own_goal_distance \
+ sum([4000 / max(24, (p.vpos - pos).length()) for p in game.players if p.team != team]) \
+ ((pos.x - HALF_LEVEL_W)**2 / 200 \
- pos.y * (4 * team - 2)) \
+ handicap
return result, pos
class Player(MyActor):
ANCHOR = (25,37)
def __init__(self, x, y, team):
# Player objects are recreated each time there is a kickoff
# Team will be 0 or 1
# The x and y values supplied represent our 'home' position - the place we'll return to by default when not near
# the ball. However, on creation, we want players to be in their kickoff positions, which means all players from
# team 0 will be below the halfway line, and players from team 1 above. The player chosen to actually do the
# kickoff is moved to be alongside the centre spot after the player objects have been created.
# Calculate our initial position for kickoff by halving y, adding 550 and then subtracting either 400 for
# team 1, or nothing for team 0
kickoff_y = (y / 2) + 550 - (team * 400)
# Call the constructor of the parent class (MyActor)
super().__init__("blank", x, kickoff_y, Player.ANCHOR)
# Remember home position, where we'll stand by default if we're not active (i.e. far from the ball)
self.home = Vector2(x, y)
# Store team
self.team = team
# Facing direction: 0 = up, 1 = top right, up to 7 = top left
self.dir = 0
# Animation frame
self.anim_frame = -1
self.timer = 0
self.shadow = MyActor("blank", 0, 0, Player.ANCHOR)
# Used when DEBUG_SHOW_TARGETS is on
self.debug_target = Vector2(0, 0)
def active(self):
# Is ball within 400 pixels on the Y axis? If so I'll be considered active, meaning I'm currently doing
# something useful in the game like trying to get the ball. If I'm not active, I'll either mark another player,
# or just stay at my home position
return abs(game.ball.vpos.y - self.home.y) < 400
def update(self):
# decrement holdoff timer
self.timer -= 1
# One of the main jobs of this method is to decide where the player will run to, and at what speed.
# The default is to run slowly towards home position, but target and speed may be overwritten in the code below
target = Vector2(self.home) # Take a copy of home position
speed = PLAYER_DEFAULT_SPEED
# Some shorthand variables to make the code below a bit easier to follow
my_team = game.teams[self.team]
pre_kickoff = game.kickoff_player != None
i_am_kickoff_player = self == game.kickoff_player
ball = game.ball
if self == game.teams[self.team].active_control_player and my_team.human() and (not pre_kickoff or i_am_kickoff_player):
# This player is the currently active player for its team, and is player-controlled, and either we're not
# currently waiting for kickoff, or this player is the designated kickoff player.
# The last part of the condition ensures that in a 2 player game, player 2 can't make their active player
# run around while waiting for player 1 to do the kickoff (and vice versa)
# A player with the ball runs slightly more slowly than one without
if ball.owner == self:
speed = HUMAN_PLAYER_WITH_BALL_SPEED
else:
speed = HUMAN_PLAYER_WITHOUT_BALL_SPEED
# Find target by calling the controller for the player's team todo comment
target = self.vpos + my_team.controls.move(speed)
elif ball.owner != None:
# Someone has the ball - is it me?
if ball.owner == self:
# We are the owner, and are computer-controlled (otherwise we would have taken the other arm
# of the top-level if statement)
# Evaluate five positions (left 90, left 45, ahead, right 45, right 90)
# target is the one with the lowest value of cost()
# List comprehension steps through the angles: -2 to 2, where 0 is up, 1 is up & right, etc
# For each angle 'd', we call the cost function with a position, which is 3 pixels from the
# current position, if the player were to move in the direction of d. We also pass cost() our team number.
# The last parameter, abs(d), introduces a tendency for the player to continue running forward. Try
# multiplying it by 3 or 4 to see what happens!
# First, create a list of costs for each of the 5 tested positions - a lower number is better. Each
# element is a tuple containing the cost and the position that cost relates to.
costs = [cost(self.vpos + angle_to_vec(self.dir + d) * 3, self.team, abs(d)) for d in range(-2, 3)]
# Then choose the element with the lowest cost. We use min() to find the element with the lowest value.
# min uses < to compare pairs of elements. Each element of costs is a tuple with two elements (a cost
# value and the target position). When comparing a pair of tuples using <, Python first compares the
# first element of each tuple. If they're different, that's what determines which tuple is considered to
# have a lower value. If they're the same, Python moves on to looking at the next element. However, this
# can lead to a crash in this case as the target position is an instance of the Vector2 class, which
# does not support comparisons using <. In practice it's rare for two positions to have the same cost
# value, but it's nevertheless prudent to eliminate the risk. The solution we chosen is to use the
# optional 'key' parameter for min, telling the function to only use the first element of each tuple
# for the comparisons.
# When min finds the tuple with the minimum cost value, we extract the target pos (which is what we
# actually care about) and discard the actual cost value - hence the '_' dummy variable
_, target = min(costs, key=lambda element: element[0])
# speed depends on difficulty
speed = CPU_PLAYER_WITH_BALL_BASE_SPEED + game.difficulty.speed_boost
elif ball.owner.team == self.team:
# Ball is owned by another player on our team
if self.active():
# If I'm near enough to the ball, try to run somewhere useful, and unique to this player - we
# don't want all players running to the same place. Target is halfway between home and a point
# 400 pixels ahead of the ball. Team 0 are trying to score in the goal at the top of the
# pitch, team 1 the goal at the bottom
direction = -1 if self.team == 0 else 1
target.x = (ball.vpos.x + target.x) / 2
target.y = (ball.vpos.y + 400 * direction + target.y) / 2
# If we're not active, we'll do the default action of moving towards our home position
else:
# Ball is owned by a player on the opposite team
if self.lead is not None:
# We are one of the players chosen to pursue the owner
# Target a position in front of the ball's owner, the distance based on the value of lead, while
# making sure we keep just inside the pitch
target = ball.owner.vpos + angle_to_vec(ball.owner.dir) * self.lead
# Stay on the pitch
target.x = max(AI_MIN_X, min(AI_MAX_X, target.x))
target.y = max(AI_MIN_Y, min(AI_MAX_Y, target.y))
other_team = 1 if self.team == 0 else 0
speed = LEAD_PLAYER_BASE_SPEED
if game.teams[other_team].human():
speed += game.difficulty.speed_boost
elif self.mark.active():
# The player or goal we've been chosen to mark is active
if my_team.human():
# If I'm on a human team, just run towards the ball.
# We don't do the marking behaviour below for human teams for a number of reasons. Try changing
# the code to see how the game feels when marking behaviour applies to both human and computer
# teams.
target = Vector2(ball.vpos)
else:
# Get vector between the ball and whatever we're marking
vec, length = safe_normalise(ball.vpos - self.mark.vpos)
# Alter length to choose a position in between the ball and whatever we're marking
# We don't apply this behaviour for human teams - in that case we just run straight at the ball
if isinstance(self.mark, Goal):
# If I'm currently the goalie, get in between the ball and goal, and don't get too far
# from the goal
length = min(150, length)
else:
# Otherwise, just get halfway between the ball and whoever I'm marking
length /= 2
target = self.mark.vpos + vec * length
else:
# No-one has the ball
# If we're pre-kickoff and I'm the kickoff player, OR if we're not pre-kickoff and I'm active
if (pre_kickoff and i_am_kickoff_player) or (not pre_kickoff and self.active()):
# Try to intercept the ball
# Deciding where to go to achieve this is harder than you might think. You can't target the ball's
# current location, because (assuming it's moving) by the time you get there it'll have moved on, so
# you'll always be trailing behind it. And you can't target where it's going to end up after rolling to
# a halt, because you might end up getting there before it and just be standing around waiting for it to
# get there. What we want to do is find a target which allows us to intercept the ball along its path in
# the minimum possible time and distance.
# The code below simulates the ball's movement over a series of frames, working out where it would be
# after each frame. We also work out how far the player could have moved at each frame, and whether
# that distance would be enough to reach the currently simulated location of the ball.
target = Vector2(ball.vpos) # current simulated location of ball
vel = Vector2(ball.vel) # ball velocity - slows down each frame due to friction
frame = 0
# DRIBBLE_DIST_X is the distance at which a player can gain control of the ball.
# vel.length() > 0.5 ensures we don't keep simulating frames for longer than necessary - once the ball
# is moving that slowly, it's not going to move much further, so there's no point in simulating dozens
# more frames of very tiny movements. If you experience a decreased frame rate when no one has the ball,
# try increasing 0.5 to a higher number.
while (target - self.vpos).length() > PLAYER_INTERCEPT_BALL_SPEED * frame + DRIBBLE_DIST_X and vel.length() > 0.5:
target += vel
vel *= DRAG
frame += 1
speed = PLAYER_INTERCEPT_BALL_SPEED
elif pre_kickoff:
# Waiting for kick-off, but we're not the kickoff player
# Just stay where we are. Without this we'd run to our home position, but that is different from
# our position at kickoff (where all players are on their team's side of the pitch)
target.y = self.vpos.y
# Get direction vector and distance beteen current pos and target pos
# vec[0] and vec[1] will be the x and y components of the vector
vec, distance = safe_normalise(target - self.vpos)
self.debug_target = Vector2(target)
# Check to see if we're already at the target position
if distance > 0:
# Limit movement to our max speed
distance = min(distance, speed)
# Set facing direction based on the direction we're moving
target_dir = vec_to_angle(vec)
# Update the x and y components of the player's position - but don't allow them to go off the edge of the
# level. Processing the x and y components separately allows the player to slide along the edge when trying
# to move diagonally off the edge of the level.
if allow_movement(self.vpos.x + vec.x * distance, self.vpos.y):
self.vpos.x += vec.x * distance
if allow_movement(self.vpos.x, self.vpos.y + vec.y * distance):
self.vpos.y += vec.y * distance
# todo
self.anim_frame = (self.anim_frame + max(distance, 1.5)) % 72
else:
# Already at target position - just turn to face the ball
target_dir = vec_to_angle(ball.vpos - self.vpos)
self.anim_frame = -1
# Update facing direction - each frame, move one step towards the target direction
# This code essentially says that if the target direction is the same as the current direction, there should
# be no change; if target is between 1 and 4 steps clockwise from current, we should rotate one step clockwise,
# and if it's between 1 and 3 steps anticlockwise (which can also be thought of as 5 to 7 steps clockwise), we
# should rotate one step anticlockwise - which is equivalent to stepping 7 steps clockwise
dir_diff = (target_dir - self.dir)
self.dir = (self.dir + [0, 1, 1, 1, 1, 7, 7, 7][dir_diff % 8]) % 8
suffix = str(self.dir) + str((int(self.anim_frame) // 18) + 1) # todo
self.image = "player" + str(self.team) + suffix
self.shadow.image = "players" + suffix
# Update shadow position to track player
self.shadow.vpos = Vector2(self.vpos)
class Team:
def __init__(self, controls):
self.controls = controls
self.active_control_player = None
self.score = 0
def human(self):
return self.controls != None
class Game:
def __init__(self, p1_controls=None, p2_controls=None, difficulty=2):
self.teams = [Team(p1_controls), Team(p2_controls)]
self.difficulty = DIFFICULTY[difficulty]
try:
if self.teams[0].human():
# Beginning a game with at least 1 human player
music.fadeout(1)
sounds.crowd.play(-1)
sounds.start.play()
else:
# No players - we must be on the menu. Play title music.
music.play("theme")
sounds.crowd.stop()
except Exception:
# Ignore sound errors
pass
self.score_timer = 0
self.scoring_team = 1 # Which team has just scored - also governs who kicks off next
self.reset()
def reset(self):
# Called at game start, and after a goal has been scored
# Set up players list/positions
# The lambda function is used to give the player start positions a slight random offset so they're not
# perfectly aligned to their starting spots
self.players = []
random_offset = lambda x: x + random.randint(-32, 32)
for pos in PLAYER_START_POS:
# pos is a pair of coordinates in a tuple
# For each entry in pos, create one player for each team - positions are flipped (both horizontally and
# vertically) versions of each other
self.players.append(Player(random_offset(pos[0]), random_offset(pos[1]), 0))
self.players.append(Player(random_offset(LEVEL_W - pos[0]), random_offset(LEVEL_H - pos[1]), 1))
# Players in the list are stored in an alternating fashion - a team 0 player, then a team 1 player, and so on.
# The peer for each player is the opposing team player at the opposite end of the list. As there are 14 players
# in total, the peers are 0 and 13, 1 and 12, 2 and 11, and so on.
for a, b in zip(self.players, self.players[::-1]):
a.peer = b
# Create two goals
self.goals = [Goal(i) for i in range(2)]
# The current active player under control by each team, indicated by arrows over their heads
# Choose first two players to begin with
self.teams[0].active_control_player = self.players[0]
self.teams[1].active_control_player = self.players[1]
# If team 1 just scored (or if it's the start of the game), team 0 will kick off
other_team = 1 if self.scoring_team == 0 else 0
# Players are stored in the players list in an alternating fashion - the first player being on team 0, the
# second on team 1, the third on team 0 etc. The player that kicks off will always be the first player of
# the relevant team.
self.kickoff_player = self.players[other_team]
# Set pos of kickoff player. A team 0 player will stand to the left of the ball, team 1 on the right
self.kickoff_player.vpos = Vector2(HALF_LEVEL_W - 30 + other_team * 60, HALF_LEVEL_H)
# Create ball
self.ball = Ball()
# Focus camera on ball - copy ball pos
self.camera_focus = Vector2(self.ball.vpos)
self.debug_shoot_target = None
def update(self):
self.score_timer -= 1
if self.score_timer == 0:
# Reset for new kick-off after goal scored
self.reset()
elif self.score_timer < 0 and self.ball.is_in_goal():
game.play_sound("goal", 2)
self.scoring_team = 0 if self.ball.vpos.y < HALF_LEVEL_H else 1
self.teams[self.scoring_team].score += 1
self.score_timer = 60 # Game goes into "scored a goal" state for 60 frames
# Each frame, reset mark and lead of each player
for b in self.players:
b.mark = b.peer
b.lead = None
b.debug_target = None
# Reset debug shoot target
self.debug_shoot_target = None
if self.ball.owner:
# Ball has an owner (above is equivalent to s.ball.owner != None, or s.ball.owner is not None)
# Assign some shorthand variables
o = self.ball.owner
pos, team = o.vpos, o.team
owners_target_goal = game.goals[team]
other_team = 1 if team == 0 else 0
if self.difficulty.goalie_enabled:
# Find the nearest opposing team player to the goal, and make them mark the goal
nearest = min([p for p in self.players if p.team != team], key = dist_key(owners_target_goal.vpos))
# Set the ball owner's peer to mark whoever the goalie was marking, then set the goalie to mark the goal
o.peer.mark = nearest.mark
nearest.mark = owners_target_goal
# Choose one or two lead players to spearhead the attack on the ball owner
# Create a list of players who are on the opposite team from the ball owner, are allowed to acquire
# the ball (their hold-off timer must not be positive), are not currently being controlled by a human,
# and are not currently assigned to be the goalie. The list is sorted based on distance from the ball owner.
l = sorted([p for p in self.players
if p.team != team
and p.timer <= 0
and (not self.teams[other_team].human() or p != self.teams[other_team].active_control_player)
and not isinstance(p.mark, Goal)],
key = dist_key(pos))
# a is a list of players from l who are upfield of the ball owner (i.e. towards our own goal, away from the
# direction of the goal the ball owner is trying to score in). b is all the other players. It's possible for
# one of these to be empty, as there might not be any players in the relevant direction.
a = [p for p in l if (p.vpos.y > pos.y if team == 0 else p.vpos.y < pos.y)]
b = [p for p in l if p not in a]
# Zip a and b together in an alternating fashion. Why do we add NONE2 (i.e. [None,None]) to each list?
# Because the zip function stops when there are no more items in one of the lists. We want our final list
# to contain at least 2 elements. Adding NONE2 (i.e. [None,None] as defined near the top) ensures that each
# list has at least 2 items. But we don't want any values in the final list to be None, hence the final part
# of the list comprehension 'for s in t if s', which discards any None values from the final result
NONE2 = [None] * 2
zipped = [s for t in zip(a+NONE2, b+NONE2) for s in t if s]
# Either one or two players (depending on difficulty settings) follow the ball owner, one from up-field and
# one from down-field of the owner
zipped[0].lead = LEAD_DISTANCE_1
if self.difficulty.second_lead_enabled:
zipped[1].lead = LEAD_DISTANCE_2
# If the ball has an owner, kick-off must have taken place, so unset the kickoff player
# Of course, kick-off might have already taken place a while ago, in which case kick-off_player will already
# be None, and will remain None
self.kickoff_player = None
# Update all players and ball
for obj in self.players + [self.ball]:
obj.update()
owner = self.ball.owner
for team_num in range(2):
team_obj = self.teams[team_num]
# Manual player switching when space is pressed
if team_obj.human() and team_obj.controls.shoot():
# Find nearest player to the ball on our team
# If the ball has an owner (who must be on the other team because if not, control would have
# automatically switched to the ball owner and we wouldn't need to manually switch), we weight the
# choice in favour of players who are upfield (towards our goal), since such players may be better
# placed to intercept the ball owner.
# The function dist_key_weighted is equivalent to the dist_key function earlier in the code, but with
# this weighting added. We use this function as the key for the min function, which will choose
# the player who results in the lowest value when passed as an argument to dist_key_weighted.
def dist_key_weighted(p):
dist_to_ball = (p.vpos - self.ball.vpos).length()
# Thonny gives a warning about the following line, relating to closures (an advanced topic), but
# in this case there is not actually a problem as the closure is only called within the loop
goal_dir = (2 * team_num - 1)
if owner and (p.vpos.y - self.ball.vpos.y) * goal_dir < 0:
return dist_to_ball / 2
else:
return dist_to_ball
self.teams[team_num].active_control_player = min([p for p in game.players if p.team == team_num],
key = dist_key_weighted)
# Get vector between current camera pos and ball pos
camera_ball_vec, distance = safe_normalise(self.camera_focus - self.ball.vpos)
if distance > 0:
# Move camera towards ball, at no more than 8 pixels per frame
self.camera_focus -= camera_ball_vec * min(distance, 8)
def draw(self):
# For the purpose of scrolling, all objects will be drawn with these offsets
offset_x = max(0, min(LEVEL_W - WIDTH, self.camera_focus.x - WIDTH / 2))
offset_y = max(0, min(LEVEL_H - HEIGHT, self.camera_focus.y - HEIGHT / 2))
offset = Vector2(offset_x, offset_y)
screen.blit("ice_hockey_rink", (-offset_x, -offset_y))
# Prepare to draw all objects
# 1. Create a list of all players and the ball, sorted based on their Y positions
# 2. Add object shadows to the list
# 3. Add the two goals at each end of the list
# (note - technically we're not adding items to the list in steps two and three, we're creating a new list
# which consists of the old list plus the new items)
objects = sorted([self.ball] + self.players, key = lambda obj: obj.y)
objects = objects + [obj.shadow for obj in objects]
objects = [self.goals[0]] + objects + [self.goals[1]]
# Draw all objects
for obj in objects:
obj.draw(offset_x, offset_y)
# Show active players
for t in range(2):
# Only show arrow for human teams
if self.teams[t].human():
arrow_pos = self.teams[t].active_control_player.vpos - offset - Vector2(11, 45)
screen.blit("arrow" + str(t), arrow_pos)
if DEBUG_SHOW_LEADS:
for p in self.players:
if game.ball.owner and p.lead:
line_start = game.ball.owner.vpos - offset
line_end = p.vpos - offset
pygame.draw.line(screen.surface, (0,0,0), line_start, line_end)
if DEBUG_SHOW_TARGETS:
for p in self.players:
line_start = p.debug_target - offset
line_end = p.vpos - offset
pygame.draw.line(screen.surface, (255,0,0), line_start, line_end)
if DEBUG_SHOW_PEERS:
for p in self.players:
line_start = p.peer.vpos - offset
line_end = p.vpos - offset
pygame.draw.line(screen.surface, (0,0,255), line_start, line_end)
if DEBUG_SHOW_SHOOT_TARGET:
if self.debug_shoot_target and self.ball.owner:
line_start = self.ball.owner.vpos - offset
line_end = self.debug_shoot_target - offset
pygame.draw.line(screen.surface, (255,0,255), line_start, line_end)
if DEBUG_SHOW_COSTS and self.ball.owner:
for x in range(0,LEVEL_W,60):
for y in range(0, LEVEL_H, 26):
c = cost(Vector2(x,y), self.ball.owner.team)[0]
screen_pos = Vector2(x,y)-offset
screen_pos = (screen_pos.x,screen_pos.y) # draw.text can't reliably take a Vector2
screen.draw.text("{0:.0f}".format(c), center=screen_pos)
def play_sound(self, name, c):
# Only play sounds if we're not in the menu state
if state != State.MENU:
try:
getattr(sounds, name+str(random.randint(0, c-1))).play()
except:
# Ignore sound errors
pass
# Dictionary to keep track of which keys are currently being held down
key_status = {}
# Was the given key just pressed? (i.e. is it currently down, but wasn't down on the previous frame?)
def key_just_pressed(key):
result = False
# Get key's previous status from the key_status dictionary. The dictionary.get method allows us to check for a given
# entry without giving an error if that entry is not present in the dictionary. False is the default value returned
# when the key is not present.
prev_status = key_status.get(key, False)
# If the key wasn't previously being pressed, but it is now, we're going to return True
if not prev_status and keyboard[key]:
result = True
# Before we return, we need to update the key's entry in the key_status dictionary (or create an entry if there
# wasn't one already
key_status[key] = keyboard[key]
return result
class Controls:
def __init__(self, player_num):
if player_num == 0:
self.key_up = keys.UP
self.key_down = keys.DOWN
self.key_left = keys.LEFT
self.key_right = keys.RIGHT
self.key_shoot = keys.SPACE
else:
self.key_up = keys.W
self.key_down = keys.S
self.key_left = keys.A
self.key_right = keys.D
self.key_shoot = keys.LSHIFT
def move(self, speed):
# Return vector representing amount of movement that should occur
dx, dy = 0, 0
if keyboard[self.key_left]:
dx = -1
elif keyboard[self.key_right]:
dx = 1
if keyboard[self.key_up]:
dy = -1
elif keyboard[self.key_down]:
dy = 1
return Vector2(dx, dy) * speed
def shoot(self):
return key_just_pressed(self.key_shoot)
# Pygame Zero calls the update and draw functions each frame
class State(Enum):
MENU = 0
PLAY = 1
GAME_OVER = 2
class MenuState(Enum):
NUM_PLAYERS = 0
DIFFICULTY = 1
def update():
global state, game, menu_state, menu_num_players, menu_difficulty
if state == State.MENU:
if key_just_pressed(keys.SPACE):
if menu_state == MenuState.NUM_PLAYERS:
# If we're doing a 2 player game, skip difficulty selection
if menu_num_players == 1:
menu_state = MenuState.DIFFICULTY
else:
# Start 2P game
state = State.PLAY
menu_state = None
game = Game(Controls(0), Controls(1))
else:
# Start 1P game
state = State.PLAY
menu_state = None
game = Game(Controls(0), None, menu_difficulty)
else:
# Detect + act on up/down arrow keys
selection_change = 0
if key_just_pressed(keys.DOWN):
selection_change = 1
elif key_just_pressed(keys.UP):
selection_change = -1
if selection_change != 0:
try:
sounds.move.play()
except Exception:
# Ignore sound errors
pass
if menu_state == MenuState.NUM_PLAYERS:
menu_num_players = 2 if menu_num_players == 1 else 1
else:
menu_difficulty = (menu_difficulty + selection_change) % 3
game.update()
elif state == State.PLAY:
# First player to 9 wins
if max([team.score for team in game.teams]) == 9 and game.score_timer == 1:
state = State.GAME_OVER
else:
game.update()
elif state == State.GAME_OVER:
if key_just_pressed(keys.SPACE):
# Switch to menu state, and create a new game object without a player
state = State.MENU
menu_state = MenuState.NUM_PLAYERS
game = Game()
def draw():
game.draw()
if state == State.MENU:
# Draw title screen and menu
# There are 5 menu images numbered 01, 02, 10, 11 and 12.
# 01 and 02 are the images for indicating whether 1 or 2 player mode
# is selected; 10, 11 and 12 are for the difficulty selection screen -
# easy, medium or hard
if menu_state == MenuState.NUM_PLAYERS:
image = "menu0" + str(menu_num_players)
else:
image = "menu1" + str(menu_difficulty)
screen.blit(image, (0, 0))
screen.draw.line((190, 50), (610, 225), (255,0,0))
screen.draw.line((610, 50), (190, 225), (255,0,0))
screen.draw.filled_rect(Rect((200, 75), (400, 125)), (255,255,255))
screen.draw.text(TITLE, center=(400, 135), color=(0,0,0), fontsize=64)
elif state == State.PLAY:
# Display score bar at top
screen.blit("bar", (HALF_WINDOW_W - 176, 0))
# Show score for each team
for i in range(2):
screen.blit("s" + str(game.teams[i].score), (HALF_WINDOW_W + 7 - 39 * i, 6))
# Show GOAL image if a goal has recently been scored
if game.score_timer > 0:
screen.blit("goal", (HALF_WINDOW_W - 300, HEIGHT / 2 - 88))
elif state == State.GAME_OVER:
# Display "Game Over" image
img = "over" + str(int(game.teams[1].score > game.teams[0].score))
screen.blit(img, (0, 0))
# Show score for each team
for i in range(2):
img = "l" + str(i) + str(game.teams[i].score)
screen.blit(img, (HALF_WINDOW_W + 25 - 125 * i, 144))
# Set up sound system
try:
pygame.mixer.quit()
pygame.mixer.init(44100, -16, 2, 1024)
except Exception:
# Ignore sound errors
pass
# Set the initial game state
state = State.MENU
# Menu state
menu_state = MenuState.NUM_PLAYERS
menu_num_players = 1
menu_difficulty = 0
# Create a new Game object
game = Game()
pgzrun.go()