1127 lines
53 KiB
Python
1127 lines
53 KiB
Python
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 = "Substitute Soccer"
|
|
|
|
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 = 442
|
|
HALF_PITCH_H = 622
|
|
|
|
GOAL_WIDTH = 186
|
|
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 - HALF_PITCH_H - GOAL_DEPTH,
|
|
HALF_LEVEL_H + HALF_PITCH_H + 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 = 78
|
|
AI_MAX_X = LEVEL_W - 78
|
|
AI_MIN_Y = 98
|
|
AI_MAX_Y = LEVEL_H - 98
|
|
|
|
PLAYER_START_POS = [(350, 550), (650, 450), (200, 850), (500, 750), (800, 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 = 0 if team == 0 else LEVEL_H
|
|
super().__init__("goal" + str(team), x, y)
|
|
|
|
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__("ball", 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 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 abs(self.vpos.y - HALF_LEVEL_H) > HALF_PITCH_H:
|
|
bounds_x = GOAL_BOUNDS_X
|
|
else:
|
|
bounds_x = PITCH_BOUNDS_X
|
|
|
|
# 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 abs(self.vpos.x - HALF_LEVEL_W) < HALF_GOAL_W:
|
|
bounds_y = GOAL_BOUNDS_Y
|
|
else:
|
|
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
|
|
|
|
elif abs(x - HALF_LEVEL_W) < HALF_GOAL_W + 20:
|
|
# Player is within the bounds of the goals on the X axis, don't let them walk into, through or behind the goal
|
|
# +20 takes with of player sprite into account
|
|
return abs(y - HALF_LEVEL_H) < HALF_PITCH_H
|
|
|
|
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, 78 if team == 1 else LEVEL_H - 78)
|
|
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 1
|
|
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 abs(self.ball.vpos.y - HALF_LEVEL_H) > HALF_PITCH_H:
|
|
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 1
|
|
|
|
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("pitch", (-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))
|
|
|
|
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()
|