Now I've grasped the very basics of Pygame, I thought it'd be useful to make a few classes that I could use later on if I try make a simple RPG style game. I've done this to handle the coordinates for the player movement (and possibly for other things too), and tried to do it in a way where you wouldn't get the floating point precision errors that Minecraft and similar games get when you travel out very far.
I tested it against the decimal
module, and mine appeared to up to 10x faster for small movements, though the speeds evened out when using super large movements like +- 10000000000000.
It's only half a days worth of work so it's not perfect, I can't find any more bugs though. It's recommended you input the coordinates as strings though if you're using large ones to start with, especially if you're using floats.
class Movement(object):
"""This was built to allow large coordinates to be stored without
causing any floating point precision errors.
It is faster than the decimal module, especially with processing
large amounts of small movements.
It works by breaking down the coordinates into 'blocks', where each
new block is the squared amount of the previous one.
The method is very similar to the base number system, in which a
block size of 10 will split 235.9 into [5.9, 3, 2].
A large block size is faster than a small one, though the precision
will be worse. At 16 digits, Python can't store any more decimals,
so definitely keep it under that.
"""
BLOCK_SIZE = 65535
COORDINATES = range(3)
def __init__(self, x=0, y=0, z=0, block_size=None):
"""Convert the starting coordinates into the format accepted
by the class.
>>> m = Movement('15',
... '-31564.99933425584842',
... '1699446367870005.2')
>>> m.player_loc
[[15.0], [-31564.99933425585], [38640.2, 17514, 2485, 6]]
>>> print m
(15.0, -31564.9993343, 1699446367870005.2)
"""
#Set a new block size if needed
if block_size is not None:
self.BLOCK_SIZE = block_size
#Store the initial coordinates
self.player_loc = [self.calculate(self._get_integer(i),
self._get_decimal(i))
for i in map(str, (x, y, z))]
def __repr__(self):
"""This needs improving, currently it just converts back to
the absolute coordinates."""
return 'Movement({}, {}, {})'.format(*self._convert_to_world())
def __str__(self):
"""Print the absolute coordinates."""
return str(tuple(self._convert_to_world())).replace("'", "")
def __getitem__(self, i):
"""Return an absolute value for X, Y or Z.
Parameters:
i (int): Index of coordinate.
"""
try:
return self._convert_to_world()[i]
except IndexError:
MovementError.index_error_coordinate()
def __setitem__(self, i, n):
"""Set an absolute value for X, Y or Z.
Parameters:
i (int): Index of coordinate.
n (int/float): New value to set.
"""
n = str(n)
try:
self.player_loc[i] = self.calculate(self._get_integer(n),
self._get_decimal(n))
except IndexError:
MovementError.index_error_coordinate()
@classmethod
def _get_integer(self, n):
"""Convert the input to an integer.
Parameters:
n (str): Integer to convert.
>>> Movement._get_integer('15.35321')
15
"""
return int(n) if '.' not in n else int(n.split('.')[0])
@classmethod
def _get_decimal(self, n):
"""Get the decimal number from the input.
Parameters:
n (str): Decimal to convert.
>>> Movement._get_decimal('15.35321')
35321
"""
return 0 if '.' not in n else int(n.split('.')[1])
def calculate(self, amount, decimal=0):
"""Convert the coordinate into a block.
Parameters:
amount (int/str): Total value without any decimals.
decimal (int, optional): The decimal value as an integer
without the leading '0.'.
>>> Movement().calculate(128)
[128.0]
>>> Movement().calculate(128, 5176)
[128.5176]
>>> Movement().calculate(4294836224)
[65534.0, 65534]
>>> Movement().calculate(4294836225)
[0.0, 0, 1]
"""
coordinate = []
amount = int(amount)
negative = amount < 0
multiplier = int('-1'[not negative:])
if negative:
amount *= -1
while amount > self.BLOCK_SIZE - 1:
remainder = amount % self.BLOCK_SIZE
amount = (amount - remainder) / self.BLOCK_SIZE
coordinate.append(int(remainder * multiplier))
coordinate.append(int(amount * multiplier))
decimal = float('0.' + str(int(decimal)))
coordinate[0] += decimal * int('-1'[not coordinate[0] < 0:])
return coordinate
def _move(self, direction, amount, final_run=False):
"""Add the coordinate to a block.
Parameters:
direction (int): Represents X, Y, or Z as a number.
amount (int/float): Amount to add or subtract from the
coordinate.
>>> m = Movement(135, 426.42, -1499941.5002)
>>> print m
(135.0, 426.42, -1499941.5002)
>>> m.move(100, -5133.100532, 5)
>>> print m
(235.0, -4706.680532, -1499936.5002)
"""
#Fix to keep decimals on large numbers
if not final_run and amount > self.BLOCK_SIZE:
decimal = self.player_loc[direction][0] % 1
if '.' in str(amount):
decimal += float('0.' + str(amount).split('.')[1])
self.player_loc[direction][0] += int(amount)
else:
self.player_loc[direction][0] += amount
#Recalculate and add blocks if needed
i = 0
while i < len(self.player_loc[direction]):
stop = True
current_block = self.player_loc[direction][i]
while not -self.BLOCK_SIZE < current_block < self.BLOCK_SIZE:
stop = False
remainder = current_block % self.BLOCK_SIZE
new_addition = int(current_block - remainder) / self.BLOCK_SIZE
if i:
remainder = int(remainder)
self.player_loc[direction][i] = remainder
try:
self.player_loc[direction][i + 1] += new_addition
except IndexError:
self.player_loc[direction].append(new_addition)
#Break execution if higher blocks are not edited
if stop:
break
i += 1
#Add the final decimals if a large number was input
try:
self._move(direction, decimal, final_run=True)
except UnboundLocalError:
pass
def move(self, x, y, z):
"""Update the coordinates with a new relative location."""
for i, amount in enumerate((x, y, z)):
if amount:
self._move(i, amount)
def _convert_to_world(self):
"""Convert the blocks into absolute coordinates as a string."""
#Convert coordinates back to numbers, without using floats
coordinates = [sum(int(amount) * pow(self.BLOCK_SIZE, i)
for i, amount in enumerate(coordinate))
for coordinate in self.player_loc]
#Add the decimal points as strings
coordinates = [(str(coordinates[i])
+ '.'
+ str(float(self.player_loc[i][0])).split('.')[1])
for i in self.COORDINATES]
#Fix for numbers between -1 and 0
for i in self.COORDINATES:
if (len(self.player_loc[i]) == 1
and str(self.player_loc[i][0]).startswith('-0.')):
coordinates[i] = '-' + coordinates[i]
return coordinates
class MovementError(Exception):
"""Custom movement exceptions."""
@classmethod
def index_error_coordinate(self):
raise MovementError('coordinate index out of range')
if __name__ == '__main__':
import doctest
doctest.testmod()
In response to the comment by holroy, I decided to keep with float (as opposed to store the decimal value as a separate int) because I've still got to think about conversion to and from the game world. With the current way, unless the float value goes above the block size, it's literally just a single addition, whereas the other way would be a lot slower, and the precision to that level isn't really needed.
I did the block method in a previous project which was about octrees, where the coordinates were stored relative to each 3D block, which was split into 8 smaller blocks (so a coordinate would be [(1, 1, 1), (-1, 1, 1), (1, 1, -1), (-1, -1, 1)]
for 4 nested blocks, which results in (5, 13, 11)
. The problem with this approach is if a coordinate was at 16, that's too high to store, so you'd need to recalculate everything for 5 blocks instead of 4 (which upon writing this, I've realised it may be as simple as multiplying all of block 4 by -1, though I'm not sure).
The boundaries can also be a bit confusing, as the size of the block is -(2^x)+1
to 2^x
, like -255 to 256.
I wanted this to be able to scale as much as needed, without getting overly complicated, and the current way seems to accomplish this fine.