Table of Contents
- Overview
- Core Gameplay
- Design Approach
- Development Workflow – Framework
- Key Steps and Solutions – Framework
This project spans over 2,000 lines of code and covers a wide range of concepts, so I’ve split the write-up into two parts. This first post provides an overview of the entire project, with a focus on the design and implementation of the framework-related components.
Overview
Tech Stack: C++ + EasyX
Project Goal: The project is organized using multiple header files, with a modular structure that introduces the concept of “scenes” to separate different stages of gameplay and decouple core functionalities. It explores basic physics simulation techniques and implements one-way collision platforms. Popular game development features like the camera, timer, and particle system are encapsulated to enhance the project’s level of polish and professionalism. Additionally, smoother animations and richer sound effects are integrated to improve the overall completeness of the game.
Course Source:Bilibili-Voidmatrix
Core Gameplay
Two players can choose different characters for local multiplayer battles. They jump and move between platforms, using a variety of normal and special attacks to damage their opponent and earn energy rewards. Once enough energy is accumulated, players can unleash unique skills. A player is defeated either by falling off the platform or when their health reaches zero.
Design Approach
Using Multiple Header Files
As the project grows in size, keeping all the code in main.cpp quickly becomes unwieldy. It leads to messy namespaces and tangled dependencies, making debugging and maintenance difficult. To manage complexity, this project organizes code by encapsulating different game stages into separate classes, each placed in its own header file.
Common Pitfall – Duplicate Includes:
When we use #include to bring in a header file, the compiler performs a literal copy-paste of that file’s contents at the include location. For example, if A.h includes B.h, and main.cpp includes both A.h and B.h, then B.h ends up being included twice. If B.h contains class definitions or other declarations, this can lead to duplicate definitions and compilation errors.
How to Avoid This:
-
Use the preprocessor directive
#pragma once: This tells the compiler to include the contents of a header file only once, no matter how many times it’s referenced. -
Alternatively, use include guards with
#ifndef: This checks whether a macro (usually based on the filename) has already been defined. If not, it defines the macro and includes the file; otherwise, it skips it.Example syntax:
1 2 3 4#ifndef _SCENE_H_ #define _SCENE_H_ #endlif // !_SCENE_H_
Both methods are widely used and generally interchangeable in most cases.
Differences Between the Two Approaches:
| Feature | #ifndef / #define |
#pragma once |
|---|---|---|
| Compatibility | Supported by all standard C/C++ compilers | Supported by most modern compilers, but not standardized |
| Mechanism | Uses macro definitions to check for duplicates | Compiler internally tracks whether the file has been processed |
| Filename Dependency | Independent of file name; relies on macro name | Depends on file path; may be affected by hard or symbolic links |
| Conflict Risk | Macro names must be unique; naming conflicts may cause issues | No naming required; avoids conflicts |
| Compilation Speed | Slightly slower (macro parsing required) | Faster (direct skip on repeat includes) |
Designing the Game Framework
Creating the window, setting up the main game loop, and stabilizing the frame rate—this structure is essentially a standard pattern in game development frameworks:
|
|
Object/Class Design
This project introduces architectural concepts by analyzing core functionalities and designing a well-structured program layout. The goal is to improve scalability and make debugging during development more manageable.
- Scene Base Class
- Menu Scene Class
- Selector Scene Class
- Game Scene Class
- Scene Manager Class
- Atlas Class
- Animation Class
- Vector2 Class
- Camera Class
- Timer Class
- Platform Class
- Player Base Class
- Peashooter Class
- Sunflower Class
- Player ID Enumeration Class
- Bullet Base Class
- Pea Bullet Class
- Sun Bullet Class
- Sun Bullet Ex Class
- Status Bar Class
- Particle Class
Development Workflow - Framework
- Game Framework Design
- Scene System Architecture
- Solution: Use inheritance, with each game stage implemented as a subclass of the base
Sceneclass, allowing for more flexible scene management. - Challenge: How to switch between scenes?
- Solution: Introduce a Scene Manager to handle transitions.
- Solution: Use inheritance, with each game stage implemented as a subclass of the base
- Resource Loading
- Challenge 1: How to manage animations efficiently and enable resource reuse?
- Solution: Implement
AtlasandAnimationclasses.
- Solution: Implement
- Challenge 2: How to trigger death animations when an enemy is defeated?
- Solution: Use callback functions to handle animation logic.
- Challenge 1: How to manage animations efficiently and enable resource reuse?
- Improving Visual Flexibility
- Solution: Implement a
Cameraclass to control the viewport. - Challenge 1: How to track camera position more precisely?
- Solution: Avoid using EasyX’s built-in
POINTclass (which uses integers); instead, create a customVector2class with floating-point coordinates.
- Solution: Avoid using EasyX’s built-in
- Challenge 2: How to express impact effects visually?
- Solution: Add camera shake effects for hit feedback.
- Solution: Implement a
- Timer Usage Across Gameplay
- Challenge: Beyond animations and camera shake, many features (e.g. special skills, attack cooldowns) require timing control.
- Solution: Encapsulate a reusable
Timerclass to provide unified management for time-sensitive features.
- Solution: Encapsulate a reusable
- Challenge: Beyond animations and camera shake, many features (e.g. special skills, attack cooldowns) require timing control.
Key Steps and Solutions - Framework
Scene System Design
If we think of a scene as a stage in a play, then each scene has its own “script” logic and a unique cast of characters. These characters are what game developers commonly refer to as GameObjects—players, enemies, bullets, items, and so on. Conceptually, they all fall under the GameObject category, each performing different logic under the direction of the scene’s script.
From a programming perspective, a game can be divided into several stages: the main menu, the character selection screen, and the in-game scene. Based on this, we define a base Scene class. The main menu, character selection, and gameplay scenes can each inherit from this base class to implement their own event handling and rendering logic.
Scene Base Class
All member functions are defined as virtual, allowing each specific scene class to override them with its own logic. The Scene base class serves as a template for all concrete scene subclasses.
|
|
MenuScene Class (Main Menu Scene)
You can override the necessary member functions:
|
|
Instantiating in main.cpp
|
|
All scene subclasses follow the same design pattern.
Implementing the Scene Manager
A game program is essentially a massive loop—and also a massive state machine. Each game scene represents a distinct state, and the system that manages these states is commonly referred to in game development as the Scene Manager.
|
|
The on_enter and on_exit methods serve similar purposes to constructors and destructors—they’re used to initialize and release resources. So why not just use constructors and destructors directly?
The reason is that constructors and destructors control the memory lifecycle of scene objects. If we rely on them to handle all enter/exit logic, we’d need to constantly create and destroy scene objects during transitions, which is not performance-friendly. As game logic grows more complex, some resources may be shared across scenes. In other words, objects within a scene may need to outlive the scene itself, which introduces additional memory management challenges.
This design offers a cleaner and more flexible approach: scene objects live as long as the game itself. All scenes are created during game initialization and destroyed when the game exits. During runtime, we avoid calling constructors and destructors for scene transitions, and instead use clearly defined on_enter and on_exit methods. These methods should avoid creating or destroying internal members—instead, they reset internal state.
Example: Suppose the game scene contains a player object. When the player’s health reaches zero, the game transitions to the main menu. If we use constructors/destructors, we’d need to delete the game scene and new the menu scene. Then, when returning to the game scene, we’d have to recreate everything just to reset the player’s health.
With on_enter and on_exit, we avoid this overhead. Instead, we simply reset the player’s health variable when re-entering the game scene—achieving the same “fresh start” effect without costly object reconstruction.
Why use a pointer for setting the current scene, but an enum for switching scenes?
This design choice reflects usage context:
set_current_sceneis typically called during game initialization, when scenes are being instantiated—so passing a pointer is straightforward.switch_tois usually called during runtime from within scene logic. If scenes hold references to each other, passing pointers directly can lead to memory issues. Using an enum abstracts away internal pointer management and keeps transitions safe and clean.
With this setup, the scene-related logic in main can be fully delegated to the Scene Manager:
|
|
Scene transitions are handled within each scene’s on_input method.
|
|
Resource Loading Design
Implementing the Atlas Class
The Atlas class serves as a container for a series of related image resources:
|
|
Resource Loading Strategy
Before implementing the Animation class, we need a way to horizontally flip animation frames. This avoids the need for duplicate assets. Since pixel-level flipping is computationally expensive, it should be done during game initialization, not during frame updates.
This flipping function is a utility and should be placed in util.h for easy access across the project. A detailed explanation is available in another post: Teyvat Survivor.
In main.cpp, we define flip_atlas as a global function so it can be called before the main loop:
|
|
For now, all resources are loaded via a global function in main.cpp. It’s important to use meaningful and consistent naming for assets. A recommended format is type_character_direction, such as Atlas atlas_peashooter_idle_left;. While verbose, this naming convention improves editor searchability and debugging efficiency.
The resource loading logic includes three parts:
- Loading game fonts
- Loading and processing image assets
- Loading sound effects
Sound effects are handled using mciSendString, so don’t forget to include the appropriate library.
Implementing the Animation Class
The Animation class acts as a lightweight controller for rendering atlases. It builds on top of the Atlas class and is designed around two components:
- Member variables: define the data structure
- Member functions: provide external interfaces for querying and modifying state
Since frame progression is automatic during playback, there’s no need for external set methods. Instead, only get_idx_frame and get_frame are exposed for frame access.
The two most important methods—on_update and on_draw—are explained in detail in the Teyvat Survivor article.
How should we handle disappearing animations for objects like enemies or bullets when their lifecycle ends?
We shouldn’t delete the Enemy object immediately upon death. Instead, we delay deletion until the death animation finishes. This requires the animation system to signal when playback is complete.
A common solution: use callback functions.
Callback Functions
A callback is a function object passed as a parameter and stored for later execution. This allows logic to be triggered at the “right moment.”
For example, when an enemy dies, we define the deletion logic as a function and store it in the animation object. Once the death animation finishes, the callback is invoked to remove the enemy.
Be sure to write #include <functional> at the top. Here’s a sample implementation:
|
|
Here’s the full implementation of the Animation class:
|
|
Camera System Design
Window Coordinates vs. World Coordinates
In EasyX, the origin of the window coordinate system is at the top-left corner of the screen:
In contrast, the world coordinate system represents a much larger virtual space. Think of it as the entire game world where all objects—players, enemies, bullets, items—are placed and interact. Player movement, collisions, triggers, and all game logic operate within this world space. Only when rendering the game do we need to convert world coordinates into window coordinates.
The camera acts as a bridge between these two systems. This aligns with the game development principle of separating data from rendering.
When we ignore zooming (i.e., the camera’s width and height match the window’s), we can treat the camera as a single point in the world. In a side-scrolling game, for example, the camera simply follows the player’s position. To render other objects, we subtract the camera’s world position from each object’s world position to get their window coordinates.
Key concept:
Window Coordinate = World Coordinate - Camera Coordinate
Implementing the Vector2 Class
To allow precise control of the camera’s position using floating-point values, we define a commonly used 2D vector class. Operator overloading makes it easier to perform arithmetic operations similar to built-in types:
|
|
Implementing the Camera Class
Camera Shake Effect
This is a common visual effect in games. When firing a weapon or triggering an explosion, the screen shakes briefly to convey impact. It’s simple to implement but highly effective.
Implementation Strategy
Since the shake only lasts for a short time, we need a way to start and stop it. Like animations, this is best handled with a timer.
Implementing a General-Purpose Timer
There are two main design approaches:
- Inheritance-based
- Callback-based
Inheritance Approach
Define a base Timer class with an on_update method. Subclasses override the callback method to define custom behavior:
|
|
To use it:
|
|
Callback Approach
Similar to the Animation class, we store a function and invoke it when the timer completes:
|
|
Usage:
|
|
Comparison
The callback-based approach is more concise and flexible. If you need multiple timers with different behaviors, inheritance requires creating multiple subclasses. With callbacks, you just write a lambda function.
For general-purpose timers that only differ in behavior—not data—it’s better to use callbacks. This reduces boilerplate and improves clarity.
Full Timer Class Implementation
|
|
Camera Class with Integrated Timer
Design Approach for Camera Shake Effect
To make the entire screen appear to shake, we simply need to shake the camera’s position. In other words, the shake effect is achieved by rapidly changing the coordinates of the Camera object.
A straightforward approach is to randomly reposition the camera within a circle whose radius equals the shake intensity. Since frame updates happen frequently, this randomness creates a convincing shake effect at runtime.
For smoother and more natural motion—especially with stronger shake effects—noise algorithms like Perlin noise can be used instead of pure randomness. However, for the subtle shake used in this project, the visual improvement is minimal, so we stick with the simpler random-based implementation.
Full Camera Class Implementation:
|
|
In the on_update method, the camera’s position is randomly set within a circle defined by the shake intensity. The random coefficient before shaking_strength represents a value in the range of -1.0 to 1.0, simulating a unit circle.
With this, the core game framework is complete. For the second half of this project, covering gameplay implementation, continue reading the companion article: Plant Star Brawl - Gameplay Layer.