Last active
September 30, 2020 00:30
-
-
Save imposeren/107bd5b311432ad40cdcfe959fb69c55 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env python3 | |
"""Dummy straight-forward implementation of Langton's Ant. | |
More ugly version but with better defaults for stdout drawings: | |
https://gist.github.com/imposeren/f8dcfa1791b6efb296a2b9428df15dfa | |
""" | |
import argparse | |
import math # Only used for "example code" that is not needed really. | |
import os | |
import shutil | |
import sys | |
import time | |
import unittest | |
from collections import namedtuple | |
from copy import deepcopy | |
from enum import Enum, auto | |
from itertools import product | |
# Note both for "Position" vectors and for state matrix it may be easier to use | |
# `np.array` (more convenient indexing and vector+vector support out of the | |
# box), but for "demonstration purposes" following code will not use numpy | |
class Vector(namedtuple('Vector', ['x', 'y'])): | |
__slots__ = () | |
def __add__(self, other): | |
cls = type(self) | |
if not isinstance(other, cls): | |
other = cls(other[0], other[1]) | |
return cls(self.x + other.x, self.y + other.y) | |
class Direction(int, Enum): | |
"""Four directions where ant can be headed. | |
Incrementing direction by 1 means clockwise rotation by 90 degrees. | |
""" | |
North = 0 | |
East = 1 | |
South = 2 | |
West = 3 | |
# Aliases: | |
N = North | |
Up = North | |
U = North | |
E = East | |
Right = East | |
R = East | |
S = South | |
Down = South | |
D = South | |
W = West | |
Left = West | |
L = West | |
@classmethod | |
def _missing_(cls, value): | |
if value > 3 or value < 0: | |
value = value % 4 | |
return cls(value) | |
@property | |
def shift_vector(self): | |
cls = type(self) | |
if self == cls.North: | |
v = (0, -1) | |
elif self == cls.South: | |
v = (0, 1) | |
elif self == cls.West: | |
v = (-1, 0) | |
elif self == cls.East: | |
v = (1, 0) | |
else: | |
raise NotImplementedError( | |
'Direction subclass defines member with unknown shift vector' | |
) | |
# Universal approach may look like this: | |
# Use West as zero value and go counter-closkwise: | |
pre_rad_value = (1-self.value) % 4 | |
# Convert value to radians: | |
rad_value = pre_rad_value / 2 * math.pi | |
x = math.cos(rad.value) | |
y = -math.sin(rad.value) | |
# We should only shift to integer coords of specific cell, | |
# so shift_vector must be rounded | |
v = round(x), round(y) | |
return Vector(*v) | |
def __add__(self, other): | |
cls = type(self) | |
if isinstance(other, cls): | |
raise TypeError( | |
'Can\'t add directions together, because they are not "angles"' | |
) | |
# They can be angles if one of them is considered as a start of | |
# coordinates but that will complicate things (should North be | |
# considered as zero or east as in math? Should they be | |
# incrementing clockwise or counter-clockwise?) | |
return cls(self.value+other) | |
class EdgeHandling(Enum): | |
OPPOSITES_CONNECTED = auto() | |
#: Going through edge means starging near opposite edge | |
INFINITE = auto() | |
#: Game field is not limited (automatically grows) | |
EXTRA_ROTATION = auto() | |
#: If rotation will cause ant to hit a wall, then he should rotate more. | |
class LangtonsAnt: | |
"""Initialize a handler for Langtons's Ant "game".""" | |
def __init__( | |
self, | |
state_or_size, | |
start_position=(0, 0), | |
start_direction=Direction.North, | |
default_filler=False, | |
edge_handling=EdgeHandling.OPPOSITES_CONNECTED, | |
grow_step=5): | |
try: | |
# Initialized with size | |
width, height = tuple(map(int, state_or_size)) | |
self._cells_state = [ | |
[default_filler for _i in range(width)] | |
for _j in range(height) | |
] | |
except TypeError: | |
self._cells_state = state_or_size | |
width = len(state_or_size[0]) | |
height = len(state_or_size) | |
self._width = width | |
self._height = height | |
self._default_filler = default_filler | |
self._edge_handling = edge_handling | |
self._grow_step = grow_step | |
if isinstance(start_position, Vector): | |
self._ant_position = start_position | |
else: | |
self._ant_position = Vector(*start_position) | |
self._ant_direction = start_direction | |
self._validate_size() | |
self._reset_prev_states() | |
self._prev_cells_state = None | |
self._prev_ant_state = None | |
def _validate_size(self): | |
if self.width < 2 or self.height < 2: | |
if self.edge_handling == EdgeHandling.EXTRA_ROTATION: | |
raise ValueError( | |
'Size of limited field should be at least 2x2.' | |
) | |
elif self.edge_handling == EdgeHandling.OPPOSITES_CONNECTED: | |
if width >= 4 or height >= 4: | |
# Allow case when field is a single line | |
pass | |
else: | |
raise ValueError( | |
'Size of field with connected edges should be at ' | |
'least 1x4 or 4x1.' | |
) | |
elif self.edge_handling == EdgeHandling.INFINITE: | |
# Field may grow, so it's ok | |
pass | |
else: | |
raise NotImplementedError( | |
'Handling of field smaller than 2x2 is not implemented for ' | |
f'{edge_handling} behaviour.' | |
) | |
def _reset_prev_states(self): | |
self._prev_cells_state = None | |
self._prev_ant_state = None | |
@property | |
def width(self): | |
return self._width | |
@property | |
def height(self): | |
return self._height | |
@property | |
def edge_handling(self): | |
return self._edge_handling | |
@property | |
def state(self): | |
"""State of cells in game.""" | |
return deepcopy(self._cells_state) | |
@property | |
def full_state(self): | |
"""Return list of tuples for each cell containing `(cell_state, ant_info)`. | |
In the tuple `ant_info` is either `None` or member of `:py:cls:Direction` | |
reperesenting direction of ant located in the cell. | |
""" | |
full_state = [ | |
[(cell_state, None) for cell_state in row] | |
for row in self._cells_state | |
] | |
x, y = self._ant_position | |
full_state[y][x] = (full_state[y][x][0], self._ant_direction) | |
return full_state | |
@property | |
def ant_position(self): | |
return self._ant_position | |
@property | |
def ant_direction(self): | |
return self._ant_direction | |
@property | |
def ant_state(self): | |
return (self.ant_position, self.ant_direction) | |
def draw(self, mapping=None, border=' '): | |
if len(border) >= 2: | |
raise ValueError('Only single character borders are supported') | |
if mapping is None: | |
mapping = { | |
(False, None): ' ', | |
(False, Direction.Up): '⇧', | |
(False, Direction.Down): '⇩', | |
(False, Direction.Left): '⇦', | |
(False, Direction.Right): '⇨', | |
(True, None): '░', | |
(True, Direction.Up): '▀', | |
(True, Direction.Down): '▃', | |
(True, Direction.Left): '▌', | |
(True, Direction.Right): '▐', | |
} | |
if border: | |
print(border*(self.width+2)) | |
for y, row in enumerate(self.state): | |
print(border, end='') | |
for x, cell_state in enumerate(row): | |
key = ( | |
cell_state, | |
self.ant_direction if self.ant_position == (x, y) else None, | |
) | |
print(mapping[key], end='') | |
print(border) | |
if border: | |
print(border*(self.width+2)) | |
def next(self): | |
self._prev_cells_state = self.state | |
self._prev_ant_state = self.ant_state | |
x, y = self._ant_position | |
current_cell = self._cells_state[y][x] | |
# Rotate the ant: | |
rotation = 1 - current_cell*2 | |
# Note: ^^^ this can be done with `if c_cell: d+=1; else: d-=1`, but doing | |
# this "mathematically" saves a little bit of time. | |
self._ant_direction += rotation | |
# Flip current cell: | |
self._cells_state[y][x] = not current_cell | |
# Move the ant: | |
new_position = None | |
while new_position is None: | |
new_position = self._ant_position + self._ant_direction.shift_vector | |
out_of_bounds = ( | |
new_position.x+1 > self.width | |
or | |
new_position.y+1 > self.height | |
or | |
new_position.x < 0 | |
or | |
new_position.y < 0 | |
) | |
if out_of_bounds: | |
if self.edge_handling == EdgeHandling.OPPOSITES_CONNECTED: | |
new_position = self._move_through_connected_edge(new_position) | |
elif self.edge_handling == EdgeHandling.EXTRA_ROTATION: | |
self._ant_direction += rotation | |
new_position = None # Recalculate new position | |
elif self.edge_handling == EdgeHandling.INFINITE: | |
new_position = self._grow_field(new_position) | |
self._ant_position = new_position | |
def revert(self): | |
if not (self._prev_cells_state and self._prev_ant_state): | |
raise RuntimeError("Previous state unknown!") | |
self._cells_state = self._prev_cells_state | |
self._ant_position, self._ant_direction = self._prev_ant_state | |
self._reset_prev_states() | |
def force_cell(self, value, x=None, y=None): | |
"""Force state of the cell at some coords (defaults to ant position). | |
""" | |
if (x, y) == (None, None): | |
x, y = self.ant_position | |
else: | |
if self.edge_handling == EdgeHandling.OPPOSITES_CONNECTED: | |
x, y = self._move_through_connected_edge((x, y)) | |
elif self.edge_handling == EdgeHandling.INFINITE: | |
x, y = self._grow_field(new_position) | |
else: | |
# Just let IndexError to be raised if x or y are incorrect. | |
pass | |
self._cells_state[y][x] = value | |
self._reset_prev_states() | |
def force_ant_direction(self, new_direction): | |
self._ant_direction = new_direction | |
self._reset_prev_states() | |
def _move_through_connected_edge(self, position): | |
return_vector = True | |
if not isinstance(position, Vector): | |
return_vector = False | |
position = Vector(*position) | |
# If ant moves through the edge, then move him to opposite edge: | |
normalized_x = position.x % self.width | |
normalized_y = position.y % self.height | |
if return_vector: | |
return Vector(normalized_x, normalized_y) | |
else: | |
return (normalized_x, normalized_y) | |
def _grow_field(self, new_position): | |
if not isinstance(new_position, Vector): | |
new_position = Vector(*new_position) | |
prepend_rows = 0 | |
append_rows = 0 | |
prepend_cols = 0 | |
append_cols = 0 | |
height_change = 0 | |
width_change = 0 | |
if new_position.y < 0: | |
prepend_rows = abs(new_position.y) | |
prepend_rows = max(prepend_rows, self._grow_step) | |
height_change += prepend_rows | |
elif new_position.y >= self.height: | |
append_rows = 1 + new_position.y - self.height | |
append_rows = max(append_rows, self._grow_step) | |
height_change += append_rows | |
if new_position.x < 0: | |
prepend_cols = abs(new_position.x) | |
prepend_cols = max(prepend_cols, self._grow_step) | |
width_change += prepend_cols | |
elif new_position.x >= self.width: | |
append_cols = 1 + new_position.x - self.width | |
append_cols = max(append_cols, self._grow_step) | |
width_change += append_cols | |
new_height = self.height + height_change | |
new_width = self.width + width_change | |
if prepend_rows: | |
# Note: if dequeue is used with extendleft then it's better than | |
# current approach. And extend_left is also better because it does | |
# not create a new object (changes existing one) | |
self._cells_state = [ | |
[self._default_filler for _i in range(new_width)] | |
for _j in range(prepend_rows) | |
] + self._cells_state | |
new_position = new_position + (0, prepend_rows) | |
if append_rows: | |
self._cells_state.extend([ | |
[self._default_filler for _i in range(new_width)] | |
for _j in range(append_rows) | |
]) | |
if prepend_cols or append_cols: | |
for y, current_row in enumerate(self._cells_state[:]): | |
# Note: See note about prepend_rows. | |
self._cells_state[y] = ( | |
[self._default_filler for _i in range(prepend_cols)] | |
+ | |
current_row | |
+ | |
[self._default_filler for _i in range(append_cols)] | |
) | |
if prepend_cols: | |
new_position = new_position + (prepend_cols, 0) | |
self._height = new_height | |
self._width = new_width | |
return new_position | |
class TestDirection(unittest.TestCase): | |
def test_aliases(self): | |
self.assertEqual(Direction.North, Direction.N) | |
self.assertEqual(Direction.North, Direction.Up) | |
self.assertEqual(Direction.North, Direction.U) | |
self.assertEqual(Direction.East, Direction.E) | |
self.assertEqual(Direction.East, Direction.Right) | |
self.assertEqual(Direction.East, Direction.R) | |
self.assertEqual(Direction.South, Direction.S) | |
self.assertEqual(Direction.South, Direction.Down) | |
self.assertEqual(Direction.South, Direction.D) | |
self.assertEqual(Direction.West, Direction.W) | |
self.assertEqual(Direction.West, Direction.Left) | |
self.assertEqual(Direction.West, Direction.L) | |
def test_rotation(self): | |
self.assertEqual(Direction(Direction.W+1), Direction.N) | |
self.assertEqual(Direction(Direction.N-1), Direction.W) | |
self.assertEqual(Direction(Direction.W+3), Direction.S) | |
self.assertEqual(Direction(Direction.E+1), Direction.S) | |
self.assertEqual(Direction(Direction.E+2), Direction.W) | |
def test_shift_vectors(self): | |
self.assertEqual(Direction.Up.shift_vector, (0, -1)) | |
self.assertEqual(Direction.Down.shift_vector, (0, 1)) | |
self.assertEqual(Direction.Left.shift_vector, (-1, 0)) | |
self.assertEqual(Direction.Right.shift_vector, (1, 0)) | |
class TestLangtonsAntSimple(unittest.TestCase): | |
"""Test simple moves of LangtonsAnt game.""" | |
def setUp(self): | |
super().setUp() | |
self.game = LangtonsAnt( | |
[ | |
[False for _i in range(3)] | |
for _j in range(3) | |
], | |
(1, 1), | |
Direction.North, | |
) | |
def test_force_cell(self): | |
game = self.game | |
self.assertEqual(game.state[1][1], False) | |
game.force_cell(True) | |
self.assertEqual(game.state[1][1], True) | |
game.force_cell(False) | |
self.assertEqual(game.state[1][1], False) | |
game.force_cell(True, x=1, y=2) | |
self.assertEqual(game.state[2][1], True) | |
game.force_cell(False, x=1, y=2) | |
self.assertEqual(game.state[2][1], False) | |
def test_force_ant_direction(self): | |
D = Direction | |
game = self.game | |
self.assertEqual(game.ant_state, ((1, 1), D.North)) | |
self.assertEqual(game.ant_direction, D.North) | |
for new_d in (D.East, D.South, D.West, D.North): | |
game.force_ant_direction(new_d) | |
self.assertEqual(game.ant_direction, new_d) | |
def test_revert(self): | |
game = self.game | |
original_state = deepcopy(game.state) | |
self.assertEqual(game.state, original_state) | |
game.next() | |
self.assertNotEqual(game.state, original_state) | |
game.revert() | |
self.assertEqual(game.state, original_state) | |
def test_moves(self): | |
"""Simple move tests. | |
Ant should determine rotation direction, rotate, | |
flip current cell and move. | |
""" | |
D = Direction | |
game = self.game | |
cell_states = [0, 1] | |
starting_directions = [D.U, D.R, D.D, D.L] | |
for cell_state, direction in product(cell_states, starting_directions): | |
with self.subTest(cell_state=cell_state, direction=direction): | |
game.force_cell(cell_state) | |
game.force_ant_direction(direction) | |
# Flip color: | |
new_state = (not cell_state) | |
# Rotate: | |
if not cell_state: | |
new_direction = D(direction + 1) | |
else: | |
new_direction = D(direction - 1) | |
# Move: | |
new_x, new_y = game.ant_position + new_direction.shift_vector | |
game.next() | |
self.assertEqual( | |
game.full_state[1][1], | |
(not cell_state, None), | |
"Initial cell should be flipped and without the Ant", | |
) | |
self.assertEqual( | |
game.full_state[new_y][new_x], | |
(False, new_direction), | |
"Ant should be moved to new cell without changing it's state" | |
) | |
game.revert() | |
class TestLangtonsAntLimitedFieldProcess(unittest.TestCase): | |
"""Test different game states progression. | |
Legend for symbols used on game state "doc-representation": | |
* `⇧⇩⇦⇨` — ant standing on False cell heading in some Direction. | |
* `↑↓←→` — ant standing on `True` cell heading in some Direction. | |
* `0` — empty `False` cell. | |
* `1` — empty `True` cell. | |
* `#` — empty cell with unknown color that is not important for current | |
context. | |
* `TV<>` -- ant headed in some Direction and MOVING there at the time. | |
""" | |
def setUp(self): | |
super().setUp() | |
self.game2x2 = LangtonsAnt( | |
[ | |
[False, False], | |
[False, True], | |
], | |
(0, 0), | |
Direction.North, | |
) | |
self.game11x11 = LangtonsAnt( | |
(11, 11), | |
(5, 5), | |
Direction.W, | |
) | |
def test_small_game(self): | |
"""Test ant on small cyclic field.""" | |
# Description of state changes: | |
# "Cyclic" field means that right edge of field is connected to the left | |
# edge, and top edge to the bottom edge. Examples: | |
# 1> transitions to →# | |
# ## ## | |
# | |
# #0 transitions to #⇩ | |
# #V ## | |
# Initial state (0): | |
# ⇧0 | |
# 01 | |
# Before flipping color: | |
# ⇨0 | |
# 01 | |
# Next state (1): | |
# 1⇨ | |
# 01 | |
# Next state (2): | |
# 11 | |
# 0↓ | |
# Befor flipping color: | |
# 11 | |
# 0→ | |
# Next state (3): | |
# 11 | |
# ⇨0 | |
# Next state (4): | |
# ↓1 | |
# 10 | |
# Next state (5): | |
# 0→ | |
# 10 | |
# Next state (6): | |
# 00 | |
# 1⇧ | |
# Next state (7): | |
# 00 | |
# →1 | |
# Next state (8): | |
# ⇧0 | |
# 01 | |
# (Equals to state 0) | |
game = self.game2x2 | |
self.assertEqual( | |
game.full_state, | |
[ | |
[(0, Direction.U), (0, None)], | |
[(0, None), (1, None)], | |
] | |
) | |
game.next() | |
self.assertEqual( | |
game.full_state, | |
[ | |
[(1, None), (0, Direction.R)], | |
[(0, None), (1, None)], | |
] | |
) | |
game.next() | |
self.assertEqual( | |
game.full_state, | |
[ | |
[(1, None), (1, None)], | |
[(0, None), (1, Direction.D)], | |
] | |
) | |
game.next() | |
self.assertEqual( | |
game.full_state, | |
[ | |
[(1, None), (1, None)], | |
[(0, Direction.R), (0, None)], | |
] | |
) | |
game.next() | |
self.assertEqual( | |
game.full_state, | |
[ | |
[(1, Direction.D), (1, None)], | |
[(1, None), (0, None)], | |
] | |
) | |
game.next() | |
self.assertEqual( | |
game.full_state, | |
[ | |
[(0, None), (1, Direction.R)], | |
[(1, None), (0, None)], | |
] | |
) | |
game.next() | |
self.assertEqual( | |
game.full_state, | |
[ | |
[(0, None), (0, None)], | |
[(1, None), (0, Direction.U)], | |
] | |
) | |
game.next() | |
self.assertEqual( | |
game.full_state, | |
[ | |
[(0, None), (0, None)], | |
[(1, Direction.R), (1, None)], | |
] | |
) | |
game.next() | |
self.assertEqual( | |
game.full_state, | |
[ | |
[(0, Direction.U), (0, None)], | |
[(0, None), (1, None)], | |
] | |
) | |
def test_big_game(self): | |
"""Test bigger game (using animation from wiki as reference).""" | |
game = self.game11x11 | |
game.next() | |
# Filled and moved up: | |
self.assertEqual(game.full_state[5][5], (1, None)) | |
self.assertEqual(game.full_state[4][5], (0, Direction.U)) | |
game.next() | |
# Filled and moved right: | |
self.assertEqual(game.full_state[4][5], (1, None)) | |
self.assertEqual(game.full_state[4][6], (0, Direction.R)) | |
game.next() | |
# Filled and moved down: | |
self.assertEqual(game.full_state[4][6], (1, None)) | |
self.assertEqual(game.full_state[5][6], (0, Direction.D)) | |
game.next() | |
# Filled and moved left (to already filled cell): | |
self.assertEqual(game.full_state[5][6], (1, None)) | |
self.assertEqual(game.full_state[5][5], (1, Direction.L)) | |
game.next() | |
# Cleared and moved down: | |
self.assertEqual(game.full_state[5][5], (0, None)) | |
self.assertEqual(game.full_state[6][5], (0, Direction.D)) | |
game.next() | |
# Filled and moved left: | |
self.assertEqual(game.full_state[6][5], (1, None)) | |
self.assertEqual(game.full_state[6][4], (0, Direction.L)) | |
def test_big_game_rotation_behaviour(self): | |
"""Test bigger game using rotation behaviour for edges.""" | |
game = LangtonsAnt( | |
(3, 3), | |
(1, 1), | |
Direction.W, | |
edge_handling=EdgeHandling.EXTRA_ROTATION, | |
) | |
for _ in range(6): | |
game.next() | |
expected_state = [ | |
[0, 1, 1], | |
[0, 0, 1], | |
[0, 1, 0], | |
] | |
self.assertEqual( | |
game.state, | |
expected_state, | |
) | |
for _ in range(4): | |
game.next() | |
expected_state = [ | |
[0, 1, 1], | |
[1, 1, 1], | |
[1, 0, 0], | |
] | |
self.assertEqual( | |
game.state, | |
expected_state, | |
) | |
# Next move from x=2, y=2 should go down to (x=2, y=3)), but it can't, | |
# so it will go to left (x=1, y=2). | |
# But this does not affect expected state of cells yet (only state of | |
# ant). | |
game.next() | |
expected_state = [ | |
[0, 1, 1], | |
[1, 1, 1], | |
[1, 0, 1], | |
] | |
self.assertEqual( | |
game.state, | |
expected_state, | |
) | |
self.assertEqual( | |
game.ant_position, | |
(1, 2), | |
"Ant should not move beyond board." | |
) | |
# Next move should be different from infinite game field. | |
game.next() | |
expected_state = [ | |
[0, 1, 1], | |
[1, 1, 1], | |
[1, 1, 1], | |
] | |
self.assertEqual( | |
game.state, | |
expected_state, | |
) | |
self.assertEqual( | |
game.ant_position, | |
(1, 1), | |
) | |
class TestLangtonsAntInfiniteFieldProcess(unittest.TestCase): | |
def test_big_game(self): | |
"""Test similar to TestLangtonsAntLimitedFieldProcess.test_big_game but on infinite board.""" | |
grow_step = 3 | |
initial_widht = 2 | |
initial_height = 2 | |
game = LangtonsAnt( | |
(initial_widht, initial_height), | |
(0, 0), | |
Direction.W, | |
edge_handling=EdgeHandling.INFINITE, | |
grow_step=grow_step, | |
) | |
for _ in range(6): | |
game.next() | |
new_width = initial_widht + grow_step | |
new_height = initial_height + grow_step | |
expected_state = [ | |
[0 for _ in range(new_width)] | |
for _j in range(new_height) | |
] | |
expected_state[-3][-2:] = [1, 1] | |
expected_state[-2][-2:] = [0, 1] | |
expected_state[-1][-2:] = [1, 0] | |
self.assertEqual( | |
game.state, | |
expected_state, | |
) | |
game.next() | |
expected_state[-1][-3:] = [1, 1, 0] | |
self.assertEqual( | |
game.state, | |
expected_state, | |
) | |
for _ in range(6): | |
game.next() | |
expected_state.extend([ | |
[0 for _i in range(new_width)] | |
for _j in range(grow_step) | |
]) | |
expected_state[-6][-3:] = [0, 1, 1] | |
expected_state[-5][-3:] = [1, 1, 1] | |
expected_state[-4][-3:] = [1, 0, 1] | |
expected_state[-3][-3:] = [0, 1, 1] | |
self.assertEqual( | |
game.state, | |
expected_state, | |
) | |
# Do all the remaining moves from wiki animation: | |
# https://upload.wikimedia.org/wikipedia/commons/0/09/LangtonsAntAnimated.gif | |
for _ in range(200-6-6-1-1): | |
game.next() | |
expected_image = ( | |
" ", | |
" ", | |
" ▓▓ ", | |
" ▓▓▓▓▓▓ ", | |
" ▓ ▓ ▓ ▓", | |
" ▓ ▓ ▓ ▓", | |
" ▓ ▓▓ ▓ ▓", | |
" ▓ ▓ ▓ ▓", | |
" ▓ ▓▓▓ ▓▓ ", | |
" ▓▓▓▓▓ ▓ ", | |
" ▓▓ ", | |
) | |
expected_state = [ | |
[True if char == '▓' else False for char in image_row] | |
for image_row in expected_image | |
] | |
self.assertEqual( | |
game.state, | |
expected_state, | |
) | |
self.assertEqual( | |
game.ant_position, | |
(9, 10), | |
) | |
def __parse_start_coord(raw_coord_value, full_size, fallback=0): | |
if raw_coord_value: | |
if raw_coord_value.endswith('%'): | |
numeric_str = raw_coord_value[:-1] | |
val_is_percents = True | |
else: | |
numeric_str = raw_coord_value | |
val_is_percents = False | |
coord_value = int(numeric_str) | |
if val_is_percents: | |
coord_value = int(full_size*coord_value/100) | |
return coord_value | |
else: | |
return fallback | |
def main(): | |
parser = argparse.ArgumentParser( | |
description="Draw Langton's ant state or run tests" | |
) | |
parser.add_argument( | |
'--test', dest='run_tests', action='store_const', const=True, | |
default=None, | |
help=( | |
'Run the tests (default: False if drawing to terminal, true ' | |
'otherwise' | |
) | |
) | |
parser.add_argument( | |
'--width', dest='draw_width', type=int, default=None, | |
metavar='NUM_COLS', | |
help='Drawing width in columns (defaults to terminal width minus 3)' | |
) | |
parser.add_argument( | |
'--height', dest='draw_height', type=int, default=None, | |
metavar='NUM_LINES', | |
help='Drawing width in lines (defaults to terminal height minus 3)' | |
) | |
parser.add_argument( | |
'--frame-duration', dest='frame_duration', type=float, default=0.1, | |
help=( | |
'Number of seconds for displaying one iteration (default: ' | |
'%(default)s)' | |
), | |
) | |
parser.add_argument( | |
'num_iterations', type=int, default=0, nargs='?', | |
metavar='N', | |
help=( | |
'Number of moves ant should make. Required to output drawing in ' | |
'terminal' | |
), | |
) | |
parser.add_argument( | |
'start_x', default=None, nargs='?', | |
metavar='AntX', | |
help=( | |
'Starting X coord of ant in percents of width if ends with %% or as plain ' | |
'index otherwise (default: around 50%%)' | |
) | |
) | |
parser.add_argument( | |
'start_y', default=None, nargs='?', | |
metavar='AntY', | |
help=( | |
'Starting Y coord of ant in percents of height if ends with %% or as plain ' | |
'index otherwise (default: around 50%%)' | |
) | |
) | |
parser.add_argument( | |
'--start-direction', dest='start_direction', default='Left', | |
choices=['Up', 'Down', 'Left', 'Right'], | |
metavar='ARROW_DIRECTION', | |
help=( | |
'Starting direction of the ant ' | |
'(default: %(default)s). ' | |
'Choices: %(choices)s' | |
), | |
) | |
parser.add_argument( | |
'--edge-handling', dest='edge_handling', default='OPPOSITES_CONNECTED', | |
choices=['OPPOSITES_CONNECTED', 'EXTRA_ROTATION'], | |
metavar='BEHAVIOUR', | |
help=( | |
'How ant should behave when it\'s going to hit edge of drawing ' | |
'(default: %(default)s). ' | |
'Choices: %(choices)s' | |
) | |
) | |
args = vars(parser.parse_args(sys.argv[1:])) | |
run_tests = args['run_tests'] | |
num_iterations = args['num_iterations'] | |
frame_duration = args['frame_duration'] | |
print_drawing = num_iterations >= 1 | |
if run_tests is None: | |
run_tests = (args['num_iterations'] == 0) | |
if run_tests: | |
unittest.main() | |
if print_drawing: | |
draw_width = args['draw_width'] | |
draw_height = args['draw_height'] | |
if not (draw_width and draw_height): | |
term_size = shutil.get_terminal_size((1, 1)) | |
term_w, term_h = term_size.columns, term_size.lines | |
if draw_width is None: | |
draw_width = max(term_w - 3, 0) | |
if draw_height is None: | |
draw_height = max(term_h - 3, 0) | |
if draw_width < 4 or draw_height < 4: | |
print( | |
'Drawing size too small or info about terminal size is not ' | |
'available.', | |
file=sys.stderr, | |
) | |
return sys.exit(1) | |
if args['start_direction'] == 'Left': | |
start_x_fallback_idx = 25 | |
start_y_fallback_idx = 30 | |
elif args['start_direction'] == 'Up': | |
start_x_fallback_idx = max(0, draw_width - 30) | |
start_y_fallback_idx = 25 | |
elif args['start_direction'] == 'Right': | |
start_x_fallback_idx = max(0, draw_width - 25) | |
start_y_fallback_idx = max(0, draw_height - 30) | |
elif args['start_direction'] == 'Down': | |
start_x_fallback_idx = 30 | |
start_y_fallback_idx = max(0, draw_height - 25) | |
try: | |
start_x = __parse_start_coord( | |
args['start_x'], | |
draw_width, | |
fallback=min( | |
int(draw_width*0.5), | |
start_x_fallback_idx, | |
), | |
) | |
except ValueError: | |
print( | |
f'Can\'t parse `AntX` value' | |
) | |
return sys.exit(1) | |
try: | |
start_y = __parse_start_coord( | |
args['start_y'], | |
draw_height, | |
fallback=min( | |
int(draw_height*0.5), | |
start_y_fallback_idx | |
), | |
) | |
except ValueError: | |
print( | |
f'Can\'t parse `AntY` value' | |
) | |
return sys.exit(1) | |
game = LangtonsAnt( | |
(draw_width, draw_height), | |
(start_x, start_y), | |
start_direction=getattr(Direction, args['start_direction']), | |
edge_handling=getattr(EdgeHandling, args['edge_handling']), | |
) | |
try: | |
# Hide cursor: | |
if os.name == 'posix': | |
print('\033[?25l', end='', flush=True) | |
# Do the drawing: | |
if frame_duration <= 0: | |
print('Calculating game state... 0%') | |
frame_duration = 3 | |
skip_frames = True | |
else: | |
skip_frames = False | |
last_draw_time = time.time() | |
cleared = False | |
for i in range(num_iterations-1): | |
needs_draw = False | |
# Update game state: | |
game.next() | |
# Ensure frame duration for single drawing: | |
t_now = time.time() | |
next_draw_time = last_draw_time + frame_duration | |
remaining_time = next_draw_time - t_now | |
if remaining_time <= 0: | |
last_draw_time = t_now | |
needs_draw = True | |
elif not skip_frames: | |
time.sleep(remaining_time) | |
last_draw_time = next_draw_time | |
needs_draw = True | |
if needs_draw: | |
if not cleared: | |
# Clear screen: | |
os.system('cls' if os.name == 'nt' else 'clear') | |
cleared = True | |
else: | |
# Move cursor to start of the term: | |
if os.name == 'posix': | |
print('\033[0;0H', end='') | |
elif os.name == 'nt': | |
os.system('cls') | |
progress = int(100*i/num_iterations) | |
game.draw() | |
print(f'Calculating game state... {progress}%') | |
finally: | |
# Move and show cursor: | |
if os.name == 'posix': | |
print(f'\033[{draw_height+3};0H', end='', flush=True) | |
print('\033[?25h', end='') | |
if __name__ == '__main__': | |
main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment