Prior to diving into the coding aspect of the game, I set up a scene that forms the foundation for the Tetris data structure. This scene includes essential elements such as a camera, the playing field, lights, and the individual Tetris figures. Each figure is composed of cubes, a design choice integral to the Tetris gameplay where complete lines vanish, yielding points. Since only parts of a figure might need to be removed when lines disappear, the cube-based structure is crucial. We'll achieve this effect by selectively deleting specific cube shapes within a figure.
In this scene, we'll extract transformation data and parameters for each object. This includes details such as the transformations and intensity of the lights, their colors, and the point positions for each mesh necessary to generate the Tetris figures. The majority of this data retrieval is facilitated by the static class OpenMayaUtils, which houses a comprehensive suite of functions compatible with the Python API. We will also be utilizing some functions from this class in subsequent stages of our development process.
Since Maya lacks a dedicated game engine, we'll utilize Maya's viewport for our project's needs. Upon creating a new viewport, we configure various parameters, including enabling lighting, setting the shading type, activating shadows, anti-aliasing, and others. To ensure an undistracted experience with our game, we will lock and disable the main viewport before launching the new window. The newly created viewport will then be encapsulated within a QWidget, allowing us to access and manipulate it as a PySide object.:
# create model panel
self.modelPanelName = cmds.modelPanel("customModelPanel_%s" % (str(uuid_test)[0:6]),
label="Tetris playground",
cam=self.cameraName
)
# setup viewport params
cmds.modelEditor(self.modelPanelName,
e=1,
rnm="vp2Renderer",
displayLights="all",
displayAppearance="smoothShaded",
cameras=False,
wireframeOnShaded=True,
shadows=True,
headsUpDisplay=False,
grid=1
)
# hide menu bar and bar layout
cmds.modelPanel(self.modelPanelName,
edit=True,
menuBarVisible=False,
barLayout=False
)
commands = "setAttr \"hardwareRenderingGlobals.lineAAEnable\" 1;" \
"setAttr \"hardwareRenderingGlobals.multiSampleEnable\" 1;" \
"setAttr \"hardwareRenderingGlobals.multiSampleCount\" 16;" \
"setAttr \"hardwareRenderingGlobals.ssaoEnable\" 1;" \
"setAttr \"hardwareRenderingGlobals.ssaoRadius\" 10;"
mel.eval(commands)
# Find a pointer to the modelPanel that we just created
ptr = OpenMayaUI.MQtUtil.findControl(self.modelPanelName)
# Wrap the pointer into a python QObject
self.modelPanel = shiboken2.wrapInstance(int(ptr), QtWidgets.QWidget)
Additionally, we'll enhance the visual appeal of the viewport by modifying its color configuration. This includes adjusting the background color, grid lines color, and shading edges color to create a more visually engaging interface. However, before implementing these changes, we will save the current color settings. This ensures that once the game concludes, we can revert the viewport to its original color scheme, thereby maintaining the user's preferred settings and avoiding any inconvenience.:
def pre_setup_viewport_colors(self):
"""
Store current maya viewport colors
and rewrites them with new ones.
"""
self.current_background_color = cmds.displayRGBColor("background", q=True)
cmds.displayRGBColor("background", 0, 0, 0)
self.color_index = 18
self.color = [0, 0.657, 1]
self.prev_color_index_value = cmds.colorIndex(self.color_index, q=True)
cmds.colorIndex(18, self.color[0], self.color[1], self.color[2])
self.current_gridAxis_index = cmds.displayColor("gridAxis", q=True)
cmds.displayColor("gridAxis", self.color_index)
self.current_gridHighlight_index = cmds.displayColor("gridHighlight", q=True)
cmds.displayColor("gridHighlight", self.color_index)
self.current_grid_index = cmds.displayColor("grid", q=True)
cmds.displayColor("grid", self.color_index)
self.current_polymesh_index = cmds.displayColor("polymesh", q=True)
cmds.displayColor("polymesh", self.color_index)
self.viewport_gradient = cmds.displayPref(q=True, displayGradient=True)
if self.viewport_gradient:
cmds.displayPref(displayGradient=False)
The color modification process involves globally overriding color data in the maya.env file. While we intend to restore the original colors upon closing the game, there's a risk involved. In the event of a game crash or a fatal error, the previous color settings may not be restored. This could lead to potential issues within the Maya environment.
Let's examine the process of figure generation within the main cycle. We have previously gathered data from our prepared scene, which includes specific details for each figure, such as point positions, the number of polygons, vertex indexes per polygon, and the number of vertices per polygon. This collected data is crucial as it will be passed to the initialization function of an MFnMesh object. This function is responsible for creating the polygons that compose each figure. Now, let's delve into the specifics of generating a single figure:
def create_figures(self, figures_creation_data, parent):
"""
Create playing figures based on given figures_creation_data
:param figures_creation_data: list with data to recreate figure
:param parent: parent transform to parent figure which will be created
"""
if not figures_creation_data or not parent:
return
for data in figures_creation_data:
number_of_vertices_per_polygon = data["number_of_vertices_per_polygon"]
vertex_indexes_per_polygon = data["vertex_indexes_per_polygon"]
vertex_positions_raw_data = data["vertex_positions"]
polygon_count = data["numPolygons"]
shape_transform_mobj = OpenMayaUtils.create_mesh(polygon_count,
vertex_positions_raw_data,
number_of_vertices_per_polygon,
vertex_indexes_per_polygon
)
transform_mfn_dag = OpenMaya.MFnDagNode(shape_transform_mobj)
cmds.xform(transform_mfn_dag.fullPathName(), centerPivots=True)
mfn_dag_modifier = OpenMaya.MDagModifier()
mfn_dag_modifier.reparentNode(shape_transform_mobj, parent)
mfn_dag_modifier.doIt()
All figure data are neatly organized within a dictionary. This allows us to generate a figure randomly by selecting a key from the dictionary using a random integer. This process not only determines the shape of the figure but also enables us to assign a randomly selected shader to each figure.
A crucial aspect to consider is the implementation of pivots for rotation. In Tetris, the ability to rotate each mesh is essential. We must devise a method to determine the pivot point around which the figure will rotate. By default, the pivot in an MFnMesh is positioned at the scene's zero coordinates. My approach to this challenge is straightforward. I designate a central shape for each figure, which is created first. Once this central shape is generated, we set the pivot of the entire figure's transformation at this centre. Subsequently, the remaining cubes are constructed around it. This strategy ensures that each figure has the correct initial pivot point, allowing us to rotate the figure in any desired direction effortlessly.
def generate_random_figure(self):
"""
Generate random figure based on all figures data.
:return: string created figure name
"""
rand_mesh_index = randint(0, len(self.figures_mesh_creation_data) - 1)
figure_transform_mfn_dag = OpenMaya.MFnDagNode()
figure_transform_mfn_dag.create("transform", "figure_%s" % str(rand_mesh_index))
figure_dag_name = figure_transform_mfn_dag.fullPathName()
self.created_nodes.append(figure_dag_name)
figure_random_key = self.figures_mesh_keys_list[rand_mesh_index]
figure_data = self.figures_mesh_creation_data[figure_random_key]
center_shape_data = figure_data["center_shape_data"]
if center_shape_data:
self.create_figures(center_shape_data, figure_transform_mfn_dag.object())
cmds.xform(figure_dag_name, centerPivots=True)
rest_shapes_data = figure_data["rest_shape_data"]
if rest_shapes_data:
self.create_figures(rest_shapes_data, figure_transform_mfn_dag.object())
if len(self.generated_indexes) > 2 and randint(0, 2)>1:
rand_shader_index = self.generated_indexes[0]
self.generated_indexes = []
else:
rand_shader_index = randint(0, 4)
rand_shader_shading_group = self.shaders_shading_groups_list[rand_shader_index]
self.generated_indexes.append(rand_shader_index)
figure_name = figure_transform_mfn_dag.fullPathName()
cmds.sets(figure_name, forceElement=rand_shader_shading_group)
self.generated_figures.add(figure_name)
return figure_dag_name
In this project, collision detection is implemented with utmost simplicity, as it isn't a necessity for such a straightforward task. Although our game features a 3D view with 3D figures, the core gameplay of Tetris remains inherently 2D, lacking any z-depth. Figures cannot be moved closer or further away from the camera. This limitation actually works to our advantage, simplifying the process significantly. We can track each occupied cell – a cell where a figure's mesh is already present – by simply recording the centroid of each cube in the figure. This approach streamlines the gameplay mechanics while staying true to the classic 2D nature of Tetris.
def move_figure(self, direction, transform_value=-1.0, shape_name=None, return_y_collided=False):
if not shape_name:
shape_name = self.active_figure_name
if not shape_name:
return
if cmds.nodeType(shape_name) == "mesh":
parents = cmds.listRelatives(shape_name, parent=True, type="transform")
if not parents:
return
shape_name = parents[0]
current_figure_translate_y_attr_name = "%s.%s" % (shape_name, direction)
current_position = cmds.getAttr(current_figure_translate_y_attr_name)
stored_centroids_before_translate = dict()
self.get_all_child_shapes_xy_centroids_list(shape_name, stored_centroids_before_translate)
cmds.setAttr(current_figure_translate_y_attr_name, current_position + transform_value)
transform_update_allowed, x_axis_collision = self.check_figure_update_allowed(shape_name,
self.locked_cells_dict,
stored_centroids_before_translate)
if not transform_update_allowed:
cmds.setAttr(current_figure_translate_y_attr_name, current_position)
if not x_axis_collision:
self.go_next_figure = True
return_y_collided = True
self.update_locked_cells_list(shape_name,
self.locked_cells_dict,
cleanup_previous_locked_cells=stored_centroids_before_translate)
cmds.refresh()
return return_y_collided
During each figure's transformation, it's crucial to ensure that none of the shapes move into a locked cell. If a proposed transformation would result in such an overlap, it must be prohibited. Additionally, our focus is on monitoring collisions in the Y direction. When a figure collides in this direction, it signals the time to halt its movement and subsequently generate a new figure. Collisions in the X direction, on the other hand, are permissible and do not require such restrictions. This approach ensures smooth gameplay, adhering to the classic mechanics of Tetris while managing the 3D aspects of our game.
def check_figure_update_allowed(self, mesh_name, locked_cells_dict, stored_centroids_before_translate):
"""
Check if already moved figure centroid position
intersect with other figures on the field.
:param mesh_name: string name of the figure
:param locked_cells_dict: dictionary with locked cells
(which contains centroids as keys)
:return: bool, bool (x and y) True if there is no intersection,
False otherwise (first boo
"""
child_shapes = self.get_all_descendent_child_shapes(mesh_name)
if not child_shapes:
return False
shape_name_prev_centr_dict = dict()
for val in stored_centroids_before_translate:
dct = stored_centroids_before_translate[val]
child_shape_name = dct['child_shape_name']
shape_name_prev_centr_dict[child_shape_name] = val
for child in child_shapes:
prev_centroid = shape_name_prev_centr_dict[child]
child_cords = self.get_shape_xy_centroid(child)
if tuple(child_cords) in locked_cells_dict.keys() \
and mesh_name != locked_cells_dict[child_cords]["parent_transform_name"]:
if child_cords[1] == prev_centroid[1]:
# this means that figured moved by x coord and collided only x
return False, True
else:
return False, False
# figure collision with field
if not self.min_y < child_cords[1] < self.max_y:
return False, False
if not self.min_x < child_cords[0] < self.max_x:
return False, True
return True, False
Our game's timing mechanism is managed by a continuous while loop, which includes various flags and conditions to pause the cycle. This setup, while effective for our project's scope, does carry the risk of infinite loops and is generally not advisable for production environments. However, for the purpose of this project, we will maintain this structure. The game operates at a default rate of 60 frames per second, equating each game tick to 1.0 sec/fps.
To introduce dynamic gameplay, we've implemented a feature that accelerates the game's speed during prolonged play. Specifically, the game's speed increases by 20% after every 10 complete rows of cubes, achieved by adjusting the speed_multiplier variable. During each iteration, the figure moves downward in the Y direction. We continuously check the position of each figure shape, and upon collision, the variable go_next_figure is set to true, triggering the generation of a new figure and updating the active_figure variable, which stores the name of the current active figure.
Moreover, it's essential to update the model panel and process events with each tick. Updating the model panel ensures the viewport is refreshed whenever a new figure is generated, preventing freezing. Concurrently, we must invoke process Events of QApplication in every tick. This step is crucial for processing the QT event stack, including the hotkeys for our game. Failing to process these events with each tick would result in QT handling them only after the while loop concludes, potentially impacting game responsiveness.
def continue_game(self):
"""
Main game function, which consists of while cycle with most of playing logic.
each iteration is 1 / fps seconds ( fps = 60).
if amount of completed lines during game is divides by 10 without remainder,
then speed will be increased in 20% each time.
"""
while True:
self.modelPanel.repaint()
QApplication.processEvents()
if self.amount_of_completed_lines != 0:
if self.amount_of_completed_lines % 10 == 0 and self.speed_multiplier>0:
self.speed_multiplier = self.speed_multiplier - 0.2
self.move_counter = 0
if self.go_next_figure:
default_cells_available = self.check_default_positions_are_locked()
if not default_cells_available:
self.is_game_over = True
self.game_over_label.setVisible(True)
self.enter_to_try_again.setVisible(True)
self.escape_to_exit.setVisible(True)
break
self.remove_complete_lines()
self.active_figure_name = self.generate_random_figure()
self.go_next_figure = False
time.sleep(1.0/self.fps)
self.move_counter += 1
if self.move_counter == int(30*self.speed_multiplier):
self.move_figure("translateY")
self.move_counter = 0
if self.is_game_paused:
break
In conclusion, there's certainly room for improvement and polish, but overall, I thoroughly enjoyed working on this project during my free time. It provided a delightful respite from serious production work and significantly enhanced my understanding of PyQt and the Python API. I'm quite pleased with the outcome and grateful for the experience. Thank you for taking the time to read about this journey! The source code for Tetris is available here.