From a542bb956a965de55001af7bee407dca2b5ea0d7 Mon Sep 17 00:00:00 2001 From: Eryn Wells Date: Mon, 20 Feb 2023 18:02:01 -0800 Subject: [PATCH] Add CellularAtomataMapGenerator First pass at a cellular atomata map generator. Add map.grid and a make_grid function to make it easier to make numpy arrays for Map purposes. Add ca.py to test the generator. --- ca.py | 44 +++++++++ erynrl/map/generator/cellular_atomata.py | 120 +++++++++++++++++++++++ erynrl/map/grid.py | 10 ++ 3 files changed, 174 insertions(+) create mode 100644 ca.py create mode 100644 erynrl/map/generator/cellular_atomata.py create mode 100644 erynrl/map/grid.py diff --git a/ca.py b/ca.py new file mode 100644 index 0000000..3d2ff6e --- /dev/null +++ b/ca.py @@ -0,0 +1,44 @@ +# Eryn Wells + +''' +Run the cellular atomaton from ErynRL and print the results. +''' + +import argparse + +from erynrl import log +from erynrl.geometry import Point, Rect, Size +from erynrl.map.generator.cellular_atomata import CellularAtomataMapGenerator + + +def parse_args(argv, *a, **kw): + '''Parse command line arguments''' + parser = argparse.ArgumentParser(*a, **kw) + parser.add_argument('--rounds', type=int, default=5) + args = parser.parse_args(argv) + return args + + +def main(argv): + '''The script''' + + args = parse_args(argv[1:], prog=argv[0]) + + log.init() + + bounds = Rect(Point(), Size(20, 20)) + + config = CellularAtomataMapGenerator.Configuration() + config.number_of_rounds = args.rounds + + gen = CellularAtomataMapGenerator(bounds, config) + + gen.generate() + + print(gen) + + +if __name__ == '__main__': + import sys + result = main(sys.argv) + sys.exit(0 if not result else result) diff --git a/erynrl/map/generator/cellular_atomata.py b/erynrl/map/generator/cellular_atomata.py new file mode 100644 index 0000000..c1e2f0b --- /dev/null +++ b/erynrl/map/generator/cellular_atomata.py @@ -0,0 +1,120 @@ +# Eryn Wells + +import random +from dataclasses import dataclass +from typing import Optional + +import numpy as np + +from ... import log +from ...geometry import Point, Rect +from ..grid import make_grid +from ..tile import Floor, Wall + + +class CellularAtomataMapGenerator: + ''' + A map generator that utilizes a cellular atomaton to place floors and walls. + ''' + + @dataclass + class Configuration: + ''' + Configuration of a cellular atomaton map generator. + + ### Attributes + `fill_percentage` : `float` + The percentage of tiles to fill with Floor when the map is seeded. + `number_of_rounds` : `int` + The number of rounds to run the atomaton. 5 is a good default. More + rounds results in smoother output; fewer rounds results in more + jagged, random output. + ''' + fill_percentage: float = 0.5 + number_of_rounds: int = 5 + + def __init__(self, bounds: Rect, config: Optional[Configuration] = None): + ''' + Initializer + + ### Parameters + `bounds` : `Rect` + A rectangle representing the bounds of the cellular atomaton + `config` : `Optional[Configuration]` + A configuration object specifying parameters for the atomaton. If + None, the instance will use a default configuration. + ''' + self.bounds = bounds + self.configuration = config if config else CellularAtomataMapGenerator.Configuration() + self.tiles = make_grid(bounds.size) + + def generate(self): + ''' + Run the cellular atomaton on a grid of `self.bounds.size` shape. + + First fill the grid with random Floor and Wall tiles according to + `self.configuration.fill_percentage`, then run the simulation for + `self.configuration.number_of_rounds` rounds. + ''' + self._fill() + self._run_atomaton() + + def _fill(self): + fill_percentage = self.configuration.fill_percentage + + for y, x in np.ndindex(self.tiles.shape): + self.tiles[x, y] = Floor if random.random() < fill_percentage else Wall + + def _run_atomaton(self): + alternate_tiles = make_grid(self.bounds.size) + + number_of_rounds = self.configuration.number_of_rounds + if number_of_rounds < 1: + raise ValueError('Refusing to run cellular atomaton for less than 1 round') + + log.MAP_CELL_ATOM.info( + 'Running cellular atomaton over %s for %d round%s', + self.bounds, + number_of_rounds, + '' if number_of_rounds == 1 else 's') + + for i in range(number_of_rounds): + if i % 2 == 0: + from_tiles = self.tiles + to_tiles = alternate_tiles + else: + from_tiles = alternate_tiles + to_tiles = self.tiles + + self._do_round(from_tiles, to_tiles) + + # If we ended on a round where alternate_tiles was the "to" tile grid + # above, save it back to self.tiles. + if number_of_rounds % 2 == 0: + self.tiles = alternate_tiles + + def _do_round(self, from_tiles: np.ndarray, to_tiles: np.ndarray): + for y, x in np.ndindex(from_tiles.shape): + pt = Point(x, y) + + # Start with 1 because the point is its own neighbor + number_of_neighbors = 1 + for neighbor in pt.neighbors: + if neighbor not in self.bounds: + continue + + if from_tiles[neighbor.x, neighbor.y] == Floor: + number_of_neighbors += 1 + + tile_is_alive = from_tiles[pt.x, pt.y] == Floor + if tile_is_alive and number_of_neighbors >= 5: + # Survival + to_tiles[pt.x, pt.y] = Floor + elif not tile_is_alive and number_of_neighbors >= 5: + # Birth + to_tiles[pt.x, pt.y] = Floor + else: + to_tiles[pt.x, pt.y] = Wall + + def __str__(self): + return '\n'.join(''.join(chr(i['light']['ch']) for i in row) for row in self.tiles) diff --git a/erynrl/map/grid.py b/erynrl/map/grid.py new file mode 100644 index 0000000..b86f8af --- /dev/null +++ b/erynrl/map/grid.py @@ -0,0 +1,10 @@ +# Eryn Wells + +import numpy as np + +from .tile import Empty +from ..geometry import Size + + +def make_grid(size: Size): + return np.full(size.numpy_shape, fill_value=Empty, order='F')