This week has been another very productive one! Let’s dig in and check out the progress!
Building
As you might recall, I left off last week with the inlays drilled to lay the stepper motors in. On Monday I jumped straight in where I left off by sanding down these inlays. My goal here was to help promote a better fit as well as easier adjustment. Below is a photo of these inlays.
After cleaning up the inlays, I was able to add some legs to the back of the clock face, acting as a support to keep it upright. I was also able to add adjustable rubber feet onto these to ensure the clock’s stability.
After I had this done, I attached a spacer to the front clock face (displaying hours). Lifting it from the rest of clock, this allows for the 2nd clock face (displaying minutes) to be accessible from the stepper motors. Pic below
After I was able to get the main pieces of the clock’s frame assembled I moved on to the remaining parts. I fitted the stepper motors in their inlays, and attached the pulleys to the motors. I then attached some rope to the gears I’m using to display the time. This brought it all together and allowed a peak into the final product.
After I got everything assembled, I quickly tore it back apart. I had stained the wood already but now it was time to add a top coat. I went with a semi-satin finish to bring the grain out without making it too glossy. Unfortunately, there’s a 24 hour drying period between coats so from Friday forward I had to put a hold on things while applying coats and waiting for them to dry.
I’m happy to say I hit my goal for the week, and even added onto it with the top coat I didn’t originally factor in (use protection kids!). This upcoming week, I’m hoping to fit all the electronics to the model, and get a start on translating that code from my first render into C+. Thanks for reading, stay posted 🙂
All right, lets get down to it, I am genuinely curious how many variables I can add to a simulation… Coded myself… Using Python, Sqlite, Matplotlib, and have it continue for a lengthy period of time… How much? you might ask. The answer is, of course, AS MUCH AS WE CAN GET! ONWARD!
To start with, I think I will use the tutorial here to get my feet wet on animated graphs with Matplotlib, because who wants to watch the statistics of your simulation in snapshots? (Not this guy)
We also need to think about data storage, I think (getting the objects that will be my actors/variables to behave in a semi ‘normal’ way). In the short term, we can stick with just local variables like lists and dictionaries. However, reaching any further than even just a couple of variables we, I think, should implement a Sqlite back end to store the information. This will make retrieval and multi-threaded things easier I think.
Let’s also define when the simulation is ‘broken’ and should be ‘stopped’… Let’s say:
When any one variable climbs way beyond any other.
When all variables are ‘dead.’
When a ‘steady state’ is achieved.
Meaning we lose interest in the animated graph because it no longer seems interesting… Though a steady state is kind of fun to think about.
Like, how? You know? How is everything 1 to 1 like that? Idk…
Time to code!
# -*- coding: utf-8 -*-
"""
Created on Tue Jan 11 18:13:50 2022
@author: Travis Adsitt
"""
from dataclasses import dataclass
from enum import Enum
import matplotlib.pyplot as plt
import random
GESTATION_TIME = 9
LIFE_TIME = 876
class Gender(Enum):
Male = "Male"
Female = "Female"
@dataclass(init=True)
class WorldTraits:
population: list
time: int
class World:
def __init__(self, population):
assert isinstance(population, int), "population must be of type 'int'"
self.traits = WorldTraits(
population=[],
time=0
)
self.traits.population = [Person(self, 0.0) for p in range(0, population)]
def birth(self):
"""
Helper function for the Person object to inform the World that they have
had a child.
Returns
-------
None.
"""
# Create a new person
new_peep = Person(self, 0.0)
# Add them to the population
self.traits.population.append(new_peep)
def attempt_mate(self, person_one, person_two):
"""
Helper function to handle the resolution as to whether to individuals
can mate, and attempt to impregnate them if they can
Parameters
----------
person_one : Person
Any individual person
person_two : Person
Any other individual person
Returns
-------
None.
"""
# Check if they are female
one_kids = person_one.traits.gender == Gender.Female
two_kids = person_two.traits.gender == Gender.Female
# Check if one female and one male
if one_kids and not two_kids or not one_kids and two_kids:
# Attempt impregnation on the correct individual
if one_kids:
person_one.attempt_to_impregnate(self.traits.time)
else:
person_two.attempt_to_impregnate(self.traits.time)
def reproduction_cycle(self):
"""
Helper function to handle the reproduction behavior of the population.
Returns
-------
None.
"""
# Shuffle everyone so we can pick peeps at 'random'
random.shuffle(self.traits.population)
# Make a copy of the population to ensure we don't change under the
# iterator
pop_copy = self.traits.population.copy()
people_iterator = iter(pop_copy)
# attempt mating in twos
for person in people_iterator:
try:
self.attempt_mate(person, next(people_iterator))
except StopIteration:
break
def tick_time(self):
"""
Helper function to push the world forward one month at a time.
Returns
-------
None.
"""
# Tick time on all people
for person in self.traits.population:
person.tick_time(self.traits.time)
# Try and mate
self.reproduction_cycle()
# Tick our time forward
self.traits.time += 1
@dataclass(init=True)
class PersonTraits:
alive: bool
ispregnant: bool
pregnancystart: int
age: int
gender: Gender
money: float
class Person:
def __init__(self, world, money):
assert isinstance(world, World), "world, must be of type 'World'"
assert isinstance(money, float), "money must be of type 'float'"
# Set our world object
self.world = world
# Set our initial 'last_time'
self.last_time = self.world.traits.time
# Set our initial traits
self.traits = PersonTraits(
alive=True,
ispregnant=False,
pregnancystart=None,
age=0,
gender=Gender[random.choice([g.name for g in Gender])],
money=money
)
def spend_money(self, amount):
"""
Used to check if this person can spend money, and if so, does spend
the amount passed in.
Parameters
----------
amount : float
How much money should I spend?
Returns
-------
bool
If I spent the money
"""
spent = False
if(self.money >= amount):
self.money -= amount
spent = True
return spent
def advance_automatic_tickers(self, time):
"""
Helper function to advance the automatic tickers for a person, such as
age or checking for a pregnancy... Those sorts of things.
Parameters
----------
time : int
The current time in the world.
Returns
-------
None.
"""
# We get older
self.traits.age += time - self.last_time
# We sometimes die
if(self.traits.age > 876):
self.traits.alive = False
# Sometimes people give birth
if self.traits.ispregnant and ((time - self.traits.pregnancystart) > GESTATION_TIME):
self.world.birth()
self.traits.ispregnant = False
self.traits.pregnancystart = None
def wants_childeren(self):
"""
For the world to call when determining if a pregnancy should happen.
Returns
-------
bool
If I want childeren
"""
# ~16 to ~35 years old
wants_childeren = self.traits.age > 192 and self.traits.age < 420
# Can't have childeren if we already have them
wants_childeren = not self.traits.ispregnant and wants_childeren
return wants_childeren
def attempt_to_impregnate(self, time):
"""
Impregnate this person(female)
Returns
-------
None.
"""
if self.traits.gender == Gender.Female and self.wants_childeren() and self.traits.alive:
self.traits.ispregnant = True
self.traits.pregnancystart = time
def tick_time(self, time):
"""
A 'behavior' function to encapsulate a decision point for this person.
Parameters
----------
time : int
The current time tick of the world.
Returns
-------
None.
"""
self.advance_automatic_tickers(time)
self.last_time = time
def get_plot_vars(world):
men = 0
women = 0
alive = 0
dead = 0
for person in world.traits.population:
if person.traits.alive:
alive += 1
if person.traits.gender == Gender.Female:
women += 1
else:
men += 1
else:
dead += 1
return (women, men, alive, dead)
if __name__ == "__main__":
new_world = World(1000)
women_list = []
men_list = []
alive_list = []
dead_list = []
time_list = []
for time in range(0,100000):
new_world.tick_time()
women, men, alive, dead = get_plot_vars(new_world)
women_list.append(women)
men_list.append(men)
alive_list.append(alive)
dead_list.append(dead)
time_list.append(time)
plt.plot(time_list, women_list, label="Women")
plt.plot(time_list, men_list, label="Men")
plt.plot(time_list, alive_list, label="Alive")
plt.plot(time_list, dead_list, label="Dead")
plt.draw()
plt.pause(0.05)
plt.show()
Ok, so this code represents the base system for a world and people to ‘exist.’ In this world people can reproduce and die. After a few minutes of thinking, maybe even less, you’ll likely ask me — but Travis, won’t this just be an exponential increase in population? Didn’t we agree that that was a ‘broken’ simulation?
Yes. This is just the base, and unfortunately in order to balance this out, we will need to introduce something that kills people… That we will have to find out tomorrow, because I need sleep now. To keep you company while I am away, here is an animated image of our exponential growth!
Label your graph, Travis! the teacher screams. Yeah, yeah, I will next time. The x-axis represents time in months, the blue/orange lines are one of the two genders, the green line is the total alive, and the red line is dead (which might be broken).
END DAY 1
Thoughts
Ok, I have been gone a day and had a chance to think about this a bit, so to start I am simply going to clean up any magic numbers I have lingering and place them at the top as constants.
Along with this I am adding a ‘DIVIDER’ variable to cut down things to a reasonable scale for short-term experiments. The new variables can be seen below:
With these variables installed throughout the code, it becomes much easier to adjust things and see how they change the graph. For instance, using the settings above we get the following graph:
Which is quite exciting, considering we are trying to “balance” our simulation. I am thinking it would be a good idea to change the way we count deaths, that is, instead of counting total deaths, we count deaths in the last cycle. To do this, we simply need to keep a running value of total deaths and subtract it each time from the previous month.
So it seems that starting conditions are a huge factor in this. The first one we ran (above) ended in 3000 months. Below is an example of one that ran until I had to shut it down because I didn’t put a stop condition in and I needed to go to bed.
In the next couple days I want to port this to SQLite so we can have some memory savings and maybe data access speedup (doubt it though). I find it overwhelmingly fascinating that even at these seemingly small variable counts, we can have such large differences between runs. I look forward to the data this will generate 🙂 Goodnight!
END DAY 2
Another day later, I realize I didn’t talk much about one of the things we noticed about yesterday’s graph: There is almost always a spike in population when the blue line (I think this is the women population) crosses to be above the orange line (the men population). This is interesting for a number of reasons, and our speculation is that when there are more women in the world, clearly there is a higher probability to have children. Also, when those women get too old to have children, if they didn’t have girls when they were having children, then we are likely to see a downturn in population as there aren’t enough women to bear children.
These findings are hardly revolutionary, but it is still cool to uncover, and feel like we are discovering something. Probably shouldn’t make it a habit though 🙂
Anyway, today I want to improve the efficiency of my simulation, and maybe even get started with the move to a Model View Controller(MVC) architecture.
To start, let’s take some timings. For this, I am thinking of 4 places:
Render
Stats Collection
World Resolver
Person Resolver
In order to do it, I will install time-collection points at the beginning and end of each of these, subtracting the last from the first. I’ll let it run for a while, storing all these measurements and averaging them at the end.
On the left you can see different timing measurements (in seconds) over the current world time. On the right is the graph that represents the world run. From the timing measurements, we can see our World Resolver is going to quickly become unmanageable — I would imagine the memory usage is horrendous on this as well.
For the World Resolver, I think we can make it a shallower linear increase by simply removing the dead Persons and placing them in their own list each time we resolve. This will reduce the number of Persons we need to resolve way down the line (dead people don’t change much). We could also have a win by multi-threading this to parallelize the Person resolutions. This is almost trivial to do, but we have the pesky births that might cause race conditions — so let’s mutex the population list somehow and we should be clear to multi-thread.
Let’s see how those two changes affect our timings:
Those changes were right on point, you can clearly see we have cut down any latency that we might have had to almost nothing! Now we can run some truly long simulations without as much worry over memory and execution time. Speculating on this, I think that even the transfer of dead to their own list has memory implications, namely, we no longer need to keep them in cache or nearby because of their less frequent use. Let’s take off the gloves and run 500 months without any scaling, that is, no division of 50…
Oooo, look at that! We are starting to see the signs of stress as our population comes to 20,000. Wonder how far we can take this, let’s run for 1.5x a lifetime 🙂
All right, that caps the day 🙂 See you tomorrow on POST DAY!
# -*- coding: utf-8 -*-
"""
Created on Tue Jan 11 18:13:50 2022
@author: Travis Adsitt
"""
from dataclasses import dataclass
from enum import Enum
from threading import Thread, Lock
import matplotlib.pyplot as plt
import random
import time as real_time
# Timing variables for benchmarking different portions of code
timing_vars = {
"Render": [],
"StatCollection": [],
"PersonResolver": [],
"WorldResolver": [],
"Population": []
}
DIVIDER = 1
THREADS = 16
START_POP = 1000
RUN_IN_MONTHS = 1000
REPRODUCTIVE_AGE_START = int(192 / DIVIDER)
REPRODUCTIVE_AGE_END = int(420 / DIVIDER)
GESTATION_TIME_MONTHS = int(9 / DIVIDER)
LIFE_TIME_MONTHS = int(876 / DIVIDER)
class Gender(Enum):
Male = "Male"
Female = "Female"
@dataclass(init=True)
class WorldTraits:
population: list
pop_mutex: Lock
time: int
dead: list
dead_mutex: Lock
def tick_people(people, time):
for person in people:
person.tick_time(time)
class World:
def __init__(self, population):
assert isinstance(population, int), "population must be of type 'int'"
self.traits = WorldTraits(
population=[],
pop_mutex=Lock(),
time=0,
dead=[],
dead_mutex=Lock()
)
self.traits.population = [Person(self, 0.0) for p in range(0, population)]
def birth(self):
"""
Helper function for the Person object to inform the World that they have
had a child.
Returns
-------
None.
"""
# Create a new person
new_peep = Person(self, 0.0)
#Get our mutex
self.traits.pop_mutex.acquire()
# Add them to the population
self.traits.population.append(new_peep)
self.traits.pop_mutex.release()
def death(self, new_dead):
if new_dead not in self.traits.population:
return
self.traits.dead_mutex.acquire()
self.traits.dead.append(new_dead)
self.traits.dead_mutex.release()
self.traits.pop_mutex.acquire()
self.traits.population.remove(new_dead)
self.traits.pop_mutex.release()
def attempt_mate(self, person_one, person_two):
"""
Helper function to handle the resolution as to whether to individuals
can mate, and attempt to impregnate them if they can
Parameters
----------
person_one : Person
Any individual person
person_two : Person
Any other individual person
Returns
-------
None.
"""
# Check if they are female
one_kids = person_one.traits.gender == Gender.Female
two_kids = person_two.traits.gender == Gender.Female
# Check if one female and one male
if one_kids and not two_kids or not one_kids and two_kids:
# Attempt impregnation on the correct individual
if one_kids:
person_one.attempt_to_impregnate(self.traits.time)
else:
person_two.attempt_to_impregnate(self.traits.time)
def reproduction_cycle(self):
"""
Helper function to handle the reproduction behavior of the population.
Returns
-------
None.
"""
# Shuffle everyone so we can pick peeps at 'random'
random.shuffle(self.traits.population)
# Make a copy of the population to ensure we don't change under the
# iterator
pop_copy = self.traits.population.copy()
people_iterator = iter(pop_copy)
# attempt mating in twos
for person in people_iterator:
try:
self.attempt_mate(person, next(people_iterator))
except StopIteration:
break
def tick_time(self):
"""
Helper function to push the world forward one month at a time.
Returns
-------
None.
"""
world_resolution_time = 0
person_resolution_time = 0
threads = []
person_time = real_time.time()
people = self.traits.population
num_per_thread = int(len(people) / THREADS) + 1
thread_data = [people[i:i + num_per_thread] for i in range(0, len(people), num_per_thread)]
for t in range(0,THREADS):
threads.append(Thread(target=tick_people, args=(thread_data[t],self.traits.time, )))
threads[-1].start()
for t in threads:
t.join()
person_time = (real_time.time() - person_time) / len(people)
person_resolution_time = person_time if person_resolution_time == 0 else (person_time + person_resolution_time) / 2
world_resolution_time += person_resolution_time
world_time = real_time.time()
# Try and mate
self.reproduction_cycle()
# Tick our time forward
self.traits.time += 1
world_time = real_time.time() - world_time
timing_vars["WorldResolver"].append(
world_resolution_time + world_time
)
timing_vars["PersonResolver"].append(
person_resolution_time
)
@dataclass(init=True)
class PersonTraits:
alive: bool
ispregnant: bool
pregnancystart: int
age: int
gender: Gender
money: float
class Person:
def __init__(self, world, money):
assert isinstance(world, World), "world, must be of type 'World'"
assert isinstance(money, float), "money must be of type 'float'"
# Set our world object
self.world = world
# Set our initial 'last_time'
self.last_time = self.world.traits.time
# Set our initial traits
self.traits = PersonTraits(
alive=True,
ispregnant=False,
pregnancystart=None,
age=0,
gender=Gender[random.choice([g.name for g in Gender])],
money=money
)
def spend_money(self, amount):
"""
Used to check if this person can spend money, and if so, does spend
the amount passed in.
Parameters
----------
amount : float
How much money should I spend?
Returns
-------
bool
If I spent the money
"""
spent = False
if(self.money >= amount):
self.money -= amount
spent = True
return spent
def advance_automatic_tickers(self, time):
"""
Helper function to advance the automatic tickers for a person, such as
age or checking for a pregnancy... Those sorts of things.
Parameters
----------
time : int
The current time in the world.
Returns
-------
None.
"""
# We get older
self.traits.age += time - self.last_time
# We sometimes die
if(self.traits.age > LIFE_TIME_MONTHS):
self.traits.alive = False
self.world.death(self)
# Sometimes people give birth
if self.traits.ispregnant and ((time - self.traits.pregnancystart) > GESTATION_TIME_MONTHS):
self.world.birth()
self.traits.ispregnant = False
self.traits.pregnancystart = None
def wants_childeren(self):
"""
For the world to call when determining if a pregnancy should happen.
Returns
-------
bool
If I want childeren
"""
# ~16 to ~35 years old
wants_childeren = self.traits.age > REPRODUCTIVE_AGE_START
if(self.traits.gender == Gender.Female):
wants_childeren = wants_childeren and self.traits.age < REPRODUCTIVE_AGE_END
# Can't have childeren if we already have them
wants_childeren = not self.traits.ispregnant and wants_childeren
return wants_childeren
def attempt_to_impregnate(self, time):
"""
Impregnate this person(female)
Returns
-------
None.
"""
if self.traits.gender == Gender.Female and self.wants_childeren() and self.traits.alive:
self.traits.ispregnant = True
self.traits.pregnancystart = time
def tick_time(self, time):
"""
A 'behavior' function to encapsulate a decision point for this person.
Parameters
----------
time : int
The current time tick of the world.
Returns
-------
None.
"""
self.advance_automatic_tickers(time)
self.last_time = time
def get_plot_vars(world):
men = 0
women = 0
alive = 0
dead = len(world.traits.dead)
for person in world.traits.population:
if person.traits.alive:
alive += 1
if person.traits.gender == Gender.Female:
women += 1
else:
men += 1
return (women, men, alive, dead)
if __name__ == "__main__":
new_world = World(START_POP)
women_list = []
men_list = []
alive_list = []
dead_list = []
time_list = []
prev_dead = 0
for time in range(0,RUN_IN_MONTHS):
new_world.tick_time()
stat_time = real_time.time()
women, men, alive, dead = get_plot_vars(new_world)
dead_this_month = dead - prev_dead
prev_dead = dead
women_list.append(women)
men_list.append(men)
alive_list.append(alive)
dead_list.append(dead_this_month)
time_list.append(time)
stat_time = real_time.time() - stat_time
plot_time = real_time.time()
plt.plot(time_list, women_list, label="Women")
plt.plot(time_list, men_list, label="Men")
plt.plot(time_list, alive_list, label="Alive")
plt.plot(time_list, dead_list, label="Dead")
plt.legend()
plt.draw()
plt.pause(0.05)
plot_time = real_time.time() - plot_time
timing_vars["Render"].append(plot_time)
timing_vars["StatCollection"].append(stat_time)
total_stats = [i for i in range(0,len(timing_vars["Render"]))]
plt.plot(total_stats, timing_vars["Render"], label="Render")
plt.plot(total_stats, timing_vars["PersonResolver"], label="PersonResolver")
plt.plot(total_stats, timing_vars["WorldResolver"], label="WorldResolver")
plt.plot(total_stats, timing_vars["StatCollection"], label="StatCollection")
plt.legend()
END DAY 3
Hello again! The final day for this week’s development!
First, I need to specify yesterday’s final couple of graphs. Brielle and I went to dinner and came back to my laptop still trying to crunch the numbers with four threads. So I pushed the code to my local git, pulled it on my desktop (which has a bit more compute) and spun it up on 16 threads, which as you can see towards the end took nearly 25 seconds per person to compute. Also notable is the number of people we managed to get to: ~14 million!
I am thinking the last thing to add this week is just more efficiencies in the World Resolver, in which I think we can reduce a significant amount of time by multi-threading the shuffle and mating cycles. The reason this is what I am targeting is we can infer from the stat collection time that any single-threaded operation will take ~1/4 to ~1/3 of the total time of the world resolution. So, splitting all single-threaded operations will reduce our time(duh).
Currently my reproduction cycle code looks like this:
def reproduction_cycle(self):
"""
Helper function to handle the reproduction behavior of the population.
Returns
-------
None.
"""
# Shuffle everyone so we can pick peeps at 'random'
random.shuffle(self.traits.population)
# Make a copy of the population to ensure we don't change under the
# iterator
pop_copy = self.traits.population.copy()
people_iterator = iter(pop_copy)
# attempt mating in twos
for person in people_iterator:
try:
self.attempt_mate(person, next(people_iterator))
except StopIteration:
break
First thing that pops up in my mind is that shuffle operation. I am willing to bet that is very expensive with larger sizes. Our iteration, though single-threaded, is iterating 2 at a time. This will only save us so long and I believe it would be a constant in a Big-O notation break down.
Let’s start by getting a higher resolution timing on each of the parts of the reproduction cycle function. With timers this code looks like:
def reproduction_cycle(self):
"""
Helper function to handle the reproduction behavior of the population.
Returns
-------
None.
"""
shuffle_time = real_time.time()
# Shuffle everyone so we can pick peeps at 'random'
random.shuffle(self.traits.population)
timing_vars["PeopleShuffle"].append(real_time.time() - shuffle_time)
pop_copy_time = real_time.time()
# Make a copy of the population to ensure we don't change under the
# iterator
pop_copy = self.traits.population.copy()
people_iterator = iter(pop_copy)
timing_vars["PeopleCopy"].append(real_time.time() - pop_copy_time)
repro_time = real_time.time()
# attempt mating in twos
for person in people_iterator:
try:
self.attempt_mate(person, next(people_iterator))
except StopIteration:
break
timing_vars["PeopleMate"].append(real_time.time() - repro_time)
Results over 1000 months on 16 threads:
Ok, shuffling people is expensive, but not as expensive as our mating algorithm, so let’s mix the two. We can “shuffle” by simply selecting two random people in the list and chunking at the same time. Below is my rendering of the “chunked” list generator:
def chunked_list_generator(l, chunks=1):
"""
Helper function to chunk a list into 'chunks' pieces, using the Knuth
algorithm already present in the random.shuffle method of python
Parameters
----------
l : list
The list to chunk up.
chunks : int, optional
The number of chunks to split the list into. The default is 1.
Yields
------
y_list : list
Each chunk as they are produced.
"""
# Get our list length and the number of items to place in each
len_list = len(l)
number_per_chunk = int(len_list / chunks)
# First iterator to go chunk by chunk
for curr_start in range(0, len_list, number_per_chunk):
y_list = []
chunk_end = curr_start + number_per_chunk
# Second iterator to go individual by individual
for c in range(curr_start,chunk_end):
# If we have reached the list length break
if c >= len_list: break
# Get a random list index above the current point
j = random.randint(c, len_list - 1)
# Swap the current with the random item
l[c], l[j] = l[j], l[c]
# Append our Yield list
y_list.append(l[c])
# Yield the list
yield y_list
This method should yield each of the split lists from the main population list that we can then start a thread from and use in the global function:
def mate_list(l, time):
"""
Helper function to handle the resolution as to whether to individuals
can mate, and attempt to impregnate them if they can
Parameters
----------
person_one : Person
Any individual person
person_two : Person
Any other individual person
Returns
-------
None.
"""
for i in range(0, len(l), 2):
if (i + 1) >= len(l): break
one = l[i]
two = l[i + 1]
# Check if they are female
one_kids = one.traits.gender == Gender.Female
two_kids = two.traits.gender == Gender.Female
# Check if one female and one male
if one_kids and not two_kids or not one_kids and two_kids:
# Attempt impregnation on the correct individual
if one_kids:
one.attempt_to_impregnate(time)
else:
two.attempt_to_impregnate(time)
In case it isn’t totally clear, this is intended to run inside a thread so the list should be handed to it along with the current time step of the world. Let’s see what that does:
Surprisingly, this did not yield the expected speedup I wanted. I am thinking this has to do with list copying more than shuffling, specifically when I yield back the list, so let’s attempt yielding a couple list indexes that we can use to slice and see what happens…
Ok, so interestingly enough, we see speedup when separating the shuffle from the chunking method, quite a bit of speedup actually. I should mention that I implemented a scaling multi-thread here, where as the population grows we add threads. This reduces the overhead for smaller populations. Splitting 1000 people into 16 groups and starting threads for those groups just doesn’t really make all that much sense. So, every 100,000 people we add a thread to compute them until we get to the max thread count, at which point we just accept the time impact.
Conclusion
Ok, so at the start of this post I set out to create a “balanced” simulation — and in the middle of the post we had a pretty small-scale version that was quite balanced, except when there were too few women in the world to birth children. Towards the end here, we tried to get the full-scale problem working. Though we managed to get considerable speedup, we couldn’t quite get a manageable full-scale simulation going.
Brielle and I have some ideas on how to get speed improvements for a large model. I look forward to exploring those and balancing the simulation further 🙂
Final code for this week 🙂
# -*- coding: utf-8 -*-
"""
Created on Tue Jan 11 18:13:50 2022
@author: Travis Adsitt
"""
from dataclasses import dataclass
from enum import Enum
from threading import Thread, Lock
import matplotlib.pyplot as plt
import random
import time as real_time
# Timing variables for benchmarking different portions of code
timing_vars = {
"Render": [],
"StatCollection": [],
"PersonResolver": [],
"WorldResolver": [],
"Population": [],
"PeopleShuffle":[],
"PeopleCopy":[],
"PeopleMate":[]
}
DIVIDER = 1
THREADS = 15
POPULATION_PER_THREAD = 100000
START_POP = 1000
RUN_IN_MONTHS = 1000
REPRODUCTIVE_AGE_START = int(192 / DIVIDER)
REPRODUCTIVE_AGE_END = int(420 / DIVIDER)
GESTATION_TIME_MONTHS = int(9 / DIVIDER)
LIFE_TIME_MONTHS = int(876 / DIVIDER)
class Gender(Enum):
Male = "Male"
Female = "Female"
@dataclass(init=True)
class WorldTraits:
population: list
pop_mutex: Lock
time: int
dead: list
dead_mutex: Lock
def tick_people(people, time):
for person in people:
person.tick_time(time)
def chunked_list_generator(l, chunks=1):
"""
Helper function to chunk a list into 'chunks' pieces, using the Knuth
algorithm already present in the random.shuffle method of python
Parameters
----------
l : list
The list to chunk up.
chunks : int, optional
The number of chunks to split the list into. The default is 1.
Yields
------
y_list : list
Each chunk as they are produced.
"""
# Get our list length and the number of items to place in each
len_list = len(l)
number_per_chunk = int(len_list / chunks)
# First iterator to go chunk by chunk
for curr_start in range(0, len_list, number_per_chunk):
# y_list = []
chunk_end = curr_start + number_per_chunk
yield (curr_start, chunk_end)
"""
# Second iterator to go individual by individual
for c in range(curr_start,chunk_end):
# If we have reached the list length break
if c >= len_list: break
# Get a random list index above the current point
j = random.randint(c, len_list - 1)
# Swap the current with the random item
l[c], l[j] = l[j], l[c]
# Append our Yield list
y_list.append(l[c])
# Yield the list
yield y_list
"""
def mate_list(l, time):
"""
Helper function to handle the resolution as to whether to individuals
can mate, and attempt to impregnate them if they can
Parameters
----------
person_one : Person
Any individual person
person_two : Person
Any other individual person
Returns
-------
None.
"""
for i in range(0, len(l), 2):
if (i + 1) >= len(l): break
one = l[i]
two = l[i + 1]
# Check if they are female
one_kids = one.traits.gender == Gender.Female
two_kids = two.traits.gender == Gender.Female
# Check if one female and one male
if one_kids and not two_kids or not one_kids and two_kids:
# Attempt impregnation on the correct individual
if one_kids:
one.attempt_to_impregnate(time)
else:
two.attempt_to_impregnate(time)
class World:
def __init__(self, population):
assert isinstance(population, int), "population must be of type 'int'"
self.traits = WorldTraits(
population=[],
pop_mutex=Lock(),
time=0,
dead=[],
dead_mutex=Lock()
)
self.threads = 1
self.traits.population = [Person(self, 0.0) for p in range(0, population)]
def birth(self):
"""
Helper function for the Person object to inform the World that they have
had a child.
Returns
-------
None.
"""
# Create a new person
new_peep = Person(self, 0.0)
#Get our mutex
self.traits.pop_mutex.acquire()
# Add them to the population
self.traits.population.append(new_peep)
self.traits.pop_mutex.release()
def death(self, new_dead):
if new_dead not in self.traits.population:
return
self.traits.dead_mutex.acquire()
self.traits.dead.append(new_dead)
self.traits.dead_mutex.release()
self.traits.pop_mutex.acquire()
self.traits.population.remove(new_dead)
self.traits.pop_mutex.release()
def attempt_mate(self, person_one, person_two):
"""
Helper function to handle the resolution as to whether to individuals
can mate, and attempt to impregnate them if they can
Parameters
----------
person_one : Person
Any individual person
person_two : Person
Any other individual person
Returns
-------
None.
"""
# Check if they are female
one_kids = person_one.traits.gender == Gender.Female
two_kids = person_two.traits.gender == Gender.Female
# Check if one female and one male
if one_kids and not two_kids or not one_kids and two_kids:
# Attempt impregnation on the correct individual
if one_kids:
person_one.attempt_to_impregnate(self.traits.time)
else:
person_two.attempt_to_impregnate(self.traits.time)
def reproduction_cycle(self):
"""
Helper function to handle the reproduction behavior of the population.
Returns
-------
None.
"""
shuffle_time = real_time.time()
# Shuffle everyone so we can pick peeps at 'random'
random.shuffle(self.traits.population)
timing_vars["PeopleShuffle"].append(real_time.time() - shuffle_time)
pop_copy_time = real_time.time()
# Make a copy of the population to ensure we don't change under the
# iterator
pop_copy = self.traits.population.copy()
timing_vars["PeopleCopy"].append(real_time.time() - pop_copy_time)
repro_time = real_time.time()
num_threads = self.get_num_threads()
threads = []
# attempt mating in twos
for s, e in chunked_list_generator(pop_copy, num_threads):
threads.append(Thread(target=mate_list, args=(pop_copy[s:e],self.traits.time, )))
threads[-1].start()
for t in threads:
t.join()
timing_vars["PeopleMate"].append(real_time.time() - repro_time)
def tick_time(self):
"""
Helper function to push the world forward one month at a time.
Returns
-------
None.
"""
world_resolution_time = 0
person_resolution_time = 0
threads = []
person_time = real_time.time()
people = self.traits.population
num_threads = self.get_num_threads()
num_per_thread = int(len(people) / num_threads) + 1
thread_data = [people[i:i + num_per_thread] for i in range(0, len(people), num_per_thread)]
for t in thread_data:
threads.append(Thread(target=tick_people, args=(t,self.traits.time, )))
threads[-1].start()
for t in threads:
t.join()
person_time = (real_time.time() - person_time) / len(people)
person_resolution_time = person_time if person_resolution_time == 0 else (person_time + person_resolution_time) / 2
world_resolution_time += person_resolution_time
world_time = real_time.time()
# Try and mate
self.reproduction_cycle()
# Tick our time forward
self.traits.time += 1
world_time = real_time.time() - world_time
timing_vars["WorldResolver"].append(
world_resolution_time + world_time
)
timing_vars["PersonResolver"].append(
person_resolution_time
)
def get_num_threads(self):
num_threads = int(len(self.traits.population) / POPULATION_PER_THREAD)
num_threads = num_threads if num_threads < THREADS else THREADS
num_threads = num_threads or 1
if(num_threads != self.threads):
print(f"Threads changing from {self.threads} to {num_threads}", flush=True)
self.threads = num_threads
return num_threads
@dataclass(init=True)
class PersonTraits:
alive: bool
ispregnant: bool
pregnancystart: int
age: int
gender: Gender
money: float
class Person:
def __init__(self, world, money):
assert isinstance(world, World), "world, must be of type 'World'"
assert isinstance(money, float), "money must be of type 'float'"
# Set our world object
self.world = world
# Set our initial 'last_time'
self.last_time = self.world.traits.time
# Set our initial traits
self.traits = PersonTraits(
alive=True,
ispregnant=False,
pregnancystart=None,
age=0,
gender=Gender[random.choice([g.name for g in Gender])],
money=money
)
def spend_money(self, amount):
"""
Used to check if this person can spend money, and if so, does spend
the amount passed in.
Parameters
----------
amount : float
How much money should I spend?
Returns
-------
bool
If I spent the money
"""
spent = False
if(self.money >= amount):
self.money -= amount
spent = True
return spent
def advance_automatic_tickers(self, time):
"""
Helper function to advance the automatic tickers for a person, such as
age or checking for a pregnancy... Those sorts of things.
Parameters
----------
time : int
The current time in the world.
Returns
-------
None.
"""
# We get older
self.traits.age += time - self.last_time
# We sometimes die
if(self.traits.age > LIFE_TIME_MONTHS):
self.traits.alive = False
self.world.death(self)
# Sometimes people give birth
if self.traits.ispregnant and ((time - self.traits.pregnancystart) > GESTATION_TIME_MONTHS):
self.world.birth()
self.traits.ispregnant = False
self.traits.pregnancystart = None
def wants_childeren(self):
"""
For the world to call when determining if a pregnancy should happen.
Returns
-------
bool
If I want childeren
"""
# ~16 to ~35 years old
wants_childeren = self.traits.age > REPRODUCTIVE_AGE_START
if(self.traits.gender == Gender.Female):
wants_childeren = wants_childeren and self.traits.age < REPRODUCTIVE_AGE_END
# Can't have childeren if we already have them
wants_childeren = not self.traits.ispregnant and wants_childeren
return wants_childeren
def attempt_to_impregnate(self, time):
"""
Impregnate this person(female)
Returns
-------
None.
"""
if self.traits.gender == Gender.Female and self.wants_childeren() and self.traits.alive:
self.traits.ispregnant = True
self.traits.pregnancystart = time
def tick_time(self, time):
"""
A 'behavior' function to encapsulate a decision point for this person.
Parameters
----------
time : int
The current time tick of the world.
Returns
-------
None.
"""
self.advance_automatic_tickers(time)
self.last_time = time
def get_plot_vars(world):
men = 0
women = 0
alive = 0
dead = len(world.traits.dead)
for person in world.traits.population:
if person.traits.alive:
alive += 1
if person.traits.gender == Gender.Female:
women += 1
else:
men += 1
return (women, men, alive, dead)
if __name__ == "__main__":
new_world = World(START_POP)
women_list = []
men_list = []
alive_list = []
dead_list = []
time_list = []
prev_dead = 0
for time in range(0,RUN_IN_MONTHS):
new_world.tick_time()
stat_time = real_time.time()
women, men, alive, dead = get_plot_vars(new_world)
dead_this_month = dead - prev_dead
prev_dead = dead
women_list.append(women)
men_list.append(men)
alive_list.append(alive)
dead_list.append(dead_this_month)
time_list.append(time)
stat_time = real_time.time() - stat_time
plot_time = real_time.time()
plt.plot(time_list, women_list, label="Women")
plt.plot(time_list, men_list, label="Men")
plt.plot(time_list, alive_list, label="Alive")
plt.plot(time_list, dead_list, label="Dead")
plt.legend()
plt.draw()
plt.pause(0.05)
plot_time = real_time.time() - plot_time
timing_vars["Render"].append(plot_time)
timing_vars["StatCollection"].append(stat_time)
total_stats = [i for i in range(0,len(timing_vars["Render"]))]
ignore_timings = [
# "Render",
# "StatCollection",
"PersonResolver",
# "WorldResolver",
"Population",
"PeopleShuffle",
"PeopleCopy",
"PeopleMate"
]
for key in timing_vars:
if key in ignore_timings:
continue
plt.plot(total_stats, timing_vars[key], label=key)
plt.legend()
I kept very busy this week and I am excited to share the progress with you all. I mostly focused on the physical clock, so this article should be a little bit more palatable.
Design
So, going into this week, I was planning to create the first fully functioning prototype. With that, I clearly needed a design to base all of this off of. I started brainstorming how both the minute and hour could be displayed on the same clock without interacting. If they were on a flat face, they would collide with each other. After a while I came up with the design below.
With this design, I created a very rough model using the previous prototype that I would be replacing.
Creation
Now, I had the design smoothed out, and was ready to get working. I ran to the store, and got all the supplies I thought I needed (keyword: thought) and got straight to work! I began by cutting out the outer clock face, and sanding it up to 600 grit for a nice finish, I also did this to the base of the clock after cutting the 12×12 panel out.
I realized I didn’t have all the tools needed, and kept running into this issue. Each step seemed to require a new tool, so this delayed progress by quite a bit. While I was making these runs, I found some very fascinating antique mechanical gears. I found this perfect to help the style of the clock. I let the gears soak in a mixture of vinegar, salt, and hydrogen peroxide. This promotes oxidization of the gears, and gave them the patina look I wanted.
Now, with more equipment, I was able to start drilling out the inlays for the stepper motors to set in. To my surprise, this idea worked, I was certain I would end up drilling through and having to deal with blemishes on the front.
The furthest my progress got this week was left at the motor inlays. I have all the supplies I need (I’m hoping) and all the plans made. All that remains is connecting the dots and getting it done.
Thanks for checking in, and make sure to stay posted.
This week I managed to get a black screen to show up between different scenes so you can no longer see each scene being setup. Nothing else has changed…
This post is meant to cap the progress on this game for now, I think it is at a point where the core mechanic is clear and polished enough to be fun on occasion. There are bigger visions that I have for this project, but to do weekly progress reports at this point just seems not useful as the progress would be much slower. With a full time job, limited knowledge on the Unity Game Engine and the XR utilities of the engine being fairly young still I don’t think progress will be quick enough to be interesting to any audience.
However with all of that said, I am pleased to inform you that below is a download link to the most recent build for you to download and side load to your headset. A couple things to note:
You do not need a bike to ‘ride’ simply use the right joystick to move forward
If you do have a bike capable of Bluetooth connectivity it must be a Echelon Connect to be compatible with this game.
If there is a bike that you would like me to make compatible let me know, I can try and implement a solution so you can use your bike 🙂
This is a VERY rough copy, it will be enjoyable, but don’t expect a polished game.
Thank you all for joining me in the progress reports for this early game, I have learned a TON and honestly feel pretty proud of how far I came starting with zero knowledge about game development. Of course the University Degree in Computer Science helped but…. you know. Thank you 🙂
This allowed the addition of a bunch of new houses
Added automatic target placement on front door pathway
Added Coroutines to prevent lag spikes in level loading
This week I implemented the back end for procedural generation of pathways for houses in the game. The ability to simply place ’empty’ game objects to mark the front door, garage door and back two corners of the house and have it generate the paths and the grass is so useful. Now regardless of the meshes we can generate the base environment for the front of the house.
Below you can see an animation of the paths and grass that are placed in the generation.
To explain a bit, the grass is filled in on the front door side of the driveway with each path block as it is placed. Basically we place a block, then do a little logic to tell which sides of the brick need grass, and shrink it to the width of the block give it a length and place that object on the midpoint of that side of the brick. On the opposite side of the driveway we simply place grass the same length as the driveway.
This method works really well, or at least well enough, I am pleased with the results.
Demo
I implemented Coroutines, which means there is no longer a lag spike when loading into the level, however it also means you see the level being setup which is sort of funny, but shouldn’t be included — one of the next things I would like to implement is a loading screen so all the level switches are a bit more kind to the user.
With that qualifier enjoy the demo and thank you for stopping in and reading! Please feel free to leave a comment 🙂 I would like to hear your thoughts.
I would like to preface this by acknowledging the delay of this entry. I’ve been developing this project for the past 4 weeks, but have not had the chance to enter any updates. With that out of the way, let’s catch up to speed.
Concept
I cannot take credit for the initial Idea behind the clock, it was proposed to me by my brother and colleague, Travis Adsitt. He prompted me initially with a task, to write a function that would represent the length of two strings, attached to either side of a weight, that would move the weight in a circle. In this model, the weight would be the indicator of time, as it would move in a circular motion on a clock face, and wherever it lay is the current time. To help visualize this, I have attached a rough render of this idea.
Now with this concept, it clearly needs a lot to bring it to life. I began by creating functions for the length of the string using the following steps:
Locate the point of the rotor
Locate the point it meets the circumference of the circle
Find the distance between the points
This was relatively simple to do, and I was able to create a working script in Python. This script was relatively simple, it used parsArgs to take user input of the position of the rotors, as well as the diameter and position of the clock face. I then used a “For Loop” in this script, and had it output a render of each value that was put in, or create an image for each hour and minute on the clock. I was able to compile these images into an animation, which is below.
Supplies
Now with this render completed, and knowing I had gotten the math stapled, the next step was to begin working with some hardware. Most of the materials are readily available, and I will list below for reference.
Other – Thread, Simple thread spool, Cardboard for body, and a ring, working as the weight
Single Motor Testing
With these positioned using a cardboard frame, the first prototype was created. Of course, it still needed to be developed and functioning. To start with this, I downloaded the Arduino IDE and plugged into my ESP32, and got straight to working. I began with using the myStepper library, just at an attempt to get a single stepper motor moving, as it is the most basic, yet important piece of the project. Below is the initial code snippet, using the myStepper library.
void setup() {
//Set Stepper Speed
myStepper.setSpeed(5);
//Begin Serial Monitoring
Serial.begin(115200);
}
void loop() {
// Run stepper motor, with steps set
myStepper.step(stepsPerRevolution); //move clockwise 1 rotation
delay(1000);
myStepper.step(-stepsPerRevolution); //move counter-clockwise 1 rotation
delay(1000);
while(true){};
}
Above the visible segment attached, I defined stepsPerRevolution as 2048, the number of steps for a full revolution, which is specific to the 28BYJ-48 Motor. Upon uploading this segment, the single motor connected should do 1 full rotation clockwise, followed by 1 full rotation counter-clockwise. It then ends with a while(true) loop, so that the program can continue to run without looping endlessly.
Two Motor Application
Now, the next step in this process is to get two stepper motors, with individual control. Since this is not supported in the myStepper library, I switched to the accelStepper library, as it is designed for multiple stepper motors.
void setup()
{
// Set Maximum Speed, Acceleration
stepper1.setMaxSpeed(300.0);
stepper1.setAcceleration(200.0);
// Repeat for stepper2
stepper2.setMaxSpeed(300.0);
stepper2.setAcceleration(200.0);
}
void loop()
{
stepper1.moveTo(2048); // set target forward 2048 steps
stepper2.moveTo(2048); // set target forward 2048 steps
while (stepper1.distanceToGo() != 0 || stepper2.distanceToGo() != 0)
{
stepper1.run();
stepper2.run();
}
stepper1.moveTo(0); // set target back to 0 steps
stepper2.moveTo(0); // set target back to 0 steps
while (stepper1.distanceToGo() != 0 || stepper2.distanceToGo() != 0)
{
stepper1.run();
stepper2.run();
}
}
With this segment, I was able to get both motors to do a full rotation clockwise, or move forward 2048 steps, before moving counterclockwise a full rotation, back to the original position.
Controlling String Lengths
With the ability to move both motors, now all I needed to do was control the rotations and move the weight to specific positions. To begin with this, I positioned the weight at the bottom of the circle I wanted to use as my clock face. From now on, this is position zero. From position zero, I moved the left motor Counter-Clockwise, and the right motor clockwise, moving the weight upward. I repeated this in 500 step increments, until I reached the top of the clock face. I took careful note of how many steps it took to go from top to bottom of the face, and how much the string length changed. My documentation of these changes is below.
Testing a Sample Set
With this information, I knew how to adjust the string length in centimeters, by changing the number of steps on either motor. I had observed and then verified that rotating one motor a full rotation, or 2048 steps, resulted in the correlating string length changing by 10 centimeters, dependent on the direction of rotation. To move forward from here, I then did the calculations given the motor positions as well as the position and specifications of the clock face, to find a sample set of points around the clock. I took each hour and found the string lengths needed, and then converted those lengths and converted them into the steps for each motor. Once I got these values verified I made a table of the values and where each motor needed to be for each hour.
I then put these values into my Arduino IDE, and was able to format it in a manner that the motors would move to the positions specified, at the same time, with the same rate of change. The code for this is very similar to the test values, just extended for each of the hours. The code is attached below.
void setup()
{
// Set Maximum Speed, Accelleration
stepper1.setMaxSpeed(300.0);
stepper1.setAcceleration(200.0);
// Repeat for stepper2
stepper2.setMaxSpeed(300.0);
stepper2.setAcceleration(200.0);
}
void loop()
{
stepper1.moveTo(-3200); // set target forward 500 steps
stepper2.moveTo(-3200); // set target forward 500 steps
while (stepper1.distanceToGo() != 0 || stepper2.distanceToGo() != 0)
{
stepper1.run();
stepper2.run();
}
stepper1.moveTo(-4190); // set target back to 500 steps
stepper2.moveTo(-1966); // set target back to 0 steps
while (stepper1.distanceToGo() != 0 || stepper2.distanceToGo() != 0)
{
stepper1.run();
stepper2.run();
}
stepper1.moveTo(-4506); // set target forward 500 steps
stepper2.moveTo(-817); // set target forward 500 steps
while (stepper1.distanceToGo() != 0 || stepper2.distanceToGo() != 0)
{
stepper1.run();
stepper2.run();
}
stepper1.moveTo(-3842); // set target forward 500 steps
stepper2.moveTo(80); // set target forward 500 steps
while (stepper1.distanceToGo() != 0 || stepper2.distanceToGo() != 0)
{
stepper1.run();
stepper2.run();
}
stepper1.moveTo(-2670); // set target forward 500 steps
stepper2.moveTo(391); // set target forward 500 steps
while (stepper1.distanceToGo() != 0 || stepper2.distanceToGo() != 0)
{
stepper1.run();
stepper2.run();
}
stepper1.moveTo(-1415); // set target forward 500 steps
stepper2.moveTo(252); // set target forward 500 steps
while (stepper1.distanceToGo() != 0 || stepper2.distanceToGo() != 0)
{
stepper1.run();
stepper2.run();
}
stepper1.moveTo(0); // set target forward 500 steps
stepper2.moveTo(0); // set target forward 500 steps
while (stepper1.distanceToGo() != 0 || stepper2.distanceToGo() != 0)
{
stepper1.run();
stepper2.run();
}
stepper1.moveTo(252); // set target forward 500 steps
stepper2.moveTo(-1415); // set target forward 500 steps
while (stepper1.distanceToGo() != 0 || stepper2.distanceToGo() != 0)
{
stepper1.run();
stepper2.run();
}
stepper1.moveTo(391); // set target forward 500 steps
stepper2.moveTo(-2671); // set target forward 500 steps
while (stepper1.distanceToGo() != 0 || stepper2.distanceToGo() != 0)
{
stepper1.run();
stepper2.run();
}
stepper1.moveTo(80); // set target forward 500 steps
stepper2.moveTo(-3842); // set target forward 500 steps
while (stepper1.distanceToGo() != 0 || stepper2.distanceToGo() != 0)
{
stepper1.run();
stepper2.run();
}
stepper1.moveTo(-817); // set target forward 500 steps
stepper2.moveTo(-4506); // set target forward 500 steps
while (stepper1.distanceToGo() != 0 || stepper2.distanceToGo() != 0)
{
stepper1.run();
stepper2.run();
}
stepper1.moveTo(-1966); // set target forward 500 steps
stepper2.moveTo(-4190); // set target forward 500 steps
while (stepper1.distanceToGo() != 0 || stepper2.distanceToGo() != 0)
{
stepper1.run();
stepper2.run();
}
}
You will notice this segment is extremely inefficient and lengthy, but that’s alright, as it is just a test set, and will not be used in the final iteration of the project. After writing these values in, I was able to upload the script, and watch the first working version of the clock. The video is attached below.
Conclusion and Final Thoughts
Now, as you may notice, these number sets only apply to the specific clock diameter, motor position, and pulley diameter that relate to this specific model. My goal now, is to allow this script to be fully adjustable, so the user can simply input the measurements of the specific clock being used, and it will perform the necessary functions to find the string lengths needed. This process is still under-way but can be expected soon. I will cover the creation of these functions as well as go into detail on the physical clock prototype in my next entry. Stay posted.
This week features no direct in-game demo, instead this is the theory behind procedural generation of pathways for each houses driveway and front door paths. I totally understand if the theory is something you skip — but there are some pretty pictures along the way that you can scroll through and check out 🙂
Either way thank you for stopping in — and with this theory will come (I hope) a wicked awesome post next week. Thank you!
Procedural Generation of House Paths
I want to start implementing procedural generation of the environment. This will allow me to expand the objects used in a scalable way as well as increase the variability of the environment with random generation. So to start I built a python program to generate paths from two points, the garage door and the front door, to the road.
For a little added flair the script creates a GIF to show step by step how the paths are constructed, this also helps with debug.
# -*- coding: utf-8 -*-
"""
Created on Mon Dec 20 06:34:29 2021
@author: Travis Adsitt
"""
from PIL import Image, ImageDraw, ImageColor
import random, math
GLOBAL_EDGE_COUNT = 260
CUBE_EDGE_SIZE = 20
PATH_BUFFER = 5
ROAD_Y = 200
CUBES_PERLINE = GLOBAL_EDGE_COUNT / CUBE_EDGE_SIZE
def GetNewBufferedLocation(not_x=None, not_y=None):
"""
Parameters
----------
not_x : int, optional
Avoid this x value. The default is None.
not_y : int, optional
Avoid this y value. The default is None.
Returns
-------
x : int
A new random x-point within the buffered area.
y : int
A new random y-point within the buffered area.
"""
buffered_house_area = GLOBAL_EDGE_COUNT - CUBE_EDGE_SIZE
x = random.randint(CUBE_EDGE_SIZE, buffered_house_area)
if not_x:
while x == not_x:
x = random.randint(CUBE_EDGE_SIZE, buffered_house_area)
y = random.randint(CUBE_EDGE_SIZE, ROAD_Y)
if not_y:
while y == not_y:
y = random.randint(CUBE_EDGE_SIZE, buffered_house_area)
return (x,y)
def GetRoadBoundaryLine():
"""
Helper function to get the two points describing the roads boundary
Returns
-------
List[Tuple(int x, int y), Tuple(int x, int y)]
Left and right most point representing the road boundary line.
"""
P1 = (0,ROAD_Y)
P2 = (GLOBAL_EDGE_COUNT, ROAD_Y)
return [P1,P2]
def GetGaragePathPoints(prev_point):
"""
Helper function to yield points until the driveway meets the road
Parameters
----------
prev_point : Tuple(int x, int y)
The most recent garage path point.
Yields
------
Tuple(int x, int y)
The next point for the driveway.
"""
curr_point = prev_point
while curr_point[1] <= ROAD_Y:
curr_point = (curr_point[0], curr_point[1] + 1)
yield curr_point
def GetDistance(P1, P2):
"""
Helper function to get the distance between two points in non-diagonal way
Parameters
----------
P1 : Tuple(int x, int y)
Point one.
P2 : Tuple(int x, int y)
Point two.
Returns
-------
int
Distance between the two input points on a non-dagonal basis.
"""
x1 = P1[0]
y1 = P1[1]
x2 = P2[0]
y2 = P2[1]
xd = x1 - x2 if x1 > x2 else x2 - x1
yd = y1 - y2 if y1 > y2 else y2 - y1
return xd + yd# math.sqrt(xd ** 2 + yd ** 2)
def GetFrontDoorPathTarget(fd_point, garage_points):
"""
Helper function to get the target point for the front door path
Parameters
----------
fd_point : Tuple(int x, int y)
The point of the front doors location.
garage_points : List[Tuple(int x, int y)]
All of the points describing the driveway.
Returns
-------
Tuple(int x, int y)
The point to target for front pathway.
"""
# Get the closest road point and its distance
road_point = (fd_point[0], ROAD_Y)
road_point_distance = GetDistance(fd_point, road_point)
# Get the closest drive way point and set a start distance
garage_point = garage_points[0]
garage_point_distance = GetDistance(fd_point, garage_point)
# Iterate all driveway points to ensure there isn't one closer
for point in garage_points:
curr_point_dist = GetDistance(fd_point, point)
if curr_point_dist < garage_point_distance:
garage_point = point
garage_point_distance = curr_point_dist
if curr_point_dist > garage_point_distance:
break
# Return whichever point (driveway or road) is closer to be used as a target
return garage_point if garage_point_distance < road_point_distance else road_point
def GetFrontDoorPathPoints(prev_point, garage_points):
"""
Helper function to properly draw a path from the front door to the
drive way or the road whichever is closer
Parameters
----------
prev_point : Tuple(int x, int y)
Front door location.
garage_points : List[Tuple(int x, int y)]
List of points describing the driveways path.
Yields
------
curr_point : Tuple(int x, int y)
The next point in the front path.
"""
# Get our target, either the nearest driveway point or the road
target = GetFrontDoorPathTarget(prev_point, garage_points)
# Local variable for current point tracking
curr_point = prev_point
# Iterate the y direction before the x, no diagonals are allowed
while curr_point[1] != target[1]:
yield curr_point
curr_point = (curr_point[0], curr_point[1] + 1)
# Iterate the x direction to complete the path
while curr_point[0] != target[0]:
yield curr_point
delta = 1 if curr_point[0] < target[0] else -1
curr_point = (curr_point[0] + delta, curr_point[1])
def GetPaths(garage_door_point, front_door_point):
"""
Helper function to get the garage door and front door paths to the road.
Parameters
----------
garage_door_point : TYPE
DESCRIPTION.
front_door_point : TYPE
DESCRIPTION.
Yields
------
Tuple(int x, int y) || None
Next Driveway point.
Tuple(int x, int y) || None
Next Front Pathway point.
"""
curr_g_door_point = garage_door_point
curr_f_door_point = front_door_point
garage_points = []
front_points = []
# Initial path buffer of PATH_BUFFER length to ensure distance from doors
for i in range(PATH_BUFFER):
garage_points.append(curr_g_door_point)
front_points.append(curr_f_door_point)
curr_g_door_point = (curr_g_door_point[0], curr_g_door_point[1] + 1)
curr_f_door_point = (curr_f_door_point[0], curr_f_door_point[1] + 1)
yield (curr_g_door_point, curr_f_door_point)
# Finish writing points for the garage door to go to the road
for g in GetGaragePathPoints(curr_g_door_point):
garage_points.append(g)
yield (g, None)
# Finish writing points for the front door to go to the road or garage path
for f in GetFrontDoorPathPoints(curr_f_door_point, garage_points[5:]):
yield (None, f)
if __name__ == "__main__":
# Get random front door and garage locations
front_door = GetNewBufferedLocation()
garage_door = GetNewBufferedLocation(not_x=front_door[0])
# A place to store all our images
images = []
# A place to keep track of all points to be drawn on each frame
r_points = []
g_points = []
for point in GetPaths(garage_door,front_door):
# Create a new image for current frame
out = Image.new("RGBA", (GLOBAL_EDGE_COUNT,GLOBAL_EDGE_COUNT))
d = ImageDraw.Draw(out)
# Draw Road Boundary
d.line(GetRoadBoundaryLine(), fill="blue")
# Append garage and front path points to respective lists
if point[0]:
r_points.append(point[0])
if point[1]:
g_points.append(point[1])
d.point(r_points,fill="red")
d.point(g_points,fill="green")
images.append(out)
# Save our GIF
images[0].save(
'house_paths.gif',
save_all=True,
append_images=images[1:],
optimize=True,
duration=100,
loop=0
)
Some examples of the output (Green == Front Door Path :: Red == Driveway):
So it is clear this is generating unrealistic front door and garage locations — but luckily at this stage I don’t really care about realism, I just want to know the algorithm is working. This demonstrates that it is, now we need to expand this to represent points a bit differently. In a 2D space we can have objects that cover multiple pixels, and so the first solution I propose is to represent each game object by its upper left point and its lower right point. From these two points we should be able to infer a whole lot of information about the object itself, and it should give us everything we need to properly position it in the world.
2-Point Object Representation
Assumptions
To start lets get some assumptions out there.
All objects we care to place are rectangular
All 3D rectangular objects can be measured by two corners
one at the lowest z position in any corner
the other at the highest z position in the opposite diagonal corner from the first
High Level
At a high level these two points can tell us everything we need to know about the path or driveway block we are trying to place. Specifically its rotation in the world, how it aligns or doesn’t align with other objects in the world, also should the block be to large in any one direction we should be able to work out the amount it needs to be scaled by to fit the spot. For the Unity translation this will mean attaching two empties to the parent object we can search for when generating the world. If anyone has a better way of doing this please please comment, I have been looking for an alternative including bounding boxes of the Renderer but those weren’t as reliable as the empty placement is.
So with that lets get to coding a 2D representation of what we want using Python, and hopefully after this we can move the logic to Unity and begin adding more houses without having to manually place paths!
Code
# -*- coding: utf-8 -*-
"""
Created on Tue Dec 21 09:14:20 2021
@author: Travis Adsitt
"""
from enum import Enum
from PIL import Image, ImageDraw, ImageColor
import random
WORLD_EDGE_SIZE = 250
CUBE_EDGE_SIZE = 10
ROAD_Y = 200
class CompassDirection(Enum):
North="North"
East="East"
South="South"
West="West"
class Point:
def __init__(self, x, y):
"""
Object initializer for a 2D Point
Parameters
----------
x : int
The x value of the point.
y : int
The y value of the point.
Returns
-------
None.
"""
self.x = x
self.y = y
@classmethod
def is_point(cls, P1):
"""
Used to check if an object is of type point
Parameters
----------
P1 : Object
Object to test if it is of type Point.
Returns
-------
Boolean
"""
return isinstance(P1, Point)
@classmethod
def distance(cls, P1, P2, keep_orientation=False):
"""
Get the distance between two points
Parameters
----------
P1 : Point
First point.
P2 : Point
Second point.
Returns
-------
xd : int
Distance between the points in the x direction.
yd : int
Distance between the points in the y direction.
"""
assert Point.is_point(P1), "P1 must be a point for distance"
assert Point.is_point(P2), "P2 must be a point for distance"
x1 = P1.x
y1 = P1.y
x2 = P2.x
y2 = P2.y
xd = x1 - x2 if x1 > x2 and not keep_orientation else x2 - x1
yd = y1 - y2 if y1 > y2 and not keep_orientation else y2 - y1
return (xd,yd)
@classmethod
def midpoint(cls, P1, P2):
"""
Get the midpoint between two points
Parameters
----------
P1 : Point
First point.
P2 : Point
Second point.
Returns
-------
Point
The midpoint located at the center of the two points.
"""
dist_x, dist_y = cls.distance(P1, P2)
x_offset = P1.x if P1.x < P2.x else P2.x
y_offset = P1.y if P1.y < P2.y else P2.y
return Point((dist_x / 2) + x_offset, (dist_y / 2) + y_offset)
def as_list(self):
"""
Helper function to put this point in a list
Returns
-------
list
[x, y] values.
"""
return [self.x, self.y]
def __add__(self, other):
"""
Dunder method to add two points together
Parameters
----------
other : Point
The point to add to this one.
Returns
-------
ret_point : Point
The point that results from the addition.
"""
assert Point.is_point(other), "Cannot add a Point to a non-Point"
ret_point = Point(self.x + other.x, self.y + other.y)
return ret_point
def __str__(self):
return f"({self.x},{self.y})"
ORIGIN = Point(0,0)
class Object2D:
def __init__(self, P1, P2, fill_color="gray", outline_color="red"):
"""
Object initializer for a 2D game object
Parameters
----------
P1 : Point
Corner one of the 2D object.
P2 : Point
Corner two of the 2D object.
Returns
-------
None.
"""
assert Point.is_point(P1), "P1 must be a point for 2D Object"
assert Point.is_point(P2), "P2 must be a point for 2D Object"
self.points = {"p1": P1, "p2": P2}
self.fill = fill_color
self.outline = outline_color
def move_xy(self, move_to, reference_point=None):
"""
Function to move this object using a reference point
Parameters
----------
move_to : Point
Where to move this object.
reference_point : Point, optional
The point to move in reference to. The default is None.
Returns
-------
None.
"""
ref_point = reference_point or self.points["p1"]
assert Point.is_point(move_to), "move_to must be of type Point"
assert Point.is_point(ref_point), "reference_point must be of type Point"
# Get our delta for the reference point to the move point
ref_move_delta_x, ref_move_delta_y = Point.distance(ref_point, move_to, keep_orientation=True)
self.points["p1"] += Point(ref_move_delta_x, ref_move_delta_y)
self.points["p2"] += Point(ref_move_delta_x, ref_move_delta_y)
def move_y(self, move_to, self_point="p1"):
"""
Move to a point only on the x direction
Parameters
----------
move_to : Point
Where to move to.
self_point : str, optional
The internal point to use as reference. The default is "p1".
Returns
-------
None.
"""
assert Point.is_point(move_to), "move_to must be of type Point"
assert self_point in self.points, "self_point must be either 'p1' or 'p2'"
ref_point = Point(self.points["p1"].x, move_to.y)
self.move_xy(move_to, ref_point)
def scale_x(self, percentage_scale):
"""
Helper function to scale this object by a percentage
Parameters
----------
percentage_scale : float
The percentage to scale the object by.
Returns
-------
None.
"""
# This is the amount each point should be moved
point_offset = (self.get_width() * percentage_scale) / 2
P1 = self.points["p1"]
P2 = self.points["p2"]
P1.x = P1.x + point_offset if P1.x < P2.x else P1.x - point_offset
P2.x = P2.x + point_offset if P2.x < P1.x else P2.x - point_offset
self.points["p1"] = P1
self.points["p2"] = P2
def scale_y(self, percentage_scale):
"""
Helper function to scale this object by a percentage
Parameters
----------
percentage_scale : float
The percentage to scale the object by.
Returns
-------
None.
"""
# This is the amount each point should be moved
point_offset = (self.get_height() * percentage_scale) / 2
P1 = self.points["p1"]
P2 = self.points["p2"]
P1.y = P1.y + point_offset if P1.y < P2.y else P1.y - point_offset
P2.y = P2.y + point_offset if P2.y < P1.y else P2.y - point_offset
self.points["p1"].y = P1.y
self.points["p2"].y = P2.y
def move_x(self, move_to, self_point="p1"):
"""
Move to a point only on the y direction
Parameters
----------
move_to : Point
Where to move to.
self_point : str, optional
The internal point to use as reference. The default is "p1".
Returns
-------
None.
"""
assert Point.is_point(move_to), "move_to must be of type Point"
assert self_point in self.points, "self_point must be either 'p1' or 'p2'"
ref_point = Point(move_to.x, self.points["p1"].y)
self.move_xy(move_to, ref_point)
def get_width(self):
"""
Helper function to get this objects width
Returns
-------
float
The width of this object.
"""
p1X = self.points["p1"].x
p2X = self.points["p2"].x
p1X = p1X * -1 if p1X < 0 else p1X
p2X = p2X * -1 if p2X < 0 else p2X
return p1X - p2X if p1X > p2X else p2X - p1X
def get_height(self):
"""
Helper function to get this objects height
Returns
-------
float
The height of this object.
"""
p1Y = self.points["p1"].y
p2Y = self.points["p2"].y
p1Y = p1Y * -1 if p1Y < 0 else p1Y
p2Y = p2Y * -1 if p2Y < 0 else p2Y
return p1Y - p2Y if p1Y > p2Y else p2Y - p1Y
def get_midpoint(self):
"""
Helper function to get the midpoint of this Object2D
Returns
-------
Point
The midpoint of the object.
"""
p1 = self.points["p1"]
p2 = self.points["p2"]
return Point.midpoint(p1, p2)
def get_compass_direction_edge_midpoint(self, direction=CompassDirection.South):
"""
Helper function to get the world coordinate for one of our edges
in the direction of the input compass direction.
Parameters
----------
direction : CompassDirection, optional
Which edges midpoint to get world location for.
The default is CompassDirection.South.
Returns
-------
Point
The world location for the midpoint of an edge.
"""
p1X = self.points["p1"].x
p1Y = self.points["p1"].y
p2X = self.points["p2"].x
p2Y = self.points["p2"].y
midpoint = Point.midpoint(self.points["p1"], self.points["p2"])
westX = p1X if p1X < p2X else p2X
eastX = p1X if p1X > p2X else p2X
northY = p1Y if p1Y < p2Y else p2Y
southY = p1Y if p1Y > p2Y else p2Y
if(direction == CompassDirection.North):
return Point(midpoint.x, northY)
elif(direction == CompassDirection.East):
return Point(eastX, midpoint.y)
elif(direction == CompassDirection.South):
return Point(midpoint.x, southY)
elif(direction == CompassDirection.West):
return Point(westX, midpoint.y)
def draw_self(self, draw_object):
rect_points = []
rect_points.extend(self.points["p1"].as_list())
rect_points.extend(self.points["p2"].as_list())
try:
draw_object.rectangle(rect_points, fill=self.fill, outline=self.outline)
except:
print("Couldn't draw myself :(")
class SidewalkSquare(Object2D):
def __init__(self, spawn_point=ORIGIN, side_length=CUBE_EDGE_SIZE):
P1 = spawn_point
P2 = Point(P1.x + side_length, P1.y + side_length)
super().__init__(P1, P2)
class DrivewayRectangle(Object2D):
def __init__(
self,
spawn_point=ORIGIN,
long_edge_len=3*CUBE_EDGE_SIZE,
short_edge_len=2*CUBE_EDGE_SIZE
):
P1 = spawn_point
P2 = Point(P1.x + short_edge_len, P1.y + long_edge_len)
super().__init__(P1, P2)
def GetRoadBoundaryLine():
"""
Helper function to get the two points describing the roads boundary
Returns
-------
List[Tuple(int x, int y), Tuple(int x, int y)]
Left and right most point representing the road boundary line.
"""
P1 = (0,ROAD_Y)
P2 = (WORLD_EDGE_SIZE, ROAD_Y)
return [P1,P2]
def GetNewPaddedLocation(not_x=None, not_y=None, boundary=0):
"""
Parameters
----------
not_x : int, optional
Avoid this x value. The default is None.
not_y : int, optional
Avoid this y value. The default is None.
boundary : int, optional
Avoid +- this on either side of not_x or not_y
Returns
-------
x : int
A new random x-point within the buffered area.
y : int
A new random y-point within the buffered area.
"""
buffered_house_area = WORLD_EDGE_SIZE - CUBE_EDGE_SIZE
x = random.randint(CUBE_EDGE_SIZE, buffered_house_area)
if not_x:
while x < not_x + boundary and x > not_x - boundary:
x = random.randint(CUBE_EDGE_SIZE, buffered_house_area)
y = random.randint(CUBE_EDGE_SIZE, ROAD_Y)
if not_y:
while y < not_y + boundary and y > not_y - boundary:
y = random.randint(CUBE_EDGE_SIZE, buffered_house_area)
return Point(x,y)
def GetDoorandGarageLocations():
"""
Helper function to get two points for the Garage and Front Door locations
Returns
-------
dict
"Garage" -- the garage point :: "Door" -- the frontdoor point.
"""
# Start with the garage point
GaragePoint = GetNewPaddedLocation()
# Get the front door point, with the boundary of half a GarageRect and
DoorPoint = GetNewPaddedLocation(
not_x=GaragePoint.x,
boundary=DrivewayRectangle().get_width() / 2
)
return {"Garage":GaragePoint,"Door":DoorPoint}
def place_north_to_south(base_rect, move_rect):
"""
Helper function to place one objects north to the other objects south
Parameters
----------
base_rect : Object2D
This object stays in its original position, its southern midpoint will
be where we place the other objects northern midpoint.
move_rect : Object2D
This object is moved, we move it in reference to its northern midpoint
and place it at the base_rect southern midpoint.
Returns
-------
None.
"""
# Get the two points we care about
root = base_rect.get_compass_direction_edge_midpoint(CompassDirection.South)
place = move_rect.get_compass_direction_edge_midpoint(CompassDirection.North)
# Move the object as expected
move_rect.move_xy(root, place)
def GetDrivewayPoints(garage_point, road_boundary_line):
"""
Helper function to yield each driveway block
Parameters
----------
garage_point : Point
Where the driveway is to start
road_boundary_line : List[Point]
The points that represent the road boundary
Yields
------
Object2D
The blocks to be drawn for the driveway
"""
road_y = road_boundary_line[0][1]
driveway_blocks = []
def scale_and_move(garage_point, road_y):
"""
Helper function to scale a driveway block to fit the space between
the last block and the road if it is over reaching.
Parameters
----------
garage_point : Point
The start of the driveway.
road_y : int
The y position of the road.
Returns
-------
None.
"""
move_block = driveway_blocks[-1]
# We need to set the root block and place it at the last blocks
# southern point before proceeding
if len(driveway_blocks) > 1:
print("Moving North to South 1")
root_block = driveway_blocks[-2]
place_north_to_south(root_block, move_block)
# Check if we are over the road
over_boundary = move_block.get_compass_direction_edge_midpoint(CompassDirection.South).y > road_y
# Shrink to fit if we are over the road
if over_boundary:
print("Over boundary")
drive_y = move_block.get_compass_direction_edge_midpoint(CompassDirection.South).y
shrink_perc = (drive_y - road_y) / move_block.get_height()
move_block.scale_y(shrink_perc)
# If we are over the boundary and have more than one block move north to south
if len(driveway_blocks) > 1 and over_boundary:
print("Moving North to South 2")
place_north_to_south(root_block, move_block)
# If we are over boundary on the first block move to the garage point
elif over_boundary:
driveway_blocks[-1].move_xy(
garage_point,
driveway_blocks[-1].get_compass_direction_edge_midpoint(
CompassDirection.North
)
)
return over_boundary
# Create and move our first block to the start position
driveway_blocks.append(DrivewayRectangle())
driveway_blocks[-1].move_xy(
garage_point,
driveway_blocks[-1].get_compass_direction_edge_midpoint(
CompassDirection.North
)
)
# Now continue creating and placing blocks
while not scale_and_move(garage_point, road_y):
yield(driveway_blocks[-1])
driveway_blocks.append(DrivewayRectangle())
# Finally get the last block in the list that wouldn't yield in the
# While loop
yield(driveway_blocks[-1])
def GetFrontDoorPathwayBlocks(front_door_point, target_point):
"""
Helper function to generate new sidewalk squares for a pathway that leads
either to the driveway or the road depending on distance.
Parameters
----------
front_door_point : Point
Where the center of the front door is.
target_point : Point
The location we want the pathway to go to.
Yields
------
Object2D
The next sidewalk square to be rendered.
"""
sidewalk_blocks = []
def place_on_south_edge():
"""
Helper function to spawn and place a new block on the southern edge of
the previous block.
Returns
-------
None.
"""
sidewalk_blocks.append(SidewalkSquare())
root_block = sidewalk_blocks[-2]
move_block = sidewalk_blocks[-1]
root_midpoint = root_block.get_midpoint()
target_midpoint = Point(root_midpoint.x, root_midpoint.y + SidewalkSquare().get_height())
move_block.move_xy(target_midpoint, move_block.get_midpoint())
def place_on_lateral_edge():
"""
Helper function to spawn and place a new sidewalk square on either the
east or west edges of the last block based on target location.
Returns
-------
None.
"""
sidewalk_blocks.append(SidewalkSquare())
root_block = sidewalk_blocks[-2]
move_block = sidewalk_blocks[-1]
root_midpoint = root_block.get_midpoint()
to_right = root_block.get_midpoint().x < target_point.x
offset = SidewalkSquare().get_width() if to_right else -SidewalkSquare().get_width()
target_midpoint = Point(root_midpoint.x + offset, root_midpoint.y)
move_block.move_xy(target_midpoint, move_block.get_midpoint())
def in_edge(block, target):
"""
Helper function to check if our target is lined up with either the
vertical or horizontal edges of our current block.
Parameters
----------
block : Object2D
The object we are checking is lined up with the target.
target : Point
What we want the object to be lined up with.
Returns
-------
vertical_in_edge : bool
If we are lined up in the vertical direction.
horizontal_in_edge : bool
If we are lined up in the horizontal direction.
"""
pm_width = block.get_width() / 2
pm_height = block.get_height() / 2
midpoint = block.get_midpoint()
top = midpoint.y + pm_height
bottom = midpoint.y - pm_height
left = midpoint.x - pm_width
right = midpoint.x + pm_width
to_right = block.get_midpoint().x < target_point.x
# Check for the in edge
vertical_in_edge = target.y > bottom and target.y < top or target.y < top
horizontal_in_edge = target.x < right and target.x > left
# Check if we accidentally passed the edge in the horizontal direction
if to_right:
horizontal_in_edge = horizontal_in_edge or target.x < left
else:
horizontal_in_edge = horizontal_in_edge or target.x > right
return (vertical_in_edge, horizontal_in_edge)
def scale_final_block(horizontal):
"""
Helper function to scale the final block in our front door pathway
Parameters
----------
horizontal : bool
Tells the algorithm if this block is intended for a horizontal
scaling or a vertical one.
Returns
-------
None.
"""
root_block = sidewalk_blocks[-2] if len(sidewalk_blocks) > 1 else -1
move_block = sidewalk_blocks[-1]
# Shrink vertical aka 'not horisontal'
if not horizontal:
block_y = move_block.get_compass_direction_edge_midpoint(CompassDirection.South).y
shrink_perc = (block_y - target_point.y) / move_block.get_height()
move_block.scale_y(shrink_perc)
if len(sidewalk_blocks) > 1:
move_block.move_xy(
root_block.get_compass_direction_edge_midpoint(CompassDirection.South),
move_block.get_compass_direction_edge_midpoint(CompassDirection.North)
)
# Scale the horizontal
else:
# get the direction we will need to measure and move in
if root_block.get_midpoint().x < move_block.get_midpoint().x:
direction = CompassDirection.East
else:
direction = CompassDirection.West
# Get the x values to measure from
root_x = root_block.get_compass_direction_edge_midpoint(direction).x
target_x = target_point.x
# Measure the gap we need to fill
gap_width = root_x - target_x if root_x > target_x else target_x - root_x
# Get the srink percentage
shrink_perc = (move_block.get_width() - gap_width) / move_block.get_width()
# Scale the block
move_block.scale_x(shrink_perc)
# Move the block into place dependent on direction
if direction == CompassDirection.East:
move_block.move_xy(
root_block.get_compass_direction_edge_midpoint(direction),
move_block.get_compass_direction_edge_midpoint(CompassDirection.West)
)
else:
move_block.move_xy(
root_block.get_compass_direction_edge_midpoint(direction),
move_block.get_compass_direction_edge_midpoint(CompassDirection.East)
)
# Add the first block
sidewalk_blocks.append(SidewalkSquare())
sidewalk_blocks[-1].move_xy(
front_door_point,
sidewalk_blocks[-1].get_compass_direction_edge_midpoint(
CompassDirection.North
)
)
horizontal = False
# Add in the vertical direction first
while not in_edge(sidewalk_blocks[-1], target_point)[0]:
yield sidewalk_blocks[-1]
place_on_south_edge()
# Add in the horizontal direction second
while not in_edge(sidewalk_blocks[-1], target_point)[1]:
horizontal = True
yield sidewalk_blocks[-1]
place_on_lateral_edge()
scale_final_block(horizontal)
yield sidewalk_blocks[-1]
if __name__ == "__main__":
# Generate the road boundary
roadBoundaryLine = GetRoadBoundaryLine()
# Generate some random points for the garage and door locations
GandDPoints = GetDoorandGarageLocations()
# specific variables for easier use
garage_point = GandDPoints["Garage"]
frontdoor_point = GandDPoints["Door"]
# Which direction is the driveway from the front door
frontdoor_left_of_driveway = frontdoor_point.x < garage_point.x
dw_center_offset = DrivewayRectangle().get_width() / 2
# Get a x value for where to target the driveway
if frontdoor_left_of_driveway:
driveway_point_x = garage_point.x - dw_center_offset
else:
driveway_point_x = garage_point.x + dw_center_offset
# This is to ensure the front walk comes out a certain amount before bending
# to lead to the driveway
block_lead = frontdoor_point.y + (3 * SidewalkSquare().get_height())
# Get a y value for where to target the driveway
if garage_point.y > frontdoor_point.y:
driveway_point_y = garage_point.y + SidewalkSquare().get_height() / 2
else:
driveway_point_y = block_lead
# Create the points
fpt_driveway_point = Point(driveway_point_x, driveway_point_y)
fpt_road_point = Point(frontdoor_point.x, roadBoundaryLine[0][1])
# Measure the distance to both
dist_fp_dw = sum(Point.distance(frontdoor_point, fpt_driveway_point))
dist_fp_road = sum(Point.distance(frontdoor_point, fpt_road_point))
# Choose which to target
if dist_fp_dw < dist_fp_road:
print("Targetting Driveway")
fpt = fpt_driveway_point
else:
print("Targetting Road")
fpt = fpt_road_point
images = []
dws = []
sss = []
def draw_items(d):
"""
Helper function to draw items in each frame
Parameters
----------
d : draw object
What to use to draw
Returns
-------
None.
"""
d.line(roadBoundaryLine)
d.point(garage_point.as_list())
d.point(frontdoor_point.as_list())
for dw in dws:
dw.draw_self(d)
for ss in sss:
ss.draw_self(d)
# Get all the driveway blocks and draw them
for dw in GetDrivewayPoints(garage_point, roadBoundaryLine):
out = Image.new("RGBA", (WORLD_EDGE_SIZE,WORLD_EDGE_SIZE))
d = ImageDraw.Draw(out)
dws.append(dw)
draw_items(d)
images.append(out)
# Get all the front pathway blocks and draw them
for fp in GetFrontDoorPathwayBlocks(frontdoor_point, fpt):
out = Image.new("RGBA", (WORLD_EDGE_SIZE,WORLD_EDGE_SIZE))
d = ImageDraw.Draw(out)
sss.append(fp)
draw_items(d)
images.append(out)
# Save our GIF
images[0].save(
'house_paths.gif',
save_all=True,
append_images=images[1:],
optimize=True,
duration=100,
loop=0
)
Some examples of the output (small squares are the front pathway)
The theoretical code got away from me in time, I didn’t intend for this to take as long as it did, which is why there isn’t any in-game demo. However it did give me interesting problems that I might not have figured out so quickly in Unity.
First we need to be aware of which direction to scale the blocks at the end, for the driveway this is easy, it is only going to be in the vertical direction and determined by the roads y position. For the front door pathway it is a bit more tricky because you have to know which direction the path is headed in order to scale and adjust its location properly.
Second, determination of what to target for the front door path is difficult in its own ways, such as measuring the distance to an arbitrary point on the drive way and which side of the path the driveway is on.
Conclusion
With the logic worked out(mostly there are still some hidden bugs I will have to work out), I am ready to begin porting this to Unity this next week which will bring with it many more houses. I look forward to the next post and the NEW YEAR happy New Year everyone!
This week was busy preparing at work for the holiday break, but I still managed to get some things done on the game. The following are the changes for this week and I am very excited for the future of this game, it is getting to the point now where I almost feel comfortable sharing an APK here on the site for people to download and try. Thank you for checking in!
Change Log
Added Hand Break
Pull trigger to increase decay on slowdown
Added Sound, and Music
This will expand as we go it is a minimal implementation at this point
Added a room for the lobby
Added persistent game data for round saving across app restarts
This week I would say we had a bit of a regression (Meshes), and a couple big moves forward. Thank you for coming to this progress report, please leave a comment with feedback/encouragement if you have it, and enjoy the update!
Change Log
Added a main menu
Added game modes
Added game results display in main menu
Added dampening function to bike movement speed to prevent motion sickness when bike stops
Changed house meshes
Reduced poly count on houses by importing a new unity asset set
Lower quality but definitely made a difference with frame rate
This also allowed us to dress up the scene a lot more, with hopes to procedurally generate the house surroundings in the future
Changed to a multi-scene work flow
In addition we reorganized code to allow for better future expansion (this still needs work)
Changed targets to a cube area as apposed to an orb
This weeks update was awesome! Huge milestone hit, WE GOT THE HEADSET CONNECTED TO THE BIKE! I didn’t have as much time this week to work on the app but getting the headset to connect to the bike was a huge win! Also this week I added an intro to the demo, along with overlaid narration to explain the changes.
Change Log
In-Game speed adjusted by real world bike cadence
Mirrored Houses to ‘fill-out’ the neighborhood
Increases immersion
Added a sound for the paper hitting the ground (not audible in the demo)
Lessons/Funny Story
This week I learned how to package our game into and APK and side load it onto our headset, this allowed me to use the Bluetooth device in the headset to connect to the bike.
The sound used for the paper hitting the ground is actually extracted from a very random YouTube video here.