Featured image of post 【C++ Game Dev from Scratch】Plant Star Brawl - Framework Design

【C++ Game Dev from Scratch】Plant Star Brawl - Framework Design

Creating a simplified two-player platform shooter inspired by Super Smash Bros. mechanics. This post covers the first half of my notes from the second project of this tutorial series.

Table of Contents

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 SourceBilibili-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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
int main()
{
    //========= Initialize data ==========
	initgraph(1280, 720);
	
    bool is_running = true;
    const int FPS = 60;
	
	ExMessage msg;
	
	BeginBatchDraw();
   
	while (is_running)	// Main game loop
	{
		DWORD start_time = GetTickCount();
        
        //========= Handle input =========
		while (peekmessage(&msg))
		{
			
		}	
        
        //======== Handle update =========

		cleardevice();

		//======== Handle rendering =========
		
		FlushBatchDraw();

        //========= Stabilize frame rate =========
		DWORD end_time = GetTickCount();
		DWORD delta_time = end_time - start_time;
		if (delta_time < 1000 / FPS)
		{
			Sleep(1000 / FPS - delta_time);
		}
	}

	EndBatchDraw();
	return 0;
}

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 Scene class, allowing for more flexible scene management.
    • Challenge: How to switch between scenes?
      • Solution: Introduce a Scene Manager to handle transitions.
  • Resource Loading
    • Challenge 1: How to manage animations efficiently and enable resource reuse?
      • Solution: Implement Atlas and Animation classes.
    • Challenge 2: How to trigger death animations when an enemy is defeated?
      • Solution: Use callback functions to handle animation logic.
  • Improving Visual Flexibility
    • Solution: Implement a Camera class to control the viewport.
    • Challenge 1: How to track camera position more precisely?
      • Solution: Avoid using EasyX’s built-in POINT class (which uses integers); instead, create a custom Vector2 class with floating-point coordinates.
    • Challenge 2: How to express impact effects visually?
      • Solution: Add camera shake effects for hit feedback.
  • 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 Timer class to provide unified management for time-sensitive features.

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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
class Scene
{
public:
	Scene() = default;
	~Scene() = default;
    
	virtual void on_enter() {}; 					// Enter the scene
	virtual void on_update(int delta) {};			// Handle updates
	virtual void on_draw() {};						// Handle rendering
	virtual void on_input(const ExMessage& msg) {};	// Handle player input
	virtual void on_exit() {}; 						// Exit the scene

private:
};

You can override the necessary member functions:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class MenuScene : public Scene
{
public:
	MenuScene() = default;
	~MenuScene() = default;

	// Override required virtual functions from the base Scene class
	void on_enter()
	{
		cout << "Entered Main Menu" << endl;
	}
	void on_update(int delta)
	{
		cout << "Main Menu is running......" << endl;
	}
	void on_draw()
	{
		outtxtxy(10, 10, _T("Drawing Main Menu content"))
	}
	void on_input(const ExMessage& msg)
	{
         // Handle input if needed
	}
	void on_exit()
	{
		cout << "Exiting Main Menu" << endl;
	}

private:

};

Instantiating in main.cpp

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
int main()
{
	initgraph(1280, 720, EW_SHOWCONSOLE);	// Keep the console window visible 

	BeginBatchDraw();
    Scene* scene = new MenuScene();	// Instantiate before entering the main loop
    
	while (running) 
	{
		DWORD frame_start_time = GetTickCount(); 
		while (peekmessage(&msg))
		{
			scene->on_input(msg); // Handle input
		} 

		// Update game logic
		scene->on_update();

		cleardevice();
        
		// Render game screen
		scene->on_draw(); 
		FlushBatchDraw();

		DWORD frame_end_time = GetTickCount();
		DWORD frame_delta_time = frame_end_time - frame_start_time;
		if (frame_delta_time < 1000 / FPS) 
		{
			Sleep(1000 / FPS - frame_delta_time); 
		}

	}
	EndBatchDraw();

	return 0;
}

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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
class SceneManager
{
public:
	enum class SceneType	// Marks the current scene state
	{
		Menu,
		Game,
		Selector

	};

public:
	SceneManager() = default;
	~SceneManager() = default;

	// Set the current scene
	void set_current_scene(Scene* scene)
	{
		current_scene = scene;
		current_scene->on_enter();	// Ensure the scene lifecycle is properly triggered
	}

	// Switch scenes (exit the current scene, then select and enter the new one)
	void switch_to(SceneType type)
	{
		current_scene->on_exit();	// Exit the current scene
		switch (type)
		{
		case SceneType::Menu:
			current_scene = menu_scene;
			break;
		case SceneType::Game:
			current_scene = game_scene;
			break;
		case SceneType::Selector:
			current_scene = selector_scene;
			break;
		default:
			break;
		}
		current_scene->on_enter();	// Enter the newly selected scene
	}

	void on_update(int delta)
	{
		current_scene->on_update(delta);
	}
	void on_draw()
	{
		current_scene->on_draw();
	}
	void on_input(const ExMessage& msg)
	{
		current_scene->on_input(msg);
	}

private:
	Scene* current_scene = nullptr;
};

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_scene is typically called during game initialization, when scenes are being instantiated—so passing a pointer is straightforward.
  • switch_to is 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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
menu_scene = new MenuScene();
game_scene = new GameScene();
selector_scene = new SelectorScene();

scene_manager.set_current_scene(menu_scene);

BeginBatchDraw();
while (running) 
{
	DWORD frame_start_time = GetTickCount(); 
	while (peekmessage(&msg)) 
	{
		scene_manager.on_input(msg);
	} 

	scene_manager.on_update();

	cleardevice();

	scene_manager.on_draw(main_camera);
	FlushBatchDraw();

Scene transitions are handled within each scene’s on_input method.

1
2
3
4
5
6
7
8
// MenuScene.h
void on_input(const ExMessage& msg)
{
	if (msg.message == WM_KEYUP)
	{
		scene_manager.switch_to(SceneManager::SceneType::Selector);
	}
}

Resource Loading Design

Implementing the Atlas Class

The Atlas class serves as a container for a series of related image resources:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
class Atlas
{
public:
	Atlas() = default;
	~Atlas() = default;

	void load_from_file(LPCTSTR path_template, int num)
	{
        // Important: Clear the image list before loading
        // and resize it to the expected number of frames.
        // This prevents mismatches when load_from_file is called multiple times.
		img_list.clear();		
		img_list.resize(num);

		TCHAR path_file[256];
		for (int i = 0; i < num; i++)
		{
			_stprintf_s(path_file, path_template, i + 1);
			loadimage(&img_list[i], path_file);
		}
	}

	void clear()	// Clear all loaded images in the atlas
	{
		img_list.clear();
	}

	size_t get_size()	// Get the number of images in the atlas
	{
		return img_list.size();
	}

	IMAGE* get_image(int idx)	// Retrieve a specific animation frame
	{
		if (idx < 0 || idx >= img_list.size())
			return nullptr;

		return &img_list[idx];	// Return the address of the image at the given index
	}

    // Add an existing image to the atlas
    // This may seem redundant with file loading,
    // but it's useful for generating horizontally flipped atlases
    void add_image(const IMAGE& img)	
	{
		img_list.push_back(img);
	}

private:
	vector<IMAGE> img_list;
};

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// main.cpp

void flip_atlas(Atlas& src, Atlas& dst)
{
	dst.clear(); // Prevent issues from reusing the same container
	for (int i = 0; i < src.get_size(); i++)
	{
		IMAGE img_flipped;
		flip_image(src.get_image(i), &img_flipped);
		dst.add_image(img_flipped);
	}
}

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class Animation
{
public:
	......

	void on_update(int delta)
	{
		timer += delta;
		if (timer > interval)
		{
			timer = 0;
			idx_frame++;
			if (idx_frame >= atlas->get_size())
			{
				idx_frame = is_loop ? 0 : atlas->get_size() - 1;

				if (!is_loop && callback) // If animation is non-looping and callback exists, invoke it
				{
					callback();
				}
			}
		}
	}

	void set_callback(function<void()> callback)
	{
		this->callback = callback;
	}

private:
	......

	function<void()> callback; // Callback to trigger object removal after animation finishes
}; 

Here’s the full implementation of the Animation class:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
class Animation
{
public:
	Animation() = default;
	~Animation() = default;

	void reset()
	{
		timer = 0;
		idx_frame = 0;
	}

	void set_atlas(Atlas* new_atlas)
	{
		reset();
		atlas = new_atlas;
	}

	void set_loop(bool flag)
	{
		is_loop = flag;
	}

	void set_interval(int ms)
	{
		interval = ms;
	}

	int get_idx_frame()
	{
		return idx_frame;
	}

	IMAGE* get_frame()
	{
		return atlas->get_image(idx_frame);
	} 

	bool check_finished()
	{
		if (is_loop)
			return false;

		return (idx_frame == atlas->get_size() - 1);
	}

	void on_update(int delta)
	{
		timer += delta;
		if (timer > interval)
		{
			timer = 0;
			idx_frame++;
			if (idx_frame >= atlas->get_size())
			{
				idx_frame = is_loop ? 0 : atlas->get_size() - 1;

				if (!is_loop && callback) // If animation is non-looping and callback exists, invoke it
				{
					callback();
				}
			}
		}
	}

	void on_draw(const Camera& camera, int x, int y) const
	{
		putimage_alpha(camera, x, y, atlas->get_image(idx_frame));
	}

	void set_callback(function<void()> callback)
	{
		this->callback = callback;
	}

private:
	Atlas* atlas = nullptr;
	bool is_loop = true; // Whether the animation loops

	int timer = 0;		 // Frame timer
	int interval = 0;	 // Frame interval in milliseconds
	int idx_frame = 0;	 // Current frame index

	function<void()> callback; // Callback to trigger object removal after animation finishes
}; 

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:

EasyX窗口坐标系
EasyX Window Coordinates

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
class Vector2
{
public:
	Vector2() = default;
	~Vector2() = default;

	Vector2(float x, float y) // Constructor for direct initialization
		: x(x),y(y){ }

	//======== Operator Overloads ========
	// Enables intuitive vector arithmetic
	Vector2 operator+(const Vector2& vec) const
	{
		return Vector2(x + vec.x, y + vec.y);
	}

	void operator+=(const Vector2& vec)
	{
		x += vec.x, y += vec.y;
	}

	Vector2 operator-(const Vector2& vec) const
	{
		return Vector2(x - vec.x, y - vec.y);
	}

	void operator-=(const Vector2& vec)
	{
		x -= vec.x, y -= vec.y;
	}

	Vector2 operator*(const Vector2& vec) const
	{
		return Vector2(x * vec.x, y * vec.y);
	}

	Vector2 operator*(float val) const
	{
		return Vector2(x * val, y * val);
	}

	void operator*=(float val) 
	{
		x *= val, y *= val;
	}

	float length()
	{
		return sqrt(x * x + y * y);
	}

	Vector2 normalize()
	{
		float len = length();

		if (len == 0)
		{
			return Vector2(0, 0);
		}

		return Vector2(x / len, y / len);
	}

public:
	float x = 0.0f;
	float y = 0.0f;
};

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
class Timer
{
public:
	Timer() = default;
    ~Timer() = default;
    
    void on_update(int delta)
    {
        // ......
        callback();
    }
    
protected:
    virtual void callback()
    {
        // Timer logic
    }
}

To use it:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
class MyTimer : public Timer
{
 public:
    MyTimer() = default;
    ~MyTimer() = default;
    
 protected:
    void callback() override
    {
        // Custom timer logic
    }
}
    
Timer* my_timer = new MyTimer();

Callback Approach

Similar to the Animation class, we store a function and invoke it when the timer completes:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
class Timer
{
public:
    Timer() = default;
    ~Timer() = default;
    
    void on_update(int delta)
    {
        // ......
        callback();
    }
    
    void set_callback(function<void()> callback)
    {
        this->callback = callback;
    }
 
protected:
    function<void()> callback;
}

Usage:

1
2
3
4
5
Timer my_timer;
mu_timer.set_back([]()
    {
        // Custom timer logic           
    })

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
// General-purpose timer class
// Uses callbacks to define behavior on timeout
class Timer
{
public:
	Timer() = default;
	~Timer() = default;

	void restart() // Reset the timer
	{
		pass_time = 0;
		shotted = false;
	}

	void set_wait_time(int val)
	{
		wait_time = val;
	}

	void set_one_shot(bool flag)
	{
		one_shot = flag;
	}

	void set_callback(function<void()> callback)
	{
		this->callback = callback;
	}

	void pause()
	{
		paused = true;
	}

	void resume()
	{
		paused = false;
	}

	void on_update(int delta)
	{
		if (paused)
			return;

		pass_time += delta;
		if (pass_time >= wait_time)
		{
			if (!one_shot || (one_shot && !shotted) && callback)
				callback();

			shotted = true;
			pass_time = 0;
		}
	}

private:
	int pass_time = 0;			// Elapsed time
	int wait_time = 0;			// Wait duration
	bool paused = false;		// Pause flag
	bool shotted = false;		// Triggered flag
	bool one_shot = false;		// One-time trigger
	function<void()> callback;	// Callback function
};
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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
class Camera
{
public:
	Camera()
	{
		timer_shake.set_one_shot(true);
		timer_shake.set_callback([&]()
			{
				is_shaking = false;
				reset();
			}
		);
	}

	~Camera() = default;

	const Vector2& get_position() const // First const: returns a read-only reference; second const: ensures this method doesn't modify the object
	{
		return position;
	}

	void reset() // Reset camera position to origin
	{
		position.x = 0;
		position.y = 0;
	}

	void on_update(int delta)
	{
		timer_shake.on_update(delta);

		if (is_shaking)
		{
			position.x = (-50 + rand() % 101) / 50.0f * shaking_strength;
			position.y = (-50 + rand() % 101) / 50.0f * shaking_strength;
		}
	}

    // Start camera shake effect
    // Parameters: strength = shake intensity, duration = shake duration in milliseconds
    void shake(float strength, int duration)
	void shake(float strength, int duration)
	{
		is_shaking = true;
		shaking_strength = strength;

		timer_shake.set_wait_time(duration);
		timer_shake.restart();
	}

private:
	Vector2 position;			// Camera position

	// Shake effect implementation
	Timer timer_shake;			// Timer controlling shake duration
	bool is_shaking = false;
	float shaking_strength = 0; // Shake intensity
};

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.

Licensed under CC BY-NC-SA 4.0
Last updated on Oct 16, 2025 22:22 +0200
发表了12篇文章 · 总计5万0千字
本博客已运行
Built with Hugo
Theme Stack designed by Jimmy