8

Building a command line tool to design a farm layout in Stardew Valley

 4 years ago
source link: https://johnlekberg.com/blog/2020-02-26-cli-stardew-sprinkler.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

By John Lekberg on February 26, 2020.

This week's post will cover a command line tool that helps you play the video game Stardew Valley .

Stardew Valley is a farming game (like Harvest Moon ). You can manually water your crops, or you can use sprinklers to automate the process.

I wrote a Python script, sprinkler-layout , that designs a layout of sprinklers for me, for a given number of sprinklers (e.g. 10 sprinklers). The goals of the layout are:

  • water as much land as possible.
  • have a reasonably small perimeter.

Script source code

sprinkler-layout

#!/usr/bin/env python3

import itertools


class Layout:
    """A layout of sprinklers on a grid."""

    def __init__(self):
        self._sprinklers = set()
        self._watered_squares = set()

    @classmethod
    def generate(cls, *, num_sprinklers, coordinates):
        """Generate a layout, given:

        - how many sprinklers to place.
        - which positions to attempt to place them at.

        """
        layout = cls()

        while layout.count_sprinklers() < num_sprinklers:
            position = next(coordinates)
            if layout.is_open(position):
                layout.add_sprinkler(position)

        return layout

    def count_sprinklers(self):
        """The current number of placed sprinklers."""
        return len(self._sprinklers)

    def _watering_positions(self, sprinkler_position):
        """Generate positions watered by a sprinkler at a
        given position."""
        x, y = sprinkler_position
        yield x - 1, y
        yield x + 1, y
        yield x, y - 1
        yield x, y + 1

    def is_open(self, position):
        """Check if a position is open for placing a
        sprinkler.

        A position is open if

        - the set of the position and its watered squares

        does not intersect with

        - the set of already placed sprinklers and their
          watered squares.

        """
        new_positions = {position, *self._watering_positions(position)}
        return not (
            new_positions & (self._sprinklers | self._watered_squares)
        )

    def add_sprinkler(self, position):
        """Add a sprinkler at a position."""
        self._sprinklers.add(position)
        self._watered_squares.update(
            self._watering_positions(position)
        )

    def print_report(self):
        """Print out a report of the current layout.

        The report includes:

        - The dimensions of the layout.
        - The materials cost of the layout.
        - A visualization of the layout.

        """
        squares = self._sprinklers | self._watered_squares
        X = [x for x, _ in squares]
        Y = [y for _, y in squares]
        span = lambda Z: range(min(Z), max(Z) + 1)
        grid = [
            [
                "#" if (x, y) in self._sprinklers else "."
                for x in span(X)
            ]
            for y in span(Y)
        ]
        width = len(span(X)) + 2
        height = len(span(Y)) + 2

        print(len(self._sprinklers), "sprinklers")
        print(len(self._watered_squares), "watered squares")
        print(width, "x", height, "squares, including perimeter wall")
        print(2 * (width + height), "square perimeter")

        block = 3
        print(f"map of sprinklers ({block} by {block} blocks)")
        for i, row in enumerate(grid):
            if i % block == 0:
                print()
            for j, square in enumerate(row):
                if j % block == 0:
                    print(end=" ")
                print(square, end="")
            print()
        print()


def spiral_coordinates():
    """Generate positions along a spiral.
    
    The first nine steps of the spiral look like this

        7 6 5  |  v < <
        8 1 4  |  v v ^
        9 2 3  |  v > ^

    """
    yield 0, 0
    for radius in itertools.count(start=1):
        x, y = 1 - radius, radius
        while x < radius:
            yield x, y
            x += 1
        while y > -radius:
            yield x, y
            y -= 1
        while x > -radius:
            yield x, y
            x -= 1
        while y < radius:
            yield x, y
            y += 1

        yield x, y


if __name__ == "__main__":
    import argparse

    parser = argparse.ArgumentParser(
        description=
            "generate a layout of sprinklers for Stardew Valley."
    )
    parser.add_argument(
        "--sprinklers",
        type=int,
        required=True,
        metavar="N",
        help="the number of sprinklers to place",
    )
    args = parser.parse_args()

    layout = Layout.generate(
        num_sprinklers=args.sprinklers,
        coordinates=spiral_coordinates()
    )
    layout.print_report()
$ sprinkler-layout --help
usage: sprinkler-layout [-h] --sprinklers N

generate a layout of sprinklers for Stardew Valley.

optional arguments:
  -h, --help      show this help message and exit
  --sprinklers N  the number of sprinklers to place

Using the script to design a layout

I'm starting a new farming season in Stardew Valley and I have 25 sprinklers available. I use sprinkler-layout to design a layout:

$ sprinkler-layout --sprinklers 25
25 sprinklers
100 watered squares
16 x 17 squares, including perimeter wall
66 square perimeter
map of sprinklers (3 by 3 blocks)

 ... ... ... ... ..
 ... ... ... ... #.
 ..# ..# ... ... ..

 ... ... .#. ..# ..
 ... #.. ... #.. ..
 .#. ..# ... ... ..

 ... ... ..# ..# ..
 ... #.. #.. ... ..
 .#. ... ... .#. ..

 ... ..# ..# ... ..
 ... #.. ... ... #.
 ... ... #.. #.. ..

 ..# ... ... ..# ..
 ... .#. .#. ... ..
 ... ... ... ... ..

Then:

#

How the script works

I use a custom class, Layout , to represent a sprinkler layout. Layout manages the internal state of:

  • Where sprinklers have been placed.
  • Which positions are watered by the placed sprinklers.

Layout has a class method , generate , that attempts to position sprinklers by choosing from given positions. generate uses a greedy strategy to place the sprinklers:

  • Loop until I have placed enough sprinklers:
    • Get the next position to try.
    • If I can place a sprinkler at this position, do it.

I check if I can place a sprinkler by using sets of coordinates and checking that these sets are disjoint :

  • the set of the new sprinkler and its watered squares.
  • the set of already placed sprinklers and their watered squares.

I have a generator function, spiral_coordinates , the generates positions in a spiral that looks like this: (starting from the center)

v < < < < < <
v v < < < < ^
v v v < < ^ ^
v v v v ^ ^ ^
v v v > ^ ^ ^
v v > > > ^ ^
v > > > > > ^
> ...

I use this technique because it designs good enough layouts for me. spiral_coordinates is simple to implement and keeps the overall perimeter of the layout small.

The report function, print_report , computes a bounding box that encloses:

  • the sprinklers that have been placed.
  • the squares that are watered by the placed sprinklers.

Then, I take into account a 1 square thick perimeter wall and report:

  • The dimensions of the bounding box.
  • The perimeter of the bounding box.

The report generates a map of the placed sprinklers and partitions it into chunks:

... ... ... ... ..
 ... ... ... ... #.
 ..# ..# ... ... ..

 ... ... .#. ..# ..
 ... #.. ... #.. ..
 .#. ..# ... ... ..

 ... ... ..# ..# ..
 ... #.. #.. ... ..
 .#. ... ... .#. ..

 ... ..# ..# ... ..
 ... #.. ... ... #.
 ... ... #.. #.. ..

 ..# ... ... ..# ..
 ... .#. .#. ... ..
 ... ... ... ... ..

I find the map harder to read without the partitioning:

..............
............#.
..#..#........
.......#...#..
...#.....#....
.#...#........
........#..#..
...#..#.......
.#........#...
.....#..#.....
...#........#.
......#..#....
..#........#..
....#..#......
..............

In conclusion...

This week's post covered a Python script that assists people playing Stardew Valley by designing a layout of sprinklers. You learned about:

  • Using Python classes to manage internal state.
  • Using Python sets to check if two sets of positions are disjoint.
  • Using a simple greedy strategy to make decisions (placing the sprinklers).

My challenge to you:

Create a different way to generate coordinates than spiral_coordinates .

For example, here's what a placement of 8 sprinklers looks like with spiral_coordinates :

Layout.generate(
    num_sprinklers = 8,
    coordinates = spiral_coordinates()
).print_report()
8 sprinklers
32 watered squares
11 x 10 squares, including perimeter wall
42 square perimeter
map of sprinklers (3 by 3 blocks)

 ... ... ...
 .#. ... .#.
 ... #.. ...

 ... ... #..
 .#. .#. ...
 ... ... ...

 ... #.. #..
 ... ... ...

And here's a placement of 8 sprinklers that tries positions only in a horizontal line:

from itertools import count

Layout.generate(
    num_sprinklers = 8,
    coordinates = ((i, 0) for i in count())
).print_report()
8 sprinklers
32 watered squares
26 x 5 squares, including perimeter wall
62 square perimeter
map of sprinklers (3 by 3 blocks)

 ... ... ... ... ... ... ... ...
 .#. .#. .#. .#. .#. .#. .#. .#.
 ... ... ... ... ... ... ... ...

If you enjoyed this week's post, share it with your friends and stay tuned for next week's post. See you then!


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK