10: Track
This is the eleventh procedural note, the first in four months.
Between the last one and this one, I’ve been working on this and other projects.
A lot of that work’s been restructuring and refactoring - including how I use this newsletter.
Up till now, I’ve been promising goals by dates and then delaying their delivery.
That’s changing with this note, because I’m not using it for intermittent progress updates anymore.
I’m using it for notifications of things I’ve published elsewhere, most often on my own site.
Starting from this one, each of these newsletters will be a preview of about half of a complete new thing, followed by a link to that new thing.
In this note, the new thing is finalized Track generation logic for Rampant on the Tracks.
Tracks are generated from Voronoi diagrams created via Voronout.
Every diagram is a collection of regions in a two-dimensional space.
Each region is concerned with a point P in that space.
A region's dimensions is determined by the idea that it contains all points closer to its own P than any other region's P.
A set of region points SP can easily produce a diagram whose regions vary in size:

With region size variance comes variance in the length of any region R's " border " with one of its neighbors:

A Track sees the set of all borders in a diagram as a collection of edges, each edge between two points.
It doesn't want that entire set, because each point - each node - can be reached from every other point through multiple different " paths " from edge to edge .
What it wants is a subset of those edges where a start and an end can be picked such that, if going from edge to edge was restricted by limited resources, it would be very likely to go from start and run out of resources before getting to end.
(Finding and traveling on a path where you could get from start to end would be more of a challenge.)
That subset would be a set of edges where every node cannot be reached from every other node.
TrackGenerator.generateTrack() creates that set using the following parameters:
diagram_width: int,
diagram_height: int,
num_diagram_regions: int,
length_min_quantile: float,
(See source for all parameters.)
diagram_width, diagram_height, and num_diagram_regions are used to generate the Voronoi diagram:
voronoi_points = tuple(Point(x = random.random(), y = random.random()) for _ in range(num_diagram_regions))
..
voronoi_diagram = VoronoiDiagram(basePoints = voronoi_points, planeWidth = diagram_width, planeHeight = diagram_height)
In this case,
diagram_width = 600
diagram_height = 600
num_diagram_regions = 18,
length_min_quantile = 0.50
and
voronoi_points = (
Point(x= 0.9463, y= 0.6669),
Point(x= 0.4353, y= 0.5272),
Point(x= 0.4222, y= 0.5968),
Point(x= 0.9876, y= 0.5229),
Point(x= 0.5794, y= 0.964),
Point(x= 0.5983, y= 0.0106),
Point(x= 0.687, y= 0.1437),
Point(x= 0.4132, y= 0.2723),
Point(x= 0.0361, y= 0.1436),
Point(x= 0.062, y= 0.2417),
Point(x= 0.8191, y= 0.4291),
Point(x= 0.5383, y= 0.8066),
Point(x= 0.5011, y= 0.365),
Point(x= 0.3063, y= 0.9677),
Point(x= 0.9008, y= 0.0296),
Point(x= 0.2748, y= 0.7383),
Point(x= 0.8428, y= 0.0683),
Point(x= 0.1703, y= 0.0407)
)
Voronout creates the diagram in a 2-D space where 0 <= x <= 1 and 0 <= y <= 1, with (x = 0, y = 0) in the upper-left, then scales the diagram's coordinates on x*=diagram_width and y*=diagram_height.
Voronout is a wrapper around SciPy's Voronoi diagram generation that
that translates its output into more easily parsible data.
Any adjustment to the generated diagram is best done by whomever generates the diagram.
In TrackGenerator's case, the adjustment is removing all node " overlaps ". The diagram generated by voronoi_points illustrates the necessity of this:

(The overlap's in the center, where it looks like four edges are meeting - that's actually where two edges are meeting overlapping with where another two edges are meeting.)
How overlaps are identified:
JUNCTION_RADIUS = 3
..
@staticmethod
def _junction_intersection_check(junction_1: Point, junction_2: Point, junction_diameter: float) -> bool:
return Point.distance(p1 = junction_1, p2 = junction_2) <= junction_diameter
..
for (voronoi_diagram_vertex_id, voronoi_diagram_vertex) in voronoi_diagram_vertices.items():
for (other_voronoi_diagram_vertex_id, other_voronoi_diagram_vertex) in voronoi_diagram_vertices.items():
if voronoi_diagram_vertex_id != other_voronoi_diagram_vertex_id and voronoi_diagram_vertex_id not in voronoi_diagram_vertex_replacements and other_voronoi_diagram_vertex_id not in voronoi_diagram_vertex_replacements:
vertices_intersect = TrackGenerator._junction_intersection_check(junction_1 = voronoi_diagram_vertex, junction_2 = other_voronoi_diagram_vertex, junction_diameter = JUNCTION_RADIUS * 2)
if vertices_intersect:
vertex_to_keep = random.choice([voronoi_diagram_vertex_id, other_voronoi_diagram_vertex_id])
vertex_to_replace = other_voronoi_diagram_vertex_id if vertex_to_keep == voronoi_diagram_vertex_id else voronoi_diagram_vertex_id
voronoi_diagram_vertex_replacements[vertex_to_replace] = vertex_to_keep
(Since voronoi_diagram_vertex and other_voronoi_diagram_vertex are so close together already, either one could resolve the overlap.
random.choice just adds variation to how the resolution happens.)
voronoi_diagram_vertex_replacements is checked when we're converting the diagram into the basis of a Track:
edge_vertex_0 = voronoi_diagram_vertex_replacements.get(voronoi_diagram_edge.vertex0Id, voronoi_diagram_edge.vertex0Id)
edge_vertex_1 = voronoi_diagram_vertex_replacements.get(voronoi_diagram_edge.vertex1Id, voronoi_diagram_edge.vertex1Id)
edge = EdgeVertexInfo(vertex_0_id = edge_vertex_0, vertex_1_id = edge_vertex_1)
The Track basis consequently starts with those overlaps resolved:

(Resolution resulted in the four edges actually meeting at one point.)
The full explanation of Track generation logic can be seen on my site, where the code excerpts are better formatted.
To see those mechanics in action as they are now, look on my site for a playable demo.
My next newsletter'll cover improvements made to procedurally generated Track graphics.