EMF_Camp_Badge/settlers_of_emf/main.py

1322 lines
50 KiB
Python

"""Settlers of EMF
After a long voyage and great deprivation of wifi, your vehicles have finally reached the edge of an uncharted field at the foot of a great castle. The Electromagnetic Field!
But you are not the only discoverer. Other fearless voyagers have also arrived at the foot the castle; the race to settle the Electricmagnetic Field has begun!
Settlers of EMF is a pass-and-play game of ruthless strategy for up to 4 players of all ages."""
___title___ = "Settlers of EMF"
___license___ = "MIT"
___dependencies___ = ["sleep"]
___categories___ = ["Games"]
___bootstrapped___ = False
import random, ugfx, math, time
from app import restart_to_default
from tilda import Buttons
ugfx.init()
# Because the button constants have no kind of order, it's hard to iterate
# through them, so keep a global list here to help us keep the code concise
BUTTONS = [
Buttons.BTN_1,
Buttons.BTN_2,
Buttons.BTN_3,
Buttons.BTN_4,
Buttons.BTN_5,
Buttons.BTN_6,
Buttons.BTN_7,
Buttons.BTN_8,
Buttons.BTN_9,
Buttons.BTN_0,
]
# Kinds of resource
SHEEP = {'kind':0, 'col': ugfx.html_color(0xd4e157)}
WHEAT = {'kind':1, 'col': ugfx.html_color(0xffc107)}
WOOD = {'kind':2, 'col': ugfx.html_color(0x993300)}
BRICK = {'kind':3, 'col': ugfx.html_color(0xff0000)}
ORE = {'kind':4, 'col': ugfx.html_color(0x757575)}
DESERT = {'kind':5, 'col': ugfx.html_color(0xffee55)} # Not really a resource
RESOURCE_KINDS = [ SHEEP, WHEAT, WOOD, BRICK, ORE ]
# Set this to true to enable a "cheat code" to get more resources for testing
# When in the main game running state, press 5 five times to get five more of
# every resource
TEST_MODE = False
class State:
def run(self):
"""Runs the main loop for this state"""
self.done = False
self.redraw = False
self.selection = 0
# Render the current screen
self.draw()
# Enter the loop and spin until the user makes a choice
self.initialise()
while not self.done:
time.sleep_ms(10)
if self.redraw:
self.draw()
self.redraw = False
self.deinitialise()
# Then drop back out into the state machine returning the selected choice
return self.selection
def draw(self):
"""Renders the current state to the screen"""
pass
def initialise(self):
"""Perform actions that need to happen before we enter the loop"""
pass
def deinitialise(self):
"""Perform actions that need to happen after we exit the loop"""
pass
class Menu(State):
def __init__(self, question, choices, clear_title=True):
self.question = question
self.choices = choices
self.clear_title = clear_title
if self.question:
if isinstance(self.question, list):
self.menu_offset = 100 + (20 * len(self.question))
else:
self.question = [question]
self.menu_offset = 120
else:
self.menu_offset = 100
self.back = -1
def is_choice_enabled(self, num):
c = self.choices[num]
return 'disabled' not in c or not c['disabled']
def get_selected_choice(self):
return self.choices[self.selection].copy()
def draw(self):
# Draw the menu on screen
if self.clear_title:
ugfx.clear(ugfx.BLACK)
ugfx.display_image(0, 0, 'settlers_of_emf/title.png')
else:
ugfx.area(0, 95, 240, 225, ugfx.BLACK)
if self.question:
for i in range(len(self.question)):
ugfx.text(2, 95 + (i * 20), self.question[i], ugfx.WHITE)
offset = 0
for i in range(len(self.choices)):
self._draw_choice(i, offset)
offset = offset + 20
if 'cost' in self.choices[i]:
offset = offset + 20
# Set the initial selection
if self.is_choice_enabled(self.selection):
self._set_selection(self.selection)
else:
self._set_selection(self._next_valid_selection(self.selection))
def _draw_choice(self, i, offset):
c = self.choices[i]
col = ugfx.WHITE
if 'colour' in c:
col = c['colour']
if not self.is_choice_enabled(i):
col = ugfx.html_color(0x676767)
if len(self.choices) == 1:
text = "{} ".format(c['name'])
else:
if c['name'] == "Back":
text = "B - {} ".format(c['name'])
self.back = i
else:
text = "{} - {} ".format(i + 1, c['name'])
ugfx.text(18, offset + self.menu_offset, text, col)
if 'cost' in c:
for j in range(len(c['cost'])):
cost = c['cost'][j]
if 'cost_or' in c and c['cost_or']:
cost_text = "x{} / ".format(cost['amount'])
if j == len(c['cost']) - 1:
cost_text = "x{} ".format(cost['amount'])
ugfx.area((63 * j) + 45, 21 + offset + self.menu_offset, 16, 16, cost['resource']['col'])
ugfx.text((63 * j) + 61, 20 + offset + self.menu_offset, cost_text, col)
else:
ugfx.area((42 * j) + 45, 21 + offset + self.menu_offset, 16, 16, cost['resource']['col'])
ugfx.text((42 * j) + 61, 20 + offset + self.menu_offset, "x{} ".format(cost['amount']), col)
def initialise(self):
# Register callbacks
Buttons.enable_interrupt(Buttons.BTN_A, self._button_callback)
Buttons.enable_interrupt(Buttons.BTN_B, self._button_callback)
Buttons.enable_interrupt(Buttons.JOY_Up, self._button_callback)
Buttons.enable_interrupt(Buttons.JOY_Down, self._button_callback)
for i in range(len(self.choices)):
if self.is_choice_enabled(i):
Buttons.enable_interrupt(BUTTONS[i], self._button_callback)
def deinitialise(self):
# Unregister callbacks
Buttons.disable_interrupt(Buttons.BTN_A)
Buttons.disable_interrupt(Buttons.BTN_B)
Buttons.disable_interrupt(Buttons.JOY_Up)
Buttons.disable_interrupt(Buttons.JOY_Down)
for i in range(len(self.choices)):
if self.is_choice_enabled(i):
Buttons.disable_interrupt(BUTTONS[i])
def _button_callback(self, btn):
if btn == Buttons.BTN_A:
self.done = True
elif btn == Buttons.BTN_B:
if self.back > -1:
self._set_selection(self.back)
self.done = True
else:
if btn == Buttons.JOY_Up:
new_selection = self._prev_valid_selection(self.selection)
elif btn == Buttons.JOY_Down:
new_selection = self._next_valid_selection(self.selection)
else:
for i in range(len(self.choices)):
if btn == BUTTONS[i]:
new_selection = i
self._set_selection(new_selection)
def _prev_valid_selection(self, sel):
# Calculate the next enabled option going upwards
if sel == 0:
next_sel = len(self.choices) - 1
else:
next_sel = sel - 1
while True:
if self.is_choice_enabled(next_sel):
break
if next_sel == 0:
next_sel = len(self.choices) - 1
else:
next_sel = next_sel - 1
return next_sel
def _next_valid_selection(self, sel):
# Calculate the next enabled option going downwards
if sel == len(self.choices) - 1:
next_sel = 0
else:
next_sel = sel + 1
while True:
if self.is_choice_enabled(next_sel):
break
if next_sel == len(self.choices) - 1:
next_sel = 0
else:
next_sel = next_sel + 1
return next_sel
def _set_selection(self, new_selection):
# Redraws the selection box
size = 2 if 'cost' in self.choices[self.selection] and len(self.choices[self.selection]['cost']) > 0 else 1
ugfx.box(0, self._get_offset_for_selection(self.selection) + self.menu_offset, 240, 20 * size, ugfx.BLACK)
self.selection = new_selection
size = 2 if 'cost' in self.choices[self.selection] and len(self.choices[self.selection]['cost']) > 0 else 1
ugfx.box(0, self._get_offset_for_selection(self.selection) + self.menu_offset, 240, 20 * size, ugfx.WHITE)
def _get_offset_for_selection(self, sel):
# Menu items are double height if they need to show a cost, so iterate
# through the choices to find out exactly what the offset should be
offset = 0
for i in range(len(self.choices)):
if i == sel:
return offset
offset = offset + 20
if 'cost' in self.choices[i]:
offset = offset + 20
class MainMenu(Menu):
options = [
{'name': "Start New Game"},
{'name': "Continue Game"},
{'name': "Exit"},
]
NEW_GAME = 0
CONTINUE_GAME = 1
EXIT = 2
def __init__(self, disable_continue_option=True, clear_title=True):
MainMenu.options[MainMenu.CONTINUE_GAME]['disabled'] = disable_continue_option
super().__init__('Welcome!', MainMenu.options, clear_title)
class TeamMenu(Menu):
options = [
{'name': "Scottish Consulate",
'colour': ugfx.html_color(0x0000ff)},
{'name': "Camp Holland",
'colour': ugfx.html_color(0xff8c00)},
{'name': "Sheffield Hackspace",
'colour': ugfx.html_color(0x26c6da)},
{'name': "Milliways",
'colour': ugfx.html_color(0xff00ff)},
{'name': "Robot Arms",
'colour': ugfx.html_color(0xeaeaea)},
{'name': "Null Sector",
'colour': ugfx.html_color(0x9c27b0),
'cost': []},
{'name': "Start Game"},
{'name': "Back"},
]
TEAM_MAX = len(options) - 3
START_GAME = len(options) - 2
BACK = len(options) - 1
def __init__(self, teams):
# Disable team options based on which have already been chosen
for option in TeamMenu.options:
if 'colour' not in option:
continue
option['disabled'] = option['name'] in [team['name'] for team in teams]
TeamMenu.options[TeamMenu.START_GAME]['disabled'] = len(teams) == 0
super().__init__('Player {}, choose a team:'.format(len(teams) + 1), TeamMenu.options, False)
class ActionMenu(Menu):
options = [
{'name': "Build"},
{'name': "Trade"},
{'name': "End Turn"},
{'name': "Exit Game"},
{'name': "Back"},
]
BUILD = 0
TRADE = 1
END_TURN = 2
EXIT_GAME = 3
BACK = 4
def __init__(self, dice_roll, clear_title=True):
# Rolling the dice is mandatory, so don't let the turn end unless it happened
ActionMenu.options[ActionMenu.END_TURN]['disabled'] = dice_roll == 0
super().__init__('Do a thing:', ActionMenu.options, clear_title)
class BuildMenu(Menu):
options = [
{'name': "Build Road (0 points)",
'cost': [{'resource': BRICK, 'amount': 1},
{'resource': WOOD, 'amount': 1}]},
{'name': "Build Town (1 point)",
'cost': [{'resource': BRICK, 'amount': 1},
{'resource': WOOD, 'amount': 1},
{'resource': SHEEP, 'amount': 1},
{'resource': WHEAT, 'amount': 1}]},
{'name': "Upgrade to City (2 points)",
'cost': [{'resource': WHEAT, 'amount': 2},
{'resource': ORE, 'amount': 3}]},
{'name': "Back"},
]
ROAD = 0
TOWN = 1
CITY = 2
BACK = 3
def __init__(self, resources):
# Disable build options based on whether the player can afford them
for option in BuildMenu.options:
if 'cost' not in option:
continue
option['disabled'] = False
for cost in option['cost']:
for resource in resources:
if resource.resource == cost['resource']:
if resource.quantity < cost['amount']:
option['disabled'] = True
super().__init__(None, BuildMenu.options, False)
class TradeMenu(Menu):
BACK = len(RESOURCE_KINDS)
def __init__(self, resources):
# Disable trade options based on whether the player can afford them
options = []
for i in range(len(RESOURCE_KINDS)):
option = {'name': "Buy a", 'resource': RESOURCE_KINDS[i], 'cost': [], 'cost_or': True}
option['disabled'] = True
for resource_kind in RESOURCE_KINDS:
if resource_kind['kind'] != i:
for resource in resources:
if resource.resource == resource_kind and resource.quantity >= 4:
option['cost'].append({'resource': resource_kind, 'amount': 4})
option['disabled'] = False
options.append(option)
options.append({'name': "Back"})
super().__init__(None, options, False)
def _draw_choice(self, i, offset):
super()._draw_choice(i, offset)
if i < len(RESOURCE_KINDS):
ugfx.area(93, 1 + offset + self.menu_offset, 16, 16, RESOURCE_KINDS[i]['col'])
class NextPlayer(Menu):
def __init__(self, team):
super().__init__('Pass the badge to next team:', [team], False)
class GameOver(Menu):
def __init__(self, team):
super().__init__(['Game over!', 'Congrats to the winning team:'], [team], False)
class Hex:
"""Hexes are the games tiles. They have a resource kind, correspond to the value
of a roll of two D6 and may or may not contain the robber."""
# Screen coords are x,y values that locate pixels on the physical display:
#
# 0,0 → → 240,0
# ↓ ↓
# 0,320 → 240,320
#
# Hex coords are x,y,z values that locate the relative positions of hexagons:
#
# 0,1,-1
# -1,1,0 ↖ ↑ ↗ 1,0,-1
# 0,0,0
# -1,0,1 ↙ ↓ ↘ 1,-1,0
# 0,-1,1
#
# Converting between the two systems can be done by multiplying the x and y
# coordinates against a matrix. When converting to hex coords, the z value
# can be computed from the new x and y values because x + y + z must always
# equal zero.
#
# This is the matrix used to convert from hex coords to screen coords
matrix = [3.0 * 0.5, 0.0, math.sqrt(3.0) * 0.5, math.sqrt(3.0)]
# The screen coordinate to use as the origin for hex coordinates,
# the centre of hex 0,0,0 will be at this coordinate
origin = [math.ceil(ugfx.width() / 2), math.ceil(ugfx.height() / 2)]
# Size in pixels of the hex, from the centre point to each corner
size = 25
# Transformations for how to get to the neighbouring hexes
directions = {
0: [-1, 1, 0], # South West
1: [0, 1, -1], # South
2: [1, 0, -1], # South East
3: [1, -1, 0], # North East
4: [0, -1, 1], # North
5: [-1, 0, 1], # North West
}
def __init__(self, coords, resource, number, robber):
"""Create a new hex at the given hex coordinates, of the given kind of resource"""
# Validate coords
assert len(coords) == 3, 'Invalid number of hexagon coordinates'
assert coords[0] + coords[1] + coords[2] == 0, 'Invalid hexagon coordinate values'
self.coords = coords
# The kind of resource hosted by this hex
self.resource = resource
# The dice roll required to win this resource
self.number = number
# Whether this hex contains the robber
self.robber = robber
# Whether this hex should be highlighted
self.highlight = False
# A hex is quite expensive to draw, so keep track of whether the state changed recently
# to avoid redrawing where possible
self.changed = True
# Compute the screen coordinates of the centre of the hex
x = self.coords[0]
y = self.coords[1]
newX = (Hex.matrix[0] * x + Hex.matrix[1] * y) * Hex.size
newY = (Hex.matrix[2] * x + Hex.matrix[3] * y) * Hex.size
self.centre = [newX + Hex.origin[0], newY + Hex.origin[1]]
# Generate the list of screen coordinates for each of the corners of the hex
self.nodes = []
for i in range(0, 6):
angle = 2.0 * math.pi * (0 - i) / 6
offset = [Hex.size * math.cos(angle), Hex.size * math.sin(angle)]
self.nodes.append([int(self.centre[0] + offset[0]), int(self.centre[1] + offset[1])])
# Generate the list of pairs of screen coordinates for each of the sides of the hex
self.edges = []
for i in range(0, 6):
node1 = self.nodes[i]
if i < 5:
node2 = self.nodes[i + 1]
else:
node2 = self.nodes[0]
if node1[0] <= node2[0]:
self.edges.append([node1, node2])
else:
self.edges.append([node2, node1])
def set_highlight(self, highlight):
if self.highlight != highlight:
self.highlight = highlight
self.changed = True
@staticmethod
def get_neighbouring_hex_coords(coords, direction):
return [a + b for a, b in zip(coords, Hex.directions[direction])]
def draw(self):
if self.changed:
self.changed = False
ugfx.fill_polygon(0, 0, self.nodes, self.resource['col'])
if self.highlight:
text_colour = ugfx.WHITE
else:
text_colour = ugfx.BLACK
text_offset = Hex.size * 0.45
if self.robber:
ugfx.text(round(self.centre[0] - Hex.size * 0.75), round(self.centre[1] - text_offset), "Rob ", text_colour)
else:
if self.resource != DESERT:
ugfx.text(round(self.centre[0] - text_offset), round(self.centre[1] - text_offset), "{} ".format(self.number['roll']), text_colour)
class Selectable:
EMPTY = 0
def __init__(self, data):
# Screen coords that define the selectable object
self.data = data
# The list of hexes next to which this selectable object is adjacent
self.hexes = []
# What is built here and who owns it
self.team = None
self.contents = Selectable.EMPTY
# Whether to draw selection indicator
self.selected = False
def is_empty(self):
return self.contents == Selectable.EMPTY
def set_selection(self, selected):
self.selected = selected
# Notify the surrounding hexes that they need to redraw themselves
for h in self.hexes:
h.changed = True
class Settlement(Selectable):
"""A node at which it is possible to build a settlement."""
# Possible things this location may contain, the values here are the number of
# victory points that the building is worth to the player who built it
TOWN = 1
CITY = 2
def prob_score(self):
"""The probability score of the location is the sum of the probability of all adjacent hexes"""
score = 0
for h in self.hexes:
score = score + h.number['prob']
return score
def build_town(self, team):
assert self.contents == Selectable.EMPTY, 'Town can only be built in empty location'
self.team = team
self.contents = Settlement.TOWN
def build_city(self, team):
assert self.contents == Settlement.TOWN and self.team['name'] == team['name'], 'City can only be built in place of one of your own towns'
self.contents = Settlement.CITY
def draw(self):
if self.contents == Settlement.TOWN:
ugfx.fill_circle(self.data[0], self.data[1], 4, self.team['colour'])
ugfx.circle(self.data[0], self.data[1], 4, ugfx.WHITE)
elif self.contents == Settlement.CITY:
ugfx.fill_circle(self.data[0], self.data[1], 8, self.team['colour'])
ugfx.circle(self.data[0], self.data[1], 8, ugfx.WHITE)
# A selection highlight
if self.selected:
# We can't draw circle primitives with thick lines, so need to draw it twice
ugfx.circle(self.data[0], self.data[1], 10, ugfx.WHITE)
ugfx.circle(self.data[0], self.data[1], 9, ugfx.WHITE)
class Road(Selectable):
"""An edge along which it is possible to build a road."""
ROAD = 1
def build_road(self, team):
assert self.contents == Selectable.EMPTY, 'Road can only be built in empty location'
self.team = team
self.contents = Road.ROAD
def draw(self):
x0, y0 = (self.data[0][0], self.data[0][1])
x1, y1 = (self.data[1][0], self.data[1][1])
if self.contents == Road.ROAD:
ugfx.thickline(x0, y0, x1, y1, ugfx.WHITE, 6, False)
ugfx.thickline(x0, y0, x1, y1, self.team['colour'], 4, False)
# A selection highlight
if self.selected:
# We can't draw a rectangle at an angle, so lets calculate the points for an
# appropriate polygon manually for drawing the selection box...
# Get the vector and a normal, we'll use the magnitude of the normal for the
# width of the selection box, so make it half the size for a long narrow box
vx, vy = (x1 - x0, y1 - y0)
nx, ny = (int(vy / 2), -(int(vx / 2)))
# Draw with an origin that is offset by half the width of the box to straddle
# the two adjacent hexes
x = x0 - int(nx / 2)
y = y0 - int(ny / 2)
# It would be more efficient to call polygon directly like this:
# ugfx.polygon(x, y, [[0, 0], [nx, ny], [vx + nx, vy + ny], [vx, vy]], ugfx.WHITE)
# But we can't draw polygon primitives with thick lines, so draw them individually
ugfx.thickline(x, y, x + nx, y + ny, ugfx.WHITE, 2, False)
ugfx.thickline(x + nx, y + ny, x + nx + vx, y + ny + vy, ugfx.WHITE, 2, False)
ugfx.thickline(x + nx + vx, y + ny + vy, x + vx, y + vy, ugfx.WHITE, 2, False)
ugfx.thickline(x + vx, y + vy, x, y, ugfx.WHITE, 2, False)
class Resource():
def __init__(self, resource):
self.resource = resource
self.quantity = 0
def increment(self, num=1):
self.quantity = self.quantity + num
def decrement(self, num=1):
self.quantity = self.quantity - num
if self.quantity < 0:
self.quantity = 0
class Player:
"""The player's hand of resource cards and their score and what not."""
STATUS_ACTIONS_DICE = "menu = actions / # = roll dice "
STATUS_ACTIONS = "menu = actions "
STATUS_MOVE_ROBBER = "Move the robber! "
STATUS_MUST_MOVE_ROBBER = "Robber MUST be moved! "
STATUS_BUILD = "Select a build location. "
STATUS_NO_BUILD = "No valid build locations. "
def __init__(self, team, roads, settlements):
# Player team details
self.team = team
# All the buildable game board locations
self.roads = roads
self.settlements = settlements
# Player's hand of resources
self.resources = []
for kind in RESOURCE_KINDS:
r = Resource(kind)
self.resources.append(r)
# Turn number
self.turn = 0
self.status = Player.STATUS_ACTIONS_DICE
def score(self):
points = 0
for s in [x for x in self.settlements if x.team == self.team]:
points = points + s.contents
return points
def increment_turn(self):
self.turn = self.turn + 1
self.status = Player.STATUS_ACTIONS_DICE
def num_resources(self):
"""Total number of all resources the player has"""
return sum([x.quantity for x in self.resources])
def collect_starting(self):
"""Execute resource collection for our starting towns"""
# Find the hexes adjacent to our settlements
for s in [x for x in self.settlements if x.team == self.team]:
for h in s.hexes:
# Increment the appropriate resource
for r in self.resources:
if r.resource == h.resource:
r.increment()
def collect(self, num):
"""Execute resource collection or loss for a given dice roll"""
if num == 7:
# If total number of resources is over 7, lose half of them (rounded down)
total = self.num_resources()
if total > 7:
lose = int(total / 2)
while self.num_resources() > total - lose:
self.resources[random.randrange(0, len(self.resources))].decrement()
else:
# Collect resources for each hex adjacent to our settlements that corresponds
# with the given dice roll
for s in [x for x in self.settlements if x.team == self.team]:
for h in s.hexes:
if h.number['roll'] == num and not h.robber:
for r in self.resources:
if r.resource == h.resource:
if s.contents == Settlement.TOWN:
r.increment()
elif s.contents == Settlement.CITY:
r.increment(2)
def trade(self, buy_kind, sell_kind, sell_amount):
"""Executes a simple resource trade"""
for r in self.resources:
if r.resource == buy_kind:
r.increment()
if r.resource == sell_kind:
r.decrement(sell_amount)
def pay(self, cost):
for c in cost:
for r in self.resources:
if c['resource'] == r.resource:
r.decrement(c['amount'])
def build_road_candidates(self):
"""Return the list of all roads that are valid candidates for building"""
candidates = []
# Road segments that belong to us
for r in [x for x in self.roads if x.team == self.team]:
# Settlement spaces that these road segments connect
for s in [x for x in self.settlements if x.data in r.data]:
# Empty road segments connecting those settlement spaces
for road in [x for x in self.roads if x.is_empty() and s.data in x.data]:
if road not in candidates:
candidates.append(road)
return candidates
def can_build_town_at(self, settlement):
"""Determines whether a town can be built at the given settlement according to proximity rules"""
# Find the road segments connecting the given settlement
for road in [x for x in self.roads if settlement.data in x.data]:
# Get adjacent settlements (those at the other end of the road segments)
for s in [x for x in self.settlements if x.data in road.data and x != settlement]:
# If all adjacent settlements are empty, it means that we are at least two road
# segments from any other built settlement, which is the required distance
if not s.is_empty():
return False
return True
def build_town_candidates(self):
"""Return the list of all settlements that are valid candidates for towns to be built"""
candidates = []
# Road segments that belong to us
for r in [x for x in self.roads if x.team == self.team]:
# Empty settlement spaces at each end of the road segments
for s in [x for x in self.settlements if x.is_empty() and x.data in r.data]:
# Settlement is a candidate if we can build there
if self.can_build_town_at(s) and s not in candidates:
candidates.append(s)
return candidates
def build_city_candidates(self):
"""Return the list of all settlements that are valid candidates for being upgraded to city"""
candidates = []
# Settlement spaces that belong to us and contain a town
for s in [x for x in self.settlements if x.team == self.team and x.contents == Settlement.TOWN]:
candidates.append(s)
return candidates
def draw(self):
# Blank out the score in case it changed
ugfx.area(60, 28, 25, 18, ugfx.BLACK)
# Player's team and score
ugfx.text(5, 8, "{} ".format(self.team['name']), self.team['colour'])
ugfx.text(5, 28, "Points: {} ".format(self.score()), ugfx.WHITE)
ugfx.text(5, 48, "Turn: {} ".format(self.turn), ugfx.WHITE)
# Blank out the status/resource area
ugfx.area(0, 275, 240, 50, ugfx.BLACK)
# Status line
ugfx.text(5, 275, self.status, ugfx.WHITE)
# Player's resources
offset = int(ugfx.width() / len(self.resources))
square = int(offset / 3)
for i in range(len(self.resources)):
ugfx.area((offset * i) + 1, 295, square, 20, self.resources[i].resource['col'])
ugfx.text((offset * i) + 1 + square, 295, "{} ".format(self.resources[i].quantity), ugfx.WHITE)
class Dice:
# Size in pixels that the dice will be drawn on screen
size = 25
def __init__(self):
self.reset()
def roll(self):
self.die1 = random.randint(1, 6)
self.die2 = random.randint(1, 6)
def reset(self):
self.die1 = 0
self.die2 = 0
def total(self):
return self.die1 + self.die2
def draw(self):
self._draw_die(210, 5, self.die1)
self._draw_die(210, 5 + Dice.size + 5, self.die2)
def _draw_die(self, x, y, num):
ugfx.box(x, y, Dice.size, Dice.size, ugfx.html_color(0x676767))
ugfx.area(x + 1, y + 1, Dice.size - 2, Dice.size - 2, ugfx.BLACK)
if num == 1:
self._draw_one_dot(x, y)
if num == 2:
self._draw_two_dot(x, y)
if num == 3:
self._draw_one_dot(x, y)
self._draw_two_dot(x, y)
if num == 4:
self._draw_four_dot(x, y)
if num == 5:
self._draw_one_dot(x, y)
self._draw_four_dot(x, y)
if num == 6:
self._draw_six_dot(x, y)
def _draw_one_dot(self, x, y):
ugfx.fill_circle(x + int(Dice.size / 2), y + int(Dice.size / 2), 1, ugfx.WHITE)
def _draw_two_dot(self, x, y):
ugfx.fill_circle(1 + x + int(Dice.size / 8), (y - 1) + (Dice.size - int(Dice.size / 8)), 1, ugfx.WHITE)
ugfx.fill_circle((x - 2) + (Dice.size - int(Dice.size / 8)), 1 + y + int(Dice.size / 8), 1, ugfx.WHITE)
def _draw_four_dot(self, x, y):
self._draw_two_dot(x, y)
ugfx.fill_circle(1 + x + int(Dice.size / 8), 1 + y + int(Dice.size / 8), 1, ugfx.WHITE)
ugfx.fill_circle((x - 2) + (Dice.size - int(Dice.size / 8)), (y - 1) + (Dice.size - int(Dice.size / 8)), 1, ugfx.WHITE)
def _draw_six_dot(self, x, y):
self._draw_four_dot(x, y)
ugfx.fill_circle(1 + x + int(Dice.size / 8), y + int(Dice.size / 2), 1, ugfx.WHITE)
ugfx.fill_circle((x - 2) + (Dice.size - int(Dice.size / 8)), y + int(Dice.size / 2), 1, ugfx.WHITE)
class GameBoard(State):
MENU = 0
# List of resources (pre-randomised to combat the not-very random number
# generator) that make up the hexes on the game board for 4 players
resources = [ORE, SHEEP, WHEAT, ORE, ORE, WOOD, DESERT, BRICK, SHEEP, WOOD,
WHEAT, WOOD, WOOD, WHEAT, SHEEP, BRICK, SHEEP, BRICK, WHEAT]
# Dice roll probabilities
TWO = {'roll':2, 'prob':1}
THREE = {'roll':3, 'prob':2}
FOUR = {'roll':4, 'prob':3}
FIVE = {'roll':5, 'prob':4}
SIX = {'roll':6, 'prob':5}
SEVEN = {'roll':7, 'prob':0} # Most probable, but zero because desert
EIGHT = {'roll':8, 'prob':5}
NINE = {'roll':9, 'prob':4}
TEN = {'roll':10, 'prob':3}
ELEVEN = {'roll':11, 'prob':2}
TWELVE = {'roll':12, 'prob':1}
# Dice rolls for (these have a strict order) to be assigned to the resource
# hexes for 4 player games
numbers = [FIVE, TWO, SIX, THREE, EIGHT, TEN, NINE, TWELVE, ELEVEN, FOUR,
EIGHT, TEN, NINE, FOUR, FIVE, SIX, THREE, ELEVEN]
# Interactivity modes for allowing the the user to navigate and select things on the game board
ROBBER_MODE = 1
ROAD_MODE = 2
TOWN_MODE = 3
CITY_MODE = 4
def __init__(self, teams):
# Two rings of hexes around the centre
radius = 2
# Choose a starting hex on the outermost ring of hexes
choice = random.randrange(0, 6)
coords = [0, 0, 0]
for i in range(radius):
coords = [a + b for a, b in zip(coords, Hex.directions[choice])]
# Copy lists so we can edit them with impunity
r_copy = GameBoard.resources.copy()
n_copy = GameBoard.numbers.copy()
self.hexes = []
while radius > 0:
# From the starting hex, go radius hexes in each of the 6 directions
for i in list(range((choice + 2) % 6, 6)) + list(range(0, (choice + 2) % 6)):
for _ in range(radius):
# The resources are picked at random from the list
resource = r_copy.pop(random.randrange(0, len(r_copy)))
# But the dice roll numbers are picked in order, unless it's
# the desert in which case that is always 7
number = GameBoard.SEVEN
if resource['kind'] != 5:
number = n_copy.pop(0)
self.hexes.append(Hex(coords, resource, number, number['roll'] == 7))
coords = Hex.get_neighbouring_hex_coords(coords, i)
# Go into the next ring of hexes (opposite direction of starting choice)
coords = Hex.get_neighbouring_hex_coords(coords, (choice + 3) % 6)
radius = radius - 1
# The final, centre hex
resource = r_copy.pop()
number = GameBoard.SEVEN
if resource['kind'] != 5:
number = n_copy.pop(0)
self.hexes.append(Hex(coords, resource, number, number['roll'] == 7))
# Note the initial location of the robber to ensure it moves when activated
self.robber_hex = self.get_robber_hex()
# The mode that dictates how we are interpreting button presses
self.interactive_mode = None
# Generate lists of unique valid locations for building settlements and roads
self.roads = []
self.settlements = []
for h in self.hexes:
for edge in h.edges:
already_got = False
for r in self.roads:
if r.data == edge:
already_got = True
r.hexes.append(h)
if not already_got:
r = Road(edge)
r.hexes.append(h)
self.roads.append(r)
for node in h.nodes:
already_got = False
for s in self.settlements:
if s.data == node:
already_got = True
s.hexes.append(h)
if not already_got:
s = Settlement(node)
s.hexes.append(h)
self.settlements.append(s)
# Create a player for each team and give the team starting towns in the two settlements
# with the highest probability score that not already taken
# Each team gets a settlement in player order, then again but in reverse, so the last
# player gets the first pick of the second settlements
self.players = []
for team in teams:
self.players.append(Player(team, self.roads, self.settlements))
self.player = self.players[-1]
for team in teams:
self.pick_starting_settlement(team)
teams.reverse()
for team in teams:
self.pick_starting_settlement(team)
teams.reverse()
# Each player can now collect their starting resources
for p in self.players:
p.collect_starting()
# The dice roller
self.dice = Dice()
# Cheat code counter
self.cheat = 0
def get_roads_for_settlement(self, settlement):
"""Return a list of roads that connect to the given settlement"""
roads = []
for road in self.roads:
if settlement.data in road.data:
roads.append(road)
return roads
def pick_starting_settlement(self, team):
"""Choose a starting settlement for the given team, and place a town and a connecting road there"""
# Sort the settlements by highest dice roll probability
sorted_settlements = sorted(self.settlements, key=lambda s: s.prob_score())
sorted_settlements.reverse()
# Build at the highest probability settlement that is still available
for s in sorted_settlements:
if s.is_empty() and self.player.can_build_town_at(s):
s.build_town(team)
s_roads = self.get_roads_for_settlement(s)
s_roads[random.randrange(0, len(s_roads))].build_road(team)
break
def draw(self):
if not self.redraw:
ugfx.clear(ugfx.BLACK)
self.dice.draw()
for h in self.hexes:
h.draw()
for r in self.roads:
r.draw()
for s in self.settlements:
s.draw()
self.player.draw()
def initialise(self):
# Register callbacks
Buttons.enable_interrupt(Buttons.BTN_Menu, self._button_callback)
Buttons.enable_interrupt(Buttons.BTN_A, self._button_callback)
Buttons.enable_interrupt(Buttons.BTN_B, self._button_callback)
Buttons.enable_interrupt(Buttons.BTN_Hash, self._button_callback)
# For moving the robber and building selections
Buttons.enable_interrupt(Buttons.JOY_Up, self._button_callback)
Buttons.enable_interrupt(Buttons.JOY_Down, self._button_callback)
Buttons.enable_interrupt(Buttons.JOY_Left, self._button_callback)
Buttons.enable_interrupt(Buttons.JOY_Right, self._button_callback)
# Cheat code button
Buttons.enable_interrupt(Buttons.BTN_5, self._button_callback)
def deinitialise(self):
# Unregister callbacks
Buttons.disable_interrupt(Buttons.BTN_Menu)
Buttons.disable_interrupt(Buttons.BTN_A)
Buttons.disable_interrupt(Buttons.BTN_B)
Buttons.disable_interrupt(Buttons.BTN_Hash)
# For moving the robber and building selections
Buttons.disable_interrupt(Buttons.JOY_Up)
Buttons.disable_interrupt(Buttons.JOY_Down)
Buttons.disable_interrupt(Buttons.JOY_Left)
Buttons.disable_interrupt(Buttons.JOY_Right)
# Cheat code button
Buttons.disable_interrupt(Buttons.BTN_5)
# Ensure all hexes are drawn next time we enter this state
for h in self.hexes:
h.changed = True
def _button_callback(self, btn):
if not self.interactive_mode:
if btn == Buttons.BTN_Menu:
self.selection = GameBoard.MENU
self.done = True
if btn == Buttons.BTN_Hash:
# Only roll the dice if not already rolled
if self.dice.total() == 0:
self.dice.roll()
# Highlight the hexes corresponding with the dice roll
num = self.dice.total()
for h in self.hexes:
if (h.number['roll'] == num and not h.robber) or (num == 7 and h.robber):
h.set_highlight(True)
else:
h.set_highlight(False)
# All players collect resources corresponding with the dice roll
for p in self.players:
p.collect(num)
# Activate the robber on a seven
if num == 7:
self.interactive_mode = GameBoard.ROBBER_MODE
self.player.status = Player.STATUS_MOVE_ROBBER
else:
self.player.status = Player.STATUS_ACTIONS
self.redraw = True
# Cheat code to get more resources for testing
if btn == Buttons.BTN_5 and TEST_MODE:
self.cheat = self.cheat + 1
if self.cheat == 5:
self.cheat = 0
for r in self.player.resources:
r.increment(5)
self.redraw = True
elif self.interactive_mode == GameBoard.ROBBER_MODE:
h_current = self.get_robber_hex()
if btn == Buttons.BTN_A:
self.redraw = True
# The robber may not stay in the same hex, ensure it moved
if h_current != self.robber_hex:
self.robber_hex = h_current
self.robber_hex.set_highlight(False)
self.interactive_mode = None
self.player.status = Player.STATUS_ACTIONS
# TODO: Steal a card from a player at this hex
else:
self.player.status = Player.STATUS_MUST_MOVE_ROBBER
if btn == Buttons.JOY_Up:
self._move_robber(h_current, 4)
if btn == Buttons.JOY_Down:
self._move_robber(h_current, 1)
if btn == Buttons.JOY_Left:
self._move_robber(h_current, 0 if h_current.coords[0] % 2 == 0 else 5)
if btn == Buttons.JOY_Right:
self._move_robber(h_current, 2 if h_current.coords[0] % 2 == 0 else 3)
elif self.interactive_mode in (GameBoard.ROAD_MODE, GameBoard.TOWN_MODE, GameBoard.CITY_MODE):
if btn == Buttons.BTN_A:
for candidate in self.build_candidates:
if candidate.selected:
# Build a road on the selected road segment
if self.interactive_mode == GameBoard.ROAD_MODE:
candidate.build_road(self.player.team)
# Build a town on the selected settlement
if self.interactive_mode == GameBoard.TOWN_MODE:
candidate.build_town(self.player.team)
# Upgrade a town on the selected settlement to a city
if self.interactive_mode == GameBoard.CITY_MODE:
candidate.build_city(self.player.team)
candidate.set_selection(False)
self.player.pay(self.build_cost)
self.interactive_mode = None
self.redraw = True
if self.dice.total():
self.player.status = Player.STATUS_ACTIONS
else:
self.player.status = Player.STATUS_ACTIONS_DICE
if btn == Buttons.JOY_Left or btn == Buttons.JOY_Up:
self._select_prev_build_candidate(self.build_candidates)
if btn == Buttons.JOY_Right or btn == Buttons.JOY_Down:
self._select_next_build_candidate(self.build_candidates)
def _move_robber(self, h_current, direction):
coords = Hex.get_neighbouring_hex_coords(h_current.coords, direction)
h_next = self.get_hex_for_coords(coords)
self.move_robber(h_current, h_next)
self.redraw = True
def _select_prev_build_candidate(self, candidates):
if len(candidates) > 1:
for i in range(len(candidates)):
if candidates[i].selected:
candidates[i].set_selection(False)
if i == 0:
candidates[len(candidates) - 1].set_selection(True)
else:
candidates[i - 1].set_selection(True)
break
self.redraw = True
def _select_next_build_candidate(self, candidates):
if len(candidates) > 1:
for i in range(len(candidates)):
if candidates[i].selected:
candidates[i].set_selection(False)
if i == len(candidates) - 1:
candidates[0].set_selection(True)
else:
candidates[i + 1].set_selection(True)
break
self.redraw = True
def get_robber_hex(self):
for h in self.hexes:
if h.robber:
return h
return None
def get_hex_for_coords(self, coords):
for h in self.hexes:
if h.coords == coords:
return h
return None
def move_robber(self, from_hex, to_hex):
if to_hex:
from_hex.robber = False
from_hex.set_highlight(False)
to_hex.robber = True
to_hex.set_highlight(True)
def next_player(self):
"""Called from the state machine to reset the board for the next player"""
for i in range(len(self.players)):
if self.player == self.players[i]:
if i + 1 == len(self.players):
self.player = self.players[0]
else:
self.player = self.players[i + 1]
break
self.player.increment_turn()
self.dice.reset()
for h in self.hexes:
h.set_highlight(False)
def build_mode(self, mode, cost):
"""Called from the state machine to enter building selection mode"""
if mode == GameBoard.ROAD_MODE:
self.build_candidates = self.player.build_road_candidates()
if mode == GameBoard.TOWN_MODE:
self.build_candidates = self.player.build_town_candidates()
if mode == GameBoard.CITY_MODE:
self.build_candidates = self.player.build_city_candidates()
if self.build_candidates:
self.build_candidates[0].set_selection(True)
self.build_cost = cost
self.interactive_mode = mode
self.player.status = Player.STATUS_BUILD
else:
self.player.status = Player.STATUS_NO_BUILD
class Settlers:
"""A lean mean state machine"""
# Game states
EXIT = 0
MAIN_MENU = 1
TEAM_MENU = 2
GAME = 3
ACTION_MENU = 4
ACTION_TRADE_MENU = 5
ACTION_BUILD_MENU = 6
ACTION_END_TURN = 7
def __init__(self):
self.old_state = None
self.state = Settlers.MAIN_MENU
self.game = None
self.teams = []
def enter_state(self, state):
self.old_state = self.state
self.state = state
def run(self):
while self.state != Settlers.EXIT:
if self.state == Settlers.MAIN_MENU:
menu = MainMenu(self.game is None, self.old_state != Settlers.TEAM_MENU and self.old_state != Settlers.ACTION_END_TURN)
x = menu.run()
if x == MainMenu.NEW_GAME:
self.teams = []
self.enter_state(Settlers.TEAM_MENU)
if x == MainMenu.CONTINUE_GAME:
self.enter_state(Settlers.GAME)
if x == MainMenu.EXIT:
self.enter_state(Settlers.EXIT)
elif self.state == Settlers.TEAM_MENU:
menu = TeamMenu(self.teams)
x = menu.run()
if x <= TeamMenu.TEAM_MAX:
self.teams.append(menu.get_selected_choice())
if len(self.teams) >= 4:
x = TeamMenu.START_GAME
if x == TeamMenu.BACK:
self.enter_state(Settlers.MAIN_MENU)
if x == TeamMenu.START_GAME:
self.game = GameBoard(self.teams)
self.game.next_player()
self.enter_state(Settlers.GAME)
elif self.state == Settlers.GAME:
x = self.game.run()
if x == GameBoard.MENU:
self.enter_state(Settlers.ACTION_MENU)
elif self.state == Settlers.ACTION_MENU:
menu = ActionMenu(self.game.dice.total(), self.old_state != Settlers.ACTION_BUILD_MENU and self.old_state != Settlers.ACTION_TRADE_MENU)
x = menu.run()
if x == ActionMenu.BUILD:
self.enter_state(Settlers.ACTION_BUILD_MENU)
if x == ActionMenu.TRADE:
self.enter_state(Settlers.ACTION_TRADE_MENU)
if x == ActionMenu.END_TURN:
self.enter_state(Settlers.ACTION_END_TURN)
if x == ActionMenu.EXIT_GAME:
self.enter_state(Settlers.MAIN_MENU)
if x == ActionMenu.BACK:
self.enter_state(Settlers.GAME)
elif self.state == Settlers.ACTION_BUILD_MENU:
menu = BuildMenu(self.game.player.resources)
x = menu.run()
if x == BuildMenu.BACK:
self.enter_state(Settlers.ACTION_MENU)
else:
choice = menu.get_selected_choice()
if x == BuildMenu.ROAD:
self.game.build_mode(GameBoard.ROAD_MODE, choice['cost'])
if x == BuildMenu.TOWN:
self.game.build_mode(GameBoard.TOWN_MODE, choice['cost'])
if x == BuildMenu.CITY:
self.game.build_mode(GameBoard.CITY_MODE, choice['cost'])
self.enter_state(Settlers.GAME)
elif self.state == Settlers.ACTION_TRADE_MENU:
menu = TradeMenu(self.game.player.resources)
x = menu.run()
if x == TradeMenu.BACK:
self.enter_state(Settlers.ACTION_MENU)
else:
trade_choice = menu.get_selected_choice()
# TODO: ask user which resource to trade when they have >= 4 of more than one kind of resource
# TODO: for now, just trade the first one in the cost list
cost = trade_choice['cost'][0]
self.game.player.trade(trade_choice['resource'], cost['resource'], cost['amount'])
self.enter_state(Settlers.GAME)
elif self.state == Settlers.ACTION_END_TURN:
if self.game.player.score() >= 10:
menu = GameOver(self.game.player.team)
menu.run()
self.game = None
self.enter_state(Settlers.MAIN_MENU)
else:
self.game.next_player()
menu = NextPlayer(self.game.player.team)
menu.run()
self.enter_state(Settlers.GAME)
# User chose exit, a machine reset is the easiest way :-)
restart_to_default()
game = Settlers()
game.run()