TetrArcade/arcade/sprite_list.py

689 lines
22 KiB
Python

"""
This module provides functionality to manage Sprites in a list.
"""
from typing import Iterable
from typing import TypeVar
from typing import Generic
from typing import List
import pyglet.gl as gl
import math
import numpy as np
from PIL import Image
from arcade.sprite import Sprite
from arcade.sprite import get_distance_between_sprites
from arcade.sprite import AnimatedTimeBasedSprite
from arcade.draw_commands import rotate_point
from arcade.window_commands import get_projection
from arcade import shader
from arcade.arcade_types import Point
VERTEX_SHADER = """
#version 330
uniform mat4 Projection;
// per vertex
in vec2 in_vert;
in vec2 in_texture;
// per instance
in vec2 in_pos;
in float in_angle;
in vec2 in_scale;
in vec4 in_sub_tex_coords;
in vec4 in_color;
out vec2 v_texture;
out vec4 v_color;
void main() {
mat2 rotate = mat2(
cos(in_angle), sin(in_angle),
-sin(in_angle), cos(in_angle)
);
vec2 pos;
pos = in_pos + vec2(rotate * (in_vert * in_scale));
gl_Position = Projection * vec4(pos, 0.0, 1.0);
vec2 tex_offset = in_sub_tex_coords.xy;
vec2 tex_size = in_sub_tex_coords.zw;
v_texture = (in_texture * tex_size + tex_offset) * vec2(1, -1);
v_color = in_color;
}
"""
FRAGMENT_SHADER = """
#version 330
uniform sampler2D Texture;
in vec2 v_texture;
in vec4 v_color;
out vec4 f_color;
void main() {
vec4 basecolor = texture(Texture, v_texture);
basecolor = basecolor * v_color;
if (basecolor.a == 0.0){
discard;
}
f_color = basecolor;
}
"""
def _create_rects(rect_list: Iterable[Sprite]) -> List[float]:
"""
Create a vertex buffer for a set of rectangles.
"""
v2f = []
for shape in rect_list:
x1 = -shape.width / 2 + shape.center_x
x2 = shape.width / 2 + shape.center_x
y1 = -shape.height / 2 + shape.center_y
y2 = shape.height / 2 + shape.center_y
p1 = x1, y1
p2 = x2, y1
p3 = x2, y2
p4 = x1, y2
if shape.angle:
p1 = rotate_point(p1[0], p1[1], shape.center_x, shape.center_y, shape.angle)
p2 = rotate_point(p2[0], p2[1], shape.center_x, shape.center_y, shape.angle)
p3 = rotate_point(p3[0], p3[1], shape.center_x, shape.center_y, shape.angle)
p4 = rotate_point(p4[0], p4[1], shape.center_x, shape.center_y, shape.angle)
v2f.extend([p1[0], p1[1],
p2[0], p2[1],
p3[0], p3[1],
p4[0], p4[1]])
return v2f
class _SpatialHash:
"""
Structure for fast collision checking.
See: https://www.gamedev.net/articles/programming/general-and-gameplay-programming/spatial-hashing-r2697/
"""
def __init__(self, cell_size):
self.cell_size = cell_size
self.contents = {}
def _hash(self, point):
return int(point[0] / self.cell_size), int(point[1] / self.cell_size)
def reset(self):
self.contents = {}
def insert_object_for_box(self, new_object: Sprite):
"""
Insert a sprite.
"""
# Get the corners
min_x = new_object.left
max_x = new_object.right
min_y = new_object.bottom
max_y = new_object.top
# print(f"New - Center: ({new_object.center_x}, {new_object.center_y}), Angle: {new_object.angle}, "
# f"Left: {new_object.left}, Right {new_object.right}")
min_point = (min_x, min_y)
max_point = (max_x, max_y)
# print(f"Add 1: {min_point} {max_point}")
# hash the minimum and maximum points
min_point, max_point = self._hash(min_point), self._hash(max_point)
# print(f"Add 2: {min_point} {max_point}")
# print("Add: ", min_point, max_point)
# iterate over the rectangular region
for i in range(min_point[0], max_point[0] + 1):
for j in range(min_point[1], max_point[1] + 1):
# append to each intersecting cell
bucket = self.contents.setdefault((i, j), [])
if new_object in bucket:
# print(f"Error, {new_object.guid} already in ({i}, {j}) bucket. ")
pass
else:
bucket.append(new_object)
# print(f"Adding {new_object.guid} to ({i}, {j}) bucket. "
# f"{new_object._position} {min_point} {max_point}")
def remove_object(self, sprite_to_delete: Sprite):
"""
Remove a Sprite.
:param Sprite sprite_to_delete: Pointer to sprite to be removed.
"""
# Get the corners
min_x = sprite_to_delete.left
max_x = sprite_to_delete.right
min_y = sprite_to_delete.bottom
max_y = sprite_to_delete.top
# print(f"Del - Center: ({sprite_to_delete.center_x}, {sprite_to_delete.center_y}), "
# f"Angle: {sprite_to_delete.angle}, Left: {sprite_to_delete.left}, Right {sprite_to_delete.right}")
min_point = (min_x, min_y)
max_point = (max_x, max_y)
# print(f"Remove 1: {min_point} {max_point}")
# hash the minimum and maximum points
min_point, max_point = self._hash(min_point), self._hash(max_point)
# print(f"Remove 2: {min_point} {max_point}")
# print("Remove: ", min_point, max_point)
# iterate over the rectangular region
for i in range(min_point[0], max_point[0] + 1):
for j in range(min_point[1], max_point[1] + 1):
bucket = self.contents.setdefault((i, j), [])
try:
bucket.remove(sprite_to_delete)
# print(f"Removing {sprite_to_delete.guid} from ({i}, {j}) bucket. {sprite_to_delete._position} "
# f"{min_point} {max_point}")
except ValueError:
print(f"Warning, tried to remove item {sprite_to_delete.guid} from spatial hash {i} {j} when "
f"it wasn't there. {min_point} {max_point}")
def get_objects_for_box(self, check_object: Sprite) -> List[Sprite]:
"""
Returns colliding Sprites.
:param Sprite check_object: Sprite we are checking to see if there are
other sprites in the same box(es)
:return: List of close-by sprites
:rtype: List
"""
# Get the corners
min_x = check_object.left
max_x = check_object.right
min_y = check_object.bottom
max_y = check_object.top
min_point = (min_x, min_y)
max_point = (max_x, max_y)
# hash the minimum and maximum points
min_point, max_point = self._hash(min_point), self._hash(max_point)
close_by_sprites = []
# iterate over the rectangular region
for i in range(min_point[0], max_point[0] + 1):
for j in range(min_point[1], max_point[1] + 1):
# print(f"Checking {i}, {j}")
# append to each intersecting cell
new_items = self.contents.setdefault((i, j), [])
# for item in new_items:
# print(f"Found {item.guid} in {i}, {j}")
close_by_sprites.extend(new_items)
return close_by_sprites
def get_objects_for_point(self, check_point: Point) -> List[Sprite]:
"""
Returns Sprites at or close to a point.
:param Point check_point: Point we are checking to see if there are
other sprites in the same box(es)
:return: List of close-by sprites
:rtype: List
"""
hash_point = self._hash(check_point)
close_by_sprites = []
new_items = self.contents.setdefault(hash_point, [])
close_by_sprites.extend(new_items)
return close_by_sprites
T = TypeVar('T', bound=Sprite)
class SpriteList(Generic[T]):
next_texture_id = 0
def __init__(self, use_spatial_hash=False, spatial_hash_cell_size=128, is_static=False):
"""
Initialize the sprite list
:param bool use_spatial_hash: If set to True, this will make moving a sprite
in the SpriteList slower, but it will speed up collision detection
with items in the SpriteList. Great for doing collision detection
with walls/platforms.
:param int spatial_hash_cell_size:
:param bool is_static: Speeds drawing if this list won't change.
"""
# List of sprites in the sprite list
self.sprite_list = []
self.sprite_idx = dict()
# Used in drawing optimization via OpenGL
self.program = None
self.sprite_data = None
self.sprite_data_buf = None
self.texture_id = None
self._texture = None
self.vao = None
self.vbo_buf = None
self.array_of_texture_names = []
self.array_of_images = []
# Used in collision detection optimization
self.is_static = is_static
self.use_spatial_hash = use_spatial_hash
if use_spatial_hash:
self.spatial_hash = _SpatialHash(cell_size=spatial_hash_cell_size)
else:
self.spatial_hash = None
def append(self, item: T):
"""
Add a new sprite to the list.
:param Sprite item: Sprite to add to the list.
"""
idx = len(self.sprite_list)
self.sprite_list.append(item)
self.sprite_idx[item] = idx
item.register_sprite_list(self)
self.vao = None
if self.use_spatial_hash:
self.spatial_hash.insert_object_for_box(item)
def _recalculate_spatial_hash(self, item: T):
""" Recalculate the spatial hash for a particular item. """
if self.use_spatial_hash:
self.spatial_hash.remove_object(item)
self.spatial_hash.insert_object_for_box(item)
def _recalculate_spatial_hashes(self):
if self.use_spatial_hash:
self.spatial_hash.reset()
for sprite in self.sprite_list:
self.spatial_hash.insert_object_for_box(sprite)
def remove(self, item: T):
"""
Remove a specific sprite from the list.
:param Sprite item: Item to remove from the list
"""
self.sprite_list.remove(item)
# Rebuild index list
self.sprite_idx[item] = dict()
for idx, sprite in enumerate(self.sprite_list):
self.sprite_idx[sprite] = idx
self.vao = None
if self.use_spatial_hash:
self.spatial_hash.remove_object(item)
def update(self):
"""
Call the update() method on each sprite in the list.
"""
for sprite in self.sprite_list:
sprite.update()
def update_animation(self, delta_time=1/60):
for sprite in self.sprite_list:
if isinstance(sprite, AnimatedTimeBasedSprite):
sprite.update_animation(delta_time)
else:
sprite.update_animation()
def move(self, change_x: float, change_y: float):
"""
Moves all Sprites in the list by the same amount.
:param float change_x: Amount to change all x values by
:param float change_y: Amount to change all y values by
"""
for sprite in self.sprite_list:
sprite.center_x += change_x
sprite.center_y += change_y
def preload_textures(self, texture_names: List):
"""
Preload a set of textures that will be used for sprites in this
sprite list.
:param array texture_names: List of file names to load in as textures.
"""
self.array_of_texture_names.extend(texture_names)
self.array_of_images = None
def _calculate_sprite_buffer(self):
if len(self.sprite_list) == 0:
return
# Loop through each sprite and grab its position, and the texture it will be using.
array_of_positions = []
array_of_sizes = []
array_of_colors = []
array_of_angles = []
for sprite in self.sprite_list:
array_of_positions.append([sprite.center_x, sprite.center_y])
array_of_angles.append(math.radians(sprite.angle))
size_h = sprite.height / 2
size_w = sprite.width / 2
array_of_sizes.append([size_w, size_h])
array_of_colors.append(sprite.color + (sprite.alpha, ))
new_array_of_texture_names = []
new_array_of_images = []
new_texture = False
if self.array_of_images is None:
new_texture = True
# print()
# print("New texture start: ", new_texture)
for sprite in self.sprite_list:
if sprite._texture is None:
raise Exception("Error: Attempt to draw a sprite without a texture set.")
name_of_texture_to_check = sprite._texture.name
if name_of_texture_to_check not in self.array_of_texture_names:
new_texture = True
# print("New because of ", name_of_texture_to_check)
if name_of_texture_to_check not in new_array_of_texture_names:
new_array_of_texture_names.append(name_of_texture_to_check)
image = sprite._texture.image
new_array_of_images.append(image)
# print("New texture end: ", new_texture)
# print(new_array_of_texture_names)
# print(self.array_of_texture_names)
# print()
if new_texture:
# Add back in any old textures. Chances are we'll need them.
for index, old_texture_name in enumerate(self.array_of_texture_names):
if old_texture_name not in new_array_of_texture_names and self.array_of_images is not None:
new_array_of_texture_names.append(old_texture_name)
image = self.array_of_images[index]
new_array_of_images.append(image)
self.array_of_texture_names = new_array_of_texture_names
self.array_of_images = new_array_of_images
# print(f"New Texture Atlas with names {self.array_of_texture_names}")
# Get their sizes
widths, heights = zip(*(i.size for i in self.array_of_images))
# Figure out what size a composite would be
total_width = sum(widths)
max_height = max(heights)
if new_texture:
# TODO: This code isn't valid, but I think some releasing might be in order.
# if self.texture is not None:
# shader.Texture.release(self.texture_id)
# Make the composite image
new_image = Image.new('RGBA', (total_width, max_height))
x_offset = 0
for image in self.array_of_images:
new_image.paste(image, (x_offset, 0))
x_offset += image.size[0]
# Create a texture out the composite image
self._texture = shader.texture(
(new_image.width, new_image.height),
4,
np.asarray(new_image)
)
if self.texture_id is None:
self.texture_id = SpriteList.next_texture_id
# Create a list with the coordinates of all the unique textures
tex_coords = []
start_x = 0.0
for image in self.array_of_images:
end_x = start_x + (image.width / total_width)
normalized_width = image.width / total_width
start_height = 1 - (image.height / max_height)
normalized_height = image.height / max_height
tex_coords.append([start_x, start_height, normalized_width, normalized_height])
start_x = end_x
# Go through each sprite and pull from the coordinate list, the proper
# coordinates for that sprite's image.
array_of_sub_tex_coords = []
for sprite in self.sprite_list:
index = self.array_of_texture_names.index(sprite._texture.name)
array_of_sub_tex_coords.append(tex_coords[index])
# Create numpy array with info on location and such
buffer_type = np.dtype([('position', '2f4'), ('angle', 'f4'), ('size', '2f4'),
('sub_tex_coords', '4f4'), ('color', '4B')])
self.sprite_data = np.zeros(len(self.sprite_list), dtype=buffer_type)
self.sprite_data['position'] = array_of_positions
self.sprite_data['angle'] = array_of_angles
self.sprite_data['size'] = array_of_sizes
self.sprite_data['sub_tex_coords'] = array_of_sub_tex_coords
self.sprite_data['color'] = array_of_colors
if self.is_static:
usage = 'static'
else:
usage = 'stream'
self.sprite_data_buf = shader.buffer(
self.sprite_data.tobytes(),
usage=usage
)
vertices = np.array([
# x, y, u, v
-1.0, -1.0, 0.0, 0.0,
-1.0, 1.0, 0.0, 1.0,
1.0, -1.0, 1.0, 0.0,
1.0, 1.0, 1.0, 1.0,
], dtype=np.float32
)
self.vbo_buf = shader.buffer(vertices.tobytes())
vbo_buf_desc = shader.BufferDescription(
self.vbo_buf,
'2f 2f',
('in_vert', 'in_texture')
)
pos_angle_scale_buf_desc = shader.BufferDescription(
self.sprite_data_buf,
'2f 1f 2f 4f 4B',
('in_pos', 'in_angle', 'in_scale', 'in_sub_tex_coords', 'in_color'),
normalized=['in_color'], instanced=True)
vao_content = [vbo_buf_desc, pos_angle_scale_buf_desc]
# Can add buffer to index vertices
self.vao = shader.vertex_array(self.program, vao_content)
def dump(self):
buffer = self.sprite_data.tobytes()
record_size = len(buffer) / len(self.sprite_list)
for i, char in enumerate(buffer):
if i % record_size == 0:
print()
print(f"{char:02x} ", end="")
def _update_positions(self):
""" Called by the Sprite class to update position, angle, size and color
of all sprites in the list.
Necessary for batch drawing of items. """
if self.vao is None:
return
for i, sprite in enumerate(self.sprite_list):
self.sprite_data[i]['position'] = [sprite.center_x, sprite.center_y]
self.sprite_data[i]['angle'] = math.radians(sprite.angle)
self.sprite_data[i]['size'] = [sprite.width / 2, sprite.height / 2]
self.sprite_data[i]['color'] = sprite.color + (sprite.alpha, )
def update_texture(self, sprite):
""" Make sure we update the texture for this sprite for the next batch
drawing"""
if self.vao is None:
return
self._calculate_sprite_buffer()
def update_position(self, sprite: Sprite):
"""
Called by the Sprite class to update position, angle, size and color
of the specified sprite.
Necessary for batch drawing of items.
:param Sprite sprite: Sprite to update.
"""
if self.vao is None:
return
i = self.sprite_idx[sprite]
self.sprite_data[i]['position'] = [sprite.center_x, sprite.center_y]
self.sprite_data[i]['angle'] = math.radians(sprite.angle)
self.sprite_data[i]['size'] = [sprite.width / 2, sprite.height / 2]
self.sprite_data[i]['color'] = sprite.color + (sprite.alpha, )
def update_location(self, sprite: Sprite):
"""
Called by the Sprite class to update the location in this sprite.
Necessary for batch drawing of items.
:param Sprite sprite: Sprite to update.
"""
if self.vao is None:
return
i = self.sprite_idx[sprite]
self.sprite_data[i]['position'] = sprite.position
def update_angle(self, sprite: Sprite):
"""
Called by the Sprite class to update the angle in this sprite.
Necessary for batch drawing of items.
:param Sprite sprite: Sprite to update.
"""
if self.vao is None:
return
i = self.sprite_idx[sprite]
self.sprite_data[i]['angle'] = math.radians(sprite.angle)
def draw(self):
""" Draw this list of sprites. """
if self.program is None:
# Used in drawing optimization via OpenGL
self.program = shader.program(
vertex_shader=VERTEX_SHADER,
fragment_shader=FRAGMENT_SHADER
)
if len(self.sprite_list) == 0:
return
if self.vao is None:
self._calculate_sprite_buffer()
self._texture.use(0)
gl.glEnable(gl.GL_BLEND)
gl.glBlendFunc(gl.GL_SRC_ALPHA, gl.GL_ONE_MINUS_SRC_ALPHA)
# gl.glTexParameterf(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_MIN_FILTER, gl.GL_NEAREST)
# gl.glTexParameterf(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_MAG_FILTER, gl.GL_NEAREST)
with self.vao:
self.program['Texture'] = self.texture_id
self.program['Projection'] = get_projection().flatten()
if not self.is_static:
self.sprite_data_buf.write(self.sprite_data.tobytes())
self.vao.render(gl.GL_TRIANGLE_STRIP, instances=len(self.sprite_list))
if not self.is_static:
self.sprite_data_buf.orphan()
def __len__(self) -> int:
""" Return the length of the sprite list. """
return len(self.sprite_list)
def __iter__(self) -> Iterable[T]:
""" Return an iterable object of sprites. """
return iter(self.sprite_list)
def __getitem__(self, i):
return self.sprite_list[i]
def pop(self) -> Sprite:
"""
Pop off the last sprite in the list.
"""
self.program = None
return self.sprite_list.pop()
def get_closest_sprite(sprite: Sprite, sprite_list: SpriteList) -> (Sprite, float):
"""
Given a Sprite and SpriteList, returns the closest sprite, and its distance.
:param Sprite sprite: Target sprite
:param SpriteList sprite_list: List to search for closest sprite.
:return: Closest sprite.
:rtype: Sprite
"""
if len(sprite_list) == 0:
return None
min_pos = 0
min_distance = get_distance_between_sprites(sprite, sprite_list[min_pos])
for i in range(1, len(sprite_list)):
distance = get_distance_between_sprites(sprite, sprite_list[i])
if distance < min_distance:
min_pos = i
min_distance = distance
return sprite_list[min_pos], min_distance