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.
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.
intmain(){//========= Initialize data ==========
initgraph(1280,720);boolis_running=true;constintFPS=60;ExMessagemsg;BeginBatchDraw();while(is_running)// Main game loop
{DWORDstart_time=GetTickCount();//========= Handle input =========
while(peekmessage(&msg)){}//======== Handle update =========
cleardevice();//======== Handle rendering =========
FlushBatchDraw();//========= Stabilize frame rate =========
DWORDend_time=GetTickCount();DWORDdelta_time=end_time-start_time;if(delta_time<1000/FPS){Sleep(1000/FPS-delta_time);}}EndBatchDraw();return0;}
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)
intloadimage(IMAGE*pDstImg,// Pointer to the IMAGE object that stores the image
LPCTSTRpImgFile,// File path of the image
intnWidth=0,// Optional: stretch width度
intnHeight=0,// Optional: stretch height
boolbResize=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
IMAGEimg;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
// 绘制图像
voidputimage(intdstX,// X coordinate on screen
intdstY,// Y coordinate on screen
IMAGE*pSrcImg,// Pointer to the IMAGE object to draw
DWORDdwRop=SRCCOPY//Rasteroperationcode(usuallyignored));
The first two parameters specify the position on screen.
The last parameter is a raster operation code, which we’ll ignore for this project.
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.
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:
intidx_current_anim=0;// 1. Index of the current animation frame
constintPLAYER_ANIM_NUM=6;// Total number of animation frames
intmain(){.....while(is_running){while(peekmessage(&msg)){}staticintcounter=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++;if(idx_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
constintPLAYER_ANIM_NUM=6;// Total number of animation frames
IMAGEimg_player_left[PLAYER_ANIM_NUM];IMAGEimg_player_right[PLAYER_ANIM_NUM];voidload_animation(){for(size_ti=0;i<PLAYER_ANIM_NUM;i++){std::wstringpath=L"img/player_left_"+std::to_wstring(i)+L".png";loadimage(&img_player_left[i],path.c_str());}for(size_ti=0;i<PLAYER_ANIM_NUM;i++){std::wstringpath=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:
intmain(){......while(is_running)// Main game loop
{DWORDstart_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.
classAnimation{public:Animation(LPCTSTRpath,intnum,intinterval)// Load animation frames
{interval_ms=interval;TCHARpath_file[256];for(size_ti=0;i<num;i++){_sprintf_s(path_file,path,i);IMAGE*frame=newIMAGE();loadimage(frame,path_file);frame_list.push_bacl(frame);}}voidPlay(intx,inty,intdelta)// 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_ti=0;i<frame_list.size();i++){deleteframe_list[i];}}private:vector<IMAGE*>frame_list;// List of animation frames
intinterval_ms=0;// Time between frames (in milliseconds)
inttimer=0;// Time accumulator
intidx_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:
To prevent the player from moving faster diagonally than horizontally or vertically, we normalize the movement vector. This ensures consistent speed in all directions:
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:
classPlayer{public:Player(){// Initialize resources: animation frames, image assets, etc.
}~Player(){// Release resources
}voidProcessEvent(constExMessage&msg){// Handle player input
}voidMove(){// Handle player movement
}voidDraw(intdelta){// Render the player
}private:// Internal data members
......}
// Define spawn edges
enumclassSpawnEdge{Up=0,Down,Left,Right};// Randomly select one edge
SpawnEdgeedge=(SpawnEdge)(rand()%4);// Assign spawn coordinates based on selected edge
switch(edge){caseSpawnEdge::Up:position.x=rand()%WINDOW_WIDTH;position.y=-FRAME_HEIGHT;break;caseSpawnEdge::Down:position.x=rand()%WINDOW_WIDTH;position.y=WINDOW_HEIGHT;break;caseSpawnEdge::Left:position.x=-FRAME_WIDTH;position.y=rand()%WINDOW_HEIGHT;break;caseSpawnEdge::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:
voidMove(constPlayer&player){constPOINT&player_position=player.GetPosition();intdir_x=player_position.x-position.x;intdir_y=player_position.y-position.y;doubledir_len=sqrt(dir_x*dir_x+dir_y*dir_y);if(dir_len!=0){doublenormalized_x=dir_x/dir_len;doublenormalized_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;elseif(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
boolCheckBulletCollision(constBullet&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
boolis_overlap_x=bullet.position.x>=position.x&&bullet.position.x<=position.x+FRAME_WIDTH;boolis_overlap_y=bullet.position.y>=position.y&&bullet.position.y<=position.y+FRAME_HEIGHT;returnis_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
boolCheckPlayerCollision(constPlayer&player){// Use the enemy's center point as the collision point
POINTcheck_position={position.x+FRAME_WIDTH/2,position.y+FRAME_HEIGHT/2};boolis_overlap_x=check_position.x>=player.GetPosition().x&&check_position.x<=player.GetPosition().x+player.FRAME_WIDTH;boolis_overlap_y=check_position.y>=player.GetPosition().y&&check_position.y<=player.GetPosition().y+player.FRAME_HEIGHT;returnis_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
voidUpdateBullets(vector<Bullet>&bullet_list,constPlayer&player){// Create a pulsating effect for visual flair
constdoubleRADIAL_SPEED=0.0045;// Speed of radial oscillation
constdoubleTANGENT_SPEED=0.0055;// Speed of tangential rotation
doubleradian_interval=2*PI/bullet_list.size();// Angular spacing between bullets
// Update each bullet's position based on the player's location
POINTplayer_position=player.GetPosition();doubleradius=BULLET_BASE_RADIUS+BULLET_RADIUS_CHANGE_RANGE*sin(GetTickCount()*RADIAL_SPEED);for(size_ti=0;i<bullet_list.size();i++){doubleradian=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_ti=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();deleteenemy;}}
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 =========
structTree{Modelmodel;// Tree model
Texturetexture;// Tree texture
intx,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
structTreeAsset{Modelmodel;// Tree model
Texturetexture;// Tree texture
}// Tree instance structure
structTree{TreeAsset*asset;// Pointer to shared asset
intx,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.
classAnimation{public:Animation(Atlas*atlas,intinterval){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
voidPlay(intx,inty,intdelta_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:intinterval_ms=0;// Frame interval
inttimer=0;// Animation timer
intidx_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.
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
structColor{intr;intg;intb;};
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
IMAGEimg_player_left[6];// Load player left-facing animation
for(inti=0;i<6;i++){staticTCHARimg_path[256];_stprintf_s(img_path,_T("img/paimon_left_&d.png"),i);loadimage(&img_player_left[i],img_path);}
IMAGEimg_player_right[6];for(inti=0;i<6;i++){intwidth=img_player_left[i].getwidth();intheight=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(inty=0;y<height;y++){for(intx=0;x<width;x++){intidx_left_img=y*width+x;// Source pixel index
intidx_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:
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:
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:
IMAGEimg_player_left_sketch[6];// Generate silhouette frames for left-facing animation
for(inti=0;i<6;i++){intwidth=img_player_left[i].getwidth();intheight=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(inty=0;y<height;y++){for(intx=0;x<width;x++){intidx=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:
Image Overlay Without Alpha BlendingImage 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.
// Copy current frame for processing
IMAGEimg_current_frame(img_player_left[counter]);intwidth=img_curent_frame.getwidth();intheight=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(inty=0;y<height;y++){for(intx=0;x<width;x++){intidx=y*width+x;staticconstfloatRATIO=0.25f;// Blend ratio
DWORDcolor_ice_img=color_buffer_ice_img[idx];DWORDcolor_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.
BYTEr=(BYTE)(GetBValue(color_frame_img)*RATIO+GetBValue(color_ice_img)*(1-RATIO));BYTEg=(BYTE)(GetGValue(color_frame_img)*RATIO+GetGValue(color_ice_img)*(1-RATIO));BYTEb=(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.
voidRenderFrozenPlayer(){staticconstPOINTposition={1075,345};staticintcounter=0;// Animation frame index
staticintanim_timer=0;// Animation timer
staticintfrozen_timer=0;// Freeze state timer
staticconstintTHICKNESS=5;// Scanline thickness
staticinthightlight_pos_y=0;// Vertical position of the scanline
staticboolis_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
IMAGEimg_current_frame(img_player_left[counter]);intwidth=img_curent_frame.getwidth();intheight=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(inty=0;y<height;y++){for(intx=0;x<width;x++){intidx=y*width+x;staticconstfloatRATIO=0.25f;// Blending ratio
staticconstfloatTHRESHOLD=0.84f;// Highlight brightness threshold
DWORDcolor_ice_img=color_buffer_ice_img[idx];DWORDcolor_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
BYTEr=(BYTE)(GetBValue(color_frame_img)*RATIO+GetBValue(color_ice_img)*(1-RATIO));BYTEg=(BYTE)(GetGValue(color_frame_img)*RATIO+GetGValue(color_ice_img)*(1-RATIO));BYTEb=(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);}elseputimage_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.
#include<graphics.h>#include<string>#include<vector>usingnamespacestd;/* =============== 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.
*/constintWINDOW_WIDTH=1280;constintWINDOW_HEIGHT=720;constintFPS=60;constdoublePI=3.14159;constintBULLET_BASE_RADIUS=100;constintBULLET_RADIUS_CHANGE_RANGE=25;constintPLAYER_ANIM_NUM=6;constintENEMY_ANIM_NUM=6;constintBUTTON_WIDTH=192;constintBUTTON_HEIGHT=75;boolis_game_started=false;boolis_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
voidputimage_alpha(intx,inty,IMAGE*img);// Optimize resources loading
classAtlas{public:Atlas(LPCTSTRpath,intnum){// Load image frames
TCHARpath_file[256];for(inti=0;i<num;i++){_stprintf_s(path_file,path,i);IMAGE*frame=newIMAGE();loadimage(frame,path_file);frame_list.push_back(frame);}}~Atlas(){for(inti=0;i<frame_list.size();i++){deleteframe_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;classAnimation{public:Animation(Atlas*atlas,intinterval){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
voidPlay(intx,inty,intdelta_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:intinterval_ms=0;// Frame interval
inttimer=0;// Animation timer
intidx_frame=0;// Current frame index
private:Atlas*anim_atlas;// Pointer to shared atlas
};classPlayer{public:Player(){loadimage(&img_shadow,_T("img/shadow_player.png"));anim_left=newAnimation(atlas_player_left,45);anim_right=newAnimation(atlas_player_right,45);}~Player(){deleteanim_left;deleteanim_right;}voidProcessEvent(constExMessage&msg){if(msg.message==WM_KEYDOWN){switch(msg.vkcode){caseVK_UP:is_moving_up=true;break;caseVK_DOWN:is_moving_down=true;break;caseVK_LEFT:is_moving_left=true;break;caseVK_RIGHT:is_moving_right=true;break;}}if(msg.message==WM_KEYUP){switch(msg.vkcode){caseVK_UP:is_moving_up=false;break;caseVK_DOWN:is_moving_down=false;break;caseVK_LEFT:is_moving_left=false;break;caseVK_RIGHT:is_moving_right=false;break;}}}voidMove(){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
intdir_x=is_moving_right-is_moving_left;// Right is positive X direction
intdir_y=is_moving_down-is_moving_up;// Down is positive Y direction
doublelen_dir=sqrt(dir_x*dir_x+dir_y*dir_y);if(len_dir!=0){doublenormalized_x=dir_x/len_dir;doublenormalized_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;}voidDraw(intdelta_time){// Draw shadow before rendering the player
intshadow_pos_x=position.x+(FRAME_WIDTH/2-SHADOW_WIDTH/2);intshadow_pos_y=position.y+FRAME_HEIGHT-8;putimage_alpha(shadow_pos_x,shadow_pos_y,&img_shadow);staticboolfacing_left=false;intdir_x=is_moving_right-is_moving_left;if(dir_x<0)facing_left=true;elseif(dir_x>0)facing_left=false;if(facing_left)anim_left->Play(position.x,position.y,delta_time);elseanim_right->Play(position.x,position.y,delta_time);}constPOINT&GetPosition()const{returnposition;}public:constintFRAME_WIDTH=80;constintFRAME_HEIGHT=80;private:constintSPEED=3;// Player movement speed
constintSHADOW_WIDTH=32;private:IMAGEimg_shadow;// Shadow beneath the player
Animation*anim_left;Animation*anim_right;POINTposition={500,500};// Player position
// Fix stuttering caused by asynchronous message handling and key input
boolis_moving_up=false;boolis_moving_down=false;boolis_moving_left=false;boolis_moving_right=false;};classBullet{public:Bullet()=default;~Bullet()=default;voidDraw()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:POINTposition={0,0};private:constintRADIUS=10;};classEnemy{public:Enemy(){loadimage(&img_shadow,_T("img/shadow_enemy.png"));anim_left=newAnimation(atlas_enemy_left,45);anim_right=newAnimation(atlas_enemy_right,45);// Spawn boundaries for the enemy
enumclassSpawnEdge{Up=0,Down,Left,Right};// Randomly spawn the enemy on one of the four edges
SpawnEdgeedge=(SpawnEdge)(rand()%4);// Generate specific random coordinates
switch(edge){caseSpawnEdge::Up:position.x=rand()%WINDOW_WIDTH;position.y=-FRAME_HEIGHT;break;caseSpawnEdge::Down:position.x=rand()%WINDOW_WIDTH;position.y=WINDOW_HEIGHT;break;caseSpawnEdge::Left:position.x=-FRAME_WIDTH;position.y=rand()%WINDOW_HEIGHT;break;caseSpawnEdge::Right:position.x=WINDOW_WIDTH;position.y=rand()%WINDOW_HEIGHT;break;default:break;}}~Enemy(){deleteanim_left;deleteanim_right;}boolCheckBulletCollision(constBullet&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
boolis_overlap_x=bullet.position.x>=position.x&&bullet.position.x<=position.x+FRAME_WIDTH;boolis_overlap_y=bullet.position.y>=position.y&&bullet.position.y<=position.y+FRAME_HEIGHT;returnis_overlap_x&&is_overlap_y;}boolCheckPlayerCollision(constPlayer&player){// Use the center point of the enemy as the collision point
POINTcheck_position={position.x+FRAME_WIDTH/2,position.y+FRAME_HEIGHT/2};boolis_overlap_x=check_position.x>=player.GetPosition().x&&check_position.x<=player.GetPosition().x+player.FRAME_WIDTH;boolis_overlap_y=check_position.y>=player.GetPosition().y&&check_position.y<=player.GetPosition().y+player.FRAME_HEIGHT;returnis_overlap_x&&is_overlap_y;}voidMove(constPlayer&player){constPOINT&player_position=player.GetPosition();intdir_x=player_position.x-position.x;intdir_y=player_position.y-position.y;doubledir_len=sqrt(dir_x*dir_x+dir_y*dir_y);if(dir_len!=0){doublenormalized_x=dir_x/dir_len;doublenormalized_y=dir_y/dir_len;position.x+=(int)(normalized_x*SPEED);position.y+=(int)(normalized_y*SPEED);}if(dir_x>0)facing_left=false;elseif(dir_x<0)facing_left=true;}voidDraw(intdelta_time){intshadow_pos_x=position.x+(FRAME_WIDTH/2-SHADOW_WIDTH/2);intshadow_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);elseanim_right->Play(position.x,position.y,delta_time);}voidHurt(){alive=false;}boolCheckAlive(){returnalive;}private:constintSPEED=2;constintFRAME_WIDTH=80;constintFRAME_HEIGHT=80;constintSHADOW_WIDTH=48;private:IMAGEimg_shadow;Animation*anim_left;Animation*anim_right;POINTposition={0,0};boolfacing_left=false;boolalive=true;};// Button base class
classButton{public:Button(RECTrect,LPCTSTRpath_imag_idle,LPCTSTRpath_imag_hovered,LPCTSTRpath_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;voidDraw(){switch(status){caseStatus::Idle:putimage(region.left,region.top,&img_idle);break;caseStatus::Hovered:putimage(region.left,region.top,&img_hovered);break;caseStatus::Pushed:putimage(region.left,region.top,&img_pushed);break;}}voidProcessEvent(constExMessage&msg){switch(msg.message){caseWM_MOUSEMOVE:if(status==Status::Idle&&CheckCursorHit(msg.x,msg.y))status=Status::Hovered;elseif(status==Status::Idle&&!CheckCursorHit(msg.x,msg.y))status=Status::Idle;elseif(status==Status::Hovered&&!CheckCursorHit(msg.x,msg.y))status=Status::Idle;break;caseWM_LBUTTONDOWN:if(CheckCursorHit(msg.x,msg.y))status=Status::Pushed;break;caseWM_LBUTTONUP:if(status==Status::Pushed)OnClick();break;default:break;}}protected:virtualvoidOnClick()=0;private:boolCheckCursorHit(intx,inty){returnx>=region.left&&x<=region.right&&y>=region.top&&y<=region.bottom;}private:enumclassStatus{Idle=0,Hovered,Pushed};private:RECTregion;IMAGEimg_idle;IMAGEimg_hovered;IMAGEimg_pushed;Statusstatus=Status::Idle;};classStartGameButton:publicButton{public:StartGameButton(RECTrect,LPCTSTRpath_imag_idle,LPCTSTRpath_imag_hovered,LPCTSTRpath_imag_pushed):Button(rect,path_imag_idle,path_imag_hovered,path_imag_pushed){}~StartGameButton()=default;protected:voidOnClick(){is_game_started=true;mciSendString(_T("play bgm repeat from 0"),NULL,0,NULL);// Play bgm repeatly
}};classQuitGameButton:publicButton{public:QuitGameButton(RECTrect,LPCTSTRpath_imag_idle,LPCTSTRpath_imag_hovered,LPCTSTRpath_imag_pushed):Button(rect,path_imag_idle,path_imag_hovered,path_imag_pushed){}~QuitGameButton()=default;protected:voidOnClick(){is_running=false;}};voidTryGenerateEnemy(vector<Enemy*>&enemy_list);voidUpdateBullets(vector<Bullet>&bullet_list,constPlayer&player);voidDrawPlayerScore(intscore);intmain(){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=newAtlas(_T("img/player_left_%d.png"),PLAYER_ANIM_NUM);atlas_player_right=newAtlas(_T("img/player_right_%d.png"),PLAYER_ANIM_NUM);atlas_enemy_left=newAtlas(_T("img/enemy_left_%d.png"),ENEMY_ANIM_NUM);atlas_enemy_right=newAtlas(_T("img/enemy_right_%d.png"),ENEMY_ANIM_NUM);Playerplayer;vector<Enemy*>enemy_list;vector<Bullet>bullet_list(3);// Only three bullets are needed, so raw pointers are avoided to prevent memory leaks
ExMessagemsg;IMAGEimg_menu;IMAGEimg_background;intscore=0;RECTregion_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;StartGameButtonbtn_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"));QuitGameButtonbtn_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){DWORDstart_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)){staticTCHARtext[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(constBullet&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_ti=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();deleteenemy;}}}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();DWORDend_time=GetTickCount();DWORDdelta_time=end_time-start_time;if(delta_time<1000/FPS){Sleep(1000/FPS-delta_time);}}// Release atlas pointers after the main loop
deleteatlas_player_left;deleteatlas_player_right;deleteatlas_enemy_left;deleteatlas_enemy_right;EndBatchDraw();return0;}voidputimage_alpha(intx,inty,IMAGE*img){intw=img->getwidth();inth=img->getheight();AlphaBlend(GetImageHDC(NULL),x,y,w,h,GetImageHDC(img),0,0,w,h,{AC_SRC_OVER,0,255,AC_SRC_ALPHA});}voidTryGenerateEnemy(vector<Enemy*>&enemy_list){constintINTERVAL=100;staticintcounter=0;if(++counter%INTERVAL==0){enemy_list.push_back(newEnemy());}}// Update bullet postions
voidUpdateBullets(vector<Bullet>&bullet_list,constPlayer&player){// Create a pulsating effect for visual flair
constdoubleRADIAL_SPEED=0.0045;// Speed of radial oscillation
constdoubleTANGENT_SPEED=0.0055;// Speed of tangential rotation
doubleradian_interval=2*PI/bullet_list.size();// Angular spacing between bullets
// Update each bullet's position based on the player's location
POINTplayer_position=player.GetPosition();doubleradius=BULLET_BASE_RADIUS+BULLET_RADIUS_CHANGE_RANGE*sin(GetTickCount()*RADIAL_SPEED);for(size_ti=0;i<bullet_list.size();i++){doubleradian=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));}}voidDrawPlayerScore(intscore){staticTCHARtext[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.