Featured image of post 【C++ Game Dev from Scratch】Teyvat Survivors

【C++ Game Dev from Scratch】Teyvat Survivors

Make a simple Vampire Survivors-style game. Notes for the first project of this tutorial series

Table of Contents

Overview

Tech Stack:C++ + EasyX

Project Goal:Design an animation framework for the game, integrate keyboard input, and create more interactive gameplay. On the data and logic level, implement basic 2D collision detection, random enemy spawning, and enemy tracking behavior. Add music and sound effects to enhance the overall polish. Include a main menu interface. Use the Flyweight pattern to optimize program performance.

Course SourceBilibili-Voidmatrix

Core Gameplay

The player clicks the Start button to enter the game and uses the Arrow Keys (Up, Down, Left, Right) to control character movement. A ring of bullets surrounds the player, while wild boar enemies continuously rush in from off-screen. When a bullet hits an enemy, the enemy is defeated and the player’s score increases. If an enemy touches the player, the game ends.

Main Development Process

Design Approach

Designing the Core Game Framework

Setting up the game window, creating the main loop, and stabilizing the frame rate follow a standard structure commonly used in game development. Since these steps are explained in detail in Episode 0: Fundamentals of the tutorial series, they won’t be repeated here.Setting up the game window, creating the main loop, and stabilizing the frame rate follow a standard structure commonly used in game development. Since these steps are explained in detail in Episode 0: Fundamentals of the tutorial series, they won’t be repeated here.

 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 and Class Design

This project does not use polymorphism to abstract higher-level objects. Instead, each game element is implemented as a separate class, following a one-object-per-class approach. To make the structure intuitive for beginners, all code is written in main.cpp, using many global variables.

  • Player class
  • Bullet class (bullets orbiting the player
  • Enemy class
  • Animation and Atlas classes (for animation effects and optimization)
  • Button base class
    • StartGameButton
    • ExitGameButton

Development Workflow

  • Game Framework Setup
  • Image Loading and Rendering
    • Solution: Use EasyX functions loadimage and putimage
    • Issue: PNG images show black edges
      • Solution: Override putimage with a version that supports transparency
      • Note: Include the library #pragma comment(lib, "MSIMG32.LIB")
  • Animation Implementation
    • Challenge1: How to animate images?
      • Solution: Use a counter → optimize with a timer
    • Challenge2: Repetitive animation code
      • Solution: Encapsulate logic in an Animation class
  • Player Movement
    • Issue 1: Movement feels choppy
      • Solution: Use boolean flags to track key press/release and control movement indirectly
    • Issue 2: Diagonal movement is faster than horizontal/vertical
      • Solution: Normalize movement vectors to ensure consistent speed
  • Animation Data Mixing Issue
    • Issue: Enemy and player data get mixed in the Animation class
    • Solution: Use object-oriented design to separate logic and data into distinct classes
  • Implementing Player, Bullet, and Enemy Classes
  • Enemy Behavior
    • Challenge 1: Enemy spawn logic
      • Solution: Randomly spawn enemies along one edge of the screen
    • Note: When passing other class objects into functions, use references. If the object won’t be modified, add const.
    • Challenge 2: Enemy auto-tracking logic
      • Solution: Calculate the unit vector from enemy to player and move in that direction
  • 2D Collision Detection
    • Challenge 1: Bullet vs. Enemy
      • Solution: Treat enemies as rectangles and bullets as points
    • Challenge 2: Player vs. Enemy
      • Solution: Use the enemy’s center point for collision detection against the player’s rectangle
  • Bullet Updates and Visual Effects
    • Challenge: Make bullet movement more dynamic
      • Solution: Add tangential and radial velocity components
  • Removing Defeated Enemies
    • Solution: Use swap to move the enemy to the end of the vector, then pop_back and delete to free memory.
      • This is efficient when element order doesn’t matter.
  • Displaying Player Score
  • Adding Sound Effects
    • Solution: Use Windows API function mciSendString
    • Note: Include the library #pragma comment(lib, "Winmm.lib")
  • Performance Optimization
    • Solution: Use the Flyweight Pattern to optimize resource loading
    • Note: Do not release the shared Atlas inside the Animation class—control it from a higher level
  • Main Menu UI and Button Class Design

Key Steps and Implementation Ideas

Image Loading and Rendering

Loading Images

According to the documentation, EasyX provides a function called loadimage to load images. It has several overloads, but here we’ll use the simplest version: loading an image directly from a file.

1
2
3
4
5
6
7
8
// Load image from file (supports bmp/gif/jpg/png/tif/emf/wmf/ico)
int loadimage(
	IMAGE* pDstImg,			// Pointer to the IMAGE object that stores the image
	LPCTSTR pImgFile,		// File path of the image
	int nWidth = 0,			// Optional: stretch width度
	int nHeight = 0,		// Optional: stretch height
	bool bResize = false	// Optional: resize IMAGE to fit image size
);

A few things to note:

  • The first parameter must be a non-null pointer to an IMAGE object. We load the image into memory first, not directly onto the screen.
  • The second parameter is the file path as a string.
  • The last three parameters are optional and control image scaling. We won’t use them in this project.

To load an image named test.jpg from the project directory:

1
2
IMAGE img;
loadimage(&img, _T("test.jpg"));
Rendering Images

To draw the image on screen, EasyX provides the putimage function:

1
2
3
4
5
6
7
// 绘制图像
void putimage(
	int dstX,				// X coordinate on screen
	int dstY,				// Y coordinate on screen
	IMAGE *pSrcImg,			// Pointer to the IMAGE object to draw
	DWORD dwRop = SRCCOPY	// Raster operation code (usually ignored)
);
  • The first two parameters specify the position on screen.

  • The last parameter is a raster operation code, which we’ll ignore for this project.

To render the image we just loaded:

1
2
3
4
5
6
// Load image
IMAGE img;
loadimage(&img, _T("test.jpg"));

// Draw image
putimage(100, 200, &img);

If the image is 300 * 300 pixels, it will appear at position (100, 200) on the game screen like this:

Handling PNG Transparency

PNG images may show black edges when rendered. To fix this, we need to create a custom putimage_alpha function that supports transparency using AlphaBlend.

1
2
3
4
5
6
7
8
9
#pragma comment(lib, "MSIMG32.LIB") 

void putimage_alpha(int x, int y, IMAGE* img)
{
	int w = img->getwidth();
	int h = img->getheight();
	AlphaBlend(GetImageHDC(NULL), x, y, w, h, GetImageHDC(img),
		0, 0, w, h, { AC_SRC_OVER,0,255,AC_SRC_ALPHA });
}

Make sure to include the required library at the top of your file: #pragma comment(lib, "MSIMG32.LIB") .

Implementing Animation and Rendering

How to Make the Scene Move?

In game development, character animation is typically implemented in two main ways: frame-by-frame animation and keyframe animation.

  • Frame-by-frame animation uses a sequence of images. By displaying these images one after another over time, we create the illusion of motion through visual persistence.
  • Keyframe animation, such as skeletal animation, involves more advanced graphics techniques and won’t be covered here.

It’s important to note that we should not use the Sleep() function to control animation timing. Calling Sleep() causes the program to pause, which is a blocking operation. In our game framework, rendering and updates should happen continuously within the game loop, with each loop cycle completing within 1/60 of a second (i.e., 60 FPS). Animation frame switching should be spread across multiple frames—not completed in a single loop. This reflects a core principle in game programming: Avoid blocking operations or heavy tasks inside the main loop.

To switch animation frames at fixed intervals, we use a timer-based counter:

 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
int idx_current_anim = 0;	// 1. Index of the current animation frame

const int PLAYER_ANIM_NUM = 6;	// Total number of animation frames

int main()
{
    .....
        
    while (is_running)
    {
        while (peekmessage(&msg))
  	    {
        
        }
    
        static int counter = 0;	// 2. Counts how many game frames have passed
                                // 'static' ensures it's initialized only once
        // Switch animation frame every 5 game frames
        if (++counter % 5 == 0)
            idx_current_anim++;

        ifidx_current_anim % PLAYER_ANIM_NUM == 0)
            idx_current_anim = 0;
        }  

        ......
}
Rendering Animation Frames

To render animation, we simply draw images from an array in sequence. First, load the frames:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
const int PLAYER_ANIM_NUM = 6;	// Total number of animation frames

IMAGE img_player_left[PLAYER_ANIM_NUM];
IMAGE img_player_right[PLAYER_ANIM_NUM];

void load_animation()
{
    for (size_t i = 0; i < PLAYER_ANIM_NUM; i++)
    {
        std::wstring path = L"img/player_left_" + std::to_wstring(i) + L".png";
        loadimage(&img_player_left[i], path.c_str());
    }
    
    for (size_t i = 0; i < PLAYER_ANIM_NUM; i++)
    {
        std::wstring path = L"img/player_right_" + std::to_wstring(i) + L".png";
        loadimage(&img_player_right[i], path.c_str());
    }
}

Then, inside the main loop, draw the current frame:

 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
int main()
{
    ......
   
	while (is_running)	// Main game loop
	{
		DWORD start_time = GetTickCount();
        
        //========= Handle Input =========
		while (peekmessage(&msg))
		{
			
		}	
        
        //======== Update =========

		cleardevice();

		//======== Render =========
		putimage_alpha(500, 500, &img_player_left[idx_current_anim]);
        
		FlushBatchDraw();

       ......
    }
}

This setup ensures smooth animation playback by cycling through frames at a consistent rate.

Encapsulating Animation into a Class

From a data structure perspective, we use a vector<IMAGE*> to store pointers to the images needed for the animation. In the constructor, we load the image resources and allocate memory for each frame. Correspondingly, the destructor is responsible for releasing that memory to avoid leaks.

When playing the animation, we pass not only the position where the animation should be rendered, but also an int delta parameter that represents the time elapsed since the last call to Play(). This replaces the earlier “frame counter” approach with a timer-based system.

Why this change? Because animation speed—defined by the frame interval—should depend on actual time, not the game’s frame rate. We want the animation to play at a consistent speed regardless of how fast the game loop runs. Using a time-based timer ensures smoother and more predictable playback than simply incrementing a counter every frame.

Here’s the 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
class Animation
{
public:
	Animation(LPCTSTR path, int num, int interval)	// Load animation frames
    {
        interval_ms = interval;
        
        TCHAR path_file[256];
        for (size_t i = 0; i < num; i++)
        {
            _sprintf_s(path_file, path, i);
            
            IMAGE* frame = new IMAGE();
            loadimage(frame, path_file);
            frame_list.push_bacl(frame);
        }
    }
    
    void Play(int x, int y, int delta)	// Play animation
    {
        timer += delta;
        if (timer >= interval_ms)
        {
            idx_frame = (idx_frame + 1) % frame_list.size();
            timer =0;
        }
        
        //  Render current frame
        pitimage_alpha(x, y, frame_list[idx_frame]);
    }
    
    ~Animation()	// Release resources
    {
        for (size_t i = 0; i < frame_list.size(); i++)
        {
            delete frame_list[i];
        }
    }
private:
    vector<IMAGE*> frame_list;	// List of animation frames
    int interval_ms = 0;		// Time between frames (in milliseconds)
    int timer = 0;              // Time accumulator
    int idx_frame = 0;          // Current frame index
}

Implementing Player Movement

If we control player movement by directly adding displacement to the position when a key is pressed, it can result in a choppy or inconsistent feel. This happens because WM_KEYDOWN messages are generated asynchronously from the main game loop, and their frequency depends on the operating system and hardware. As a result, some frames may process multiple WM_KEYDOWN messages, while others may receive few or none—causing the player to move unevenly across frames and creating a stuttering effect.

Additionally, when a directional key is pressed, the first WM_KEYDOWN message enters the event queue immediately. However, continuous WM_KEYDOWN messages only begin after a short delay if the key remains held down. This behavior contributes further to inconsistent movement.

To ensure smooth and consistent movement across all frames, we treat movement as a state:

  • When a key is pressed (WM_KEYDOWN), the player starts moving.
  • When the key is released (WM_KEYUP), the player stops moving.

We use four boolean flags to represent movement directions. These flags are updated based on key press and release events, and the player’s position is updated accordingly in each frame:

 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
void ProcessEvent(const ExMessage& msg)
{
	if (msg.message == WM_KEYDOWN)
	{
		switch (msg.vkcode)
		{
		case VK_UP:
			is_moving_up = true;
			break;
		case VK_DOWN:
			is_moving_down = true;
			break;
		case VK_LEFT:
			is_moving_left = true;
			break;
		case VK_RIGHT:
			is_moving_right = true;
			break;
		}
	}

	if (msg.message == WM_KEYUP)
	{
		switch (msg.vkcode)
		{
		case VK_UP:
			is_moving_up = false;
			break;
		case VK_DOWN:
			is_moving_down = false;
			break;
		case VK_LEFT:
			is_moving_left = false;
			break;
		case VK_RIGHT:
			is_moving_right = false;
			break;
		}
	}		
}

void Move()
{
    if (is_moving_up)
        position.y -= SPEED;
    if (is_moving_down)
        position.y += SPEED;
    if (is_moving_left)
        position.x -= SPEED;
    if (is_moving_right)
        position.x += SPEED;
}

To prevent the player from moving faster diagonally than horizontally or vertically, we normalize the movement vector. This ensures consistent speed in all directions:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
int dir_x = is_move_right - is_move_left;
int dir_y = is_move_down - is_move_up;
double len_dir = sqrt(dir_x * dir_x + dir_y + dir_y);
if(len_dir != 0)
{
    double normalized_x = dir_x / len_dir;
    double normalized_y = dir_y / len_dir;
    player_pos.x += (int)(PLAYER_SPEED * normalized_x);
    player_pos.y += (int)(PLAYER_SPEED * normalized_y);
}

Class Encapsulation

To prevent data and logic from being scattered across the project, each object’s behavior and data are encapsulated into dedicated classes. For example, the Player class is structured roughly as follows:

 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
class Player
{
public:
    Player()
    {
          // Initialize resources: animation frames, image assets, etc.
    }
    ~Player()
    {
        // Release resources
    }
    void ProcessEvent(const ExMessage& msg)
    {
        // Handle player input
    }
    void Move()
    {
        // Handle player movement
    }
    void Draw(int delta)
    {
        // Render the player
    }   
private:
    // Internal data members
    ......
}

Enemy Class Implementation Details

Random Enemy Spawning
 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
// Define spawn edges
enum class SpawnEdge
{
	Up = 0,
	Down,
	Left,
	Right
};

// Randomly select one edge
SpawnEdge edge = (SpawnEdge)(rand() % 4);
// Assign spawn coordinates based on selected edge
switch (edge)
{
case SpawnEdge::Up:
	position.x = rand() % WINDOW_WIDTH;
	position.y = -FRAME_HEIGHT;
	break;
case SpawnEdge::Down:
	position.x = rand() % WINDOW_WIDTH;
	position.y = WINDOW_HEIGHT;
	break;
case SpawnEdge::Left:
	position.x = -FRAME_WIDTH;
	position.y = rand() % WINDOW_HEIGHT;
	break;
case SpawnEdge::Right:
	position.x = WINDOW_WIDTH;
	position.y = rand () % WINDOW_HEIGHT;
	break;
default:
	break;
}
Enemy Pathfinding Logic

Each enemy automatically moves toward the player by calculating the direction vector between their positions and normalizing it. This ensures consistent movement speed regardless of distance:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
void Move(const Player& player)
{
    const POINT& player_position = player.GetPosition();
    int dir_x = player_position.x - position.x;
    int dir_y = player_position.y - position.y;
    double dir_len = sqrt(dir_x * dir_x + dir_y * dir_y);
    if (dir_len != 0)
    {
        double normalized_x = dir_x / dir_len;
        double normalized_y = dir_y / dir_len;
        position.x += (int)(normalized_x * SPEED);
        position.y += (int)(normalized_y * SPEED);
    }

    // Update facing direction based on horizontal movement
    if (dir_x > 0)
        facing_left = false;
    else if (dir_x < 0)
        facing_left = true;
}

Implementing 2D Collision Detection

All collision logic is implemented within the Enemy class. To avoid unnecessary copying, the Player and Bullet objects are passed by reference.

Enemy vs. Bullet
1
2
3
4
5
6
7
8
bool CheckBulletCollision(const Bullet& bullet) // 'const' ensures the bullet won't be modified
{
    // Treat the bullet as a point and check if it's inside the enemy's rectangle
    bool is_overlap_x = bullet.position.x >= position.x && bullet.position.x <= position.x + FRAME_WIDTH;
    bool is_overlap_y = bullet.position.y >= position.y && bullet.position.y <= position.y + FRAME_HEIGHT;
		
    return is_overlap_x && is_overlap_y;
}
Enemy vs. Player

In most games, collision detection isn’t overly strict. If both the enemy and player are treated as rectangles, it’s possible for only a corner to overlap visually without feeling like a real collision—leading to confusion. To improve the experience, hitboxes are often smaller than the actual image size.

Here, we treat the enemy’s center point as the collision point and check if it overlaps with the player’s rectangle:

1
2
3
4
5
6
7
8
9
bool CheckPlayerCollision(const Player& player)
{
	// Use the enemy's center point as the collision point
	POINT check_position = { position.x + FRAME_WIDTH / 2, position.y + FRAME_HEIGHT / 2 };
	bool is_overlap_x = check_position.x >= player.GetPosition().x && check_position.x <= player.GetPosition().x + player.FRAME_WIDTH;
	bool is_overlap_y = check_position.y >= player.GetPosition().y && check_position.y <= player.GetPosition().y + player.FRAME_HEIGHT;
	
	return is_overlap_x && is_overlap_y;
}

Bullet Updates and Visual Effects

The bullets orbiting the player are handled as a group of three and updated using a global function.

To create a dynamic visual effect, we animate the bullets by adjusting their angle (α) over time. All angles are calculated in radians for simplicity:

Here’s the corresponding code:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
//  Update bullet positions
void UpdateBullets(vector<Bullet>& bullet_list, const Player& player)
{
	// Create a pulsating effect for visual flair
	const double RADIAL_SPEED = 0.0045; // Speed of radial oscillation
	const double TANGENT_SPEED = 0.0055; // Speed of tangential rotation

	double radian_interval = 2 * PI / bullet_list.size(); // Angular spacing between bullets

	// Update each bullet's position based on the player's location
	POINT player_position = player.GetPosition();
	double radius = BULLET_BASE_RADIUS + BULLET_RADIUS_CHANGE_RANGE * sin(GetTickCount() * RADIAL_SPEED);
	for (size_t i = 0; i < bullet_list.size(); i++)
	{
		double radian = GetTickCount() * TANGENT_SPEED + radian_interval * i;
		bullet_list[i].position.x = player_position.x + player.FRAME_WIDTH / 2 + (int)(radius * sin(radian));
		bullet_list[i].position.y = player_position.y + player.FRAME_HEIGHT / 2 + (int)(radius * cos(radian));
	}
}

Removing Defeated Enemies

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// Iterate through the enemy list and remove defeated enemies
for (size_t i = 0; i < enemy_list.size(); i++) // Avoid using iterators since the 												// container is modified during iteration
{
	Enemy* enemy = enemy_list[i];
	if (!enemy->CheckAlive())
	{
		// Swap with the last element and remove it
		// * This is an efficient deletion method when element order doesn't matter
		swap(enemy_list[i], enemy_list.back());
		enemy_list.pop_back();
		delete enemy;
	}
}

Playing Sound Effects

This project uses Windows API functions to play sound. Here’s how to do it:

1
2
3
4
5
// Open the bgm.mp3 file located in the mus folder and assign it the alias "bgm"
mciSendString(_T("open mus/bgm.mp3 alias bgm"), NULL, 0, NULL);	// Load sound

// Play the sound with alias "bgm" in a loop starting from the beginning
mciSendString(_T("play bgm repeat from 0"), NULL, 0, NULL);		// Remove 'repeat' if looping is not needed	

Performance Optimization: Using the Flyweight Pattern

Game assets like models and textures often consume significant disk space and increase loading time. The Flyweight pattern is commonly used in game development to optimize resource usage.

Here’s a comparison between a typical implementation and a Flyweight-based one:

1
2
3
4
5
6
7
//========= Typical Implementation =========
struct Tree
{
    Model model;	// Tree model
    Texture texture;// Tree texture
    int x, y, z;	// Tree position
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
//======= Flyweight Implementation =======

// Shared asset structure
// All trees use the same TreeAsset instance for model and texture
struct TreeAsset
{
    Model model;	// Tree model
    Texture texture;// Tree texture
}

// Tree instance structure
struct Tree
{
    TreeAsset* asset;	// Pointer to shared asset
    int x, y, z;		// Tree position
}

In this project, we refactor the Animation class to separate shared and instance-specific data. The shared data—std::vector<IMAGE*> frame_list—is stored in an Atlas class, while each enemy instance maintains its own animation state.

Shared image data is managed by the Atlas 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
// Optimized resource loading
class Atlas
{
public:
	Atlas(LPCTSTR path, int num)
	{
		// Load image frames
		TCHAR path_file[256];
		for (int i = 0; i < num; i++)
		{
			_stprintf_s(path_file, path, i);

			IMAGE* frame = new IMAGE();
			loadimage(frame, path_file);
			frame_list.push_back(frame);
		}
	}

	~Atlas()
	{
		for (int i = 0; i < frame_list.size(); i++)
		{
			delete frame_list[i];
		}
	}

public:
	vector<IMAGE*> frame_list;
};

Instance-specific animation logic is encapsulated in 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
class Animation
{
public: 
	Animation(Atlas* atlas, int interval)
	{
		anim_atlas = atlas;
		interval_ms = interval;
	}

	~Animation() = default; // The atlas is shared, so we don't delete it here
							// It should be released at a higher level (e.g., in main)
							// Also, we didn't allocate it with 'new' here

	// Play animation
	void Play(int x, int y, int delta_time)
	{
		timer += delta_time;
		if (timer >= interval_ms)
		{
			idx_frame = (idx_frame + 1) % anim_atlas->frame_list.size();
			timer = 0;
		}

		putimage_alpha(x, y, anim_atlas->frame_list[idx_frame]);
	}

private:
	int interval_ms = 0;// Frame interval
    int timer = 0;		// Animation timer
	int idx_frame = 0; 	// Current frame index

private:
	Atlas* anim_atlas; 	// Pointer to shared atlas
};

Button Class Design

A button can have three states: Idle, Hovered, and Pushed. Understanding the transitions between these states is key to implementing input logic:

Accordingly, we need to handle three types of input events: mouse movement, left mouse button press, and left mouse button release.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
void ProcessEvent(const ExMessage& msg)
{
	switch (msg.message)
	{
	case WM_MOUSEMOVE:
		if (status == Status::Idle && CheckCursorHit(msg.x, msg.y))
			status = Status::Hovered;
		else if (status == Status::Idle && !CheckCursorHit(msg.x, msg.y))
			status = Status::Idle;
		else if (status == Status::Hovered && !CheckCursorHit(msg.x, msg.y))
			status = Status::Idle;
		break;
	case WM_LBUTTONDOWN:
		if (CheckCursorHit(msg.x, msg.y))
			status = Status::Pushed;
		break;
	case WM_LBUTTONUP:
		if (status == Status::Pushed)
			OnClick();
		break;
	default:
		break;
	}
}

Extras: Animation Effects & Pixel Buffers

Color Basics

An image is made up of pixels, and each pixel’s color is determined by the three primary colors: Red, Green, and Blue—commonly referred to as RGB. These components are mixed at varying intensities to produce different colors. A simple Color structure can be defined as:

1
2
3
4
5
6
struct Color
{
    int r;
    int g;
    int b;
};

Essentially, an image is a 2D array of pixels. For example, a ``100*100 image can be represented asColor image[100][100]`. Rendering an image to the window involves copying this smaller array into the larger pixel buffer of the window. The drawing coordinates determine the pixel index.

In EasyX, the IMAGE class contains a pointer DWORD* m_pBuffer that points to the image’s pixel buffer. Internally, the pixel data is stored in memory from left to right, top to bottom. To access the color of a pixel at (x, y), you would use:

  • In array form: Color pix_color = image[y][x]
  • In EasyX: DWORD pix_color = buffer[y * width + x] (where width is the image width)

You can retrieve the pixel buffer using EasyX’s API: DWORD* buffer = GetImageBuffer(&image). Each DWORD element occupies 4 bytes and stores RGBA data (Red, Green, Blue, Alpha).

Implementing Image Flip Effects

Start by loading the original animation frames for the player facing left. Then define a new array for the flipped (right-facing) frames. Each left-facing image is horizontally flipped to create its right-facing counterpart.

Before flipping, we use Resize to allocate memory and set the size of the right-facing IMAGE objects. If an IMAGE object is not created via copy constructor or loadimage, its pixel buffer is uninitialized—so Resize also handles memory allocation.

Next, we retrieve the pixel buffers of both the left and right images and copy each pixel row by row. The horizontal flip is achieved by reversing the x-axis index: width - 1 - x.

Loading left-facing animation frames:

1
2
3
4
5
6
7
8
9
IMAGE img_player_left[6];

// Load player left-facing animation
for (int i = 0; i < 6; i++)
{
    static TCHAR img_path[256];
    _stprintf_s(img_path, _T("img/paimon_left_&d.png"), i);
    loadimage(&img_player_left[i], img_path);
}

Creating right-facing flipped frames:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
IMAGE img_player_right[6];

for (int i = 0; i < 6; i++)
{
    int width = img_player_left[i].getwidth();
    int height = img_player_left[i].getheight();
    Resize(&img_player_right[i], width, height);	// Resize and allocate memory for right-facing image
    
    // Flip each row horizontally
    DWORD* color_buffer_left_img = GetImageBuffer(&img_player_left[i]);
    DWORD* color_buffer_right_img = GetImageBuffer(&img_player_right[i]);
    for (int y = 0; y < height; y++)
    {
        for (int x = 0; x < width; x++)
        {
            int idx_left_img = y * width + x;					// Source pixel index
            int idx_right_img = y * width + (width - 1 - x);	// Target pixel index
            color_buffer_right_img[idx_right_img] = color_buffer_left_img[idx_left_img];
        }
    }
}

Extracting RGB Components from Each Pixel

EasyX provides three macros—GetGValue, GetRValue, and GetBValue—to extract the individual RGB components from a pixel. However, note that the commonly used COLORREF type (a 32-bit integer representing color) is stored in memory as 0xbbggrr, meaning the red and blue channels are swapped. So when using EasyX macros, you need to reverse the R and B components:

1
2
3
4
DWORD pix_color = buffer[y * width + x];
BYTE r = GetBValue(pix_color);
BYTE g = GetGValue(pix_color);
BYTE b = GetRValue(pix_color);

Implementing Image Flicker Effect

The flicker effect is essentially a switch between the normal animation frames and a set of pure white silhouette frames. These silhouette frames can be dynamically generated by manipulating the pixel buffer.

To set a pixel to pure white, you can use the RGB macro to create a COLORREF value, then swap the red and blue channels using BGR, and finally add an alpha channel:

1
2
3
4
// RGB(255, 255, 255) gives 0x00FFFFFF
// Adding full opacity: (BYTE)(255) << 24 = 0xFF000000
// Combined: 0xFFFFFFFF = fully opaque white
DWORD white_pix = BGR(RGB(255, 255, 255)) | (((DWORD)(BYTE)(255)) << 24);

To implement the image flicker effect, the basic idea is to first define an array of silhouette animation frames. Then, for each original image frame, use Resize to match its size and allocate memory for the silhouette frame. After that, retrieve the color buffers of both the original and silhouette images. In a nested loop, check the color of each pixel—if the pixel is not already white, set it to pure white:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
IMAGE img_player_left_sketch[6];

// Generate silhouette frames for left-facing animation
for (int i = 0; i < 6; i++)
{
    int width = img_player_left[i].getwidth();
    int height = img_player_left[i].getheight();
    Resize(&img_player_left_sketch[i], width, height);	// Allocate memory and set size
    
    DWORD* color_buffer_raw_img = GetImageBuffer(&img_player_left[i]);
    DWORD* color_buffer_sketch_img = GetImageBuffer(&img_player_left_sketch[i]);
    
   	for (int y = 0; y < height; y++)
    {
        for (int x = 0; x < width; x++)
        {
            int idx = y * width + x;
            if ((color_buffer_raw_img[idx] & 0xFF000000) >> 24)	// If pixel is not fully transparent
                color_buffer_sketch_img[idx] = BGR(RGB(255, 255, 255)) | (((DWORD)(BYTE)(255)) << 24);
        }
    }
}

Implementing Freeze Effect

Principle of Alpha Blending

EasyX doesn’t automatically handle transparency. When alpha blending is applied, the final pixel color is calculated as: Final Color = Source Color * Alpha + Destination Color * (1 - Alpha). Here, Alpha is a float between 0 and 1. For example, overlaying a green image on a red background would blend the colors based on this formula:

不考虑Alpha混合图片叠加效果
Image Overlay Without Alpha Blending
考虑Alpha混合图片叠加效果
Image Overlay With Alpha Blending and the Formula
To simulate a freeze effect, overlay a semi-transparent icy image on top of the current animation frame.

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
// Copy current frame for processing
IMAGE img_current_frame(img_player_left[counter]);
int width = img_curent_frame.getwidth();
int height = img_curent_frame.getheight();

DWORD* color_buffer_ice_img = GetImageBuffer(&img_ice);
DWORD* color_buffer_frame_img = GetImageBuffer(&img_current_frame);

// Traverse the color buffer of the current frame and blend the non-transparent regions
for (int y = 0; y < height; y++)
{
    for (int x = 0; x < width; x++)
    {
        int idx = y * width + x;
        static const float RATIO = 0.25f;	// Blend ratio
        DWORD color_ice_img = color_buffer_ice_img[idx];
        DWORD color_frame_img = color_buffer_frame_img[idx];
        if ((color_frame_img & 0xFF000000) >> 24)	// 0xFF000000: Alpha channel
        {
            // Note: The color buffer stores pixel data in BGR order, so you need to swap the red and blue channels when retrieving color values.
            BYTE r = (BYTE)(GetBValue(color_frame_img) * RATIO + GetBValue(color_ice_img) * (1 - RATIO));
            BYTE g = (BYTE)(GetGValue(color_frame_img) * RATIO + GetGValue(color_ice_img) * (1 - RATIO));
            BYTE b = (BYTE)(GetRValue(color_frame_img) * RATIO + GetRValue(color_ice_img) * (1 - RATIO));
            // Blend with the Alpha channel
            color_buffer_frame_img[idx] = (BGR(RGB(r, g, b)) | (((DWORD)(BYTE)(255)) << 24);
        }
    }
}

Enhancement: Adding Highlight to Frozen State

To make the freeze effect more vivid, add a white scanning line from top to bottom. Only pixels with brightness above a threshold will be highlighted.

 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
void RenderFrozenPlayer()
{
    static const POINT position = { 1075, 345};
    
    static int counter = 0;			// Animation frame index
    static int anim_timer = 0;		// Animation timer
    static int frozen_timer = 0;	// Freeze state timer
    static const int THICKNESS = 5;	// Scanline thickness
    static int hightlight_pos_y = 0;// Vertical position of the scanline
    static bool is_frozen = false;	// Whether the player is currently frozen
    
    // If not frozen, update animation timer
    if ((!is_frozen) && (++anim_timer % 3 == 0))
        counter = (counter + 1) % 6;
    // Update freeze timer and reset scanline position
    if (++frozen_timer % 100 == 0)
    {
        is_frozen = !is_frozen;
        highlight_pos_y = -THICKNESS;
    }
    
    // Draw shadow beneath the player
    putimage_alpha(position.x + (80 - 32) / 2, position.y + 80, &img_shadow);
    
    // Render different animation frames depending on freeze state
    if (is_frozen)
    {
        // Copy current frame for further processing
        IMAGE img_current_frame(img_player_left[counter]);
        int width = img_curent_frame.getwidth();
        int height = img_curent_frame.getheight();

        // Update vertical position of the highlight scanline
        highlight_pos_y = (highlight_pos_y + 2) % height;
        
        //  Get color buffers of the current frame and the ice overlay
		DWORD* color_buffer_ice_img = GetImageBuffer(&img_ice);
		DWORD* color_buffer_frame_img = GetImageBuffer(&img_current_frame);
        
        for (int y = 0; y < height; y++)
        {
            for (int x = 0; x < width; x++)
            {
                int idx = y * width + x;
                static const float RATIO = 0.25f;		// Blending ratio
                static const float THRESHOLD = 0.84f;	// Highlight brightness threshold
                DWORD color_ice_img = color_buffer_ice_img[idx];
                DWORD color_frame_img = color_buffer_frame_img[idx];
                if ((color_frame_img & 0xFF000000) >> 24)	// Check alpha channel (non-transparent)
                {
                    // Note: Color buffer stores pixels in BGR order, so swap red and blue when extracting
                    BYTE r = (BYTE)(GetBValue(color_frame_img) 
                                    * RATIO + GetBValue(color_ice_img) * (1 - RATIO));
                    BYTE g = (BYTE)(GetGValue(color_frame_img) 
                                    * RATIO + GetGValue(color_ice_img) * (1 - RATIO));
                    BYTE b = (BYTE)(GetRValue(color_frame_img) 
                                    * RATIO + GetRValue(color_ice_img) * (1 - RATIO));

                    // If pixel brightness at scanline exceeds threshold, set it to pure white
                    if ((y >= hightlight_pos_y && y < = highlight_pos_y + THICKNESS)
                       && ((r / 255.0f) * 0.2126f + (g / 255.0f) * 0.7152f + (b / 255.0f) * 0.0722f 
                           >= TRESHOLD))
                    {
                    	color_buffer_frame_img[idx] 
                            = (BGR(RGB(255, 255, 255)) | (((DWORD)(BYTE)(255)) << 24);
                        continue;
                    }
                    color_buffer_frame_img[idx] = (BGR(RGB(r, g, b)) | (((DWORD)(BYTE)(255)) << 24);
                }
            }
        }
        putimage_alpha(position.x, position.y, &img_current_frame);	
    }
	else
        putimage_alpha(position.x, position.y, &img_player_left[counter]);
}

The brightness coefficients (0.2126, 0.7152, 0.0722) are based on a standard formula for perceived luminance.

Full Source Code

  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
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
#include <graphics.h>
#include <string>
#include <vector>
using namespace std;

/* =============== Key Concepts ===============
* 1. Avoid blocking operations or heavy, time-consuming tasks inside the main game loop.
* 2. Difference between using a frame counter and a timer to control animation updates:
*    - Counter-based: Frame updates speed up on faster machines with higher refresh rates.
*    - Timer-based: Frame updates stay consistent across all machines, tied to real elapsed time.
* 3. Use the Flyweight pattern to optimize resource loading.
*/

const int WINDOW_WIDTH = 1280;
const int WINDOW_HEIGHT = 720;

const int FPS = 60;

const double PI = 3.14159;

const int BULLET_BASE_RADIUS = 100;
const int BULLET_RADIUS_CHANGE_RANGE = 25;

const int PLAYER_ANIM_NUM = 6;
const int ENEMY_ANIM_NUM = 6;

const int BUTTON_WIDTH = 192;
const int BUTTON_HEIGHT = 75;

bool is_game_started = false;
bool is_running = true;

#pragma comment(lib, "MSIMG32.LIB") // Links the Windows GDI+ library for advanced image operations
#pragma comment(lib, "Winmm.lib") // Links the Windows multimedia library

// Custom Transparent Image Rendering
void putimage_alpha(int x, int y, IMAGE* img);

// Optimize resources loading
class Atlas
{
public:
	Atlas(LPCTSTR path, int num)
	{
		// Load image frames
		TCHAR path_file[256];
		for (int i = 0; i < num; i++)
		{
			_stprintf_s(path_file, path, i);

			IMAGE* frame = new IMAGE();
			loadimage(frame, path_file);
			frame_list.push_back(frame);
		}
	}

	~Atlas()
	{
		for (int i = 0; i < frame_list.size(); i++)
		{
			delete frame_list[i];
		}
	}

public:
	vector<IMAGE*> frame_list;
};

Atlas* atlas_player_left; // Initialize in main()
Atlas* atlas_player_right;
Atlas* atlas_enemy_left;
Atlas* atlas_enemy_right;

class Animation
{
public: 
	Animation(Atlas* atlas, int interval)
	{
		anim_atlas = atlas;
		interval_ms = interval;
	}

	~Animation() = default;	// The atlas is shared, so we don't delete it here 
							// It should be released at a higher level (e.g., in main)
	// Play animation
	void Play(int x, int y, int delta_time)
	{
		timer += delta_time;
		if (timer >= interval_ms)
		{
			idx_frame = (idx_frame + 1) % anim_atlas->frame_list.size();
			timer = 0;
		}

		putimage_alpha(x, y, anim_atlas->frame_list[idx_frame]);
	}

private:
	int interval_ms = 0; // Frame interval
    int timer = 0; 		// Animation timer
	int idx_frame = 0; 	// Current frame index

private:
    Atlas* anim_atlas;	// Pointer to shared atlas
};

class Player
{
public:
	Player()
	{
		loadimage(&img_shadow, _T("img/shadow_player.png"));
		anim_left = new Animation(atlas_player_left, 45);
		anim_right = new Animation(atlas_player_right, 45);
	}

	~Player()
	{
		delete anim_left;
		delete anim_right;
	}

	void ProcessEvent(const ExMessage& msg)
	{
		if (msg.message == WM_KEYDOWN)
		{
			switch (msg.vkcode)
			{
			case VK_UP:
				is_moving_up = true;
				break;
			case VK_DOWN:
				is_moving_down = true;
				break;
			case VK_LEFT:
				is_moving_left = true;
				break;
			case VK_RIGHT:
				is_moving_right = true;
				break;
			}
		}

		if (msg.message == WM_KEYUP)
		{
			switch (msg.vkcode)
			{
			case VK_UP:
				is_moving_up = false;
				break;
			case VK_DOWN:
				is_moving_down = false;
				break;
			case VK_LEFT:
				is_moving_left = false;
				break;
			case VK_RIGHT:
				is_moving_right = false;
				break;
			}
		}		
	}

	void Move()
	{
		if (is_moving_up)
			position.y -= SPEED;
		if (is_moving_down)
			position.y += SPEED;
		if (is_moving_left)
			position.x -= SPEED;
		if (is_moving_right)
			position.x += SPEED;

		// Fix diagonal movement being faster than horizontal/vertical
		int dir_x = is_moving_right - is_moving_left; // Right is positive X direction
		int dir_y = is_moving_down - is_moving_up; // Down is positive Y direction
		double len_dir = sqrt(dir_x * dir_x + dir_y * dir_y);
		if (len_dir != 0)
		{
			double normalized_x = dir_x / len_dir;
			double normalized_y = dir_y / len_dir;
			position.x += (int)(SPEED * normalized_x);
			position.y += (int)(SPEED * normalized_y);
		}

		//  Clamp player movement within window boundaries
		if (position.x < 0) position.x = 0;
		if (position.y < 0) position.y = 0;
		if (position.x + FRAME_WIDTH > WINDOW_WIDTH) position.x = WINDOW_WIDTH - FRAME_WIDTH;
		if (position.y + FRAME_HEIGHT > WINDOW_HEIGHT) position.y = WINDOW_HEIGHT - FRAME_HEIGHT;
	}

	void Draw(int delta_time)
	{
		// Draw shadow before rendering the player
		int shadow_pos_x = position.x + (FRAME_WIDTH / 2 - SHADOW_WIDTH / 2);
		int shadow_pos_y = position.y + FRAME_HEIGHT - 8;
		putimage_alpha(shadow_pos_x, shadow_pos_y, &img_shadow);

		static bool facing_left = false;
		int dir_x = is_moving_right - is_moving_left;
		if (dir_x < 0)
			facing_left = true;
		else if (dir_x > 0)
			facing_left = false;

		if (facing_left)
			anim_left->Play(position.x, position.y, delta_time);
		else
			anim_right->Play(position.x, position.y, delta_time);
	}

	const POINT& GetPosition() const
	{
		return position;
	}

public:
	const int FRAME_WIDTH = 80;
	const int FRAME_HEIGHT = 80;

private:
	const int SPEED = 3; // Player movement speed
	const int SHADOW_WIDTH = 32;	

private:	
	IMAGE img_shadow; // Shadow beneath the player
	Animation* anim_left;
	Animation* anim_right;
	POINT position = { 500, 500 }; // Player position

	// Fix stuttering caused by asynchronous message handling and key input
	bool is_moving_up = false;
	bool is_moving_down = false;
	bool is_moving_left = false;
	bool is_moving_right = false;
};

class Bullet
{
public:
	Bullet() = default;
	~Bullet() = default;

	void Draw() const // Add 'const' after the member function: this method does not modify any member variables
	{
		setlinecolor(RGB(255, 155, 50));
		setfillcolor(RGB(200, 75, 10));
		fillcircle(position.x, position.y, RADIUS);
	}

public:
	POINT position = { 0, 0 };

private:
	const int RADIUS = 10;
};

class Enemy
{
public:
	Enemy()
	{
		loadimage(&img_shadow, _T("img/shadow_enemy.png"));
		anim_left = new Animation(atlas_enemy_left, 45);
		anim_right = new Animation(atlas_enemy_right, 45);

		// Spawn boundaries for the enemy
		enum class SpawnEdge
		{
			Up = 0,
			Down,
			Left,
			Right
		};

		// Randomly spawn the enemy on one of the four edges
		SpawnEdge edge = (SpawnEdge)(rand() % 4);
		// Generate specific random coordinates
		switch (edge)
		{
		case SpawnEdge::Up:
			position.x = rand() % WINDOW_WIDTH;
			position.y = -FRAME_HEIGHT;
			break;
		case SpawnEdge::Down:
			position.x = rand() % WINDOW_WIDTH;
			position.y = WINDOW_HEIGHT;
			break;
		case SpawnEdge::Left:
			position.x = -FRAME_WIDTH;
			position.y = rand() % WINDOW_HEIGHT;
			break;
		case SpawnEdge::Right:
			position.x = WINDOW_WIDTH;
			position.y = rand () % WINDOW_HEIGHT;
			break;
		default:
			break;
		}
	}

	~Enemy()
	{
		delete anim_left;
		delete anim_right;
	}

	bool CheckBulletCollision(const Bullet& bullet) // Add 'const' before the parameter: this argument will not be modified inside the function
	{
		// Treat the bullet as a point and check if it lies within the enemy's rectangle
		bool is_overlap_x = bullet.position.x >= position.x && bullet.position.x <= position.x + FRAME_WIDTH;
		bool is_overlap_y = bullet.position.y >= position.y && bullet.position.y <= position.y + FRAME_HEIGHT;
		
		return is_overlap_x && is_overlap_y;
	}

	bool CheckPlayerCollision(const Player& player)
	{
		// Use the center point of the enemy as the collision point
		POINT check_position = { position.x + FRAME_WIDTH / 2, position.y + FRAME_HEIGHT / 2 };
		bool is_overlap_x = check_position.x >= player.GetPosition().x && check_position.x <= player.GetPosition().x + player.FRAME_WIDTH;
		bool is_overlap_y = check_position.y >= player.GetPosition().y && check_position.y <= player.GetPosition().y + player.FRAME_HEIGHT;
		
		return is_overlap_x && is_overlap_y;
	}

	void Move(const Player& player)
	{
		const POINT& player_position = player.GetPosition();
		int dir_x = player_position.x - position.x;
		int dir_y = player_position.y - position.y;
		double dir_len = sqrt(dir_x * dir_x + dir_y * dir_y);
		if (dir_len != 0)
		{
			double normalized_x = dir_x / dir_len;
			double normalized_y = dir_y / dir_len;
			position.x += (int)(normalized_x * SPEED);
			position.y += (int)(normalized_y * SPEED);
		}

		if (dir_x > 0)
			facing_left = false;
		else if (dir_x < 0)
			facing_left = true;
	}

	void Draw(int delta_time)
	{
		int shadow_pos_x = position.x + (FRAME_WIDTH / 2 - SHADOW_WIDTH / 2);
		int shadow_pos_y = position.y + FRAME_HEIGHT - 35;
		putimage_alpha(shadow_pos_x, shadow_pos_y, &img_shadow);

		if (facing_left)
			anim_left->Play(position.x, position.y, delta_time);
		else
			anim_right->Play(position.x, position.y, delta_time);
	}

	void Hurt()
	{
		alive = false;
	}

	bool CheckAlive()
	{
		return alive;
	}

private: 
	const int SPEED = 2; 
	const int FRAME_WIDTH = 80;
	const int FRAME_HEIGHT = 80;
	const int SHADOW_WIDTH = 48;	

private:
	IMAGE img_shadow; 
	Animation* anim_left;
	Animation* anim_right;
	POINT position = { 0, 0 }; 
	bool facing_left = false;
	bool alive = true;
}; 

// Button base class
class Button
{
public:
	Button(RECT rect, LPCTSTR path_imag_idle, LPCTSTR path_imag_hovered, LPCTSTR path_imag_pushed) // load images
	{
		region = rect;
		loadimage(&img_idle, path_imag_idle);
		loadimage(&img_hovered, path_imag_hovered);
		loadimage(&img_pushed, path_imag_pushed);
	}

	~Button() = default;

	void Draw()
	{
		switch (status)
		{
		case Status::Idle:
			putimage(region.left, region.top, &img_idle);
			break;
		case Status::Hovered:
			putimage(region.left, region.top, &img_hovered);
			break;
		case Status::Pushed:
			putimage(region.left, region.top, &img_pushed);
			break;
		}
	}

	void ProcessEvent(const ExMessage& msg)
	{
		switch (msg.message)
		{
		case WM_MOUSEMOVE:
			if (status == Status::Idle && CheckCursorHit(msg.x, msg.y))
				status = Status::Hovered;
			else if (status == Status::Idle && !CheckCursorHit(msg.x, msg.y))
				status = Status::Idle;
			else if (status == Status::Hovered && !CheckCursorHit(msg.x, msg.y))
				status = Status::Idle;
			break;
		case WM_LBUTTONDOWN:
			if (CheckCursorHit(msg.x, msg.y))
				status = Status::Pushed;
			break;
		case WM_LBUTTONUP:
			if (status == Status::Pushed)
				OnClick();
			break;
		default:
			break;
		}
	}

protected:
	virtual void OnClick() = 0;

private:
	bool CheckCursorHit(int x, int y)
	{
		return x >= region.left && x <= region.right && y >= region.top && y <= region.bottom;
	}

private:
	enum class Status
	{
		Idle = 0,
		Hovered,
		Pushed
	};

private:
	RECT region;
	IMAGE img_idle;
	IMAGE img_hovered;
	IMAGE img_pushed;
	Status status = Status::Idle;
};

class StartGameButton : public Button
{
public:
	StartGameButton(RECT rect, LPCTSTR path_imag_idle, LPCTSTR path_imag_hovered, LPCTSTR path_imag_pushed)
		: Button(rect, path_imag_idle, path_imag_hovered, path_imag_pushed) {}
	~StartGameButton() = default;

protected:
	void OnClick()
	{
		is_game_started = true;
		mciSendString(_T("play bgm repeat from 0"), NULL, 0, NULL); // Play bgm repeatly
	}
};

class QuitGameButton : public Button
{
public:
	QuitGameButton(RECT rect, LPCTSTR path_imag_idle, LPCTSTR path_imag_hovered, LPCTSTR path_imag_pushed)
		: Button(rect, path_imag_idle, path_imag_hovered, path_imag_pushed) {}
	~QuitGameButton() = default;
protected:
	void OnClick()
	{
		is_running = false;
	}
};

void TryGenerateEnemy(vector<Enemy*>& enemy_list);
void UpdateBullets(vector<Bullet>& bullet_list, const Player& player);
void DrawPlayerScore(int score);

int main()
{
	initgraph(WINDOW_WIDTH, WINDOW_HEIGHT);
	mciSendString(_T("open mus/bgm.mp3 alias bgm"), NULL, 0, NULL); // load audio
	mciSendString(_T("open mus/hit.wav alias hit"), NULL, 0, NULL); 

	// Both the player and enemy constructors require atlas resources,
	// so atlas objects must be initialized before creating player and enemy instances.
	atlas_player_left = new Atlas(_T("img/player_left_%d.png"), PLAYER_ANIM_NUM);
	atlas_player_right = new Atlas(_T("img/player_right_%d.png"), PLAYER_ANIM_NUM);
	atlas_enemy_left = new Atlas(_T("img/enemy_left_%d.png"), ENEMY_ANIM_NUM);
	atlas_enemy_right = new Atlas(_T("img/enemy_right_%d.png"), ENEMY_ANIM_NUM);

	Player player; 
	vector<Enemy*> enemy_list;
	vector<Bullet> bullet_list(3); // Only three bullets are needed, so raw pointers are avoided to prevent memory leaks	
	
	ExMessage msg;
	IMAGE img_menu;	
	IMAGE img_background;	

	int score = 0;

	RECT region_btn_start_game, region_btn_quit_game;
	// ================ UI ================
	region_btn_start_game.left = (WINDOW_WIDTH - BUTTON_WIDTH) / 2;
	region_btn_start_game.right = region_btn_start_game.left + BUTTON_WIDTH;
	region_btn_start_game.top = 430;
	region_btn_start_game.bottom = region_btn_start_game.top + BUTTON_HEIGHT;

	region_btn_quit_game.left = (WINDOW_WIDTH - BUTTON_WIDTH) / 2;
	region_btn_quit_game.right = region_btn_quit_game.left + BUTTON_WIDTH;
	region_btn_quit_game.top = 550;
	region_btn_quit_game.bottom = region_btn_quit_game.top + BUTTON_HEIGHT;

	StartGameButton btn_start_game = StartGameButton(region_btn_start_game,
		_T("img/ui_start_idle.png"), _T("img/ui_start_hovered.png"), _T("img/ui_start_pushed.png"));

	QuitGameButton btn_quit_game = QuitGameButton(region_btn_quit_game,
		_T("img/ui_quit_idle.png"), _T("img/ui_quit_hovered.png"), _T("img/ui_quit_pushed.png"));
	
	loadimage(&img_menu, _T("img/menu.png"));
	loadimage(&img_background, _T("img/background.png"));
	
	BeginBatchDraw();
	while (is_running)
	{
		DWORD start_time = GetTickCount();
		while (peekmessage(&msg))
		{
			if (is_game_started)
			{
				player.ProcessEvent(msg);
			}
			else
			{
				btn_start_game.ProcessEvent(msg);
				btn_quit_game.ProcessEvent(msg);
			}
		}	

		if (is_game_started)
		{
			player.Move();
			UpdateBullets(bullet_list, player);

			TryGenerateEnemy(enemy_list);
			for (Enemy* enemy : enemy_list)
				enemy->Move(player);

            // Collision detection: enemies vs. player
			for (Enemy* enemy : enemy_list)
			{
				if (enemy->CheckPlayerCollision(player))
				{
					static TCHAR text[128];
					_stprintf_s(text, _T("最终得分:%d!"), score);
					MessageBox(GetHWnd(), text, _T("游戏结束"), MB_OK);
					is_running = false;
					break;
				}
			}

			// Collision detection: enemies vs. bullets
			for (Enemy* enemy : enemy_list)
			{
				for (const Bullet& bullet : bullet_list)
				{
					if (enemy->CheckBulletCollision(bullet))
					{
						mciSendString(_T("play hit from 0"), NULL, 0, NULL);
						enemy->Hurt();
						score++;
					}
				}
			}

			//  Iterate through the enemy list and remove defeated enemies
			for (size_t i = 0; i < enemy_list.size(); i++) // Avoid using iterators since the container is modified during iteration
			{
				Enemy* enemy = enemy_list[i];
				if (!enemy->CheckAlive())
				{
					 // Swap with the last element and remove it
                    // * This is an efficient deletion method when element order doesn't matter
					swap(enemy_list[i], enemy_list.back());
					enemy_list.pop_back();
					delete enemy;
				}
			}
		}

		cleardevice();

		// ======= Draw  =======
		if (is_game_started)
		{
			putimage(0, 0, &img_background);
			player.Draw(1000 / FPS);
			for (Enemy* enemy : enemy_list)
				enemy->Draw(1000 / FPS);
			for (Bullet& bullet : bullet_list)
				bullet.Draw();

			DrawPlayerScore(score);
		}
		else
		{
			putimage(0, 0, &img_menu);
			btn_start_game.Draw();
			btn_quit_game.Draw();
		}

		FlushBatchDraw();

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

    // Release atlas pointers after the main loop
	delete atlas_player_left;
	delete atlas_player_right;
	delete atlas_enemy_left;
	delete atlas_enemy_right;

	EndBatchDraw();
	return 0;
}

void putimage_alpha(int x, int y, IMAGE* img)
{
	int w = img->getwidth();
	int h = img->getheight();
	AlphaBlend(GetImageHDC(NULL), x, y, w, h, GetImageHDC(img),
		0, 0, w, h, { AC_SRC_OVER,0,255,AC_SRC_ALPHA });
}

void TryGenerateEnemy(vector<Enemy*>& enemy_list)
{
	const int INTERVAL = 100;
	static int counter = 0;
	if (++counter % INTERVAL == 0)
	{
		enemy_list.push_back(new Enemy());
	}
}

// Update bullet postions
void UpdateBullets(vector<Bullet>& bullet_list, const Player& player)
{
	// Create a pulsating effect for visual flair
	const double RADIAL_SPEED = 0.0045; // Speed of radial oscillation
	const double TANGENT_SPEED = 0.0055; // Speed of tangential rotation

	double radian_interval = 2 * PI / bullet_list.size(); // Angular spacing between bullets

	// Update each bullet's position based on the player's location
	POINT player_position = player.GetPosition();
	double radius = BULLET_BASE_RADIUS + BULLET_RADIUS_CHANGE_RANGE * sin(GetTickCount() * RADIAL_SPEED);
	for (size_t i = 0; i < bullet_list.size(); i++)
	{
		double radian = GetTickCount() * TANGENT_SPEED + radian_interval * i;
		bullet_list[i].position.x = player_position.x + player.FRAME_WIDTH / 2 + (int)(radius * sin(radian));
		bullet_list[i].position.y = player_position.y + player.FRAME_HEIGHT / 2 + (int)(radius * cos(radian));
	}
}

void DrawPlayerScore(int score)
{
	static TCHAR text[64];
	_stprintf_s(text, _T("当前玩家得分:%d"), score);

	setbkmode(TRANSPARENT);
	settextcolor(RGB(255, 85, 185));
	outtextxy(10, 10, text);
}

Reflection and Summary

Although this project was developed largely based on beginner-level intuition without much architectural planning, I still learned a great deal. The instructor started with the overall game framework and gradually refined each module, clearly demonstrating the problems encountered in each part and the thought process behind solving them. This was highly valuable for my own development practice.

I also gained a deeper understanding of how animations work at the pixel level, supplemented my knowledge of color and image buffers, and reviewed key concepts like vector motion and 2D collision detection. Through a simple yet hands-on example, I finally grasped the purpose of the Flyweight pattern and design patterns in general. It often felt like a moment of sudden clarity—like fog lifting to reveal the sky.

Looking ahead, there are two areas I need to explore further. First is 3D collision detection and related topics. Although I studied them in school through traditional textbook exercises, I didn’t encounter any real development examples, so my memory is vague and I feel like I’ve forgotten most of it. Second is design patterns in game development. Back then, I read the books and answered questions in a very mechanical way, often feeling lost and confused. I plan to continue learning from this instructor’s design pattern courses to gain a more practical and intuitive understanding through real-world application.

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