diff --git a/settlers_of_emf/main.py b/settlers_of_emf/main.py new file mode 100644 index 0000000..82b308c --- /dev/null +++ b/settlers_of_emf/main.py @@ -0,0 +1,1321 @@ +"""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() diff --git a/settlers_of_emf/title.png b/settlers_of_emf/title.png new file mode 100644 index 0000000..89e1dab Binary files /dev/null and b/settlers_of_emf/title.png differ diff --git a/settlers_of_emf/title.svg b/settlers_of_emf/title.svg new file mode 100644 index 0000000..b670071 --- /dev/null +++ b/settlers_of_emf/title.svg @@ -0,0 +1,153 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + SETTLERS + EMF + OF + +