[{"content":"Course Reference and Asset Source：Youtube-Code Monkey\nProject Overview \u0026ldquo;Lua Lander\u0026rdquo; is a 2D physics-based mini game inspired by the classic arcade game Lunar Lander. The core gameplay revolves around carefully piloting a small spacecraft to land safely on an alien surface.\nKey mechanics of the original Lunar Lander include:\nUsing thrust and rotation to control the ship\u0026rsquo;s direction and velocity Avoiding crashes caused by excessive speed or steep landing angles Gently touching down on designated landing pads to earn higher scores Managing limited resources: speed, angle, and fuel \u0026ldquo;Lua Lander\u0026rdquo; is a faithful recreation and expansion by Code Monkey, a well-known game development creator on YouTube. While preserving the original mechanics, the project introduces several enhanced gameplay features:\nMore nuanced landing validation based on both velocity and angle Multiple landing platforms with score multipliers Fuel consumption system that adds flight cost management Coin collection for bonus points Thruster visual effects powered by a particle system and event-driven logic Beyond gameplay, the project also includes system-level improvements:\nA complete UI system A game state machine (Start → Playing → Game Over) Multi-level design using prefabs rather than separate scenes Level preview with Cinemachine and a custom 2D zoom solution Multi-input support (keyboard, gamepad, and touchscreen) Dedicated screens for main menu, pause menu, and game over Integrated sound effects and background music system Learning Goals Code Monkey tends to build games with complex systems, large-scale projects, and high maintainability requirements. Consequently, his tutorials—regardless of difficulty level—consistently adopt professional-grade structures that demonstrate how to integrate clean code principles and industry best practices into real-world Unity projects.\nFrom this project, I aim not only to understand his problem-solving approaches and techniques for writing clean, readable code, but also to learn how to properly architect a game project and cultivate genuine game engineer thinking—laying a solid foundation for building my own complex projects in the future.\nTherefore, I’ll closely follow along with his development process, carefully documenting his methods and design decisions so I can apply them to my own work later.\nDevelopment Breakdown The game was developed roughly in the following sequence:\nPost-processing effects (can be added at any stage) Lander implementation Physics-based movement Visual effects for thrusters Terrain creation Using Sprite Shape Camera follow system Implemented with Cinemachine Background setup Landing logic Landing validation (based on speed and angle) Landing detection on platforms Landing pad prefabs Prefab Variants (platforms with different sizes and score multipliers) Dynamic visual indicator for score multiplier Fuel system Fuel capacity management Lander collecting fuel pickups Coin collection (implemented similarly to fuel) GameManager UI system Stats panel (fuel, score, etc.) Thrust direction indicator Fuel progress bar Game Over UI Final score and stats summary Restart button Game state management Start Restart Game Over Explosion VFX New levels Built using prefabs (not separate scenes) Minimap / overview map Input System Keyboard/button input Touch input Joystick/gamepad support Main Menu Scene Pause UI Triggered by UI button or Esc key Game Over Scene Displays total score Button to return to main menu SoundManager \u0026amp; MusicManager Persistent background music across level transitions Volume control buttons Polish (final touches, tuning, and refinement) Key Insights and Critical Steps PostProcessing Visual enhancements like Bloom and Vignette were applied using Unity’s Volume → Global Volume → Add Override system. This affects both the in-game view and the Scene/Game window background. While only two effects were used here, Unity’s post-processing stack offers many more parameters—exposure, color grading, depth of field, etc.—that can be explored later as needed for mood, polish, or visual clarity.\nSeparation of Visuals and Logic A powerful architectural pattern used in this project:\nCreate an empty root GameObject to hold all logic scripts (movement, physics, state, input handling, etc.). Attach the Sprite Renderer (or any visual element) as a child object, solely responsible for rendering. Additional visual elements—like particle effects, trails, or decals—are also added as separate child objects. This approach, though simple, delivers significant benefits:\nDecoupled logic and presentation Logic scripts no longer depend on visual components (e.g., SpriteRenderer, Animator). They operate purely on data and state, making code cleaner, more testable, and easier to debug. Effortless visual iteration Swapping sprites for animations, 2D art for 3D models, or changing visual styles becomes trivial—just replace or modify child objects. No need to touch core logic. Independent visual transformations The visual child can rotate, scale, shake, or animate without affecting the parent’s stable transform—critical for effects like screen shake, hit reactions, or spinning thrusters while maintaining consistent physics behavior. Precise control over collision and logic origin The root object’s transform serves as the true “center” for physics, AI, or gameplay logic, unaffected by artistic offsets in sprite pivots or mesh origins. Prefab flexibility and reuse The same logic prefab can be paired with different visual variants (e.g., themed skins, difficulty-based appearances) simply by swapping child prefabs—ideal for scalable, maintainable projects. Code Clarity Matters Even when optional, explicitly declare access modifiers (e.g., private, public, protected). While C# defaults class members to private and interface methods to public, writing them out improves readability—especially in team environments or when reviewing code months later. Clear intent = fewer bugs and faster onboarding.\nInput Handling in Unity As of Unity 6, both input systems remain available:\nThe legacy Input Manager (Input.GetKey, etc.) The newer, more robust Input System package (with support for keyboard, gamepad, touch, and custom devices) This project uses the new Input System, which enables:\nUnified handling of multiple input devices (keyboard, controller, touchscreen) Clean separation of input actions from gameplay logic Runtime rebinding and better platform portability For future projects targeting multiple platforms (especially mobile + desktop + console), adopting the new Input System is strongly recommended—it aligns with modern Unity best practices and scales far better than the legacy approach.\n1 2 3 4 5 6 7 8 9 10 11 12 13 private void Update() { // Previous version if(Input.GetKey(KeyCode.UpArrow)) { Debug.Log(\u0026#34;Up\u0026#34;); } // Unity6 if(Keyboard.current.UpArrowKey.isPressed) { Debug.Log(\u0026#34;Up\u0026#34;); } } Input Placement Generally, input should be placed in Update(), because Update checks every frame for input. However, in this project, input can be directly placed in FixedUpdate() because we’re using isPressed—meaning that as long as a key is held down (isPressed / GetKey), it will still be detected even when checked at the fixed timestep of FixedUpdate.\nHowever, if you’re using APIs that detect the exact moment a key is pressed or released (GetKeyDown / GetKeyUp), it’s recommended to place them in Update(), as they might be missed in FixedUpdate.\nAdditionally, any physics-related updates must always be placed in FixedUpdate().\nIf you must put movement logic in Update(), always multiply by Time.deltaTime to ensure consistent speed across computers with different framerates. Although you don’t need to multiply by Time.fixedDeltaTime in FixedUpdate, adding it doesn’t hurt.\nCoordinate System Setup Because we’ve adopted the visual/logic separation approach, we must set the gizmo coordinate system to Pivot and Local.\nIf set to Center, the pivot point becomes the center between the parent and child objects. Setting it to Pivot ensures each object uses its own origin as the center.\nIn this project, we want the lander to move upward relative to its own orientation when the up arrow key is pressed. Therefore, the coordinate reference must be set to Local. If set to Global, pressing the up arrow would always move the lander toward the screen’s top, regardless of its current rotation.\nLanding Implementation Logic Landing validation has two aspects:\nThe ship must be nearly upright upon landing. The ship must land gently (at low speed). For landing softness, we use Collision.relativeVelocity—a read-only property of the Collision class that represents the relative linear velocity between the two colliding objects.\nFor landing angle validation, we use the dot product between two vectors: the object’s local up vector (transform.up) and the world’s up vector (Vector2.up).\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 //============ Lander.cs =========== private void OnCollisionEnter2D(Collision2D other) { float softLandingVelocityMagnitude = 3f;\t// Set based on in-game performance metrics if (other.relativeVelocity.magnitude \u0026gt; softLandingVelocityMagnitude) { print(\u0026#34;Landing too hard!\u0026#34;); return; } float minDotVector = 0.92f;\t// Set based on in-game performance metrics float dotVector = Vector2.Dot(Vector2.up, transform.up); print(dotVector); if (dotVector \u0026lt; minDotVector) { print(\u0026#34;landing angle too steep!\u0026#34;); return; } print(\u0026#34;Safe landing!\u0026#34;); } How to determine appropriate threshold values? If you’re unsure what value to use for a certain condition, print it out in the console and observe the actual values during gameplay under different scenarios. In this project, the landing/crash thresholds were determined exactly this way.\nDon’t Use Strings to Identify Game Objects Strings should only be used for display text—never for finding objects or relying on Unity’s built-in Tags. It’s extremely error-prone: a typo, incorrect capitalization, or an accidental trailing space will cause the lookup to fail silently.\nUse component-based queries instead.\n1 2 3 4 5 // Identify whether it\u0026#39;s a landing pad if (other.gameObject.TryGetComponent\u0026lt;LandingPad\u0026gt;(out LandingPad landingPad)) { print(\u0026#34;landing pad\u0026#34;); } Thruster Visual Effect Implementation Again, for the sake of decoupling, Lander.cs should contain no visual-related code. Therefore, events are used to trigger effects.\nAdditionally, the Particle System—like transform.position—is read-only, meaning you cannot modify it directly. Instead, you must access and adjust it indirectly through its EmissionModule:\n1 2 ParticleSystem.EmissionModule emissionModule = leftThrusterParticalSystem.emission; emissionModule.enabled = false; Time.deltaTime and Time.fixedDeltaTime Using deltaTime in Update() Purpose: Compensate for variable frame rates Update() is called at a frequency dependent on the current frame rate (e.g., 30fps, 60fps, 144fps) deltaTime records the actual time elapsed since the last frame Multiplying movement by deltaTime ensures consistent real-world speed across different frame rates Using Time.fixedDeltaTime in FixedUpdate() Purpose: Enable physics simulation with a fixed timestep FixedUpdate() is called at fixed intervals (default: 0.02 seconds, i.e., 50 times per second), independent of frame rate Time.fixedDeltaTime holds this fixed interval value (usually constant) The physics engine requires a fixed timestep to ensure stable and predictable simulations Key Differences Update() + deltaTime FixedUpdate() + fixedDeltaTime Time Interval Variable (frame-rate dependent) Fixed (default 0.02 seconds) Purpose Compensate for frame-rate differences Ensure stable physics simulation Use Case Rendering, input, non-physics logic Physics calculations, Rigidbody operations EventHandler Because this project is relatively small, and GameManager centrally manages all game data while Lander is the core gameplay object, directly referencing GameManager as a singleton from Lander.cs would be acceptable (tight coupling between Lander and GameManager).\nHowever, an alternative—and more decoupled—approach is to use event listening: GameManager reacts to events raised by Lander.cs via EventHandler. This project adopts the latter strategy.\n1 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 //=============== Lander.cs =============== private void OnTriggerEnter2D(Collider2D other) { // ... if (other.gameObject.TryGetComponent(out CoinPickup coinPickup)) { OnCoinPickup?.Invoke(this, EventArgs.Empty); coinPickup.DestroySelf(); } } //============== GameManager.cs ============= public class GameManager : MonoBehaviour { private int score; [SerializeField] private Lander lander; private void Start() { lander = lander.GetComponent\u0026lt;Lander\u0026gt;(); // Since we\u0026#39;re getting a component from another unrelated GameObject, do it in Start() lander.OnCoinPickup += Lander_PickupCoin; } private void Lander_PickupCoin(object sender, EventArgs e) { AddScore(500); } private void AddScore(int addScoreAmount) { score += addScoreAmount; print(score); } } Even though the parameters object sender and EventArgs e aren’t used inside Lander_PickupCoin, they must remain in the method signature because:\nEvent signature matching: The lander.OnCoinPickup event expects a handler with this exact signature C# event pattern: This follows the standard .NET EventHandler delegate convention Removing these parameters would cause a compilation error.\nPurpose of the parameters:\nobject sender: Identifies which object triggered the event (here, the lander instance) EventArgs e: Carries event-specific data (e.g., coin value) If you explicitly want to indicate these parameters are unused, you can use discard syntax:\n1 2 3 4 private void Lander_PickupCoin(object _, EventArgs _) { AddScore(500); } Example of using parameters in EventHandler\nFor instance, when implementing “award different scores based on landing quality”:\n1 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 //========== Lander.cs ========== public event EventHandler\u0026lt;OnLandedEventArgs\u0026gt; OnLanded; // / Different landing outcomes yield different scores, so pass score via EventArgs public class OnLandedEventArgs : EventArgs { public int score; } // Landing on a platform private void OnCollisionEnter2D(Collision2D other) { // ... int score = Mathf.RoundToInt(landingSpeedScore + landingAngleScore) * landingPad.GetScoreMultiplier(); print(\u0026#34;score: \u0026#34; + score); OnLanded?.Invoke(this, new OnLandedEventArgs\t// Pass score when invoking { score = score, }); } //============ GameManager.cs =========== public class GameManager : MonoBehaviour { // ... // Actually use the score parameter private void Lander_OnLanded(object sender, Lander.OnLandedEventArgs e) { AddScore(e.score); } } Temporarily Adding Color to Text 1 titleTxtMesh.text = \u0026#34;\u0026lt;color=#ff0000\u0026gt;CRUSH!\u0026lt;/color\u0026gt;\u0026#34;; Using a State Machine to Manage Game States Define game states using an enum, then link them to input. This is implemented directly in the Lander/Player script and remains quite straightforward.\n1 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 //============ Lander.cs ============ public enum State { WaitingToStart, Normal, Gameover } private void FixedUpdate() { switch(state) { default: case State.WaitingToStart: if (Keyboard.current.upArrowKey.isPressed || Keyboard.current.rightArrowKey.isPressed || Keyboard.current.leftArrowKey.isPressed) { // Start game on any key press landerRigidbody2D.gravityScale = GRAVITY_NORMAL; SetState(State.Normal); } break; case State.Normal: if (fuelAmount \u0026lt;= 0) return; if (Keyboard.current.upArrowKey.isPressed || Keyboard.current.rightArrowKey.isPressed || Keyboard.current.leftArrowKey.isPressed) { ConsumeFuel(); } if (Keyboard.current.upArrowKey.isPressed) { landerRigidbody2D.AddForce(force * transform.up * Time.fixedDeltaTime); // Broadcast event OnUpForce?.Invoke(this, EventArgs.Empty); } if (Keyboard.current.leftArrowKey.isPressed) { landerRigidbody2D.AddTorque(turnSpeed * Time.fixedDeltaTime); OnLeftForce?.Invoke(this, EventArgs.Empty); } if (Keyboard.current.rightArrowKey.isPressed) { landerRigidbody2D.AddTorque(-turnSpeed * Time.fixedDeltaTime); OnRightForce?.Invoke(this, EventArgs.Empty); } break; case State.Gameover: break; } } Explosion Effect Implementation:\nThis is also handled via events. However, note that the subscription to OnLanded (which triggers the explosion) is registered in Start(), not in Awake() like the thruster effects—due to three reasons:\nOne-time event: It only fires once during the entire game Potential dependencies: Explosion effects or game state systems may rely on other objects initialized in their own Awake() No immediate need: Landing doesn’t occur instantly at game start Summary: Benefits of registering the explosion event in Start():\nCode organization: Separates initialization of frequent events (Awake) from one-off game logic (Start) Avoids potential issues: Ensures more predictable subscription order if other scripts also listen to OnLanded in their Awake() Button Click Handling The project doesn’t use Unity’s “bind button in the Inspector” approach. Instead, button callbacks are registered directly in code:\n1 2 3 4 5 6 7 8 9 [SerializeField] private Button nextBtn; private void Awake() { nextBtn.onClick.AddListener(() =\u0026gt;\t// Lambda syntax is used here since there are no parameters or return values—this is the most concise way { SceneManager.LoadScene(0); }); } After adding multiple levels, a callback function determines whether clicking the button loads the next level or restarts the current one. An Action delegate is used to store the function reference—not for its event capabilities, but for its flexibility in assignment.\n1 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 //=========== LandedUI.cs ============ public class LandedUI : MonoBehaviour { // ... [SerializeField] private Button nextBtn; private Action nextBtnClickAction; private void Awake() { nextBtn.onClick.AddListener(() =\u0026gt; { nextBtnClickAction(); }); } private void Start() { Lander.Instance.OnLanded += Lander_OnLanded; Hide(); } private void Lander_OnLanded(object sender, Lander.OnLandedEventArgs e) { if(e.landingType == Lander.LandingType.Success) { titleTxtMesh.text = \u0026#34;SUCCESSFUL LANDING!\u0026#34;; nextBtnClickAction = GameManager.Instance.GoToNextLevel;\t// Assign different callback based on landing outcome } else { titleTxtMesh.text = \u0026#34;\u0026lt;color=#ff0000\u0026gt;CRUSH!\u0026lt;/color\u0026gt;\u0026#34;; nextBtnClickAction = GameManager.Instance.RetryLevel; } // ... } } Handling Unchanging Level Number The levelNumber was initially a regular instance variable. However, since GameManager gets destroyed when a scene reloads, the variable resets to its default value (e.g., 1), preventing progression to the next level.\nThe solution used in the tutorial is to declare levelNumber as static. This keeps it alive in memory across scene reloads. When the new GameManager instance is created in the reloaded scene, it reads the preserved static value and correctly loads the next level.\nShowing a Full-Map Preview at Level Start Since each level has a different map layout, this script must be attached to every level scene. It controls the CinemachineCamera to switch the follow target between the pre-game overview state and the in-game lander-following state.\nUnity’s 2D Cinemachine setup (CinemachineVirtualCamera + Framing Transposer 2D) does not include built-in zoom functionality. Therefore, a custom script is required to implement zoom behavior.\nIn contrast, in 3D mode, CinemachineVirtualCamera provides built-in zoom via the Lens → Field of View (FOV) or Orthographic Size properties, which can be directly adjusted for zoom effects.\nCreate an empty GameObject named CinemachineCamera2D and attach the custom zoom script to it 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 //============== CinemachineCameraZoom2D.cs =============== public class CinemachineCameraZoom2D : MonoBehaviour { private const int NORMAL_ORTHOGRAPHIC_SIZE = 10; public static CinemachineCameraZoom2D Instance { get; private set; } [SerializeField] private CinemachineCamera cinemachineCamera; private float targetOrthographicSize = 10f; private void Awake() { Instance = this; } private void Update() { cinemachineCamera.Lens.OrthographicSize = targetOrthographicSize; } public void SetTargetOrthographicSize(float targetOrthographicSize) { this.targetOrthographicSize = targetOrthographicSize; } public void SetNormalOrthographicSize() { SetTargetOrthographicSize(NORMAL_ORTHOGRAPHIC_SIZE); } } Set the zoom-out value in GameLevel so each level can be configured flexibly 1 2 3 4 5 6 7 8 9 10 11 12 //============== GameLevel.cs =============== public class GameLevel : MonoBehaviour { [SerializeField] private float zoomedOutOrthographicSize; // Put this value in GameLevel so each level can set it freely public float GetZoomOutOrthographicSize() { return zoomedOutOrthographicSize; } // ... } Configure it in GameManager When the level is loaded and the player has not started yet, let Cinemachine zoom out to show the full view. When the player presses a key to start, reset the Cinemachine camera range back to normal.\n1 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 //============== GameManager.cs ============== public class GameManager : MonoBehaviour { // ... private void LoadCurrentLevel() { foreach (GameLevel level in gameLevelList) { if (level.GetLevelNumber() == levelNumber) { GameLevel spawnedLevel = Instantiate(level, Vector3.zero, Quaternion.identity); Lander.Instance.transform.position = spawnedLevel.GetLanderStartingPos(); // Use Cinemachine to show the map // Essentially set Cinemachine’s tracking target to the map center // instead of following the lander right away cinemachineCamera.Target.TrackingTarget = spawnedLevel.GetCameraStartTargetTransform(); // zoom out CinemachineCameraZoom2D.Instance.SetTargetOrthographicSize(spawnedLevel.GetZoomOutOrthographicSize()); } } } private void Lander_OnStateChange(object sender, Lander.OnStateChangeEventArgs e) { // ... // After the game starts, Cinemachine follows the lander cinemachineCamera.Target.TrackingTarget = Lander.Instance.transform; // Cancel zoom out, restore normal camera settings CinemachineCameraZoom2D.Instance.SetNormalOrthographicSize(); } } Smooth transition when switching camera 1 2 3 4 5 6 7 8 9 //============== CinemachineCameraZoom2D.cs =============== private void Update() { // Smooth camera transition using Lerp float zoomSpeed = 2f; cinemachineCamera.Lens.OrthographicSize = Mathf.Lerp(cinemachineCamera.Lens.OrthographicSize, targetOrthographicSize, Time.deltaTime * zoomSpeed); } Input improvement (support multiple input systems) Create your own InputActions =\u0026gt; Edit Asset =\u0026gt; Action Maps. In Action Maps, add inputs for Player, and bind keys for the three player actions (up / left / right). Then check the box to create a C# class. This way you can control it directly through scripts. This is the method recommended by Code Monkey, instead of using strings. As a result, the InputActions we created generate their own C# script.\nCreate your own GameInput script Create an empty GameObject and attach a new script to it. This script acts as a middle layer, so other scripts don’t directly depend on InputActions.cs (tight coupling). The benefits are:\nDecoupling – Game logic does not directly depend on InputActions implementation Single entry point – All input is managed in one place Easy testing – Input can be easily mocked (Mock GameInput) Flexible extension – Future features like input recording or replay only require changes in GameInput Extension Drawbacks of the old Input System Unity’s old input system (Input.GetKey(), Input.GetAxis(), etc.) has these problems:\nHard-coded input checks – Keys are fixed in code (e.g., Input.GetKey(KeyCode.W)) Difficult to rebind – Players cannot customize key bindings Poor multi-device support – Handling controllers, touch screens, etc. is complicated Lack of flexibility – Input logic is scattered everywhere, hard to maintain Advantages of the new Input System InputActions provide:\nConfig-driven – Configure input via the visual editor, no code changes needed Device-independent – One Action can support keyboard, controller, and touch at the same time Easy rebinding – Change key bindings dynamically at runtime Better performance – Event-driven instead of frame-by-frame polling Good practice Since input is also a kind of resource, you need to call Enable and Disable properly:\nWhen calling Enable, internally it will:\nRegister listeners with Unity’s input system Start receiving input events from hardware (keyboard, controller, etc.) Consume system resources and memory When calling Disable, internally it will:\nUnregister all listeners Stop processing input events Release related resources Touch input GamePad binding and keyboard binding are the same. Touch input is slightly different.\nCreate an empty GameObject TouchUI under Canvas Create three UIImage circles under TouchUI Add On-Screen Button components to the three circles Use the simulator to preview effects on different touch devices JoyStick Input Unlike button input, the Action type for joystick needs to be set to Value =\u0026gt; Vector2.\nJust like button input, you simply add it in the corresponding script.\nUsing SceneLoader to manage scenes To avoid using “magic numbers” like SceneManager.LoadScene(0); when loading scenes, we use a script to control scene loading.\nSince it will exist globally, we set it as a static class that does not inherit from MonoBehaviour. Note that in this case, all members inside the class must also be static.\nIn addition, the tutorial avoids using magic numbers or direct strings by setting scenes as an enum.\n1 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 //============ MainMenuUI.cs ============ public class MainMenuUI : MonoBehaviour { [SerializeField] private Button playBtn; [SerializeField] private Button quitBtn; private void Awake() { playBtn.onClick.AddListener(() =\u0026gt; { SceneLoader.LoadScene(SceneLoader.Scene.GameScene); }); quitBtn.onClick.AddListener(() =\u0026gt; { Application.Quit(); }); } } //============== SceneLoader.cs =============== public static class SceneLoader { public enum Scene { MainMenuScene, GameScene } public static void LoadScene(Scene scene) { SceneManager.LoadScene(scene.ToString()); } } Keep background music from restarting when switching levels You can handle this by making the music playback time static. (In this tutorial, any data that needs to stay consistent across levels is solved by setting it as static.)\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 public class MusicManager : MonoBehaviour { private AudioSource musicAudioSource; private static float musicTime = 0f; private void Awake() { musicAudioSource = GetComponent\u0026lt;AudioSource\u0026gt;(); musicAudioSource.time = musicTime; } private void Update() { musicTime = musicAudioSource.time; } } Extension About event unregistration In this project, events are used in many places to achieve decoupling. However, in most cases there is no unregistration. This is not best practice.\nIt is temporarily safe not to unregister in the following situations:\nSame lifecycle: LanderVisual and Lander components are on the same GameObject 1 lander = GetComponent\u0026lt;Lander\u0026gt;(); // same object Destroyed together: when the lander explodes, the whole GameObject is disabled 1 gameObject.SetActive(false); // LanderVisual and Lander are disabled together Unity automatically cleans up these references when the object is destroyed Best practice is to unregister events when the object is destroyed:\n1 2 3 4 5 6 7 8 9 10 11 private void OnDestroy() { if (lander != null) { lander.OnUpForce -= Lander_OnUpForce; lander.OnLeftForce -= Lander_OnLeftForce; lander.OnRightForce -= Lander_OnRightForce; lander.OnBeforeForce -= Lander_OnBeforeForce; lander.OnLanded -= Lander_OnLanded; } } Cases where event unregistration is required The subscriber’s lifecycle is shorter than the publisher’s The subscriber may be created/destroyed multiple times (without unregistration, it will cause duplicate subscriptions) Cross-scene events Making a habit of unregistering events is safer. It helps avoid:\nMemory leaks Duplicate triggers Hard-to-track bugs Always follow this rule: Unregister in the corresponding cleanup method where you registered.\nWhere to unregister Unregistration usually happens in Destroy or Disable. Which one to choose? It depends on the timing of event unregistration.\nCore principle: pairing\nEvent registration and unregistration should be paired in the corresponding lifecycle methods:\nRegistration Unregistration Usage scenario Awake() / Start() OnDestroy() Register once during object lifecycle OnEnable() OnDisable() Object may be enabled/disabled repeatedly Separating visual and logic In this project, Code Monkey attaches all visual scripts to the main GameObject, while the sprite is just a clean image. This approach is suitable for:\nSmall projects Simple visuals No skin system No complex animations No multi-layer visual components Low coupling between logic and visuals Another approach is to put visual scripts on child objects of the sprite. This is common in larger projects, suitable for:\nCharacters with multi-layer visuals Skin systems Animation state machines UI bindings Particles, effects, lighting Multiplayer sync (visuals not involved in sync) Advantages:\nComplete decoupling Visuals can be replaced Logic can be reused Prefabs are more modular Better suited for large projects Polish Code Monkey repeatedly emphasizes the importance of polish, and I agree. However, my main purpose in taking this course is to see how professional indie developers use clean code and modular design to build loosely coupled, scalable games. So for now, I will skip the polish part.\nI plan to take a dedicated course later to learn polish techniques.\nHere I note the plugins Code Monkey used in the course, so I can reference them when needed:\nFeel（Code Monkey有review视频） All in 1 Sprite Shader （Code Monkey有review视频） Text Animatior Hot Reload Code Monkey Toolkit Review and Next Steps Even though this is just an entry-level course, I still learned a lot. The main points include:\nSeparation of visual and logic\nFor a project of this size, no scripts are attached to the sprite; everything is placed in an empty GameObject\nSingle Responsibility Principle\nEach class and function is relatively small, responsible for only one thing\nObserver Pattern\nUsed extensively for decoupling, including UI, sound effects, etc\nAdvantages of using C# standard EventHandler\nBesides proper naming, it allows adding parameters, which is sometimes very useful\nInput System usage\nCompared to the old input system, the new one requires almost no code changes when connecting different input devices, which is very convenient\nUsing prefabs to add levels\nThis is the first time I learned this method. Summary comparison between using Scene and Prefab for level design:\nItem Scene for levels Prefab for levels Best suited for Large scenes, story levels, worlds Multi-level, repeated structures, procedural, roguelike Loading method Switch scenes (LoadScene) Instantiate Prefab in the same scene Editing experience Intuitive, what you see is what you get Modular, composable Performance Scene switching has overhead Prefab instantiation is faster Reusability Low (each Scene is independent) High (Prefabs can be reused) Dynamic generation Not convenient Very convenient Team collaboration Scenes prone to conflicts Prefabs less likely to conflict Number of levels Few levels Many levels Next Steps:\nThe original course also includes some bonus game mechanics:\nCargo transport Key switches Wind zones Dynamic meteors Turret shooting I plan to set these aside for now and try implementing them myself after more study and practice.\nIn addition, Code Monkey is the first professional game developer I’ve followed. I will study courses from two more professional developers, learn their coding styles and overall game architecture, and then try to form my own style.\n","date":"2026-01-31T08:43:30+08:00","image":"https://nullshowjl.github.io/en/p/unitylearning-commercial-grade-coding-from-a-beginner-mini-game/cover_hu_8984f1a785aad36c.jpg","permalink":"https://nullshowjl.github.io/en/p/unitylearning-commercial-grade-coding-from-a-beginner-mini-game/","title":"【Unity】Learning Commercial-Grade Coding from a Beginner Mini Game"},{"content":"Course Reference：Udemy-Niraj Vishwakarma\nAsset Sources：\nUnity Asset Store-HobiSoLoved（Background）\nUnity Asset Store-Hippo （Tanks）\nUnity Asset Store-Crehera（Drones）\nUnity Asset Store-kΩsmaragd（UI）\nIntroduction of SOLID Principles Spaghetti Code This kind of code is basically a tangled mess where everything is tightly coupled. For example, in a script like PlayerController.cs, you might see the player’s Move(), Attack(), Jump()… all crammed together, along with extra functions like PlayAudio.\nCode written this way is fragile and hard to extend. It’s like spaghetti — once everything is tangled up, pulling on one strand can easily cause multiple bugs to appear.\nSolution:\nDitch the spaghetti‑style structure and switch to a modular design.\nSpaghetti code can be fine when you’re quickly prototyping a demo or building a small game to test ideas. But if you expect the project to grow, with more game objects and features being added, setting up a solid architecture and modular design early on becomes crucial.\nWhat Are the SOLID Principles? In short, there are five main principles:\nSingle Responsibility Principle (SRP) Open-Closed Principle (OCP) Liskov Substitution Principle (LSP) Interface Segregation Principle (ISP) Dependency Inversion Principle (DIP) Single Responsibility Principle (SRP) Core Idea One script, one function. SRP breaks down big classes into smaller ones, each with its own independent responsibility. These small classes can be reused and don’t interfere with each other. Think of them like Lego bricks — you can combine them to build all kinds of larger models.\nBenefits When every script has a clear responsibility, you can swap things out freely without worrying about breaking other parts of the game.\nOpen/Closed Principle (OCP) Core Idea Classes, modules, and functions should be open for extension but closed for modification.\nBenefits You can add new features by creating new classes, without messing with the independence of existing ones. This makes your code more modular and easier to maintain.\nHow to Apply Create new classes for new features. Define interfaces or abstract classes, then implement them in your feature classes. Interact with features through abstract methods instead of calling them directly. Make good use of polymorphism. Liskov Substitution Principle (LSP) Core Idea A superclass (or parent class) should be replaceable by its subclasses without breaking functionality.\nBenefits This allows you to use subclasses in place of the parent class without directly referencing each one. It ensures flexibility and scalability when new variables or behaviors are introduced.\nHow to Apply Create a base class and declare all the methods. Build subclasses for objects with similar behaviors. In other scripts, reference the parent class instead of the subclasses directly. Interface Segregation Principle (ISP) Core Idea A class shouldn’t depend on methods it doesn’t use.\nSummary Write only what’s needed, and keep it relevant.\nDependency Inversion Principle (DIP) Core Idea High‑level modules shouldn’t be coupled to low‑level modules. Both should depend on abstractions.\nSummary Depend on abstract functions, not concrete implementations.\nHow to Apply Use interfaces to define abstract functions. Implement those interfaces in concrete classes. Interact with concrete classes through the interfaces. Practice Project We’ll use a 2D Tank vs. Drone War game to show how the SOLID principles can be applied in Unity game development.\nCore Gameplay:：\nOn the left side of the screen, there are two tanks: A light tank, controlled by pressing L A heavy tank, controlled by pressing H The light tank can move left and right, rotate its cannon, and fire light shells. The heavy tank cannot move, but it can rotate its cannon and fire heavy shells. On the right side of the screen, drones move leftward. Hitting them increases the score. Lose condition: If a drone collides with a tank, that tank is destroyed and becomes unresponsive to player input. When both of the tanks are unresponsive, the player loses. Game / Feature Breakdown Content Background image\nTanks (light and heavy)\nBullets (light and heavy)\nDrones\nUI (score, start screen, gameplay screen, end screen)\nSound effects (firing, drone hit, tank hit)\nLogic When the game starts, pressing L activates the light tank (move, rotate, fire shells). Pressing H activates the heavy tank (rotate, fire shells). Drones fly from right to left — entering from the right side of the screen and exiting on the left. If a tank is hit by a drone, it becomes inactive. If both tanks are inactive, the game ends. Module Organization Development Steps Scene Setup\nTank Development\nMovement and rotation Firing Switching between light and heavy tanks Health system Drone Development\nDrone prefab Drone spawning Health system Game Management\nConfigure game events AudioManager UIManager GameManager Feature Implementation and Principles Followed 1. Tank Movement In the tank design, we follow ISP (Interface Segregation Principle) and DIP (Dependency Inversion Principle).\n2. Tank Firing When designing the firing system, the two types of projectile follow the Open/Closed Principle (OCP). Each time we add a new type of projectile, we should extend the system rather than modify TankFire.cs. Inside TankFire.cs, the firing logic should interact through an interface instead of directly calling the specific shell prefab.\nIt’s important to note that both types of projectiles interact with TankFire.cs through an interface. However, since the interface does not inherit from MonoBehaviour, it cannot be instantiated directly. To solve this, you need to implicitly cast it to a Component.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 public class TankFire : MonoBehaviour { [SerializeField] private IProjectile iProjectile; [SerializeField] private Component projectileComponent; [SerializeField] private Transform firePos; private void Start() { iProjectile = projectileComponent as IProjectile; } private void Update() { if (Input.GetKeyDown(KeyCode.Space)) { if (iProjectile != null) iProjectile.Fire(firePos); } } 3. Tank Selector Implementation This part is designed following the Liskov Substitution Principle (LSP).\nFor smaller projects, you don’t necessarily need separate LightEngine.cs and HeavyEngine.cs files — you can simply put everything inside the TankEngine class.\n4. Tank Health Implementation Following the Dependency Inversion Principle (DIP), we use an IDamageable interface to interact with concrete classes. These concrete classes could be drones or other colliding objects such as bullets, missiles, and so on.\n5. Airplane Movement This part reuses the ImoveUp.cs code in the same way as tank movement. It’s also implemented through an interface, leaving room for adding new types of drones in the future.\n6. Automatic Destroy of Drones and Projectiles Here I use a single script, DestroyByTime.cs, following the Single Responsibility Principle (SRP). Unlike the course approach of handling each case separately, this unified script keeps the design simpler and cleaner.\n7. Collision Detection and Health System Integration (Tanks, Projectiles, Drones) This is the final application of the Dependency Inversion Principle (DIP) mentioned earlier. Since both projectiles and drones are destroyed upon collision, they don’t directly implement the IDamageable interface. Instead, the tank’s health system implements the interface. This way, even if new projectile types are added in the future with different damage values, they can be easily integrated. For drones or other enemy types, if different damage behaviors are needed, they can also be flexibly designed by inheriting from the interface.\n1 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 //========== IDamageable Interface ========== public interface IDamageable { public void TakeDamage(float damageValue); } //================ Health System of Tank =============== public class HealthSystem : MonoBehaviour, IDamageable { private float _health = 100f; [SerializeField] private Image healthProgressBar; [SerializeField] private TankEngineBase currentTankEngine; public void TakeDamage(float damageValue) { _health -= damageValue; if (_health \u0026lt;= 0) { _health = 0; currentTankEngine.StopEngine(); } float progressBarValue = _health / 100; healthProgressBar.fillAmount = progressBarValue; } } //================ Projectile Health ================= public class ShellHealth : MonoBehaviour { [SerializeField] private float damage = 100f; private void OnCollisionEnter2D(Collision2D other) { IDamageable damageable = other.gameObject.GetComponent\u0026lt;IDamageable\u0026gt;(); if(damageable != null) { damageable.TakeDamage(damage); } Destroy(gameObject); } } //============= Drone Health ============== public class DroneHealth : MonoBehaviour { [SerializeField] private float damage = 20f; private void OnCollisionEnter2D(Collision2D other) { // Call TakeDamage by Interface IDamageable damageable = other.gameObject.GetComponent\u0026lt;IDamageable\u0026gt;(); if (damageable != null) { damageable.TakeDamage(damage); } Destroy(gameObject); } } 8. Managing UI, Audio, and Game State with the Observer Pattern Register the following events so that, through a unified update interface, observers can be notified and respond whenever changes occur. At its core, this is a listen‑and‑respond system.\nBy following the Single Responsibility Principle (SRP), scripts like AudioManager.cs and UIManager.cs only handle their specific tasks — playing sounds or managing the UI — without mixing in unrelated logic.\nSuggestions on Applying SOLID Principles Start with the easier solution Never force yourself to use SOLID principles — apply them only when needed (this is my takeaway) Avoid over‑designing, especially when your design goes beyond the scope of the project itself Always try to use a modular approach, so each independent module can be reused like LEGO blocks (this is one of the most useful methods I’ve learned from other courses — it really saves time and effort) At first, applying SOLID principles in code design will definitely take more time, but for projects with long‑term scalability, the investment is absolutely worth it Apply → Fail → Learn → Apply → and you’ll be surprised by the results! Project Expansion \u0026amp; Retrospective Expansion The following features were added based on the original project:\nAdded Medium Tank Controlled by pressing the \u0026ldquo;M\u0026rdquo; key Can move up and down, but slower and with a smaller range than the Light Tank Different Bullet Types Based on Tank Type: Laser, Bullet, Bomb Light Tank: Fires lasers (shortest cooldown), deals the least damage, shoots 5 beams in a fan shape Medium Tank: Fires bullets (medium cooldown), deals moderate damage Heavy Tank: Fires rockets (longest cooldown), deals the highest damage Added Heavy Drone Moves slightly up and down randomly while flying Adjusted Drone Spawn Rules to Increase Difficulty Over Time At the start, heavy drone have a low spawn rate, which increases as time goes on Different Tanks Take Different Amounts of Damage Heavy tanks are the most durable Project Architecture Overview The project uses a hybrid design combining Component-Based Architecture + Event-Driven Programming + Strategy Pattern.\nFolder Structure 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 Assets/ ├── Audio/ # Sound effects (shooting, explosions, damage) ├── Fonts/ # Font assets ├── OtherResources/ # Third-party art assets ├── Prefabs/ # Prefabs │ ├── Drone/ # Drone prefabs (Light, Heavy) │ ├── Projectile/ # Projectile prefabs (3 types) │ └── Tank/ # Tank prefabs (Light, Medium, Heavy) ├── Scenes/ # Scene files ├── Scripts/ # Core scripts │ ├── Drone/ # Drone logic │ ├── GameUtil/ # Game event system │ ├── Mgr/ # Managers (Game, Audio, UI) │ ├── Projectile/ # Projectile logic │ └── Tank/ # Tank logic │ ├── Engine/ # Engine/selection system │ ├── Fire/ # Firing system │ ├── Health/ # Health system │ └── Move/ # Movement system └── Sprites/ # Sprite assets Architecture Overview Module Responsibility Core Classes Tank/Engine Tank engine switching system; player can switch tank types using L/M/H keys TankEngineBase → TankEngineLight/Medium/Heavy, TankEngineSelector Tank/Move Tank movement system; each tank type has its own movement logic TankMoveBase → LightTankMove/MediumTankMove/HeavyTankMove, IMoveY, TankRotate Tank/Fire Firing system; different projectile types implemented via IProjectile interface TankFire, IProjectile → LightShell/MediumShell/RocketShell/LaserShellProjectile Tank/Health Health system; unified damage handling via IDamageable interface HealthSystem, IDamageable Drone Enemy drone system, including spawning, movement, and damage handling DroneMoveBase → LightDroneMove/HeavyDroneMove, DroneHealth, DroneSpawner Mgr Global managers GameMgr (game flow), UIMgr (UI panels/score), AudioMgr (sound effects) GameUtil Global event bus GameEvents (static event class) Architecture diagram Code Reuse Observer Pattern The Observer pattern usually follows the same routine. Take AudioManager as an example:\n1 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 //======== 1. Register events ========= //========= GameEvents.cs ========= public static class GameEvents { public static Action OnGameStarted; public static Action OnGameOver; public static Action OnTankFired; public static Action OnTankDamaged; public static Action OnDroneDestroy; } //========= 2. Listen to events wherever they are needed ========= //========= TankFire.cs ========= private void Update() { if (Input.GetKeyDown(KeyCode.Space)) { GameEvents.OnTankFired?.Invoke(); if (projectile != null) { projectile.Fire(firePos); } } } //========== HealthSystem.cs ========== public void TakeDamage(int damageValue) { GameEvents.OnTankDamaged?.Invoke(); _health -= damageValue; if (_health \u0026lt;= 0) { _health = 0; currentTankEngine.StopEngine(); } float progressBarValue = _health / 100; healthProgressBar.fillAmount = progressBarValue; } //========== DroneHealth.cs =========== public void TakeDamage(int damageValue) { GameEvents.OnDroneDestroy?.Invoke(); // The sound effect only plays when a drone is hit by a projectile Destroy(gameObject); } //========== 3. Centralize responses in AudioManager based on the events being listened to ========== //========== AudioMgr.cs ========= public class AudioMgr : MonoBehaviour { [SerializeField] private AudioClip droneDestroyClip; [SerializeField] private AudioClip tankFiredClip; [SerializeField] private AudioClip tankDamagedClip; [SerializeField] private AudioSource audiosource; private void PlayDroneDetroyAudio() { audiosource.PlayOneShot(droneDestroyClip); } private void PlayTankFiredAudio() { audiosource.PlayOneShot(tankFiredClip); } private void PlayTankDamagedAudio() { audiosource.PlayOneShot(tankDamagedClip); } private void OnEnable() { SubscribeEvents(); } private void OnDisable() { UnSubscribeEvents(); } private void SubscribeEvents() { GameEvents.OnDroneDestroy += PlayDroneDetroyAudio; GameEvents.OnTankFired += PlayTankFiredAudio; GameEvents.OnTankDamaged += PlayTankDamagedAudio; } private void UnSubscribeEvents() { GameEvents.OnDroneDestroy -= PlayDroneDetroyAudio; GameEvents.OnTankFired -= PlayTankFiredAudio; GameEvents.OnTankDamaged -= PlayTankDamagedAudio; } } Event Bus System — GameEvents Create a similar static event class in any project to enable decoupled communication between modules. Simply update the event names to adapt it to different games.\n1 2 3 4 5 6 7 8 public static class GameEvents { public static Action OnGameStarted; public static Action OnGameOver; public static Action OnTankFired; public static Action OnTankDamaged; public static Action OnDroneDestroy; } Interface-Driven Damage System — IDamageable All objects that can take damage (players, enemies, destructibles) implement this interface. Attackers simply call GetComponent\u0026lt;IDamageable\u0026gt;()?.TakeDamage(), achieving complete decoupling.\n1 2 3 4 public interface IDamageable { void TakeDamage(int damage); } Analysis of SOLID Principles in the Project S — Single Responsibility Principle (SRP) Tank functionalities like movement, shooting, rotation, and health are split into independent components instead of being lumped into a massive \u0026ldquo;Tank\u0026rdquo; class. This is a classic example of SRP in practice.\nClass Responsibility TankMoveBase Solely responsible for movement logic TankFire Solely responsible for triggering shots TankRotate Solely responsible for rotation and aiming HealthSystem Solely responsible for health management AudioMgr Solely responsible for playing sound effects UIMgr Solely responsible for UI display and scoring GameMgr Solely responsible for game flow logic DroneSpawner Solely responsible for spawning drones DroneHealth Solely responsible for drone damage logic O — Open/Closed Principle (OCP) Ammo System: The IProjectile interface allows adding new ammo types (e.g., LaserShellProjectile, RocketShellProjectile) without modifying TankFire. You simply create a new class implementing IProjectile. Movement System: The combination of the IMoveY interface and TankMoveBase enables adding new tank movement styles without altering the base class. Drone Movement: DroneMoveBase allows extending new flight behaviors via inheritance (e.g., HeavyDroneMove with random offset movements). L — Liskov Substitution Principle (LSP) The three subclasses of TankEngineBase (TankEngineLight, TankEngineMedium, TankEngineHeavy) can be used interchangeably within TankEngineSelector and GameMgr. GameMgr.CheckEngine() iterates through a List\u0026lt;TankEngineBase\u0026gt; and calls GetStatus(), remaining unaware of the specific engine type. Similarly, subclasses of DroneMoveBase can be swapped wherever a DroneMoveBase is required. I — Interface Segregation Principle (ISP) IMoveY: Defines only a single method, MoveY(float), forcing implementers to ignore unrelated functions like rotation or shooting. IProjectile: Defines only Fire(Transform), allowing each ammo type to focus solely on \u0026ldquo;how to fire.\u0026rdquo; IDamageable: Defines only TakeDamage(int), enabling any object capable of taking damage to implement it easily. The interfaces are extremely granular, each containing just one method, perfectly adhering to ISP.\nD — Dependency Inversion Principle (DIP) Damage handling in DroneHealth relies on the IDamageable interface rather than the concrete HealthSystem class: 1 2 IDamageable damageable = other.gameObject.GetComponent\u0026lt;IDamageable\u0026gt;(); damageable?.TakeDamage(damage); TankFire depends on the IProjectile interface to launch ammo, not on specific ammo classes. TankMoveBase casts itself to an interface (this as IMoveY) for calls, ensuring high-level logic depends on abstractions rather than concrete implementations. Retrospective While SOLID principles greatly enhance scalability, they also significantly increase the number of scripts. For instance, implementing the Observer Pattern for the UI took nearly a full day, and debugging a single issue consumed an entire afternoon. For beginners, pitfalls often arise from where listener code is placed and the order of execution, which requires careful attention.\nAdditionally, this was my first systematic dive into game architecture. At times, the concepts felt quite abstract and difficult to grasp immediately. However, the most tangible takeaway from this project is its excellent extensibility: adding new features requires writing new scripts without touching old ones, effectively avoiding the scenario where one new feature introduces three new bugs. This is crucial for large-scale projects or online mobile games.\nI hope that as my skills grow, my exposure to excellent complex/large-scale projects increases, and the complexity of my personal projects rises, my understanding and practical application of software architecture will continue to improve.\n","date":"2026-01-19T09:54:30+08:00","image":"https://nullshowjl.github.io/en/p/solidsolid-principles-in-project-practice/cover_hu_7c26ddbc102a2c99.webp","permalink":"https://nullshowjl.github.io/en/p/solidsolid-principles-in-project-practice/","title":"【SOLID】SOLID Principles in Project Practice"},{"content":"目录\n[Merge Big Watermelon](#Merge Big Watermelon) [Space Shooter](#Space Shooter) [Flappy Bird](#Flappy Bird) Pong Asteroids The games in this practice series are all intentionally small. The goal is to get comfortable with Unity’s APIs, sharpen my problem-solving mindset, and explore different ways of thinking. The top priority is to quickly build working prototypes or extract reusable code snippets. Things like overall architecture or long-term scalability aren’t the focus here.\nMerge Big Watermelon Tutorial Reference and Assets Source：Bilibili-YouChiHui\nSource Code： on Gitee\nGame Overview Merge Big Watermelon is a casual browser game released by Weisun Games in early 2021. It quickly went viral thanks to its easy-to-learn mechanics, addictive gameplay, and strong social sharing appeal.\nCore Gameplay:\nPlayers tap the screen to drop random fruits from the top—grapes, oranges, apples, and so on. When two identical fruits collide, they merge into a larger one: two grapes become a cherry, two cherries become an orange, and so on. The ultimate goal is to create the biggest fruit—the watermelon. The game blends mechanics from Tetris and 2048: fruits fall and stack under gravity, and the game ends if the pile crosses the top of the screen. Game Breakdown Content Background image\nWalls and floor\nFruits (active and standby)\nGame-over line\nUI (score display)\nSound effects (landing, merging)\nCore Logic The standby fruit follows the mouse along the X-axis while the mouse is held down, and drops when released Fruits bounce and collide with each other when they land. If two identical fruits touch, they merge into a bigger one If any fruit crosses the game-over line, the game ends and the scene reloads The current score is displayed on screen. Development Steps Set up the scene layout Add gravity, collision, and bounce physics to fruits and other objects Turn fruits into prefabs Make the standby fruit follow the mouse Drop the fruit when the mouse is released Handle fruit merging logic Detect when fruits hit or cross the game-over line Display the score in the UI Add sound effects Problems \u0026amp; Solutions 1. No Clear Plan When I got stuck, I used the classic “How do you put an elephant in a fridge?” mindset—break things down into the tiniest possible steps. For example, Step 4 in the development plan (“make the standby fruit follow the mouse”) can be broken down like this:\nCreate an array of standby fruits (types 1–4) Define a spawn point for the standby fruit Randomly pick a fruit from the array to spawn On mouse down, get the mouse’s position and apply it to the fruit (only update the X-axis; keep Y and Z unchanged) Add boundary checks so the fruit doesn’t move past the left/right walls 2. Converting Screen Coordinates to World Coordinates 1 2 3 4 5 6 7 // Screen Coordinates -\u0026gt; World Coordinates Vector3 screenPoint = Input.mousePosition; // eg. The position of the mouse Vector3 worldPoint = Camera.main.ScreenToWorldPoint(screenPoint); // World Coordinates -\u0026gt; Screen Coordinates Vector3 worldPos = player.transform.position; Vector3 screenPos = Camera.main.WorldToScreenPoint(worldPos); One thing to watch out for: since the main camera in Unity is positioned at Z = -10 in world space, you need to manually set the Z value to 0 when converting screen coordinates to world coordinates for 2D objects. Otherwise, the object might not be visible—it’ll be behind the camera.\nIn Unity, the origin of screen coordinates is at the bottom-left corner\n3. Delayed Execution 1 Invoke(\u0026#34;funcName\u0026#34;, second); 4. Assigning the Singleton Instance 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 // After marking a class as a singleton, don’t forget to assign the instance in Awake() public class PlayerManager : MonoBehaviour { public static PlayerManager Instance; public GameObject[] fruits; public Transform createPoint; [HideInInspector] public GameObject waitingFruit; private void Awake() { Instance = this; } // Other codes } 5. Fruit Merging Logic Each fruit has a unique ID, so it can be used to identify individual objects. To avoid both fruits triggering a merge at the same time, only the one with the higher ID is allowed to spawn the next-level fruit.\n6. Custom Font Scaling Issue Custom fonts didn’t scale properly using the usual font size settings, it can be fixed by scaling the entire object instead.\n7. Sound Effect Spam on Landing To avoid messy audio from tiny movements, a velocity threshold can be set to help with this—only play the landing sound if the fruit’s velocity is above a certain value.\nReflection This was my first hands-on Unity project. I tried to write all the code myself, only checking tutorials when I got stuck—then I’d go back and implement the logic on my own. Whenever I hit an unfamiliar API, I’d dig into my old notes or check the official docs. That really helped the knowledge stick.\nThe demo covers the core gameplay, though there are still a few bugs. For example, sometimes the game doesn’t restart even when fruits stack past the game-over line. Also, after merging into the final watermelon, I still need to add a victory screen and scene. I’ll revisit to polish those once I’ve finished a few more practice projects.\nCode Reuse 1. Use Singleton Pattern for All “Manager” Classes 1 2 3 4 5 6 7 8 9 10 11 // Take PlayerManager for example public class PlayerManager : MonoBehaviour { public static PlayerManager Instance; private void Awake() { Instance = this; } // Other codes } 2. Make the game object follow the mouse 1 2 3 4 5 6 7 8 9 10 11 12 private void PlaceFruit() { // Get the mouse position in screen coordinates // Convert it to world coordinates Vector3 mousePos = Camera.main.ScreenToWorldPoint(Input.mousePosition); // When the left mouse button is held down, move the standby fruit with the mouse // In this case, the fruit only follows the mouse along the X-axis if (Input.GetMouseButton(0))\t{ waitingFruit.transform.position = new Vector3(mousePos.x, waitingFruit.transform.position.y, 0); } } Space Shooter Tutorial Reference and Assets Source：Bilibili-QiQiKer-Plane\nSource Code： on Gitee\nGame Overview Space Shooter is an official Unity 3D beginner tutorial project designed to help newcomers quickly grasp the basics of Unity development. It covers core concepts such as scene setup, player control, enemy spawning, collision detection, UI display, and audio integration. This version builds on the original tutorial by adding upgraded features—like enemies that can fire bullets.\nCore Gameplay:\nGenre: 2D space shooter (with a 3D top-down view) Objective: Pilot your spaceship and survive as long as possible while destroying waves of enemies and asteroids. Controls: Movement: Use WASD or arrow keys to move freely within the screen Shooting: Left mouse button fires bullets Enemy Behavior: Asteroids and enemy ships spawn randomly from the top of the screen, falling at different speeds Enemies move side-to-side and shoot bullets at the player The player must dodge or destroy them to avoid collisions or being hit by enemy fire Game Over: The game ends if the spaceship is hit by an asteroid or a bullet Scoring System: Each destroyed enemy adds points, with the UI showing the score in real time Game Breakdown Content Scene setup Background slowly scrolling downward\nAnimations: enemy explosions, asteroid explosions, ship tilting when moving left/right, asteroid rotation\nUI: score display\nAudio: background music, shooting, hit effects\nCore Logic Player can move in all four directions and shoot bullets Asteroids spawn randomly at the top of the screen and fall at a constant speed Enemies spawn randomly at the top, move down faster, dodge left/right, and fire bullets Current score is displayed in the UI If the player is hit by enemy bullets or collides with asteroids/enemies, the ship is destroyed and the scene reloads Development Steps Background Setup\nLooping background movement Add starfield particle effects Add background music Player Implementation\nPlayer movement controls Tilting effect when moving left/right (greater speed = larger tilt angle) Shooting mechanics Player bullets with sound effects Enemies \u0026amp; Asteroids\nRandom spawning of asteroids and enemies Random asteroid rotation effect Enemy shooting logic Enemy side-to-side dodging movement Effects\nAttack and collision effects UI\nBuild the UI layout Implement score display logic Problems \u0026amp; Solutions 1. Moving the Background Should the background move, or should the camera move? In most cases, it’s easier to move the background. Since the scene contains asteroids, enemies, the player, and other objects, keeping the camera still simplifies everything.\nOne thing to keep in mind:\nWhen doing any kind of back‑and‑forth or looping movement that needs to stay in sync with absolute time, use Time.time. For regular movement, timers, or accumulative values that must stay frame‑rate independent, use Time.deltaTime. 1 2 3 4 5 6 7 8 9 // Movement calculation (usually handled with built‑in APIs) this.transform.Translate(Vector3.forward * 1 * Time.deltaTime, space.World); // 如果是自己坐标系：Space.Self // Looping background movement private void ScrollBG() { float distance = Mathf.Repeat(_scrollSpeed * Time.time, 30); transform.position = _startPos + distance * Vector3.forward * (-1); } 2. Rigidbody calculations should be placed in Since the time between frames in Update() can vary depending on device performance and workload, running physics or Rigidbody operations there may cause jitter or inconsistent behavior. Using FixedUpdate() ensures physics calculations run at a stable, fixed timestep.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 // Control the player\u0026#39;s movement by manipulating the Rigidbody private void FixedUpdate() { MovePlayer(); } private void MovePlayer() { float horizontalVel = Input.GetAxis(\u0026#34;Horizontal\u0026#34;); // 0-1 float verticalVel = Input.GetAxis(\u0026#34;Vertical\u0026#34;); Vector3 velocity = new Vector3(horizontalVel, 0, verticalVel); _playerRB.velocity = velocity * _speed; // Other codes } 3. A Simple Way to Handle Boundary Checking It’s often easier to visually adjust boundary positions in the Inspector rather than controlling everything purely through code. The idea is to create a struct inside the class, mark it as serializable, and then reference it in your logic. (Don’t forget to instantiate the Border class before using it.)\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 public class PlayerController : MonoBehaviour { [Serializable] public struct Border { public float maxZ; public float minZ; public float minX; public float maxX; } [SerializeField] private Border _border; // Other codes private void MovePlayer() { // Other codes // Border check float posX = Mathf.Clamp(_playerRB.position.x, _border.minX, _border.maxX); float posZ = Mathf.Clamp(_playerRB.position.z, _border.minZ, _border.maxZ); _playerRB.position = new Vector3(posX, 0, posZ); } } 4. Apply tilt to the object through Rigidbody-based rotation 1 2 3 4 5 // Rotate the object 30° around the Y-axis Quaternion rotation = Quaternion.Euler(0, 30, 0); // You can directly use the Rigidbody\u0026#39;s rotation property _playerRb.rotation = Quaternion.Euler(0, 0, _playerRb.velocity.x * (-1) * tilt); 5. Component-Based (“Feature Hook”) Architecture Break reusable functionality into small, focused components (e.g., MoveController), then let higher-level behavior scripts (e.g., PlayerController / EnemyController) compose and use them.\nPros: High reusability, clear responsibilities, easy to test, easy to maintain; follows composition over inheritance.\nCons: Some objects may end up with multiple small scripts attached, but this is normal for single‑responsibility design and has minimal performance impact.\nBest Practices: Keep each component focused on a single responsibility, use interfaces/events for decoupling, use RequireComponent, store shared data in ScriptableObjects or config classes, and reuse behavior through Prefabs.\nThe “best practices” part is still a bit abstract for me. I’ve seen it once in the “2048” project, and I’ll need more hands‑on experience to fully internalize how to apply it.\n6. When to Call GetComponent A commonly recommended pattern is:\nUse Awake() when retrieving and caching components on the same GameObject. Use Start() when the component depends on other objects that also initialize in Awake() 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 // Get the Rigidbody component from the same GameObject as MoveController public class MoveController : MonoBehaviour { private Rigidbody rb; private void Awake()\t// Retrieve the Rigidbody in Awake() { rb = GetComponent\u0026lt;Rigidbody\u0026gt;(); } public void Move(Vector3 dir, float speed) { rb.velocity = dir.normalized * speed; } } // This script depends on values initialized by other objects in their Awake(), so use Start() public class EnemyAI : MonoBehaviour { private MoveController move; private Transform player; // Assume the Player finishes its setup in Awake() private void Start() { move = GetComponent\u0026lt;MoveController\u0026gt;();\t// Another object (MoveController) gets the component in Start() player = GameObject.FindWithTag(\u0026#34;Player\u0026#34;)?.transform; // Waiting until all Awake() calls are done makes the lookup safer } private void Update() { if (player == null) return; Vector3 dir = (player.position - transform.position); dir.y = 0f; move.Move(dir, 3f); } } 7. General Coroutine Usage A few things to keep in mind:\nDefine a method that returns IEnumerator, and use yield return inside it to wait (e.g., null, new WaitForSeconds(...), WaitForEndOfFrame, etc.) Start it with StartCoroutine(MyCoroutine()); you can stop it with StopCoroutine if needed Never write a while(true) loop without any yield inside a main-thread method (like Start()), or it will freeze the entire game loop 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 // Use coroutines to spawn and destroy enemies // Run the spawn loop inside a coroutine and use Instantiate to create enemies, // then use another coroutine to destroy them after a delay private void Start() { StartCoroutine(SpawnEnemyWave()); } private IEnumerator SpawnEnemyWave() { yield return new WaitForSeconds(startSpawnTime); while (true) { for (int i = 0; i \u0026lt; enemyCount; i++) { GameObject prefab = enemies[Random.Range(0, enemies.Length)]; GameObject newEnemy = Instantiate(prefab); newEnemy.transform.position = new Vector3(Random.Range(-4, 5), 2, 10); } yield return new WaitForSeconds(spawnWaitingTime); } } // Enemies and their bullets are all destroyed the same way as the player\u0026#39;s bullets, // using a unified KillBox system 8. Implementing Asteroid Self‑Rotation Use the Random.insideUnitSphere API, which returns a random Vector3 whose direction and magnitude are uniformly distributed inside a unit sphere. Multiplying this vector by rotationSpeed scales it to the desired angular velocity. Finally, assign it to Rigidbody.angularVelocity: the vector’s direction represents the rotation axis, and its magnitude represents the angular speed (in radians per second). As a result, the asteroid rotates around a random axis at the given speed.\n1 2 3 4 private void Start() { _rb.angularVelocity = Random.insideUnitSphere * rotationSpeed; } 9. Two Ways to Implement Enemy Shooting Method 1: Use the same approach as the player’s shooting logic—accumulate time and fire when the timer exceeds a threshold.\nMethod 2: Use the InvokeRepeating(string methodName, float time, float repeatRate) API. It calls methodName after time seconds, and then repeats the call every repeatRate seconds.\n1 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 // Implementation of Method 1 private void Update() { SpawnBullet(); } private void SpawnBullet() { _waitingTime += Time.deltaTime; if (_waitingTime \u0026gt; cooldown) { Instantiate(bullet, shootPos); AudioMgr.Instance.PlayAudio(AudioMgr.Instance.clips[1]); _waitingTime = 0; } } // Implementation of Method 2 private void Start() { InvokeRepeating(\u0026#34;SpawnBullet\u0026#34;, startingTime, cooldown); } private void SpawnBullet() { Instantiate(bullet, shootPos); AudioMgr.Instance.PlayAudio(AudioMgr.Instance.clips[1]); } 10. Implementing Enemy Dodging Behavior After an enemy has existed for a certain amount of time (a random value), it begins a dodge action. If the enemy spawns on the left half of the screen, it moves to the right; if it spawns on the right half, it moves to the left. After dodging for another random duration, it stops the horizontal movement, continues descending, and then repeats the dodge cycle until it is naturally destroyed.\nUse a coroutine to perform the dodge for a period of time, then return to straight downward movement To make the dodge motion smooth and natural, apply acceleration using Mathf.Lerp(float a, float b, float t), where a is the start value, b is the target value, and t is the interpolation factor between 0 and 1 Remember to perform boundary checks while the enemy is moving The tutorial uses Mathf.MoveTowards(float current, float target, float maxDelta) instead. This API is conceptually similar to Lerp in that it moves current toward target smoothly. However, using this method requires a much larger acceleration value; otherwise, the enemy may appear to not dodge at all. The reason lies in the fundamental difference between the two:\nLerp uses proportional interpolation (t is a 0–1 ratio), which pulls the value toward the target proportionally—even if the target is on the opposite side, it still produces a small counter‑directional velocity MoveTowards uses an absolute step size (maxDelta), moving only a fixed amount per frame. If maxDelta is too small (e.g., acceleration is low), the per‑frame velocity change is overshadowed by other factors (friction, resets, threshold checks), making it look like “no dodge happened” 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 private void Start() { StartCoroutine(Dodge()); } private void FixedUpdate() { // float xVel = Mathf.MoveTowards(_rb.velocity.x, _targetDodgeSpeed, Time.fixedDeltaTime * acceleration); // 100 float xVel = Mathf.Lerp(_rb.velocity.x, _targetDodgeSpeed, Time.fixedDeltaTime * acceleration); // 20 _rb.velocity = new Vector3(xVel, _rb.velocity.y, _rb.velocity.z); } private IEnumerator Dodge() { while (true) { yield return new WaitForSeconds(Random.Range(startingTimeMin, startingTimeMax)); _targetDodgeSpeed = Random.Range(dodgeSpeedMin, dodgeSpeedMax); // If the enemy spawns on the right half of the screen, dodge to the left // If it spawns on the left side, its natural horizontal speed is already positive, // so it will dodge left automatically and needs no adjustment if (_rb.position.x \u0026gt;= 0) { _targetDodgeSpeed = -_targetDodgeSpeed; } float targetTime = Random.Range(movingTimeMin, movingTimeMax); yield return new WaitForSeconds(targetTime); _targetDodgeSpeed = 0; } } 11. When to Use OnTrigger vs. OnCollision Both can be used for collision detection.\nOnCollision is used when actual physical interaction is required, such as collisions, bouncing, or applying forces. Since it calculates contact points and impulses, it has a higher performance cost and is suitable for real physics interactions.\nOnTrigger only detects overlap and does not automatically produce physical responses. Because it usually doesn’t compute contact points or impulses, it’s cheaper than OnCollision. It’s ideal for area detection, trigger zones, pickups, damage areas, and similar cases.\n12. How to Handle Effect Destruction (Separation of Concerns) Effect destruction should be handled in a dedicated script rather than mixing it into the CollisionCheck script. This keeps responsibilities separated (CollisionCheck only handles triggering and detection), improves reusability, and makes it easier to switch to an object‑pooling system instead of destroying effects every time.\nReflection The tutorial introduced several fresh ideas. In this project, I started writing my own coroutines and continued practicing the singleton pattern learned from the previous game. Whenever I had questions or felt the tutorial’s approach exceeded my current understanding, I asked AI for clarification—and the answers were generally satisfying. This project‑driven learning style feels very efficient, and the constant feedback helps prevent mental fatigue.\nThere are still concepts I don’t fully grasp yet, such as “single‑responsibility components” and “decoupling with interfaces/events.” I’ll need more hands‑on experience in future projects to internalize these ideas.\nAnother fun discovery: the Unity splash screen logo can be replaced! I immediately created a small custom logo, and it looks great.\nCode Reuse 1. Scrolling Infinite Background 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 [SerializeField] private float _scrollSpeed; private Vector3 _startPos; private void Start() { _startPos = transform.position; } private void Update() { ScrollBG(); } private void ScrollBG() { float distance = Mathf.Repeat(_scrollSpeed * Time.time, 30); transform.position = _startPos + distance * Vector3.forward * (-1); } 2. Rotation Effect 1 2 3 4 5 6 7 // Random rotation for the sphere _rb.angularVelocity = Random.insideUnitSphere * rotationSpeed; // The 3D object tilts according to its velocity on a specific axis (x‑axis here) _rb.rotation = Quaternion.Euler(0, 0, _rb.velocity.x * (-1) * tilt); float posX = Mathf.Clamp(_rb.position.x, border.minX, border.maxX); _rb.position = new(posX, _rb.position.y, _rb.position.z); 3. MoveController Using a Rigidbody allows this logic to be reused for any object moving along the Z‑axis.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 public class MoveController : MonoBehaviour { [SerializeField] private float flySpeed; private Rigidbody _rb; private void Awake() { _rb = GetComponent\u0026lt;Rigidbody\u0026gt;(); } private void FixedUpdate() { _rb.velocity = Vector3.forward * flySpeed; } } 4. DestroyByTime This logic can be reused for automatically destroying visual effects or other similar things.\n1 2 3 4 5 6 7 8 9 public class DestroyByTime : MonoBehaviour { [SerializeField] private float delay; private void Start() { Destroy(gameObject, delay); } } 5. AudioManager Reusable for managing and playing all sound effects.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 public class AudioMgr : MonoBehaviour { public static AudioMgr Instance; public AudioClip[] clips; private AudioSource _audioSource; private void Awake() { Instance = this; _audioSource = GetComponent\u0026lt;AudioSource\u0026gt;(); } public void PlayAudio(AudioClip clip) { _audioSource.PlayOneShot(clip); } } Flappy Bird Tutorial Reference and Assets Source：Youtube-Zigurous\nSource Code： on Gitee\nGame Overview Flappy Bird is a simple 2D mobile game released in 2013 by Vietnamese developer Dong Nguyen. The game became a global phenomenon due to its extremely simple visuals, extremely high difficulty, and the uniquely “addictive frustration” it creates.\nCore Gameplay:\nGenre: Pixel‑style 2D game with a scrolling background; the player controls a small pixel bird Goal: Guide the bird through pairs of pipes; the farther you fly, the higher you score Controls: Tap the screen (or press any key) to make the bird flap upward briefly Without input, the bird continuously falls due to gravity Obstacle System: Randomly generated green pipes of varying heights appear in the scene The bird must pass through the gap between each pair of pipes Touching a pipe or the ground results in an immediate game over Failure Condition: The bird hits an obstacle or the ground Scoring: Passing through each pair of pipes awards 1 point There is no upper limit to the score Game Breakdown Content Scene layout (parallax background, infinite scrolling) Animation (bird flapping) UI (score display) Sound effects (scoring, collision) Core Logic The bird moves forward automatically and falls naturally under gravity The player can press W, the Up Arrow, or click the left mouse button to make the bird move upward If the bird hits a pipe or the ground while moving forward, the game ends Display the current score Development Steps Bird Bird movement Bird flapping animation Background Background creation Parallax scrolling Pipes Pipe prefab creation Pipe generation Pipe movement Scoring UI design / setup Menu interaction implementation Adding sound effects Problems \u0026amp; Solutions 1. Implementing Touch Controls 1 2 3 4 5 6 7 8 9 10 // Example: controlling the bird\u0026#39;s upward movement // Touch input mode if (Input.touchCount \u0026gt; 0) // As long as a finger is touching the screen { Touch touch = Input.GetTouch(0); // Get the first touch if (touch.phase == TouchPhase.Began) // When the touch just begins { _dir = Vector3.up * strength; } } 2. Using InvokeRepeating to Implement a Simple Animation It’s important to note that InvokeRepeating schedules a method to be called at fixed intervals within Unity’s main loop. Each invocation finishes and returns control to the engine, and the method is called again after the next interval. Although it is not an actual infinite loop, if you write a while(true) (or any non‑terminating loop) inside the invoked method, the method will never return, blocking the main thread and freezing the game/editor.\nIn addition, InvokeRepeating is typically placed in Start (or any one‑time initialization after Awake). You only need to call it once to schedule the repeated execution, and Unity will continue calling the method at the specified interval. If you place InvokeRepeating inside Update, it will register a new repeating call every frame, resulting in a huge number of concurrent scheduled calls. This leads to the method being executed multiple times, more frequently than intended, causing logic errors and performance issues.\nUsing InvokeRepeating to animate the bird’s flapping:\n1 2 3 4 5 6 7 8 9 10 11 12 private void Start() { InvokeRepeating(nameof(AnimateSprite), 0.15f, 0.15f); } private void AnimateSprite() { _sr.sprite = sprites[_index]; _index++; if (_index \u0026gt;= sprites.Length) _index = 0; } 3. An Approach to Implementing Parallax + Infinite Background In the tutorial, all background elements are created as 3D Quads, and the 2D images are turned into Materials using the Unlit/Texture shader. The movement effect is achieved by adjusting the built‑in Offset property of the material. This method is very simple and highly reusable.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 public class Parallax : MonoBehaviour { [SerializeField] private float speed; private MeshRenderer _meshRenderer; private void Awake() { _meshRenderer = GetComponent\u0026lt;MeshRenderer\u0026gt;(); } private void Update() { _meshRenderer.material.mainTextureOffset += new Vector2(speed * Time.deltaTime, 0); } } 4. An Alternative Approach to Destroying Pipes The common approach is to place a trigger collider at the left edge of the screen, and when a pipe prefab enters that trigger, it gets destroyed. The tutorial uses a simpler method: destroying the pipe based on its pixel position.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 private float _killEdge; private void Start() { _killEdge = Camera.main.ScreenToWorldPoint(Vector3.zero).x - 1; } private void Update() { // Other codes if (transform.position.x \u0026lt; _killEdge) { Destroy(gameObject); } } 5. Coordinate Systems in Unity World Coordinates (World) Unity’s world coordinates are based on the global origin (0,0,0), measured in Unity units (commonly treated as meters) Axis directions: X to the right, Y upward, Z forward (positive Z points toward the front of the Scene view) transform.position represents a GameObject’s world position; transform.localPosition is its position relative to its parent Screen Coordinates (Screen) Measured in pixels, with the origin at the bottom‑left corner (0,0); the top‑right corner is (Screen.width, Screen.height) Legacy IMGUI (OnGUI) uses the top‑left corner as the origin; UI coordinate behavior also depends on the Canvas Render Mode (Screen Space / World Space) Viewport Coordinates (Viewport) Normalized coordinates in the range [0,1], with (0,0) at the bottom‑left and (1,1) at the top‑right Conversion available via Camera.ViewportToWorldPoint and WorldToViewportPoint In the pipe‑destruction example above, Camera.ScreenToWorldPoint(Vector3 screenPoint) is used to convert the screen‑space origin (0,0,0)—i.e., Vector3.zero—into world coordinates. This ensures that the value can be compared with transform.position.x inside Update, since both are then in the same coordinate system.\n6. Restarting and Pausing the Game The simplest approach is to use Time.timeScale.\nReflection Because this is a 2D game, directions like Vector3.up and Vector3.left can be used directly, which greatly simplifies the code. The tutorial introduced several elegant and practical design ideas—for example, using 3D quads for the background and applying _meshRenderer.material.mainTextureOffset to achieve infinite scrolling and parallax; converting a point from screen coordinates to world coordinates to determine when an object should be destroyed, instead of relying on traditional collision detection; and implementing simple animations using InvokeRepeating combined with iterating through sprites, avoiding the need for Unity’s built‑in animation system.\nIn my own practice, I revisited the singleton pattern for both the GameManager and AudioManager, and refreshed my understanding of sound playback and UI interaction design. Even though this is a beginner‑level mini‑game, I still learned a lot from it.\nCode Reuse A Spawner Implementation Using InvokeRepeating to spawn game objects at fixed intervals.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 private void OnEnable() { InvokeRepeating(nameof(Spawn), startSpawn, spawnRate); } private void OnDisable() { CancelInvoke(); } private void Spawn() { GameObject pipe = Instantiate(prefab, transform.position, Quaternion.identity); pipe.transform.position += Vector3.up * Random.Range(minHeight, maxHeight); } Other reusable code snippets have already been covered in the “Problems \u0026amp; Solutions” section above, so they won’t be repeated here.\nPong Tutorial Reference and Assets Source：Youtube-Zigurous\nSource Code： on Gitee\nGame Overview Pong is one of the earliest and most influential arcade games in video game history, released by Atari in 1972. It simulates the real‑world sport of table tennis and is widely regarded as the first commercially successful video game.\nCore Gameplay:\nThe game takes place on a 2D plane with a vertical paddle on each side and a moving ball in the center The player controls one paddle, moving it up and down (typically via joystick or buttons) to bounce the ball back toward the opponent If a player fails to return the ball, the opponent scores a point The ball bounces off the top and bottom boundaries; hitting the left or right boundary results in a score As the game progresses, the ball may gradually increase in speed, raising the difficulty Game Breakdown Content Scene layout Player paddle\nBall\nAI paddle\nUI (score display)\nCore Logic The player moves the paddle using W / Up Arrow and S / Down Arrow to hit the ball The ball bounces off the top and bottom boundaries If the ball enters the player’s side, the opponent scores a point, and vice versa Display the current score Development Steps Scene layout Player paddle movement Ball movement implementation AI paddle behavior Gradual increase in ball speed after each bounce Displaying the UI score Problems \u0026amp; Solutions 1. Angular Drag and Linear Drag In a Rigidbody, Angular Drag represents the angular damping coefficient. If you don’t want the object to rotate, you should manually set it to 0 (its default value is 0.05) and also disable rotation on the Z‑axis in the Constraints section. This is a common setup for 2D games like this one.\nLinear Drag, on the other hand, applies to positional movement. A higher drag value causes the object to stop moving or rotating more quickly after being affected by collisions or forces.\n2. Notes on Using FixedUpdate Avoid reading input directly inside FixedUpdate. Since FixedUpdate runs at the physics timestep, input events may be missed or become inconsistent.\nThe correct approach is to read input inside Update(), store the result in a field, and then apply forces based on that field inside FixedUpdate().\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 private void Update() { if (Input.GetKey(KeyCode.W) || Input.GetKey(KeyCode.UpArrow)) { _dir = Vector2.up; } else if (Input.GetKey(KeyCode.S) || Input.GetKey(KeyCode.DownArrow)) { _dir = Vector2.down; } else { _dir = Vector2.zero; } } private void FixedUpdate() { if (_dir.sqrMagnitude != 0) { Rb.AddForce(_dir * strength); } } FixedUpdate is primarily used for updates that need to stay in sync with the physics system.\nTasks appropriate for FixedUpdate:\nApplying forces/torque (Rb.AddForce / AddRelativeForce) or directly setting Rigidbody.velocity Using Rigidbody.MovePosition / MoveRotation (for Kinematic Rigidbodies) Timers, constraints, and solver logic that must match the physics timestep (deterministic simulation, server‑side physics steps) Physics‑based queries (collision checks, overlap tests, raycasts performed within the physics step) Physics prediction/interpolation/extrapolation logic (synchronized with the fixed timestep) Tasks not appropriate for FixedUpdate (require per‑frame or lower‑latency handling):\nInput reading Rendering / camera follow UI updates Animation (typically handled in Update or LateUpdate) 3. Implementing Ball Bounce Behavior Using code like this will not produce the correct effect. The issue is likely incorrect direction calculation: using -rb.velocity is not equivalent to reflecting the velocity based on the collision normal. As a result, the ball may be pushed in an unintended direction.\n1 2 3 4 5 6 7 8 9 10 private void OnCollisionEnter2D(Collision2D other) { if (other.transform.CompareTag(\u0026#34;Ball\u0026#34;)) { Rigidbody2D rb = other.gameObject.GetComponent\u0026lt;Rigidbody2D\u0026gt;(); _dir = rb.velocity.normalized; rb.AddForce(-_dir * strength); } } It can be resolved by using the collision contact normal.\n1 2 3 4 5 6 7 8 9 10 private void OnCollisionEnter2D(Collision2D other) { if (other.transform.CompareTag(\u0026#34;Ball\u0026#34;)) { Rigidbody2D rb = other.gameObject.GetComponent\u0026lt;Rigidbody2D\u0026gt;(); _dir = other.GetContact(0).normal; // Normal of the collision contact point rb.AddForce(-_dir * strength); } } 4. Using the EventSystem to Handle Player and AI Scoring The implementation feels very similar to how UI buttons work.\nExplanation of the code:\nTriggerEvent is essentially an alias/subclass of UnityEvent\u0026lt;BaseEventData\u0026gt;, allowing callbacks that receive BaseEventData to be registered either in the Inspector or via code A BaseEventData instance is created with the current EventSystem as its source; the callback can read this eventData to obtain context Calling scoreTrigger.Invoke triggers all registered callbacks and passes in the eventData 1 2 3 4 5 6 7 8 9 10 11 12 public class ScoringArea : MonoBehaviour { [SerializeField] private EventTrigger.TriggerEvent scoreTrigger; private void OnCollisionEnter2D(Collision2D other) { if (other.gameObject.CompareTag(\u0026#34;Ball\u0026#34;)) { BaseEventData eventData = new BaseEventData(EventSystem.current); scoreTrigger.Invoke(eventData); // Invoke all registered callbacks with the eventData } } } Reflection The tutorial uses a base class Paddle along with two subclasses, PlayerPaddle and ComputerPaddle. Shared logic—such as component retrieval—is placed in the parent class, while each subclass implements its own behavior. In C++ projects, I frequently rely on inheritance, but when working in Unity I often forget to apply it. In future projects, I need to remind myself to use inheritance more often when it fits the design.\nCode Reuse Reusable code in this project has already been discussed in the “Problems and Solutions” section:\nImplementation of ball bounce behavior Using the EventSystem to flexibly trigger multiple callbacks Asteroids Tutorial Reference and Assets Source：Youtube-Zigurous\nSource Code： on Gitee\nGame Overview Asteroids is a classic arcade shooter released by Atari in 1979. With its minimalist vector graphics and fast‑paced gameplay, it became a landmark title in video game history.\nCore Gameplay:\nThe player controls a small spaceship that moves freely in zero‑gravity space (able to thrust, rotate, and shoot) Press W / Up Arrow or S / Down Arrow to rotate Press A / Left Arrow or D / Right Arrow to thrust left or right Press Space or click the left mouse button to fire bullets Asteroids of various sizes continuously appear, drifting in from the edges of the screen Objective: destroy all asteroids with laser shots Large and medium asteroids split into smaller fragments when hit (e.g., Large → Medium → Small → Disappear) Failure: The spaceship is destroyed instantly upon colliding with an asteroid (typically with limited lives) Additional Features: The ship has momentum; it continues drifting even after thrust stops, requiring reverse thrust to slow down Game Breakdown1 Content Scene layout\nPlayer\nAsteroids\nExplosion effects\nCore Logic Player movement and shooting Random asteroid spawning and movement Asteroids split when hit by the player’s bullets, until they disappear The round ends if the player collides with an asteroid Display the current score When all lives are used up, the game ends Development Steps Scene setup Player implementation Movement Shooting Asteroid implementation Asteroid prefabs (different shapes, sizes, masses, initial angles) Spawning asteroids Implementing split/destroy logic Ending a round Player death and respawn Player explosion effect Displaying the UI score and player\u0026rsquo;s lives Ending the game when all lives are exhausted Problems \u0026amp; Solutions 1. Configuring Collision Layers Use Project Settings \u0026gt; Physics 2D to ignore collisions between the player and bullets, as well as between bullets themselves.\n2. Setting Initial Asteroid States Since the asteroids in this project have multiple variants—different sizes, sprites, masses, and initial angles—the strategy is to configure all these parameters directly in the asteroid prefabs. This allows the Spawner.cs script to simply instantiate the prefabs, keeping responsibilities clean and separated:\nAsteroid.cs handles the asteroid’s own properties Spawner.cs handles asteroid generation 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 public class Asteroid : MonoBehaviour { [SerializeField] private Sprite[] sprites; [SerializeField] private float size = 1f; [SerializeField] private float speed = 1f; public float minSize = 0.5f; public float maxSize = 1.5f; private SpriteRenderer _sr; private Rigidbody2D _rb; private void Awake() { _sr = GetComponent\u0026lt;SpriteRenderer\u0026gt;(); _rb = GetComponent\u0026lt;Rigidbody2D\u0026gt;(); } private void Start() { _sr.sprite = sprites[Random.Range(0, sprites.Length)]; transform.eulerAngles = new Vector3(0, 0, Random.value * 360); //size = Random.Range(minSize, maxSize); transform.localScale = Vector3.one * size; _rb.mass = size; // Adjust mass based on asteroid size } public void SetTrajectory(Vector2 dir) { _rb.AddForce(dir * speed); } Initial Rotation of Asteroids:\nUse Random.value, which returns a value between 0 and 1. Since the asteroid rotates around the Z‑axis, the rotation can be set simply as: transform.eulerAngles = new Vector3(0, 0, Random.value * 360);\nHandling Asteroid Size:\nThis logic cannot be placed inside Asteroid.cs, because that script later handles the splitting behavior. If size is randomized inside Start(), it will break the splitting logic. The correct approach is to randomize the size inside Spawner.cs. This was a major pitfall I encountered during the project.\n3. Approach to Asteroid Spawning and Movement Asteroids are spawned within a circular area centered on the screen. They move toward the center. To introduce randomness, an offset angle is added to the direction toward the center. Since the offset is applied around the Z‑axis, Quaternion.AngleAxis(float angle, Vector3.forward) can be used.\nFor asteroid movement, the movement logic itself is handled by Asteroid.cs, while Spawner.cs instantiates the asteroid and calls its movement behavior.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 private void Start() { InvokeRepeating(nameof(Spawn), spawnInterval, spawnInterval); } // Spawn the asteroid at a position within a certain radius from the spawner private void Spawn() { Vector2 spawnOffset = Random.insideUnitCircle * spawnRadius; Vector2 spawnPos = (Vector2)transform.position + spawnOffset; // Pick a random point inside the radius centered on the spawner Vector2 dir = ((Vector2)transform.position - spawnPos).normalized; // Add an angular offset to the direction toward the center float variance = Random.Range(-trajectoryAngle, trajectoryAngle); Quaternion rotation = Quaternion.AngleAxis(variance, Vector3.forward); GameObject obj = Instantiate(asteroidPrefab, spawnPos, Quaternion.identity); Asteroid asteroid = obj.GetComponent\u0026lt;Asteroid\u0026gt;(); asteroid.size = Random.Range(asteroid.minSize, asteroid.maxSize);\t// Handle the random size assignment for the asteroid here asteroid.SetTrajectory(rotation * dir); } It’s important to note that the final movement direction of an asteroid is computed as rotation * dir. Since rotation is a quaternion, this operation applies the rotation to the vector:\nRotated Vector = Quaternion * Original Vector\nThe quaternion must be on the left side of the multiplication operator.\nThe underlying mathematical principles will be explored in more detail when studying the related math topics, so they won’t be expanded on here.\nAdditional Notes on Quaternions Avoid modifying the x, y, z, or w components of a quaternion directly. These values do not represent angles; they are part of a complex mathematical structure. Directly editing them will lead to incorrect behavior.\nAlways use Unity’s built‑in API.\nCommonly Used API Methods:\nScenario Method Rotate a vector rotatedVector = quaternion * vector; Make an object face a direction Quaternion.LookRotation(vector); Convert (0, 90, 0) to a quaternion Quaternion.Euler(0, 90, 0); Get the Euler angles of a quaternion quaternion.eulerAngles; 4. Designing Player Invincibility Frames This is implemented by changing the player’s physics collision layer using LayerMask.NameToLayer(layerName);. It’s straightforward, and Invoke is used to schedule the duration of the invincibility period.\n5. Particle System Configuration Since the UI logic uses Time.timeScale to control game start and end states, the particle system must not accidentally play at the moment a new round begins. To prevent this, set the particle system’s Delta Time to Unscaled in the Inspector.\nReflection This project requires extensive use of rotation‑related methods, so becoming comfortable with the commonly used APIs is essential. It also highlights the need to strengthen the underlying math knowledge.\nThere are also two techniques in this project that are worth remembering and using more often:\nPassing references to objects from other scripts through method parameters\n1 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 //======= GameManager.cs ======= // The function is defined here because many of its parameters are managed centrally by the GameManager public void AsteroidDestroyed(Asteroid asteroid) { if (asteroid.size \u0026lt;= 0.75f) { score += 5; } else if (asteroid.size \u0026lt;= 1.3f) { score += 2; } else { score += 1; } Vector2 pos = asteroid.transform.position; explosion.transform.position = pos; explosion.Play(); } //========== Asteroid.cs ========= // The method is invoked by the Asteroid class itself when a collision occurs private void OnCollisionEnter2D(Collision2D other) { if (other.gameObject.CompareTag(\u0026#34;Bullet\u0026#34;)) { if (size * 0.5f \u0026gt;= minSize) { CreateHalf(); CreateHalf(); } Destroy(other.gameObject); Destroy(gameObject); GameManager.Instance.AsteroidDestroyed(this); } } Calling a function from another script:\nYou can obtain the reference either through a GameObject or directly from the other script’s component attached to a game object:\n1 2 3 4 5 6 7 8 9 10 11 //=========== Spawner.cs ============ // Call the SetTrajectory method by instantiating an Asteroid object private void Spawn() { // Other codes GameObject obj = Instantiate(asteroidPrefab, spawnPos, Quaternion.identity); Asteroid asteroid = obj.GetComponent\u0026lt;Asteroid\u0026gt;(); asteroid.size = Random.Range(asteroid.minSize, asteroid.maxSize); // Handle the random size assignment for the asteroid here asteroid.SetTrajectory(rotation * dir); } Code Reuse 1. Designing Player Invincibility Frames This is implemented by changing the player’s physics collision layer, combined with the use of Invoke:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 private void OnCollisionEnter2D(Collision2D other) { if (other.gameObject.CompareTag(\u0026#34;Asteroid\u0026#34;)) { GameManager.Instance.lives--; gameObject.SetActive(false); Invoke(nameof(Respawn), respawnTime); } } private void Respawn() { gameObject.SetActive(true); gameObject.transform.position = Vector3.zero; gameObject.layer = LayerMask.NameToLayer(\u0026#34;No Collision\u0026#34;); Invoke(nameof(ResetLayer), invulnerableTime); } private void ResetLayer() { gameObject.layer = LayerMask.NameToLayer(\u0026#34;Player\u0026#34;); } 2. Clearing All Objects in the Scene Both Flappy Bird and Asteroids use the same approach when resetting the game: use FindObjectsOfType to retrieve all relevant objects in the scene, iterate through the resulting array, and destroy each one.\n1 2 3 4 5 Asteroid[] asteroids = FindObjectsOfType\u0026lt;Asteroid\u0026gt;(); for (int i = 0; i \u0026lt; asteroids.Length; i++) { Destroy(asteroids[i].gameObject); } ","date":"2026-01-10T09:19:30+08:00","image":"https://nullshowjl.github.io/en/p/practice-logunity-mini-game-collection-1/cover_hu_85bb1d1de60f343d.webp","permalink":"https://nullshowjl.github.io/en/p/practice-logunity-mini-game-collection-1/","title":"【Practice Log】Unity Mini Game Collection 1"},{"content":"Course Source：Bilibili-Voidmatrix\nSDL Library Introduction SDL stands for Simple DirectMedia Layer. It is a lightweight framework that makes it possible to run the same code on different operating systems and hardware. In other words, once you build a project with SDL, you can compile and run it across multiple platforms. The interface is written in C, so it is clear and easy to use.\nCommonly used add‑on libraries include SDL_image (for loading images), SDL_ttf (for fonts), and SDL_mixer (for audio). SDL also provides SDL_Renderer, which wraps graphics APIs such as OpenGL, Vulkan, and DirectX. On each platform, SDL chooses the right backend automatically.\nSDL is widely used and trusted in the industry. Many game engines and projects are built on top of it, showing that it is a mature and reliable technology.\nGetting / Downloading Go to the official SDL GitHub repository. Open the Releases section and download the latest version marked Latest.\nAfter opening the page, choose the version that matches your operating system. Since this project uses SDL2 (note that SDL3 is already available), select the latest release that starts with 2. As the project is developed with Visual Studio, download the corresponding version listed below.\nSimilarly, install SDL_ttf (text rendering), SDL_image (image rendering), SDL_mixer (audio decoding), and SDL_gfx (basic primitive drawing). Note that this project uses SDL2, so all of these libraries should be installed in their SDL2 versions. If you are using SDL3, make sure to install the corresponding SDL3 versions instead. Also, SDL_gfx is hosted on a separate website, while the other three are available on GitHub.\nIn addition, you will need cJSON, which can be obtained in a similar way.\nDevelopment Environment Setup General Configuration Right‑click the project name (not the “solution name”), go to Properties, then under C/C++ → Code Generation, change Runtime Library to MT. Finally, click OK or Apply in the lower‑right corner of the dialog. This setting helps prevent DLL missing errors on computers that do not have Visual Studio or the related C++ libraries installed.\nConfiguring Third‑Party Libraries Place all SDL‑related files into a single folder to make future management easier. In the example below, the solution is named “TowerDefence”, and it contains two projects: “Demo” and “TowerDefence”.\nNext, configure the settings in the order used by C++ compilation: header files, library files, and dynamic link libraries.\nHeader File Configuration Open the Properties window in the same way as before. Go to C/C++, and in the first line on the right, add the relevant SDL header files. Note that by default, Visual Studio uses absolute paths when adding files. For flexibility, change them to relative paths:\nOnce this is done, Visual Studio will be able to recognize header files such as SDL.h. One important detail to note is that SDL also defines its own main function. To avoid conflicts, add the following line at the very beginning of your code:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 #define SDL_MAIN_HANDLED\t// SDL有自己的main函数定义，使用这个宏来避免冲突 #include \u0026lt;iostream\u0026gt; #include \u0026lt;SDL.h\u0026gt; #include \u0026lt;SDL_image.h\u0026gt; #include \u0026lt;SDL_mixer.h\u0026gt; #include \u0026lt;SDL_ttf.h\u0026gt; int main() { std::cout \u0026lt;\u0026lt; \u0026#34;Welcome to Demo!\u0026#34; \u0026lt;\u0026lt; std::endl; // Game initialization and main loop would go here return 0; } Setting Library Files in the Linker Again, open the Properties window. Go to Linker → General, and on the right side locate Additional Library Directories.\nSince my computer is 64‑bit (x64), the 32‑bit (x86) files are not needed and can be safely removed (no need to add them).\nSetting Up Dynamic Link Libraries Open the lib folder inside each SDL package, locate the corresponding .dll files, and copy them into your project folder (in this example, the Demo project).\nNote that SDL_gfx does not provide dynamic link libraries, so there are no DLL files.\nSetting up cJSON In the Solution Explorer, under Source Files, create a new filter. Then simply drag and drop the cJSON source files into it.\nAt this point, the full SDL2 package has been successfully configured in Visual Studio.\n","date":"2025-11-22T14:00:30+02:00","image":"https://nullshowjl.github.io/en/p/environment-setup-sdl2/cover_hu_806638e881b8bbc.webp","permalink":"https://nullshowjl.github.io/en/p/environment-setup-sdl2/","title":"【Environment Setup 】SDL2"},{"content":"Table of Contents\n[Algorithm Overview](#Algorithm Overview) Detailed Explanation of the Rules Full Implementation Reference Resources:\nBilibili-Voidmatrix YouTube-The Coding Train Algorithm Overview In 1987, computer scientist Craig Reynolds published a paper on simulating flocking behavior in birds. In it, he introduced an algorithm called Boids, which quickly found applications in movies and games. For example, it was used to animate bat and penguin swarms in Batman Returns (1992), and bird-like creatures in Half-Life (1998).\nThe Boids algorithm is based on a decentralized idea: it defines the behavior of each individual, without directly modeling the group. The “flock” is simply the visual result of many individuals following simple rules. Each Boid follows three basic principles:\nSeparation: Move away from nearby Boids to avoid crowding. Alignment: Steer toward the average heading of nearby Boids. Cohesion: Move toward the average position of nearby Boids. Detailed Explanation of the Rules Separation Rule One key behavior in a flock is that each individual tends to move away from nearby neighbors—similar to how like poles of magnets repel each other. The closer they are, the stronger this repelling force becomes. This rule helps prevent collisions and overlapping between Boids.\nHere’s how the separation rule can be implemented in code:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 // Separation Rule Vector2D separation(const std::vector\u0026lt;Boid\u0026gt;\u0026amp; boids) { Vector2D separation(0, 0); for (const Boid\u0026amp; b : boids) { float distance = (b.position - position).length(); if (distance \u0026gt; 0 \u0026amp;\u0026amp; distance \u0026lt; separation_distance) { Vector2D diff = position - b.position; separation = separation + diff * (1.0f / distance); } } return separation; } Alignment Rule In flocking simulations, alignment helps control how each individual adjusts its direction of movement. Intuitively, we often judge whether a group is truly a “flock” or just a random crowd based on whether its members are moving in a similar direction.\nAlignment rule can be implemented like this:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 // Alignment Rule Vector2D alignment(const std::vector\u0026lt;Boid\u0026gt;\u0026amp; boids) { Vector2D avg_velocity(0, 0); int total_neighbors = 0; for (const Boid\u0026amp; b : boids) { float distance = (b.position - position).length(); if (distance \u0026gt; 0 \u0026amp;\u0026amp; distance \u0026lt; neighbor_distance) { avg_velocity = avg_velocity + b.velocity; total_neighbors++; } } if (total_neighbors \u0026gt; 0) { avg_velocity = avg_velocity * (1.0f / total_neighbors); return avg_velocity - velocity; } return Vector2D(0, 0); } Cohesion Rule 与分离规则相反，聚集规则是一种凝聚力，像磁铁的异性相吸。它保证了集群中的个体不会因为分离规则而过度分散，导致无法产生“邻居个体”。\nThe cohesion rule works in the opposite way to separation—it acts like an attractive force, similar to how opposite poles of magnets pull toward each other. It helps prevent individuals from spreading out too far due to separation, ensuring that each Boid still has nearby neighbors to interact with.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 // Cohesion Rule Vector2D cohesion(const std::vector\u0026lt;Boid\u0026gt;\u0026amp; boids) { Vector2D center_of_mass(0, 0); int total_neighbors = 0; for (const Boid\u0026amp; b : boids) { float distance = (b.position - position).length(); if (distance \u0026gt; 0 \u0026amp;\u0026amp; distance \u0026lt; neighbor_distance) { center_of_mass = center_of_mass + b.position; total_neighbors++; } } if (total_neighbors \u0026gt; 0) { center_of_mass = center_of_mass * (1.0f / total_neighbors); return (center_of_mass - position); } return Vector2D(0, 0); } These three rules constantly interact and balance each other. With just basic math and vector operations, simple individuals can exhibit surprisingly complex collective intelligence. In different scenarios, flocking behavior is essentially shaped by fine-tuning the parameters of these rules. For example, in fish schools, we often simulate uneven spacing caused by water resistance by reducing the strength of the alignment rule—this creates a realistic “tail lag” effect when turning. In mouse swarms or zombie hordes, increasing the weight of the cohesion rule helps the group flow smoothly around obstacles like a stream of liquid. In sci-fi-style scenes, we might want the flock to maintain a sharp, triangular formation—this can be achieved by adding shape constraints on top of the cohesion rule. Full Implementation Implementation Approach and Workflow Creating the Boid Class Each individual in the flock needs three key properties: position, velocity, and acceleration. All three are represented as 2D vectors, since this project implements the Boids algorithm in a 2D visual space.\nHere’s how the Boid class can be defined:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 // boid.js class Boid { constructor() { this.position = createVector(width / 2, height / 2); // Position of the boid this.velocity = p5.Vector.random2D(); // Initial velocity of the boid this.velocity.setMag(random(0.5, 1.5 )) // Assign each boid a slightly different speed this.acceleration = createVector();\t// Acceleration of the boid } update(){ this.position.add(this.velocity); this.velocity.add(this.acceleration); } show(){ strokeWeight(16); stroke(255); point(this.position.x, this.position.y); } } Suppose we have 100 boids in total. In sketch.js, we can use an array to store them like this:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 // sketch.js const flock = []; function setup() { createCanvas(640, 360); for (let i = 0; i \u0026lt; 100; i++){ flock.push(new Boid()); } } function draw() { background(51); for (let boid of flock){ boid.show(); boid.update(); } } Now you can see the result looks like this:\nImplementing the Alignment Rule “Alignment is the simplest of the three rules, so we’ll start with it.\nHow it works: For each Boid, we check a circular area around it—using the Boid’s position as the center and a fixed radius. We then calculate the average velocity of all other Boids within that circle (its neighbors). This average velocity becomes the target direction the Boid should steer toward. By comparing its current velocity with the target, we can compute the steering force needed to adjust its movement.\n1 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 // boid.js align(boids){ let perceptionRadius = 50; // A circular detection range with a radius of 50 pixels let steering = createVector(); // The desired steering velocity for this boid let total = 0; // Number of neighboring boids for (let other of boids){ let d = dist(this.position.x, this.position.y, other.position.x, other.position.y); if (other != this \u0026amp;\u0026amp; d \u0026lt; perceptionRadius) { steering.add(other.velocity); // Add the velocity of each neighbor total++; } } if (total \u0026gt; 0){ steering.div(total); // Calculate the average velocity of nearby boids steering.setMag(this.maxSpeed); // Set the magnitude to the boid’s max speed steering.sub(this.velocity); // Target velocity minus current velocity = steering force steering.limit(this.maxForce); // Limit the steering force to avoid sharp turns } return steering; } flock(boids){ let alignment = this.align(boids); this.acceleration = alignment; // Apply the alignment steering force as acceleration // Force = mass × acceleration; assuming mass = 1, force equals acceleration } Now let’s add an effect where boids can “fly in and out” of the screen:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 // boid.js edges(){ if (this.position.x \u0026gt; width){ this.position.x = 0; } else if (this.position.x \u0026lt; 0){ this.position.x = width; } if (this.position.y \u0026gt; height){ this.position.y = 0; } else if (this.position.y \u0026lt; 0){ this.position.y = height; } } This gives us the following alignment effect:\nImplementing the Cohesion Rule This rule is similar to the alignment rule, but instead of calculating the average velocity of nearby Boids, we calculate their average position. This “target position” becomes the point the Boid should move toward to stay close to the group.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 cohesion(boids){ let perceptionRadius = 50; let steering = createVector(); let total = 0; for (let other of boids){ let d = dist(this.position.x, this.position.y, other.position.x, other.position.y); if (other != this \u0026amp;\u0026amp; d \u0026lt; perceptionRadius) { steering.add(other.position); // Add the positions of nearby boids total++; } } if (total \u0026gt; 0){ steering.div(total); // Calculate the average position (target position) steering.sub(this.position); // Target position - current position = desired movement direction steering.setMag(this.maxSpeed); // Set the desired speed steering.sub(this.velocity); // Desired velocity - current velocity = steering force steering.limit(this.maxForce); // Limit the steering force to avoid sharp turns } return steering; } In the cohesion rule, maxForce isn’t the most critical factor. What really matters is the value of perceptionRadius, which determines how far a Boid can “see” its surroundings—and that directly affects its tendency to flock together.\nCombining the two rules is quite straightforward. Just like in basic physics, the total force acting on an object is the sum of all individual forces.\n1 2 3 4 5 6 7 8 9 10 // boid.js flock(boids){ let alignment = this.align(boids); let cohesion = this.cohesion(boids); // Add the two forces together to get the combined steering force this.acceleration.add(alignment); this.acceleration.add(cohesion); } With both rules in effect, the flock now moves along trajectories like this:\nImplementing the Separation Rule Under the separation rule, we calculate the desired velocity of a Boid like this:\nIn essence, separation is the opposite of cohesion. So we can implement separation using a similar approach to cohesion, but instead of steering toward the average position of neighbors, we steer away from them.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 // boid.js separation(boids){ let perceptionRadius = 50; let steering = createVector(); let total = 0; for (let other of boids){ let d = dist(this.position.x, this.position.y, other.position.x, other.position.y); if (other != this \u0026amp;\u0026amp; d \u0026lt; perceptionRadius) { let diff = p5.Vector.sub(this.position, other.position); // Boid position minus neighbor position gives the desired velocity (steering away from the neighbor) diff.div(d); // The farther a neighbor is, the less influence it has; the closer it is, the stronger the effect steering.add(diff); total++; } } if (total \u0026gt; 0){ steering.div(total); steering.setMag(this.maxSpeed); steering.sub(this.velocity); steering.limit(this.maxForce); } return steering; } This is the resulting effect of the separation rule:\nWith that, the basic version of the Boids algorithm is complete. By adjusting the weights of the three rules—alignment, cohesion, and separation—we can fine-tune the flocking behavior to suit different scenarios.\nFull 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 // boid.js class Boid { constructor() { this.position = createVector(random(width), random(height)); // Initial position of the boid this.velocity = p5.Vector.random2D(); // Initial velocity this.velocity.setMag(random(0.5, 2)) // Assign each boid a different speed this.acceleration = createVector(); // Initial acceleration this.maxForce = 0.2; // Maximum steering force this.maxSpeed = 2; // Maximum speed } // Create a wrap-around effect when boids fly off the screen edges(){ if (this.position.x \u0026gt; width){ this.position.x = 0; } else if (this.position.x \u0026lt; 0){ this.position.x = width; } if (this.position.y \u0026gt; height){ this.position.y = 0; } else if (this.position.y \u0026lt; 0){ this.position.y = height; } } align(boids){ let perceptionRadius = 50; // Radius of the detection circle let steering = createVector(); // Desired steering velocity let total = 0; // Number of neighbors for (let other of boids){ let d = dist(this.position.x, this.position.y, other.position.x, other.position.y); if (other != this \u0026amp;\u0026amp; d \u0026lt; perceptionRadius) { steering.add(other.velocity); // Sum up neighbors\u0026#39; velocities total++; } } if (total \u0026gt; 0){ steering.div(total); // Average velocity of nearby boids steering.setMag(this.maxSpeed); // Normalize to max speed steering.sub(this.velocity); // Desired velocity - current velocity = steering force steering.limit(this.maxForce); // Limit the steering force } return steering; } cohesion(boids){ let perceptionRadius = 100; let steering = createVector(); let total = 0; for (let other of boids){ let d = dist(this.position.x, this.position.y, other.position.x, other.position.y); if (other != this \u0026amp;\u0026amp; d \u0026lt; perceptionRadius) { steering.add(other.position); // Sum up neighbors\u0026#39; positions total++; } } if (total \u0026gt; 0){ steering.div(total); // Average position (center of mass) steering.sub(this.position); // Target position - current position = desired movement steering.setMag(this.maxSpeed); // Normalize to max speed steering.sub(this.velocity); // Desired velocity - current velocity = steering force steering.limit(this.maxForce); // Limit the cohesion force } return steering; } separation(boids){ let perceptionRadius = 50; let steering = createVector(); let total = 0; for (let other of boids){ let d = dist(this.position.x, this.position.y, other.position.x, other.position.y); if (other != this \u0026amp;\u0026amp; d \u0026lt; perceptionRadius) { let diff = p5.Vector.sub(this.position, other.position); // Vector pointing away from neighbor diff.div(d); // Weight by inverse distance steering.add(diff); total++; } } if (total \u0026gt; 0){ steering.div(total); steering.setMag(this.maxSpeed); steering.sub(this.velocity); steering.limit(this.maxForce); } return steering; } flock(boids){ let alignment = this.align(boids); let cohesion = this.cohesion(boids); let separation = this.separation(boids); // Apply rule weights alignment.mult(alignSlider.value()); cohesion.mult(cohesionSlider.value()); separation.mult(separationSlider.value()); // Combine all steering forces this.acceleration.add(alignment); this.acceleration.add(cohesion); this.acceleration.add(separation); } update(){ this.position.add(this.velocity); this.velocity.add(this.acceleration); this.velocity.limit(this.maxSpeed); this.acceleration.set(0, 0); // Reset acceleration to avoid compounding force } show() { strokeWeight(8); stroke(255); point(this.position.x, this.position.y); } } 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 // sketch.js const flock = []; let alignSlider, cohesionSlider, separationSlider function setup() { createCanvas(640, 360); alignSlider = createSlider(0, 5, 1, 0.1); cohesionSlider = createSlider(0, 5, 1, 0.1); separationSlider = createSlider(0, 5, 1, 0.1); for (let i = 0; i \u0026lt; 100; i++){ flock.push(new Boid()); } } function draw() { background(51); for (let boid of flock){ boid.edges(); boid.flock(flock); boid.show(); boid.update(); } } ","date":"2025-10-18T20:37:14+02:00","image":"https://nullshowjl.github.io/en/p/algorithm-implementationboids-flocking-behavior/cover_hu_cc4cff5d381393a4.webp","permalink":"https://nullshowjl.github.io/en/p/algorithm-implementationboids-flocking-behavior/","title":"【Algorithm Implementation】Boids Flocking Behavior"},{"content":"Table of Contents\nWhy Do We Need Smart Pointers? Fundamental Principles of Smart Pointers Using Smart Pointers Effectively Evolution of Smart Pointers in C++ Custom Deleters Supplement 1: RAII in Practice – Lock Guards Supplement 2: Understanding Memory Leaks Reference Resource: BiteTech C++ Course\nWhy Do We Need Smart Pointers? Let\u0026rsquo;s start with an example:\n1 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 int div() { int a, b; cin \u0026gt;\u0026gt; a \u0026gt;\u0026gt; b; if (b == 0) throw invalid_argument(\u0026#34;Error: Divident is 0\u0026#34;); return a / b; } void func() { int* p = new int(); cout \u0026lt;\u0026lt; div() \u0026lt;\u0026lt; endl; delete p; } int main() { // Catching Exceptions try { func(); } catch(exception\u0026amp; e) { cout \u0026lt;\u0026lt; e.what() \u0026lt;\u0026lt; endl; } return 0; } Since the div function may throw an exception, the normal execution flow of the program can be interrupted. As a result, div might not complete, and the pointer p won\u0026rsquo;t be released. Typically, exceptions are caught at the outermost level, but in this case—without using smart pointers—you would have to add another try-catch block inside func to ensure that p gets properly deleted and memory isn\u0026rsquo;t leaked.\n1 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 int div() { int a, b; cin \u0026gt;\u0026gt; a \u0026gt;\u0026gt; b; if (b == 0) throw invalid_argument(\u0026#34;Error: Divident is 0\u0026#34;); return a / b; } void func() { int* p = new int(); try\t// Add exception handling in func to ensure p is properly released { cout \u0026lt;\u0026lt; div() \u0026lt;\u0026lt; endl; } catch (exception\u0026amp; e)\t// Alternatively, catch all exceptions using: catch (...) { delete p; p = nullptr; throw e; } delete p; p = nullptr; } int main() { // Catching Exceptions try { func(); } catch(exception\u0026amp; e) { cout \u0026lt;\u0026lt; e.what() \u0026lt;\u0026lt; endl; } return 0; } When working with code that involves multiple raw pointers, preventing memory leaks often requires adding nested layers of exception-handling logic. To address this complexity in a cleaner and more elegant way, seasoned developers came up with a better solution: smart pointers.\nFundamental Principles of Smart Pointers The implementation principle of smart pointers is quite straightforward: store the raw pointer in the constructor, and release it in the destructor.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 template\u0026lt;class T\u0026gt; class SmartPtr { public: SmartPtr(T* ptr) :_ptr(ptr) {} ~SmartPtr() { if (_ptr) { delete _ptr; _ptr = nullptr; } } private: T*_ptr; }; So, the messy and exception-sensitive code above can be simplified by using smart pointers:\n1 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 int div() { int a, b; cin \u0026gt;\u0026gt; a \u0026gt;\u0026gt; b; if (b == 0) throw invalid_argument(\u0026#34;Error: Divident is 0\u0026#34;); return a / b; } void func() { int* p = new int(); // No need for exception handling—use smart pointers instead SmartPtr\u0026lt;int\u0026gt; sp(p); cout \u0026lt;\u0026lt; div() \u0026lt;\u0026lt; endl; } int main() { // Catching Exceptions try { func(); } catch(exception\u0026amp; e) { cout \u0026lt;\u0026lt; e.what() \u0026lt;\u0026lt; endl; } return 0; } Regardless of whether an exception is thrown, the pointer p will be properly released. This is because the SmartPtr object’s lifecycle ensures that the resource is cleaned up even if the function exits prematurely due to an exception. In short, SmartPtr takes care of resource management for us. Whether the function completes normally or is interrupted by an exception, the sp object will go out of scope, triggering its destructor and releasing the memory held by p. Essentially, we are delegating resource ownership to the smart pointer, which stores the resource in its constructor and releases it in its destructor.\nRAII RAII (Resource Acquisition Is Initialization) is a technique that uses object lifetimes to manage program resources such as memory, file handles, network connections, mutexes, and more.\nIn other words, a resource is acquired during object construction, remains valid throughout the object’s lifetime, and is released when the object is destroyed. This approach effectively delegates resource management to an object.\nRAII offers two major benefits:\nNo need to manually release resources Resources remain valid throughout the object’s lifetime This technique indirectly addresses the lack of a garbage collection mechanism in C++.\nRAII is a resource management paradigm. In addition to smart pointers, constructs like unique_lock and lock_guard are also built upon this idea. Smart pointers are designed as RAII-based classes. Using Smart Pointers Effectively The SmartPtr implementation above can\u0026rsquo;t yet be considered a true smart pointer, because it doesn\u0026rsquo;t behave like a regular pointer. To make it functionally complete, we need to add some code that enables standard pointer operations.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 template\u0026lt;class T\u0026gt; class SmartPtr { public: SmartPtr(T* ptr = nullptr) : _ptr(ptr) {} ~SmartPtr() { if(_ptr) { delete _ptr; _ptr = nullptr; }\t} // Overload operators \u0026amp; and * to give SmartPtr pointer-like behavior T\u0026amp; operator*() {return *_ptr;} T* operator-\u0026gt;() {return _ptr;} private: T* _ptr; }; Potential Pitfalls of Smart Pointers Let’s start with an example:\n1 2 3 4 5 6 7 int main() { SmartPtr\u0026lt;int\u0026gt; sp1(new int()); SmartPtr\u0026lt;int\u0026gt; sp2 = sp1; return 0; } Here, both sp1 and sp2 point to the same memory location. When the program exits, both objects invoke their destructors, resulting in the same resource being deleted twice—a classic case of double deletion.\nTo address this issue, smart pointers evolved into three distinct strategies:\nOwnership transfer: auto_ptr Copy prevention: unique_ptr Shared ownership via reference counting: shared_ptr Which can lead to circular references, resolved by introducing weak_ptr auto_ptr Overall, auto_ptr is considered a flawed design. It breaks the intuitive behavior of pointers, and many companies explicitly forbid its use.\nHow auto_ptr Works 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 // C++98 ownership transfer with auto_ptr namespace my_smart_pointer { template\u0026lt;class T\u0026gt; class auto_ptr { public: auto_ptr(T* ptr) :_ptr(ptr) {} auto_ptr(auto_ptr\u0026lt;T\u0026gt;\u0026amp; sp) :_ptr(sp._ptr) { // Transfer ownership sp._ptr = nullptr; } auto_ptr\u0026lt;T\u0026gt;\u0026amp; operator=(auto_ptr\u0026lt;T\u0026gt;\u0026amp; ap) { // Check for self-assignment if (this != \u0026amp;ap) { // Release the resource held by this object if (_ptr) { delete _ptr; _ptr = nullptr; } // Transfer the resource from ap to this object _ptr = ap._ptr; ap._ptr = nullptr; } return *this; } ~auto_ptr() { if (_ptr) { cout \u0026lt;\u0026lt; \u0026#34;delete:\u0026#34; \u0026lt;\u0026lt; _ptr \u0026lt;\u0026lt; endl; delete _ptr; _ptr = nullptr; } } // Use like a regular pointer T\u0026amp; operator*() { return *_ptr; } T* operator-\u0026gt;() { return _ptr; } private: T* _ptr; }; } It may cause a null pointer error:\n1 2 3 4 5 6 7 8 9 10 11 12 13 // main.cpp int main() { std::auto_ptr\u0026lt;int\u0026gt; sp1(new int()); std::auto_ptr\u0026lt;int\u0026gt; sp2(sp1); // Ownership transfer // sp1 is dangling and can no longer be assigned //*sp1 = 10; *sp2 = 10; cout \u0026lt;\u0026lt; *sp2 \u0026lt;\u0026lt; endl; cout \u0026lt;\u0026lt; *sp1 \u0026lt;\u0026lt; endl; return 0; } unique_ptr A straightforward and aggressive solution is to simply prevent copying. This is the recommended approach, but it becomes unusable in scenarios where copying is actually required.\nHow unique_ptr Works 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 namespace my_smart_pointer { template\u0026lt;class T\u0026gt; class unique_ptr { public: unique_ptr(T* ptr) :_ptr(ptr) {} ~unique_ptr() { if (_ptr) { cout \u0026lt;\u0026lt; \u0026#34;delete:\u0026#34; \u0026lt;\u0026lt; _ptr \u0026lt;\u0026lt; endl; delete _ptr; _ptr = nullptr; } } T\u0026amp; operator*() { return *_ptr; } T* operator-\u0026gt;() { return _ptr; } unique_ptr(const unique_ptr\u0026lt;T\u0026gt;\u0026amp; sp) = delete; unique_ptr\u0026lt;T\u0026gt;\u0026amp; operator=(const unique_ptr\u0026lt;T\u0026gt;\u0026amp; sp) = delete; private: T* _ptr; }; } shared_ptr Shared ownership among multiple shared_ptr instances is achieved through reference counting.\nHow shared_ptr Works Four key points:\nInternally, shared_ptr maintains a reference count for each resource to track how many objects share it. When a shared_ptr is destroyed (i.e., its destructor is called), it signals that the object no longer uses the resource, and the reference count is decremented by one. If the reference count reaches zero, it means this was the last owner of the resource, and the resource must be released. If the reference count is not zero, it means other objects are still using the resource, so it must not be released—otherwise, they would be left with dangling pointers. 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 namespace my_smart_pointer { template\u0026lt;class T\u0026gt; class shared_ptr { public: shared_ptr(T* ptr = nullptr) :_ptr(ptr) , _pRefCount(new int(1)) {} shared_ptr(const shared_ptr\u0026lt;T\u0026gt;\u0026amp; sp) :_ptr(sp._ptr) , _pRefCount(sp._pRefCount) { ++(*_pRefCount); } ~shared_ptr() { Release(); } shared_ptr\u0026lt;T\u0026gt;\u0026amp; operator=(const shared_ptr\u0026lt;T\u0026gt;\u0026amp; sp) { //if (this != \u0026amp;sp) if (_ptr != sp._ptr) { Release();\t// First release the resource previously managed by this object _ptr = sp._ptr; _pRefCount = sp._pRefCount; ++(*_pRefCount); } return *this; } void Release() { if (--(*_pRefCount) == 0 \u0026amp;\u0026amp; _ptr) { cout \u0026lt;\u0026lt; \u0026#34;delete:\u0026#34; \u0026lt;\u0026lt; _ptr \u0026lt;\u0026lt; endl; delete _ptr; _ptr = nullptr; delete _pRefCount; _pRefCount = nullptr; } } T\u0026amp; operator*() { return *_ptr; } T* operator-\u0026gt;() { return _ptr; } private: T* _ptr; int* _pRefCount; }; } Thread Safety Issues with shared_ptr During Copy Assignment Since the reference count is stored on the heap, problems can arise when two threads—say, t1 and t2—each hold a smart pointer and simultaneously perform copy assignment from the same shared_ptr instance sp. This can lead to both threads trying to increment or decrement the reference count at the same time, resulting in incorrect behavior.\nHere’s a simplified illustration of the issue:\nWe can examine the thread safety of shared_ptr from two perspectives:\nThe reference count is shared among multiple shared_ptr instances. If two threads simultaneously increment or decrement the count (e.g., ++ or --), these operations are not atomic. For example, if the count starts at 1 and both threads increment it, the result might still be 2—leading to inconsistencies. This can cause resources to be leaked or prematurely destroyed, resulting in crashes. Therefore, reference count operations must be synchronized. In other words, shared_ptr ensures thread safety for reference counting. The actual object managed by the smart pointer resides on the heap. If two threads access it concurrently, thread safety issues may arise depending on how the object itself is used. Here’s the revised version of the code that addresses these concerns:\n1 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 namespace my_smart_pointer { template\u0026lt;class T\u0026gt; class shared_ptr { public: shared_ptr(T* ptr = nullptr) :_ptr(ptr) , _pRefCount(new int(1)) , _pmtx(new mutex) {} shared_ptr(const shared_ptr\u0026lt;T\u0026gt;\u0026amp; sp) :_ptr(sp._ptr) , _pRefCount(sp._pRefCount) , _pmtx(sp._pmtx) { AddRef(); } void Release()\t// Decrementing the reference count requires lock protection { _pmtx-\u0026gt;lock(); bool flag = false; if (--(*_pRefCount) == 0 \u0026amp;\u0026amp; _ptr) { cout \u0026lt;\u0026lt; \u0026#34;delete:\u0026#34; \u0026lt;\u0026lt; _ptr \u0026lt;\u0026lt; endl; delete _ptr; _ptr = nullptr; delete _pRefCount; _pRefCount = nullptr; flag = true; } _pmtx-\u0026gt;unlock(); if (flag == true)\t{ delete _pmtx; _pmtx = nullptr; } } void AddRef()\t// Incrementing the reference count also requires lock protection { _pmtx-\u0026gt;lock(); ++(*_pRefCount); _pmtx-\u0026gt;unlock(); } shared_ptr\u0026lt;T\u0026gt;\u0026amp; operator=(const shared_ptr\u0026lt;T\u0026gt;\u0026amp; sp) { //if (this != \u0026amp;sp) if (_ptr != sp._ptr) { Release(); _ptr = sp._ptr; _pRefCount = sp._pRefCount; _pmtx = sp._pmtx; AddRef(); } return *this; } int use_count() { return *_pRefCount; } ~shared_ptr() { Release(); } T\u0026amp; operator*() { return *_ptr; } T* operator-\u0026gt;() { return _ptr; } T* get() const { return _ptr; } private: T* _ptr; int* _pRefCount; mutex* _pmtx; }; } Smart pointer implementations in the C++ standard library are thread-safe by design.\nLimitation of shared_ptr: Circular References What Is a Circular Reference? Let’s look at a typical scenario:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 struct ListNode { int _data; shared_ptr\u0026lt;ListNode\u0026gt; _prev; shared_ptr\u0026lt;ListNode\u0026gt; _next; ~ListNode(){ cout \u0026lt;\u0026lt; \u0026#34;~ListNode()\u0026#34; \u0026lt;\u0026lt; endl; } }; int main() { shared_ptr\u0026lt;ListNode\u0026gt; node1(new ListNode); shared_ptr\u0026lt;ListNode\u0026gt; node2(new ListNode); cout \u0026lt;\u0026lt; node1.use_count() \u0026lt;\u0026lt; endl; cout \u0026lt;\u0026lt; node2.use_count() \u0026lt;\u0026lt; endl; // Circular reference prevents resource from being released node1-\u0026gt;_next = node2; node2-\u0026gt;_prev = node1; cout \u0026lt;\u0026lt; node1.use_count() \u0026lt;\u0026lt; endl; cout \u0026lt;\u0026lt; node2.use_count() \u0026lt;\u0026lt; endl; return 0; } In this scenario, the following situation occurs:\nnode1 and node2 are two smart pointer objects pointing to two separate nodes. Their reference counts are both 1, so there\u0026rsquo;s no need to manually call delete. node1\u0026rsquo;s _next points to node2, and node2\u0026rsquo;s _prev points back to node1. As a result, both reference counts become 2. When node1 and node2 are destroyed, their reference counts drop to 1. However, node1 still holds _next, which points to node2, and node2 still holds _prev, which points to node1. In theory, if _next were destroyed, node2 would be released; if _prev were destroyed, node1 would be released. But _next is a member of node1, and node1 is managed by node2\u0026rsquo;s _prev. _prev is a member of node2, which is managed by node1\u0026rsquo;s _next. This creates a circular reference, where each object indirectly keeps the other alive. As a result, neither object is ever released. This is essentially a \u0026ldquo;chicken-and-egg\u0026rdquo; problem in memory management.\nHow to Solve It: weak_ptr Strictly speaking, weak_ptr is not a smart pointer in the traditional sense, because it doesn\u0026rsquo;t follow the RAII (Resource Acquisition Is Initialization) principle. It behaves like a pointer, but it doesn\u0026rsquo;t manage or release resources. Instead, it was specifically designed to break circular references caused by shared_ptr.\nBased on the earlier implementation of shared_ptr, a simplified version of weak_ptr might look like this (the actual implementation in the C++ standard library is much more complex):\n1 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 namespace my_smart_pointer { template\u0026lt;class T\u0026gt; class weak_ptr { public: weak_ptr() :_ptr(nullptr) {} weak_ptr(const shared_ptr\u0026lt;T\u0026gt;\u0026amp; sp) :_ptr(sp.get()) {} weak_ptr\u0026lt;T\u0026gt;\u0026amp; operator=(const shared_ptr\u0026lt;T\u0026gt;\u0026amp; sp) { _ptr = sp.get(); return *this; } T\u0026amp; operator*() { return *_ptr; } T* operator-\u0026gt;() { return _ptr; } private: T* _ptr; }; } So, in the previous example, we can refactor the code to use weak_ptr. This way, when we assign node1-\u0026gt;_next = node2; and node2-\u0026gt;_prev = node1;, the _next and _prev members—now implemented as weak_ptr—won’t increase the reference counts of node1 and node2. As a result, we avoid the circular reference problem entirely.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 struct ListNode { int _data; weak_ptr\u0026lt;ListNode\u0026gt; _prev;\t// Replace shared_ptr with weak_ptr here to avoid circular reference weak_ptr\u0026lt;ListNode\u0026gt; _next; ~ListNode(){ cout \u0026lt;\u0026lt; \u0026#34;~ListNode()\u0026#34; \u0026lt;\u0026lt; endl; } }; int main() { shared_ptr\u0026lt;ListNode\u0026gt; node1(new ListNode); shared_ptr\u0026lt;ListNode\u0026gt; node2(new ListNode); cout \u0026lt;\u0026lt; node1.use_count() \u0026lt;\u0026lt; endl; cout \u0026lt;\u0026lt; node2.use_count() \u0026lt;\u0026lt; endl; node1-\u0026gt;_next = node2; node2-\u0026gt;_prev = node1; cout \u0026lt;\u0026lt; node1.use_count() \u0026lt;\u0026lt; endl; cout \u0026lt;\u0026lt; node2.use_count() \u0026lt;\u0026lt; endl; return 0; } Evolution of Smart Pointers in C++ C++ does not have a built-in garbage collection (GC) mechanism. This means that manually releasing allocated resources is a persistent challenge—especially when dealing with exception safety. A small oversight can easily lead to memory leaks. Such leaks gradually consume available memory, and since many operations in a program depend on memory, it\u0026rsquo;s crucial to avoid them as much as possible.\nTo address this, smart pointers were introduced based on the RAII (Resource Acquisition Is Initialization) principle.\nPhase One:\nC++98 introduced auto_ptr for the first time. However, its design had serious flaws and is no longer recommended for use.\nPhase Two:\nFor the next decade, the C++ standard made little progress in this area. In response, a group of experts created an unofficial community project known as Boost, which included a wide range of utilities. Within Boost, they redesigned smart pointers:\nscoped_ptr / scoped_array (non-copyable versions) shared_ptr / shared_array (reference-counted versions) weak_ptr (designed to solve circular reference issues) scoped_ptr is intended for managing single objects allocated with new, and releases them using delete in its destructor. scoped_array is for managing arrays allocated with new[], and releases them using delete[]. It also overloads the operator[] for array access. Phase Three:\nC++11 officially introduced smart pointers into the standard library, drawing inspiration from Boost’s implementation with some refinements. Features like rvalue references and move semantics in C++11 were also heavily influenced by Boost.\nCustom Deleters The official C++ standard library did not adopt the scoped_array and shared_array implementations from the Boost library. So how does it handle situations where multiple objects are allocated with new[]? We can solve this by writing a custom functor-based deleter.\n1 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 // Functor-based custom deleter template\u0026lt;class T\u0026gt; struct FreeFunc { void operator()(T* ptr) { cout \u0026lt;\u0026lt; \u0026#34;free:\u0026#34; \u0026lt;\u0026lt; ptr \u0026lt;\u0026lt; endl; free(ptr); } }; template\u0026lt;class T\u0026gt; struct DeleteArrayFunc { void operator()(T* ptr) { cout \u0026lt;\u0026lt; \u0026#34;delete[]\u0026#34; \u0026lt;\u0026lt; ptr \u0026lt;\u0026lt; endl; delete[] ptr; } }; int main() { FreeFunc\u0026lt;int\u0026gt; freeFunc; std::shared_ptr\u0026lt;int\u0026gt; sp1((int*)malloc(4), freeFunc); DeleteArrayFunc\u0026lt;int\u0026gt; deleteArrayFunc; std::shared_ptr\u0026lt;int\u0026gt; sp2((int*)malloc(4), deleteArrayFunc); std::shared_ptr\u0026lt;A\u0026gt; sp4(new A[10], [](A* p){delete[] p; }); std::shared_ptr\u0026lt;FILE\u0026gt; sp5(fopen(\u0026#34;test.txt\u0026#34;, \u0026#34;w\u0026#34;), [](FILE* p) { fclose(p); }); return 0; } Essentially, we pass a functor object to the smart pointer that defines how the resource should be released. Since the default deletion behavior in the C++ standard library\u0026rsquo;s smart pointers is delete for single objects, we can handle other cases—such as arrays or custom cleanup logic—by writing our own functor-based deleters.\nSupplement 1: RAII in Practice – Lock Guards Throwing exceptions can not only lead to memory leaks, but also cause deadlocks in certain scenarios. For example:\n1 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 #include \u0026lt;mutex\u0026gt; int div() { int a, b; cin \u0026gt;\u0026gt; a \u0026gt;\u0026gt; b; if (b == 0) throw invalid_argument(\u0026#34;Error: Divident is 0\u0026#34;); return a / b; } void func() { mutex mtx; mtx.lock(); cout \u0026lt;\u0026lt; div() \u0026lt;\u0026lt; endl;\t// If div throws an exception, the following unlock won\u0026#39;t be executed — leading to a deadlock mtx.unlock(); } int main() { try { func(); } catch(exception\u0026amp; e) { cout \u0026lt;\u0026lt; e.what() \u0026lt;\u0026lt; endl; } return 0; } By applying the RAII principle, we can encapsulate the locking and unlocking logic within a class constructor and destructor. This effectively solves the problem mentioned above.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 template\u0026lt;class Lock\u0026gt; class LockGuard { public: LockGuard(Lock\u0026amp; lock) :_lock(lock) { _lock.lock();\t// Place the locking logic inside the constructor } ~LockGuard() { cout \u0026lt;\u0026lt; \u0026#34;unlock\u0026#34; \u0026lt;\u0026lt; endl; _lock.unlock();\t// Place the unlocking logic inside the destructor } // Copying and assignment are not allowed LockGuard(LockGuard\u0026lt;Lock\u0026gt;\u0026amp;) = delete; LockGuard\u0026lt;Lock\u0026gt;\u0026amp; operator=(LockGuard\u0026lt;Lock\u0026gt;\u0026amp;) = delete; private: Lock\u0026amp; _lock;\t// Locks are non-copyable — this example uses a reference member to ensure the same lock is used } Apply the LockGuard class within the func function:\n1 2 3 4 5 6 7 void func() { mutex mtx; LockGuard\u0026lt;mutex\u0026gt; lock_guard(mtx);\t// Use LockGuard to manage locking and unlocking automatically cout \u0026lt;\u0026lt; div() \u0026lt;\u0026lt; endl;\t} Supplement 2: Understanding Memory Leaks What Is a Memory Leak?\nA memory leak occurs when a program fails to release memory or resources that are no longer in use—often due to negligence, coding errors, or poor exception safety. It doesn\u0026rsquo;t mean the memory physically disappears; rather, the application allocates a block of memory but, due to flawed design, loses control over it, resulting in wasted memory.\nIf the process terminates normally, the operating system will reclaim the memory. So in many cases, restarting the program can temporarily resolve the issue.\nWhy Memory Leaks Are Harmful\nMemory leaks can have serious consequences for long-running programs—such as operating systems or backend services that cannot be restarted casually. Over time, leaks cause available memory to shrink, leading to slower response times, system freezes, or failures in operations that require memory (e.g., storing data in containers, opening files, creating sockets, sending data).\nHow to Prevent Memory Leaks\nBe cautious when writing C/C++ code. Use smart pointers to manage resources in complex or error-prone areas (preventive strategy). If you suspect a memory leak, use diagnostic tools to detect and resolve it (reactive strategy). Recommended tool: Valgrind — a powerful memory analysis tool for Linux. ","date":"2025-10-17T20:42:14+02:00","image":"https://nullshowjl.github.io/en/p/c-syntaxsmart-pointer/cover_hu_f0eee2b9d6486c4.webp","permalink":"https://nullshowjl.github.io/en/p/c-syntaxsmart-pointer/","title":"【C++ Syntax】Smart Pointer"},{"content":"Table of Contents\nDevelopment Workflow – Gameplay Layer Key Steps and Solutions – Gameplay Layer Overall Code Review and Source Code Reflection and Summary This article dives into the implementation of the gameplay layer in detail and concludes with a link to the full project source code. If you’d like to revisit the overall project structure and framework design, check out the companion post: Plant Star Brawl - Framework Design.\nDevelopment Workflow - Gameplay Layer Main Menu and Character Selection Screens\nChallenge 1: Where should the main camera be placed, given that the project uses a camera class? Solution: Pass the camera as a parameter to rendering functions. Challenge 2: How can we give text a 3D or embossed effect? Solution: Render the text twice—once in white at the original position, and once in gray slightly offset downward and to the right. Challenge 3: How can we create a scrolling silhouette effect on the selection screen background Solution: Draw the same image twice with offset positions to simulate motion. In-Game Scene Setup and Physics Simulation\nChallenge 1: How do we simulate gravity? Solution: Gravity is represented by falling and stopping. Encapsulate platform logic in a dedicated class. Challenge 2: How can we visually inspect collision data? Solution: Implement a simple debug mode to visualize hitboxes and physics boundaries. Issue 1: Why does the player sometimes teleport upward during a fall? Solution: Use the player’s foot position from the previous frame to determine whether to correct their position in the current frame. Issue 2: How do we prevent repeated jumping (multi-jump bugs)? Solution: Ensure the player can only jump when their vertical velocity is zero. Bullet Base Class Implementation\nCore Gameplay Mechanic: Players deal damage using projectile-like bullets. Solution: Create a base Bullet class, with specific bullet types inheriting from it. Challenge: How should bullets disappear after collision? Solution: Use callback functions, similar to how death animations are handled for players. Bullet Deletion Optimization Pea Bullet Subclass Implementation\nSun Bullet Subclass, Sun Bullet Ex Subclass Implementation\nNote: Adjust sprite offsets during explosion animations for accurate visual alignment. Attack Skill System\nUse timers to track cooldown durations for special abilities Invincibility Frames\nSolution: During certain animations, alternate between normal sprites and pure white silhouettes to indicate invulnerability. Player Status Bar (Health and energy levels)\nParticle System\nWin/Loss Detection and Endgame Effects\nKey Steps and Solutions - Gameplay Layer Building the Main Menu Scene and Character Selection Scene Handling the Camera Where should the main camera be placed?\nSince this project includes a dedicated Camera class, each scene must be able to access the camera object during rendering to draw the game world relative to its position. There are three possible approaches:\nOption 1: Define the camera as a member variable inside each scene class. However, this makes it difficult to share camera data across scenes.\nOption 2: Define the camera as a global variable, similar to how image resources are handled. We could use the extern keyword to access it. But global variables tend to clutter the design and conflict with our goal of encapsulating data and minimizing global state.\nOption 3: Take inspiration from how delta (elapsed time) is passed into update functions like void on_update(int delta). We apply the same idea to rendering: pass the camera as a parameter using void on_draw(const Camera\u0026amp; camera).\nImplementing the Main Menu Scene Once the camera handling strategy is decided, here’s the full implementation of the main menu scene:\n1 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 extern IMAGE img_menu_background; extern SceneManager scene_manager; class MenuScene : public Scene { public: MenuScene() = default; ~MenuScene() = default; // Override necessary virtual functions from the base Scene class void on_enter() { mciSendString(_T(\u0026#34;play bgm_menu repeat from 0\u0026#34;), NULL, 0, NULL); } void on_update(int delta) { } void on_draw(const Camera\u0026amp; camera) { putimage(0, 0, \u0026amp;img_menu_background); } void on_input(const ExMessage\u0026amp; msg) { if (msg.message == WM_KEYUP) { mciSendString(_T(\u0026#34;play ui_confirm from 0\u0026#34;), NULL, 0, NULL); scene_manager.switch_to(SceneManager::SceneType::Selector); } } void on_exit() { } private: }; Implementing the Selector Scene Character Type Design Tip We start by defining an enumeration for the available player character types. Why include an “Invalid” character type? It acts as a safeguard to ensure that selection logic doesn’t go out of bounds. This helps prevent bugs when navigating the character list and makes the system more robust.\n1 2 3 4 5 6 enum class PlayerType { Peashooter = 0, Sunflower, Invalid }; Example: Switching Player 1’s Character to the Left\nTo implement leftward character selection for Player 1, we follow a safe and bounded approach using the PlayerType enumeration:\nConvert the current enum value to an int and subtract 1 This moves the selection one step to the left. Add the int value of PlayerType::Invalid This ensures the result is non-negative, even if the original value was 0. Take the result modulo PlayerType::Invalid This wraps the value around if it goes below the first valid type, keeping it within bounds. Cast the final result back to PlayerType This gives us a valid enum value within the range [Peashooter, Invalid)—a half-open interval that excludes the Invalid sentinel. This technique guarantees that the selected character type always stays within the valid range, and avoids out-of-bounds errors when navigating the character list.\n1 2 3 4 5 6 7 8 9 10 case WM_KEYUP: switch (msg.vkcode) { case 0x41: // \u0026#39;A\u0026#39; is_btn_1P_left_down = false; player_type_1 = (PlayerType)(((int)PlayerType::Invalid + (int)player_type_1 - 1) % (int)PlayerType::Invalid); mciSendString(_T(\u0026#34;play ui_switch from 0\u0026#34;), NULL, 0, NULL); break; // ...... } Integrating the Camera into the Animation Class Since every animation needs to access the camera’s position during rendering, and this logic is repeated across all animation draws, we apply object-oriented encapsulation to streamline it.\nInstead of calculating world position - camera position externally, we move this logic into the Animation class itself. As a result, the on_draw method is updated to: void on_draw(const Camera\u0026amp; camera, int x, int y) const . This allows each animation to handle its own coordinate transformation internally, keeping rendering logic clean and consistent.\nImplementing the Scrolling Silhouette Background Effect A simple way to create a scrolling background is to render the same image twice. Imagine a vertical reference line moving from the left edge of the screen to the right. When it reaches the far edge, it jumps back to the start.\nWe draw one copy of the image to the left of the line, and another to the right. This creates a seamless scrolling loop.\nUpdate Logic:\n1 2 3 4 5 6 7 8 9 10 11 12 void on_update(int delta) { animation_peashooter.on_update(delta); animation_sunflower.on_update(delta); // Scroll background horizontally selector_background_scroll_offset_x += 5; if (selector_background_scroll_offset_x \u0026gt;= img_peashooter_selector_background_left.getwidth()) { selector_background_scroll_offset_x = 0; } } To render cropped sections of an image with alpha blending, we overload putimage_alpha:\n1 2 3 4 5 6 7 8 inline void putimage_alpha(int dst_x, int dst_y, int width, int height, IMAGE* img, int src_x, int src_y) { int w = width \u0026gt; 0 ? width : img-\u0026gt;getwidth(); int h = height \u0026gt; 0 ? height : img-\u0026gt;getheight(); AlphaBlend(GetImageHDC(GetWorkingImage()), dst_x, dst_y, w, h, GetImageHDC(img), src_x, src_y, w, h, { AC_SRC_OVER, 0 , 255, AC_SRC_ALPHA }); } We scroll Player 2’s silhouette across Player 1’s background, and vice versa:\n1 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 void on_draw(const Camera\u0026amp; camera) { IMAGE* img_P1_selector_background = nullptr; IMAGE* img_P2_selector_background = nullptr; // Assign scrolling backgrounds based on opponent\u0026#39;s character switch (player_type_2) { case PlayerType::Peashooter: img_P1_selector_background = \u0026amp;img_peashooter_selector_background_right; break; case PlayerType::Sunflower: img_P1_selector_background = \u0026amp;img_sunflower_selector_background_right; break; default: img_P1_selector_background = \u0026amp;img_peashooter_selector_background_right; break; } switch (player_type_1) { case PlayerType::Peashooter: img_P2_selector_background = \u0026amp;img_peashooter_selector_background_left; break; case PlayerType::Sunflower: img_P2_selector_background = \u0026amp;img_sunflower_selector_background_left; break; default: img_P2_selector_background = \u0026amp;img_peashooter_selector_background_left; break; } putimage(0, 0, \u0026amp;img_selector_background); // Draw static background\t// Draw dynamic scrolling silhouettes putimage_alpha(getwidth() - selector_background_scroll_offset_x, 0, img_P2_selector_background); putimage_alpha(getwidth() - img_P2_selector_background-\u0026gt;getwidth(), 0, img_P2_selector_background-\u0026gt;getwidth() - selector_background_scroll_offset_x, 0, img_P2_selector_background, selector_background_scroll_offset_x, 0); putimage_alpha(selector_background_scroll_offset_x - img_P1_selector_background-\u0026gt;getwidth(), 0, img_P1_selector_background); putimage_alpha(selector_background_scroll_offset_x, 0, img_P1_selector_background-\u0026gt;getwidth() - selector_background_scroll_offset_x, 0, img_P1_selector_background, 0, 0); putimage_alpha(pos_img_VS.x, pos_img_VS.y, \u0026amp;img_VS); // Draw \u0026#34;VS\u0026#34; graphic // ...... } Physics Engine Implementation Platform Class Design We start by designing the platform collider based on its functional role. In most 2D platformer games, platforms are typically one-way colliders—players can land on them from above, but jump through them from below. This means we only need to determine where the player can “stand,” so we abstract the platform collider as a single horizontal line.\nIt’s important to note that while the platform’s position is stored in the collider structure, we still need to separately track its rendering position. This is because the platform image has thickness, and its top edge may not align with the collision line. Typically, the collision line is slightly above the visible top of the platform image, which better matches player expectations visually.\nThis design also aligns with our broader architectural principle of separating data logic from rendering. During collision detection, we only care about the CollisionShape data. During rendering, we only care about the image and its draw position. This reinforces decoupling and keeps the code clean.\nTo support debugging, we add a simple debug mode that draws the collision line when enabled. Here’s the full implementation of the Platform class:\n1 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 extern bool is_debug; class Platform { public: struct CollisionShape // Describes the collider shape { float y; float x_left; float x_right; }; public: CollisionShape shape; // Decoupled logic and rendering IMAGE* img = nullptr; POINT render_position = { 0 }; public: Platform() = default; ~Platform() = default; void on_draw(const Camera\u0026amp; camera) const { putimage_alpha(camera, render_position.x, render_position.y, img); // Draw collision line if debug mode is enabled if (is_debug) { setlinecolor(RGB(255, 0, 0)); line(camera, (int)shape.x_left, (int)shape.y, (int)shape.x_right, (int)shape.y); } } private: }; Player Base Class Design All shared data and logic for players are encapsulated in a base Player class. Specific characters like Peashooter and Sunflower inherit from this base and implement their own behavior.\nHow are player objects instantiated?\nSince there are two player types, the actual class to instantiate depends on the character selected during the character selection phase. This logic belongs in the selector scene, but the instantiated player objects are also needed in the gameplay scene. The simplest solution is to define them globally, just like other cross-scene resources.\nHere’s how the instantiation logic looks in the selector scene:\n1 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 // SelectorScene.h class SelectorScene : public Scene { public: // ...... void on_exit() { // Instantiate player objects based on selected character types switch (player_type_1) { case PlayerType::Peashooter: player_1 = new PeashooterPlayer(); break; case PlayerType::Sunflower: player_1 = new SunflowerPlayer(); break; } switch (player_type_2) { case PlayerType::Peashooter: player_2 = new PeashooterPlayer(); break; case PlayerType::Sunflower: player_2 = new SunflowerPlayer(); break; } } // ...... } In addition, each player needs to store their position in world coordinates using Vector2 position;. Animation definitions should also be included, with each subclass specifying which atlas to use for rendering.\nHandling player input and mapping key events to gameplay logic should be implemented in the base class. To differentiate controls between Player 1 and Player 2, we assign a player index as a member variable. This allows input handling logic to branch based on the player’s identity.\nOne-Way Platform Collision and Gravity Simulation Gravity Simulation In a free-fall scenario, two key values govern motion: gravitational acceleration and the object’s current velocity. Throughout the simulation, objects in the scene are constantly pulled downward by gravity, accelerating vertically over time.\nTo model this, we define a gravity constant in the Player class: const float gravity = 1.6e-3f;\t. This value may seem arbitrary, but in game development, gravity is often tuned to suit the game world’s scale, visual proportions, and player feel. It doesn’t need to match real-world physics—it just needs to produce the right gameplay effect.\nAll physics-related logic is placed in the move_and_collide(int delta) method. Gravity simulation itself only requires two lines:\n1 2 3 4 5 6 7 8 9 // Player.h // ...... protected: void move_and_collide(int delta) { velocity.y += gravity * delta; position += velocity * (float)delta; } // ...... One-Way Platform Collision Detection To detect collisions between the player and platforms, we check for horizontal overlap between a rectangle (the player) and a line (the platform). Specifically:\nCalculate the difference between the rightmost and leftmost edges of both shapes. If this difference is less than the sum of their widths, they overlap horizontally. Vertical collision is simpler: check whether the platform’s Y-coordinate lies between the player’s top and bottom edges. Only when both horizontal and vertical conditions are met do we consider a collision.\nPosition Correction Logic\nOnce a collision is detected, we need to adjust the player’s position so they land on the platform. However, naïvely snapping the player’s feet to the platform can cause visual glitches—especially when the player jumps upward and only partially intersects the platform before falling.\nTo fix this, we ensure that the player’s entire body has passed through the platform before snapping them onto it. We calculate the player’s foot position from the previous frame and only apply correction if it was above the platform.\nHere’s the refined collision logic:\n1 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 void move_and_collide(int delta) { velocity.y += gravity * delta; position += velocity * (float)delta; // Collision detection if (velocity.y \u0026gt; 0) { for (const Platform\u0026amp; platform : platform_list) { const Platform::CollisionShape\u0026amp; shape = platform.shape; bool is_collide_x = (max(position.x + size.x, shape.x_right) - min(position.x, shape.x_left) \u0026lt;= size.x + (shape.x_right - shape.x_left)); bool is_collide_y = (shape.y \u0026gt;= position.y \u0026amp;\u0026amp; shape.y \u0026lt;= position.y + size.y); if (is_collide_x \u0026amp;\u0026amp; is_collide_y) { // Check previous frame’s foot position to avoid snapping mid-jump float delta_pos_y = velocity.y * delta; float last_tick_foot_pos_y = position.y + size.y - delta_pos_y; if (last_tick_foot_pos_y \u0026lt;= shape.y) { position.y = shape.y - size.y; velocity.y = 0; break; } } } } } Character Skill System Functional Requirements This project features two playable characters: Peashooter and Sunflower. Each has a basic attack and a special skill.\nPeashooter: Fires pea bullets in the direction they’re facing. Hitting the opponent builds energy. Once full, Peashooter unleashes a rapid burst of pea bullets as a special attack. Sunflower: Throws sun bombs diagonally upward. These projectiles are affected by gravity, making them harder to aim but more rewarding. When energy is full, Sunflower summons a giant sun bomb above the opponent’s head, dealing massive area damage and granting high energy returns. From a design perspective, both characters rely on projectile-based combat. This allows us to unify all projectile logic under a single base class: Bullet.\nEach projectile type—pea bullet, sun bomb, super sun bomb—can inherit from Bullet and implement its own update and rendering logic. Differences like animation, damage radius, and behavior are handled in the subclasses.\nBullet Deactivation and Removal Logic Just like how we handle player death animations in the Animation class, bullets should be marked as invalid immediately upon hitting an enemy. This prevents them from colliding multiple times in subsequent frames. However, since we want to play destruction animations—like the pea bullet shattering or the sun bomb exploding—we can’t remove the bullet object from the scene right away.\nInstead, each bullet goes through three distinct states:\nActive: The bullet is moving and checking for collisions. Inactive: The bullet has collided and is playing its destruction animation. Collision checks are disabled. Removable: The animation has finished, and the bullet can now be deleted from the scene. This state logic is reflected in the bullet’s member variables:\n1 2 3 4 5 6 7 8 9 10 11 12 13 // Bullet.h protected: Vector2 size;\t// Bullet collider size Vector2 position;\t// Bullet position Vector2 velocity;\t// Bullet velocity int damage = 10;\t// Damage value bool valid = true;\t// Whether the bullet is still active bool can_remove = false;\t// Whether the bullet can be deleted function\u0026lt;void()\u0026gt; callback;\t// Collision callback PlayerID target_id = PlayerID::P1;\t// Target player ID Optimizing Bullet Removal If bullets are only removed upon collision, those that miss will remain in memory indefinitely—leading to memory leaks. To prevent this, we also check whether a bullet has moved off-screen. This logic is common to all bullet types, so we define a helper method in the base class:\n1 2 3 4 5 bool check_if_exceeds_screen() { return (position.x + size.x \u0026lt;= 0 || position.x \u0026gt;= getwidth() || position.y + size.y \u0026lt;= 0 || position.y \u0026gt;= getheight()); } Full Bullet Base Class 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 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 extern bool is_debug; // Bullet Base Class class Bullet { public: Bullet() = default; ~Bullet() = default; virtual void on_collide() { if (callback) callback(); } virtual bool check_collision(const Vector2\u0026amp; position, const Vector2\u0026amp; size) { // Check if bullet center is inside the target\u0026#39;s bounding box return this-\u0026gt;position.x + this-\u0026gt;size.x / 2 \u0026gt;= position.x \u0026amp;\u0026amp; this-\u0026gt;position.x + this-\u0026gt;size.x / 2 \u0026lt;= position.x + size.x \u0026amp;\u0026amp; this-\u0026gt;position.y + this-\u0026gt;size.y / 2 \u0026gt;= position.y \u0026amp;\u0026amp; this-\u0026gt;position.y + this-\u0026gt;size.y / 2 \u0026lt;= position.y + size.y; } virtual void on_update(int delta) { } virtual void on_draw(const Camera\u0026amp; camera) const { if (is_debug) { setfillcolor(RGB(255, 255, 255)); setlinecolor(RGB(255, 255, 255)); rectangle((int)position.x, (int)position.y, (int)(position.x + size.x), (int)(position.y + size.y)); solidcircle((int)(position.x + size.x / 2), (int)(position.y + size.y / 2), 5); } } // Setters and getters void set_damage(int val) { damage = val; } int get_damage() { return damage; } void set_position(float x, float y) { position.x = x; position.y = y; } const Vector2\u0026amp; get_position() const { return position; } const Vector2\u0026amp; get_size() const { return size; } void set_velocity(float x, float y) { velocity.x = x; velocity.y = y; } void set_collide_target(PlayerID target) { target_id = target; } PlayerID get_collide_target() const { return target_id; } void set_callback(function\u0026lt;void()\u0026gt; callback) { this-\u0026gt;callback = callback; } void set_valid(bool flag) { valid = flag; } bool get_valid() const { return valid; } bool check_can_remove() const { return can_remove; } protected: bool check_if_exceeds_screen() { return (position.x + size.x \u0026lt;= 0 || position.x \u0026gt;= getwidth() || position.y + size.y \u0026lt;= 0 || position.y \u0026gt;= getheight()); } protected: Vector2 size;\tVector2 position; Vector2 velocity; int damage = 10;\tbool valid = true;\tbool can_remove = false;\tfunction\u0026lt;void()\u0026gt; callback;\tPlayerID target_id = PlayerID::P1;\t}; Note:\nIn practice, it\u0026rsquo;s rare to design a game object class completely top-down from the start. Class design is often an iterative process—built and refined as the project evolves. We typically start with a rough structure and improve it through trial, error, and usage. Top-down design requires experience and foresight, but with continued practice, you\u0026rsquo;ll become more comfortable and confident in applying this approach.\nPea Bullet Class Design The Peashooter character fires only one type of bullet. The difference between normal and special attacks lies in the firing rate. To make the game feel more dynamic, we use three different sound effects and randomly play one when a pea bullet hits its target. This technique is common in game development—for example, footsteps or gunshots often use varied audio clips to make the experience feel more natural and lively.\nTo implement this, we override the on_collide method in the PeaBullet subclass and add randomized sound playback. Importantly, when overriding a method but still needing to execute the base class logic, we must explicitly call the parent method.\nHere’s the complete implementation of the PeaBullet subclass:\n1 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 // PeaBullet.h extern IMAGE img_pea; extern Atlas atlas_pea_break; class PeaBullet : public Bullet { public: PeaBullet() { size.x = 64, size.y = 64;\t// Set bullet size damage = 10; animation_break.set_atlas(\u0026amp;atlas_pea_break); animation_break.set_interval(100); animation_break.set_loop(false); animation_break.set_callback([\u0026amp;]() { can_remove = true; }); } ~PeaBullet() = default; void on_update(int delta) { position += velocity * (float)delta;\t// Move bullet if (!valid) animation_break.on_update(delta);\t// Play destruction animation if (check_if_exceeds_screen()) can_remove = true; } void on_draw(const Camera\u0026amp; camera) const { if (valid) putimage_alpha(camera, (int)position.x, (int)position.y, \u0026amp;img_pea); else animation_break.on_draw(camera, (int)position.x, (int)position.y); Bullet::on_draw(camera); } void on_collide() { Bullet::on_collide();\t// Call base class logic switch (rand() % 3) { case 0: mciSendString(_T(\u0026#34;play pea_break_1 from 0\u0026#34;), NULL, 0, NULL); break; case 1: mciSendString(_T(\u0026#34;play pea_break_2 from 0\u0026#34;), NULL, 0, NULL); break; case 3: mciSendString(_T(\u0026#34;play pea_break_3 from 0\u0026#34;), NULL, 0, NULL); break; } } private: Animation animation_break;\t// Destruction animation }; Building on the Bullet base class, the PeaBullet subclass only needs to extend a few unique behaviors to deliver a complete projectile implementation. This elegantly demonstrates the power of inheritance in object-oriented design: shared logic lives in the base class, while specialized behavior is layered on top with minimal duplication. It’s a clean, maintainable approach that scales well as new projectile types are introduced.\nSun Bullet Class Implementation One important detail when implementing the sun bomb is that its explosion animation frames are slightly larger than its idle animation frames. To ensure the explosion renders correctly, we need to align the centers of both animation rectangles. Since EasyX uses the top-left corner as the origin for rendering, we apply a small positional offset during the explosion animation to visually center it.\nUnlike the pea bullet, which continues flying after impact to simulate a splash effect, the sun bomb should remain stationary once it explodes. It’s affected by gravity while active, but its explosion animation plays in place.\nHere’s the full implementation of the SunBullet class:\n1 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 // SunBullet.h extern Atlas atlas_sun; extern Atlas atlas_sun_explode; extern Camera main_camera; class SunBullet : public Bullet { public: SunBullet() { size.x = 96, size.y = 96; damage = 20; animation_idle.set_atlas(\u0026amp;atlas_sun); animation_idle.set_interval(50); animation_explode.set_atlas(\u0026amp;atlas_sun_explode); animation_explode.set_interval(50); animation_explode.set_loop(false); animation_explode.set_callback([\u0026amp;]() { can_remove = true; }); // Calculate offset to center explosion animation IMAGE* frame_idle = animation_idle.get_frame(); IMAGE* frame_explode = animation_explode.get_frame(); explode_render_offset.x = (frame_idle-\u0026gt;getwidth() - frame_explode-\u0026gt;getwidth()) / 2.0f; explode_render_offset.y = (frame_idle-\u0026gt;getheight() - frame_explode-\u0026gt;getheight()) / 2.0f; } ~SunBullet() = default; void on_update(int delta) { if (valid) { velocity.y += gravity * delta; position += velocity * (float)delta; } if (!valid) animation_explode.on_update(delta); else animation_idle.on_update(delta); if (check_if_exceeds_screen()) can_remove = true; } void on_draw(const Camera\u0026amp; camera) const { if (valid) animation_idle.on_draw(camera, (int)position.x, (int)position.y); else { animation_explode.on_draw(camera, (int)(position.x + explode_render_offset.x), (int)(position.y + explode_render_offset.y)); } Bullet::on_draw(camera); } void on_collide() { Bullet::on_collide(); main_camera.shake(5, 250);\t// Camera shake effect mciSendString(_T(\u0026#34;play sun_explode from 0\u0026#34;), NULL, 0, NULL); } private: const float gravity = 1e-3f;\tprivate: Animation animation_idle;\tAnimation animation_explode;\tVector2 explode_render_offset;\t}; Sun Bullet Ex Class Implementation Sunflower’s special skill summons a massive sun bomb from off-screen. Unlike the standard sun bomb, this projectile is larger, stronger, and unaffected by gravity. It descends slowly, acting as a zoning tool to restrict enemy movement and make smaller bombs easier to land.\nTo give it a broader hitbox, we override the check_collision method to use rectangle-based collision detection instead of a single center point.\nHere’s the full implementation of the SunBulletEx class:\n1 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 // SunBulletEx.h extern Atlas atlas_sun_ex; extern Atlas atlas_sun_ex_explode; extern Camera main_camera; class SunBulletEx : public Bullet { public: SunBulletEx() { size.x = 288, size.y = 288; damage = 20; animation_idle.set_atlas(\u0026amp;atlas_sun_ex); animation_idle.set_interval(50); animation_explode.set_atlas(\u0026amp;atlas_sun_ex_explode); animation_explode.set_interval(50); animation_explode.set_loop(false); animation_explode.set_callback([\u0026amp;]() { can_remove = true; }); // Calculate offset to center explosion animation IMAGE* frame_idle = animation_idle.get_frame(); IMAGE* frame_explode = animation_explode.get_frame(); explode_render_offset.x = (frame_idle-\u0026gt;getwidth() - frame_explode-\u0026gt;getwidth()) / 2.0f; explode_render_offset.y = (frame_idle-\u0026gt;getheight() - frame_explode-\u0026gt;getheight()) / 2.0f; } ~SunBulletEx() = default; void on_update(int delta) { if (valid) { position += velocity * (float)delta; } if (!valid) animation_explode.on_update(delta); else animation_idle.on_update(delta); if (check_if_exceeds_screen()) can_remove = true; } void on_draw(const Camera\u0026amp; camera) const { if (valid) animation_idle.on_draw(camera, (int)position.x, (int)position.y); else { animation_explode.on_draw(camera, (int)(position.x + explode_render_offset.x), (int)(position.y + explode_render_offset.y)); } Bullet::on_draw(camera); } void on_collide() { Bullet::on_collide(); main_camera.shake(20, 350);\tmciSendString(_T(\u0026#34;play sun_explode_ex from 0\u0026#34;), NULL, 0, NULL); } bool check_collision(const Vector2\u0026amp; position, const Vector2\u0026amp; size) { bool is_collide_x = (max(this-\u0026gt;position.x + this-\u0026gt;size.x, position.x + size.x) - min(this-\u0026gt;position.x, position.x) \u0026lt;= this-\u0026gt;size.x + size.x); bool is_collide_y = (max(this-\u0026gt;position.y + this-\u0026gt;size.y, position.y + size.y) - min(this-\u0026gt;position.y, position.y) \u0026lt;= this-\u0026gt;size.y + size.y); return is_collide_x \u0026amp;\u0026amp; is_collide_y; } private: Animation animation_idle; Animation animation_explode; Vector2 explode_render_offset; }; From a design perspective, the super sun bomb could inherit from the standard sun bomb to reduce duplication. However, this project opts for a flatter inheritance structure to keep the codebase easier to read and maintain.\nSkill System To implement character skills, we define two virtual methods in the Player base class: virtual void on_attack and virtual void on_attack_ex. Each player subclass overrides these methods to implement its own normal and special attack logic. This keeps the skill system extensible and cleanly separated across character types.\nAttack Cooldown Implementation Most characters in action games have a cooldown period between normal attacks. During this time, pressing the attack key has no effect. To implement this, we define a boolean flag can_attack to indicate whether the character is currently allowed to perform a normal attack. We also use a timer to track the cooldown duration, represented by an int value in milliseconds.\nThe logic is simple:\nWhen an attack input is received, check if can_attack is true. If so, flip the flag to false and start the cooldown timer. Once the timer completes, reset can_attack to true. This ensures that attacks can only be triggered after the cooldown period has passed.\nInvulnerability State Implementation To implement temporary invincibility (often triggered after taking damage), we use two boolean flags:\nbool is_invulnerable = false; — indicates whether the character is currently invincible. bool is_showing_sketch_frame = false; — controls whether the character should render as a white silhouette for visual feedback. We use two timers to manage the invulnerability logic:\ntimer_invulnerable — controls the duration of invincibility. timer_invulnerable_blink — toggles between normal and sketch-frame rendering to create a blinking effect. Here’s how these timers are initialized in the Player constructor:\n1 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 // Player.h Player() { current_animation = is_facing_right; // Initialize attack cooldown timer timer_attack_cd.set_wait_time(attack_cd); timer_attack_cd.set_one_shot(true); timer_attack_cd.set_callback([\u0026amp;]() { can_attack = true; }); // Initialize invulnerability timer timer_invulnerable.set_wait_time(750); timer_invulnerable.set_one_shot(true); timer_invulnerable.set_callback([\u0026amp;]() { is_invulnerable = false; is_showing_sketch_frame = false; // Ensure sketch frame is disabled when invulnerability ends }); }); // Initialize blink timer for invulnerability animation timer_invulnerable_blink.set_wait_time(75); timer_invulnerable_blink.set_callback([\u0026amp;]() { is_showing_sketch_frame = !is_showing_sketch_frame; }); // ...... } To render the character as a white silhouette during invulnerability, we add a utility function in Util.h. This function processes the image’s pixel buffer and sets all pixels to white. For a detailed explanation, refer to the bonus section of the article Teyvat Survivors.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 // Util.h inline void sketch_image(IMAGE* src, IMAGE* dst) { int w = src-\u0026gt;getwidth(); int h = src-\u0026gt;getheight(); Resize(dst, w, h); DWORD* src_buffer = GetImageBuffer(src); DWORD* dst_buffer = GetImageBuffer(dst); for (int y = 0; y \u0026lt; h; y++) { for (int x = 0; x \u0026lt; w; x++) { int idx = y * w + x; dst_buffer[idx] = BGR(RGB(255, 255, 255)) | (src_buffer[idx] \u0026amp; 0xFF000000); } } } In the on_update method, we update all timers accordingly.\n1 2 3 4 5 6 7 8 9 10 11 12 // Player.h virtual void on_update(int delta) { // ...... // Whether to render the sketch (silhouette) image // Only generate the sketch frame when the player is invulnerable and blinking if (is_showing_sketch_frame) sketch_image(current_animation-\u0026gt;get_frame(), \u0026amp;img_sketch); // ...... } We apply the sketch rendering logic inside the on_draw method:\n1 2 3 4 5 6 7 8 9 10 11 // Player.h virtual void on_draw(const Camera\u0026amp; camera) { // ...... if (hp \u0026gt; 0 \u0026amp;\u0026amp; is_invulnerable \u0026amp;\u0026amp; is_showing_sketch_frame) putimage_alpha(camera, (int)position.x, (int)position.y, \u0026amp;img_sketch); else current_animation-\u0026gt;on_draw(camera, (int)position.x, (int)position.y); // ...... } To activate invulnerability after taking damage, we add a dedicated method to the Player class:\n1 2 3 4 5 6 7 8 9 // Player.h void make_invulnerable() { is_invulnerable = true; timer_invulnerable.restart(); is_showing_sketch_frame = true; // Immediately enter sketch frame mode timer_invulnerable_blink.restart(); // Start blinking effect } Then, inside the move_and_collide method, we update the bullet collision logic to trigger invulnerability when the player is hit:\n1 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 // Player.h void move_and_collide(int delta) { // ...... if(!is_invulnerable) { for (Bullet* bullet : bullet_list) { if (!bullet-\u0026gt;get_valid() || bullet-\u0026gt;get_collide_target() != id) continue; if (bullet-\u0026gt;check_collision(position, size)) { make_invulnerable();\t// Activate invulnerability bullet-\u0026gt;on_collide(); bullet-\u0026gt;set_valid(false); hp -= bullet-\u0026gt;get_damage(); // ...... } } // ...... }\tParticle System Implementation A particle system is a technique that uses large numbers of small graphical elements (particles) to simulate complex visual effects like smoke, fire, rain, or explosions. When designing a particle system, we typically break it down into two components:\nParticle objects: Represent individual particles, each with properties like animation, physics, and lifespan. Particle emitters: Control how particles are generated—frequency, direction, initial velocity, etc. In this project, we implement a lightweight particle system. Each particle behaves like a specialized animated object. Unlike characters or bullets, particles remain fixed in world space once emitted. After their animation finishes, they expire and are removed from the scene.\nHere’s the complete Particle class:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 class Particle { public: Particle() = default; Particle(const Vector2\u0026amp; position, Atlas* atlas, int lifespan) : position(position), atlas(atlas), lifespan(lifespan) { } ~Particle() = default; void on_update(int delta) { timer += delta; if (timer \u0026gt;= lifespan) { timer = 0; idx_frame++; if (idx_frame \u0026gt;= atlas-\u0026gt;get_size()) { idx_frame = atlas-\u0026gt;get_size() - 1; valid = false; } } } void on_draw(const Camera\u0026amp; camera) const { putimage_alpha(camera, (int)position.x, (int)position.y, atlas-\u0026gt;get_image(idx_frame)); } void set_atlas(Atlas* new_atlas) { atlas = new_atlas; } void set_position(const Vector2\u0026amp; new_position) { position = new_position; } void set_lifespan(int ms) { lifespan = ms; } bool check_valid() const { return valid; } private: int timer = 0;\t// Frame timer int lifespan = 0;\t// Duration per frame in milliseconds int idx_frame = 0;\t// Current animation frame index Vector2 position;\t// World-space position bool valid = true;\t// Whether the particle is still active Atlas* atlas = nullptr;\t// Animation atlas }; Overall Code Review and Source Code Scene Base Class This project introduces the concept of scene management for the first time. Each stage or interface in the game corresponds to a distinct scene. In the base class Scene.h, the on_enter and on_exit methods handle scene initialization and teardown respectively. The on_input, on_update, and on_draw methods correspond to the input, update, and rendering phases of the main loop, each receiving the necessary parameters. Subclasses only need to override these methods to implement their own logic.\nThe scene manager controls which scene is currently active. When switching scenes, we use an enum to abstract away direct scene pointers. The manager itself also exposes on_input, on_update, and on_draw methods that delegate to the current scene instance.\nMain Function In main.cpp, we implement two core responsibilities: resource loading and game entry point. First, we load pixel fonts and animation assets, including mirrored versions for directional rendering. Then we load background music and sound effects. In the entry function, we initialize resources, instantiate the initial scene, and invoke the scene manager’s lifecycle methods within the main game loop.\nAtlas Class For animation handling, we encapsulate an Atlas class that acts as a container for loading and storing image sequences. The Animation class is a lightweight manager that tracks playback progress. It doesn’t store image data directly—instead, it queries the atlas for the current frame. The animation class also handles frame intervals, looping, and end-of-playback callbacks.\nCamera Class Rendering uses a custom Camera class that serves as an anchor for relative positioning. By randomly jittering the camera’s position within a small range, we simulate a screen shake effect. To simplify camera-based rendering, we overload utility functions in Util.h for alpha blending, image flipping, and generating white silhouette effects.\nTimer and Vector2 Classes Two foundational utility classes support the framework: Timer and Vector2. The Timer class tracks elapsed time and triggers callbacks when a preset duration is reached. The Vector2 class handles 2D vector math, including overloaded arithmetic operators, length calculation, and normalization.\nScene Subclasses: MenuScene, SelectorScene, GameScene The game starts in the menu scene, which simply plays music and handles scene transitions. The character selection scene uses hardcoded layout logic to render UI elements, dynamically draws character previews based on player choices, and handles input for both players using separate key mappings. Upon exiting the selection scene, we instantiate the chosen characters and assign their avatars and player IDs, which are defined in PlayerID.h.\nThe in-game scene handles three main areas:\nWorld elements like background and platforms Player-related elements like characters and bullets Game state elements like health tracking and victory conditions Debug mode toggling is also managed within the game scene.\nPlayer Base Class The Player base class encapsulates shared logic for all characters. Visually, players have animations for idle, running, attacking, and dying, along with particle effects for jumping, landing, and running. Logically, normal and special attacks are controlled via cooldown timers and energy values. Movement actions like jumping and landing are wrapped in dedicated methods for easy triggering and animation updates.\nDamage triggers a brief invulnerability period with a blinking visual effect, managed by timers. Physics simulation is handled in the move_and_collide method, which updates position based on gravity and velocity, and performs collision checks with platforms and bullets.\nPeashooter and Sunflower Subclasses With the base class in place, implementing the Peashooter is straightforward—just configure its animations and define bullet spawning logic. We also use randomized sound effects to enhance feedback.\nSunflower follows a similar pattern, but its special attack includes an extra “sun” animation above the character’s head, requiring a custom rendering override.\nBullet Base Class Bullets are abstracted via the Bullet base class. Like players, bullets move through the scene and may be affected by gravity. We track velocity and update position accordingly. Bullets also store damage values, target IDs, and collision callbacks.\nPeaBullet, SunBullet, and SunBulletEx Subclasses The PeaBullet subclass overrides the collision method to play randomized sound effects and switches rendering logic based on whether the bullet has shattered. The SunBullet simulates gravity until impact, then plays an explosion animation with center alignment adjustments. The SunBulletEx is larger and stronger, descending at a constant speed rather than accelerating under gravity.\nParticle Class Particles are treated as lightweight animated objects. Once their animation finishes, they expire and are removed from the scene. Their update logic closely resembles that of the Animation class.\nPlatform Class Platforms are represented as horizontal lines in world space. For rendering, we use image assets and optionally draw debug overlays depending on the debug mode.\nStatusBar Class Each player’s status bar consists of three components: avatar, health bar, and energy bar. During gameplay, we continuously read these values from the player object and render them in the UI.\nWith that, this project’s notes are complete.\nFull Source Code on GitHub\nReflection and Summary This was my first time studying how a complete game project is designed from the top down. At the beginning, the framework felt abstract and hard to grasp—especially without something like the gameplay layer to test things frequently and intuitively. I often felt unsure of whether I was on the right track. But once the foundational structure was in place, building out the gameplay layer became a smooth and satisfying process. Everything started to click into place.\nEven with the experience I gained from the previous Teyvat Survivor project, this one was filled with intricate implementation details. A single oversight could easily lead to unexpected bugs. Some of the design ideas would have been difficult—or even impossible—for me to come up with on my own without falling into common pitfalls. Thankfully, the instructor’s explanations were clear, and the code was clean and easy to follow, which made the learning process much more approachable.\nThis project made me realize once again where I currently stand in my programming journey: just when I feel like I’ve understood something, I discover how much more there is that I don’t know. And that’s okay. It’s a reminder to keep sharpening my skills and stay committed to continuous learning.\n","date":"2025-10-12T09:17:30+02:00","image":"https://nullshowjl.github.io/en/p/c-game-dev-from-scratchplant-star-brawl-gameplay-layer/cover_hu_cd024d6af2cc5211.webp","permalink":"https://nullshowjl.github.io/en/p/c-game-dev-from-scratchplant-star-brawl-gameplay-layer/","title":"【C++ Game Dev from Scratch】Plant Star Brawl - Gameplay Layer"},{"content":"Table of Contents\nOverview Core Gameplay Design Approach Development Workflow – Framework Key Steps and Solutions – Framework This project spans over 2,000 lines of code and covers a wide range of concepts, so I’ve split the write-up into two parts. This first post provides an overview of the entire project, with a focus on the design and implementation of the framework-related components.\nOverview Tech Stack: C++ + EasyX\nProject Goal: The project is organized using multiple header files, with a modular structure that introduces the concept of “scenes” to separate different stages of gameplay and decouple core functionalities. It explores basic physics simulation techniques and implements one-way collision platforms. Popular game development features like the camera, timer, and particle system are encapsulated to enhance the project’s level of polish and professionalism. Additionally, smoother animations and richer sound effects are integrated to improve the overall completeness of the game.\nCourse Source：Bilibili-Voidmatrix\nCore Gameplay Two players can choose different characters for local multiplayer battles. They jump and move between platforms, using a variety of normal and special attacks to damage their opponent and earn energy rewards. Once enough energy is accumulated, players can unleash unique skills. A player is defeated either by falling off the platform or when their health reaches zero.\nDesign Approach Using Multiple Header Files As the project grows in size, keeping all the code in main.cpp quickly becomes unwieldy. It leads to messy namespaces and tangled dependencies, making debugging and maintenance difficult. To manage complexity, this project organizes code by encapsulating different game stages into separate classes, each placed in its own header file.\nCommon Pitfall – Duplicate Includes：\nWhen we use #include to bring in a header file, the compiler performs a literal copy-paste of that file’s contents at the include location. For example, if A.h includes B.h, and main.cpp includes both A.h and B.h, then B.h ends up being included twice. If B.h contains class definitions or other declarations, this can lead to duplicate definitions and compilation errors.\nHow to Avoid This：\nUse the preprocessor directive #pragma once: This tells the compiler to include the contents of a header file only once, no matter how many times it\u0026rsquo;s referenced.\nAlternatively, use include guards with #ifndef: This checks whether a macro (usually based on the filename) has already been defined. If not, it defines the macro and includes the file; otherwise, it skips it.\nExample syntax:\n1 2 3 4 #ifndef _SCENE_H_ #define _SCENE_H_ #endlif // !_SCENE_H_ Both methods are widely used and generally interchangeable in most cases.\nDifferences Between the Two Approaches：\nFeature #ifndef / #define #pragma once Compatibility Supported by all standard C/C++ compilers Supported by most modern compilers, but not standardized Mechanism Uses macro definitions to check for duplicates Compiler internally tracks whether the file has been processed Filename Dependency Independent of file name; relies on macro name Depends on file path; may be affected by hard or symbolic links Conflict Risk Macro names must be unique; naming conflicts may cause issues No naming required; avoids conflicts Compilation Speed Slightly slower (macro parsing required) Faster (direct skip on repeat includes) Designing the Game Framework Creating the window, setting up the main game loop, and stabilizing the frame rate—this structure is essentially a standard pattern in game development frameworks:\n1 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)\t// Main game loop { DWORD start_time = GetTickCount(); //========= Handle input ========= while (peekmessage(\u0026amp;msg)) { }\t//======== Handle update ========= cleardevice(); //======== Handle rendering ========= FlushBatchDraw(); //========= Stabilize frame rate ========= DWORD end_time = GetTickCount(); DWORD delta_time = end_time - start_time; if (delta_time \u0026lt; 1000 / FPS) { Sleep(1000 / FPS - delta_time); } } EndBatchDraw(); return 0; } Object/Class Design This project introduces architectural concepts by analyzing core functionalities and designing a well-structured program layout. The goal is to improve scalability and make debugging during development more manageable.\nScene Base Class Menu Scene Class Selector Scene Class Game Scene Class Scene Manager Class Atlas Class Animation Class Vector2 Class Camera Class Timer Class Platform Class Player Base Class Peashooter Class Sunflower Class Player ID Enumeration Class Bullet Base Class Pea Bullet Class Sun Bullet Class Sun Bullet Ex Class Status Bar Class Particle Class Development Workflow - Framework Game Framework Design Scene System Architecture Solution: Use inheritance, with each game stage implemented as a subclass of the base Scene class, allowing for more flexible scene management. Challenge: How to switch between scenes? Solution: Introduce a Scene Manager to handle transitions. Resource Loading Challenge 1： How to manage animations efficiently and enable resource reuse? Solution: Implement Atlas and Animation classes. Challenge 2: How to trigger death animations when an enemy is defeated? Solution: Use callback functions to handle animation logic. Improving Visual Flexibility Solution: Implement a Camera class to control the viewport. Challenge 1: How to track camera position more precisely? Solution: Avoid using EasyX’s built-in POINT class (which uses integers); instead, create a custom Vector2 class with floating-point coordinates. Challenge 2: How to express impact effects visually? Solution: Add camera shake effects for hit feedback. Timer Usage Across Gameplay Challenge: Beyond animations and camera shake, many features (e.g. special skills, attack cooldowns) require timing control. Solution: Encapsulate a reusable Timer class to provide unified management for time-sensitive features. Key Steps and Solutions - Framework Scene System Design If we think of a scene as a stage in a play, then each scene has its own “script” logic and a unique cast of characters. These characters are what game developers commonly refer to as GameObjects—players, enemies, bullets, items, and so on. Conceptually, they all fall under the GameObject category, each performing different logic under the direction of the scene’s script.\nFrom a programming perspective, a game can be divided into several stages: the main menu, the character selection screen, and the in-game scene. Based on this, we define a base Scene class. The main menu, character selection, and gameplay scenes can each inherit from this base class to implement their own event handling and rendering logic.\nScene Base Class All member functions are defined as virtual, allowing each specific scene class to override them with its own logic. The Scene base class serves as a template for all concrete scene subclasses.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 class Scene { public: Scene() = default; ~Scene() = default; virtual void on_enter() {}; // Enter the scene virtual void on_update(int delta) {};\t// Handle updates virtual void on_draw() {};\t// Handle rendering virtual void on_input(const ExMessage\u0026amp; msg) {};\t// Handle player input virtual void on_exit() {}; // Exit the scene private: }; MenuScene Class (Main Menu Scene) You can override the necessary member functions:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 class MenuScene : public Scene { public: MenuScene() = default; ~MenuScene() = default; // Override required virtual functions from the base Scene class void on_enter() { cout \u0026lt;\u0026lt; \u0026#34;Entered Main Menu\u0026#34; \u0026lt;\u0026lt; endl; } void on_update(int delta) { cout \u0026lt;\u0026lt; \u0026#34;Main Menu is running......\u0026#34; \u0026lt;\u0026lt; endl; } void on_draw() { outtxtxy(10, 10, _T(\u0026#34;Drawing Main Menu content\u0026#34;))； } void on_input(const ExMessage\u0026amp; msg) { // Handle input if needed } void on_exit() { cout \u0026lt;\u0026lt; \u0026#34;Exiting Main Menu\u0026#34; \u0026lt;\u0026lt; endl; } private: }; Instantiating in main.cpp\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 int main() { initgraph(1280, 720, EW_SHOWCONSOLE);\t// Keep the console window visible BeginBatchDraw(); Scene* scene = new MenuScene();\t// Instantiate before entering the main loop while (running) { DWORD frame_start_time = GetTickCount(); while (peekmessage(\u0026amp;msg)) { scene-\u0026gt;on_input(msg); // Handle input } // Update game logic scene-\u0026gt;on_update(); cleardevice(); // Render game screen scene-\u0026gt;on_draw(); FlushBatchDraw(); DWORD frame_end_time = GetTickCount(); DWORD frame_delta_time = frame_end_time - frame_start_time; if (frame_delta_time \u0026lt; 1000 / FPS) { Sleep(1000 / FPS - frame_delta_time); } } EndBatchDraw(); return 0; } All scene subclasses follow the same design pattern.\nImplementing the Scene Manager A game program is essentially a massive loop—and also a massive state machine. Each game scene represents a distinct state, and the system that manages these states is commonly referred to in game development as the Scene Manager.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 class SceneManager { public: enum class SceneType\t// Marks the current scene state { Menu, Game, Selector }; public: SceneManager() = default; ~SceneManager() = default; // Set the current scene void set_current_scene(Scene* scene) { current_scene = scene; current_scene-\u0026gt;on_enter();\t// Ensure the scene lifecycle is properly triggered } // Switch scenes (exit the current scene, then select and enter the new one) void switch_to(SceneType type) { current_scene-\u0026gt;on_exit();\t// Exit the current scene switch (type) { case SceneType::Menu: current_scene = menu_scene; break; case SceneType::Game: current_scene = game_scene; break; case SceneType::Selector: current_scene = selector_scene; break; default: break; } current_scene-\u0026gt;on_enter();\t// Enter the newly selected scene } void on_update(int delta) { current_scene-\u0026gt;on_update(delta); } void on_draw() { current_scene-\u0026gt;on_draw(); } void on_input(const ExMessage\u0026amp; msg) { current_scene-\u0026gt;on_input(msg); } private: Scene* current_scene = nullptr; }; The on_enter and on_exit methods serve similar purposes to constructors and destructors—they’re used to initialize and release resources. So why not just use constructors and destructors directly?\nThe reason is that constructors and destructors control the memory lifecycle of scene objects. If we rely on them to handle all enter/exit logic, we’d need to constantly create and destroy scene objects during transitions, which is not performance-friendly. As game logic grows more complex, some resources may be shared across scenes. In other words, objects within a scene may need to outlive the scene itself, which introduces additional memory management challenges.\nThis design offers a cleaner and more flexible approach: scene objects live as long as the game itself. All scenes are created during game initialization and destroyed when the game exits. During runtime, we avoid calling constructors and destructors for scene transitions, and instead use clearly defined on_enter and on_exit methods. These methods should avoid creating or destroying internal members—instead, they reset internal state.\nExample: Suppose the game scene contains a player object. When the player’s health reaches zero, the game transitions to the main menu. If we use constructors/destructors, we’d need to delete the game scene and new the menu scene. Then, when returning to the game scene, we’d have to recreate everything just to reset the player’s health.\nWith on_enter and on_exit, we avoid this overhead. Instead, we simply reset the player’s health variable when re-entering the game scene—achieving the same “fresh start” effect without costly object reconstruction.\nWhy use a pointer for setting the current scene, but an enum for switching scenes?\nThis design choice reflects usage context:\nset_current_scene is typically called during game initialization, when scenes are being instantiated—so passing a pointer is straightforward. switch_to is usually called during runtime from within scene logic. If scenes hold references to each other, passing pointers directly can lead to memory issues. Using an enum abstracts away internal pointer management and keeps transitions safe and clean. With this setup, the scene-related logic in main can be fully delegated to the Scene Manager:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 menu_scene = new MenuScene(); game_scene = new GameScene(); selector_scene = new SelectorScene(); scene_manager.set_current_scene(menu_scene); BeginBatchDraw(); while (running) { DWORD frame_start_time = GetTickCount(); while (peekmessage(\u0026amp;msg)) { scene_manager.on_input(msg); } scene_manager.on_update(); cleardevice(); scene_manager.on_draw(main_camera); FlushBatchDraw(); Scene transitions are handled within each scene’s on_input method.\n1 2 3 4 5 6 7 8 // MenuScene.h void on_input(const ExMessage\u0026amp; msg) { if (msg.message == WM_KEYUP) { scene_manager.switch_to(SceneManager::SceneType::Selector); } } Resource Loading Design Implementing the Atlas Class The Atlas class serves as a container for a series of related image resources:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 class Atlas { public: Atlas() = default; ~Atlas() = default; void load_from_file(LPCTSTR path_template, int num) { // Important: Clear the image list before loading // and resize it to the expected number of frames. // This prevents mismatches when load_from_file is called multiple times. img_list.clear();\timg_list.resize(num); TCHAR path_file[256]; for (int i = 0; i \u0026lt; num; i++) { _stprintf_s(path_file, path_template, i + 1); loadimage(\u0026amp;img_list[i], path_file); } } void clear()\t// Clear all loaded images in the atlas { img_list.clear(); } size_t get_size()\t// Get the number of images in the atlas { return img_list.size(); } IMAGE* get_image(int idx)\t// Retrieve a specific animation frame { if (idx \u0026lt; 0 || idx \u0026gt;= img_list.size()) return nullptr; return \u0026amp;img_list[idx];\t// Return the address of the image at the given index } // Add an existing image to the atlas // This may seem redundant with file loading, // but it\u0026#39;s useful for generating horizontally flipped atlases void add_image(const IMAGE\u0026amp; img)\t{ img_list.push_back(img); } private: vector\u0026lt;IMAGE\u0026gt; img_list; }; Resource Loading Strategy Before implementing the Animation class, we need a way to horizontally flip animation frames. This avoids the need for duplicate assets. Since pixel-level flipping is computationally expensive, it should be done during game initialization, not during frame updates.\nThis flipping function is a utility and should be placed in util.h for easy access across the project. A detailed explanation is available in another post: Teyvat Survivor.\nIn main.cpp, we define flip_atlas as a global function so it can be called before the main loop:\n1 2 3 4 5 6 7 8 9 10 11 12 // main.cpp void flip_atlas(Atlas\u0026amp; src, Atlas\u0026amp; dst) { dst.clear(); // Prevent issues from reusing the same container for (int i = 0; i \u0026lt; src.get_size(); i++) { IMAGE img_flipped; flip_image(src.get_image(i), \u0026amp;img_flipped); dst.add_image(img_flipped); } } For now, all resources are loaded via a global function in main.cpp. It’s important to use meaningful and consistent naming for assets. A recommended format is type_character_direction, such as Atlas atlas_peashooter_idle_left;. While verbose, this naming convention improves editor searchability and debugging efficiency.\nThe resource loading logic includes three parts:\nLoading game fonts Loading and processing image assets Loading sound effects Sound effects are handled using mciSendString, so don’t forget to include the appropriate library.\nImplementing the Animation Class The Animation class acts as a lightweight controller for rendering atlases. It builds on top of the Atlas class and is designed around two components:\nMember variables: define the data structure Member functions: provide external interfaces for querying and modifying state Since frame progression is automatic during playback, there’s no need for external set methods. Instead, only get_idx_frame and get_frame are exposed for frame access.\nThe two most important methods—on_update and on_draw—are explained in detail in the Teyvat Survivor article.\nHow should we handle disappearing animations for objects like enemies or bullets when their lifecycle ends?\nWe shouldn’t delete the Enemy object immediately upon death. Instead, we delay deletion until the death animation finishes. This requires the animation system to signal when playback is complete.\nA common solution: use callback functions.\nCallback Functions A callback is a function object passed as a parameter and stored for later execution. This allows logic to be triggered at the “right moment.”\nFor example, when an enemy dies, we define the deletion logic as a function and store it in the animation object. Once the death animation finishes, the callback is invoked to remove the enemy.\nBe sure to write #include \u0026lt;functional\u0026gt; at the top. Here’s a sample implementation:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 class Animation { public: ...... void on_update(int delta) { timer += delta; if (timer \u0026gt; interval) { timer = 0; idx_frame++; if (idx_frame \u0026gt;= atlas-\u0026gt;get_size()) { idx_frame = is_loop ? 0 : atlas-\u0026gt;get_size() - 1; if (!is_loop \u0026amp;\u0026amp; callback) // If animation is non-looping and callback exists, invoke it { callback(); } } } } void set_callback(function\u0026lt;void()\u0026gt; callback) { this-\u0026gt;callback = callback; } private: ...... function\u0026lt;void()\u0026gt; callback; // Callback to trigger object removal after animation finishes }; Here’s the full implementation of the Animation class:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 class Animation { public: Animation() = default; ~Animation() = default; void reset() { timer = 0; idx_frame = 0; } void set_atlas(Atlas* new_atlas) { reset(); atlas = new_atlas; } void set_loop(bool flag) { is_loop = flag; } void set_interval(int ms) { interval = ms; } int get_idx_frame() { return idx_frame; } IMAGE* get_frame() { return atlas-\u0026gt;get_image(idx_frame); } bool check_finished() { if (is_loop) return false; return (idx_frame == atlas-\u0026gt;get_size() - 1); } void on_update(int delta) { timer += delta; if (timer \u0026gt; interval) { timer = 0; idx_frame++; if (idx_frame \u0026gt;= atlas-\u0026gt;get_size()) { idx_frame = is_loop ? 0 : atlas-\u0026gt;get_size() - 1; if (!is_loop \u0026amp;\u0026amp; callback) // If animation is non-looping and callback exists, invoke it { callback(); } } } } void on_draw(const Camera\u0026amp; camera, int x, int y) const { putimage_alpha(camera, x, y, atlas-\u0026gt;get_image(idx_frame)); } void set_callback(function\u0026lt;void()\u0026gt; callback) { this-\u0026gt;callback = callback; } private: Atlas* atlas = nullptr; bool is_loop = true; // Whether the animation loops int timer = 0;\t// Frame timer int interval = 0;\t// Frame interval in milliseconds int idx_frame = 0;\t// Current frame index function\u0026lt;void()\u0026gt; callback; // Callback to trigger object removal after animation finishes }; Camera System Design Window Coordinates vs. World Coordinates In EasyX, the origin of the window coordinate system is at the top-left corner of the screen:\nEasyX Window Coordinates In contrast, the world coordinate system represents a much larger virtual space. Think of it as the entire game world where all objects—players, enemies, bullets, items—are placed and interact. Player movement, collisions, triggers, and all game logic operate within this world space. Only when rendering the game do we need to convert world coordinates into window coordinates.\nThe camera acts as a bridge between these two systems. This aligns with the game development principle of separating data from rendering.\nWhen we ignore zooming (i.e., the camera’s width and height match the window’s), we can treat the camera as a single point in the world. In a side-scrolling game, for example, the camera simply follows the player’s position. To render other objects, we subtract the camera’s world position from each object’s world position to get their window coordinates.\nKey concept:\nWindow Coordinate = World Coordinate - Camera Coordinate\nImplementing the Vector2 Class To allow precise control of the camera’s position using floating-point values, we define a commonly used 2D vector class. Operator overloading makes it easier to perform arithmetic operations similar to built-in types:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 class Vector2 { public: Vector2() = default; ~Vector2() = default; Vector2(float x, float y) // Constructor for direct initialization : x(x),y(y){ } //======== Operator Overloads ======== // Enables intuitive vector arithmetic Vector2 operator+(const Vector2\u0026amp; vec) const { return Vector2(x + vec.x, y + vec.y); } void operator+=(const Vector2\u0026amp; vec) { x += vec.x, y += vec.y; } Vector2 operator-(const Vector2\u0026amp; vec) const { return Vector2(x - vec.x, y - vec.y); } void operator-=(const Vector2\u0026amp; vec) { x -= vec.x, y -= vec.y; } Vector2 operator*(const Vector2\u0026amp; vec) const { return Vector2(x * vec.x, y * vec.y); } Vector2 operator*(float val) const { return Vector2(x * val, y * val); } void operator*=(float val) { x *= val, y *= val; } float length() { return sqrt(x * x + y * y); } Vector2 normalize() { float len = length(); if (len == 0) { return Vector2(0, 0); } return Vector2(x / len, y / len); } public: float x = 0.0f; float y = 0.0f; }; Implementing the Camera Class Camera Shake Effect This is a common visual effect in games. When firing a weapon or triggering an explosion, the screen shakes briefly to convey impact. It’s simple to implement but highly effective.\nImplementation Strategy Since the shake only lasts for a short time, we need a way to start and stop it. Like animations, this is best handled with a timer.\nImplementing a General-Purpose Timer There are two main design approaches:\nInheritance-based Callback-based Inheritance Approach\nDefine a base Timer class with an on_update method. Subclasses override the callback method to define custom behavior:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 class Timer { public: Timer() = default; ~Timer() = default; void on_update(int delta) { // ...... callback(); } protected: virtual void callback() { // Timer logic } }； To use it:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 class MyTimer : public Timer { public: MyTimer() = default; ~MyTimer() = default; protected: void callback() override { // Custom timer logic } }； Timer* my_timer = new MyTimer(); Callback Approach\nSimilar to the Animation class, we store a function and invoke it when the timer completes:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 class Timer { public: Timer() = default; ~Timer() = default; void on_update(int delta) { // ...... callback(); } void set_callback(function\u0026lt;void()\u0026gt; callback) { this-\u0026gt;callback = callback; } protected: function\u0026lt;void()\u0026gt; callback; } Usage:\n1 2 3 4 5 Timer my_timer; mu_timer.set_back([]() { // Custom timer logic })； Comparison\nThe callback-based approach is more concise and flexible. If you need multiple timers with different behaviors, inheritance requires creating multiple subclasses. With callbacks, you just write a lambda function.\nFor general-purpose timers that only differ in behavior—not data—it’s better to use callbacks. This reduces boilerplate and improves clarity.\nFull Timer Class Implementation\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 // General-purpose timer class // Uses callbacks to define behavior on timeout class Timer { public: Timer() = default; ~Timer() = default; void restart() // Reset the timer { pass_time = 0; shotted = false; } void set_wait_time(int val) { wait_time = val; } void set_one_shot(bool flag) { one_shot = flag; } void set_callback(function\u0026lt;void()\u0026gt; callback) { this-\u0026gt;callback = callback; } void pause() { paused = true; } void resume() { paused = false; } void on_update(int delta) { if (paused) return; pass_time += delta; if (pass_time \u0026gt;= wait_time) { if (!one_shot || (one_shot \u0026amp;\u0026amp; !shotted) \u0026amp;\u0026amp; callback) callback(); shotted = true; pass_time = 0; } } private: int pass_time = 0;\t// Elapsed time int wait_time = 0;\t// Wait duration bool paused = false;\t// Pause flag bool shotted = false;\t// Triggered flag bool one_shot = false;\t// One-time trigger function\u0026lt;void()\u0026gt; callback;\t// Callback function }; Camera Class with Integrated Timer Design Approach for Camera Shake Effect\nTo make the entire screen appear to shake, we simply need to shake the camera’s position. In other words, the shake effect is achieved by rapidly changing the coordinates of the Camera object.\nA straightforward approach is to randomly reposition the camera within a circle whose radius equals the shake intensity. Since frame updates happen frequently, this randomness creates a convincing shake effect at runtime.\nFor smoother and more natural motion—especially with stronger shake effects—noise algorithms like Perlin noise can be used instead of pure randomness. However, for the subtle shake used in this project, the visual improvement is minimal, so we stick with the simpler random-based implementation.\nFull Camera Class Implementation:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 class Camera { public: Camera() { timer_shake.set_one_shot(true); timer_shake.set_callback([\u0026amp;]() { is_shaking = false; reset(); } ); } ~Camera() = default; const Vector2\u0026amp; get_position() const // First const: returns a read-only reference; second const: ensures this method doesn\u0026#39;t modify the object { return position; } void reset() // Reset camera position to origin { position.x = 0; position.y = 0; } void on_update(int delta) { timer_shake.on_update(delta); if (is_shaking) { position.x = (-50 + rand() % 101) / 50.0f * shaking_strength; position.y = (-50 + rand() % 101) / 50.0f * shaking_strength; } } // Start camera shake effect // Parameters: strength = shake intensity, duration = shake duration in milliseconds void shake(float strength, int duration) void shake(float strength, int duration) { is_shaking = true; shaking_strength = strength; timer_shake.set_wait_time(duration); timer_shake.restart(); } private: Vector2 position;\t// Camera position // Shake effect implementation Timer timer_shake;\t// Timer controlling shake duration bool is_shaking = false; float shaking_strength = 0; // Shake intensity }; In the on_update method, the camera’s position is randomly set within a circle defined by the shake intensity. The random coefficient before shaking_strength represents a value in the range of -1.0 to 1.0, simulating a unit circle.\nWith this, the core game framework is complete. For the second half of this project, covering gameplay implementation, continue reading the companion article: Plant Star Brawl - Gameplay Layer.\n","date":"2025-10-11T09:47:30+02:00","image":"https://nullshowjl.github.io/en/p/c-game-dev-from-scratchplant-star-brawl-framework-design/cover_hu_767a701a5d1cdaeb.webp","permalink":"https://nullshowjl.github.io/en/p/c-game-dev-from-scratchplant-star-brawl-framework-design/","title":"【C++ Game Dev from Scratch】Plant Star Brawl - Framework Design"},{"content":"Table of Contents\nOverview Core Gameplay Main Development Process Extras: Animation Effects \u0026amp; Pixel Buffers Full Source Code Reflection and Summary Overview Tech Stack：C++ + EasyX\nProject 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.\nCourse Source：Bilibili-Voidmatrix\nCore 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\u0026rsquo;s score increases. If an enemy touches the player, the game ends.\nMain 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.\n1 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)\t// Main game loop { DWORD start_time = GetTickCount(); //========= Handle input ========= while (peekmessage(\u0026amp;msg)) { }\t//======== Handle update ========= cleardevice(); //======== Handle rendering ========= FlushBatchDraw(); //========= Stabilize frame rate ========= DWORD end_time = GetTickCount(); DWORD delta_time = end_time - start_time; if (delta_time \u0026lt; 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.\nPlayer 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, \u0026quot;MSIMG32.LIB\u0026quot;) 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, \u0026quot;Winmm.lib\u0026quot;) 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.\n1 2 3 4 5 6 7 8 // Load image from file (supports bmp/gif/jpg/png/tif/emf/wmf/ico) int loadimage( IMAGE* pDstImg,\t// Pointer to the IMAGE object that stores the image LPCTSTR pImgFile,\t// File path of the image int nWidth = 0,\t// Optional: stretch width度 int nHeight = 0,\t// Optional: stretch height bool bResize = false\t// Optional: resize IMAGE to fit image size ); A few things to note:\nThe 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:\n1 2 IMAGE img; loadimage(\u0026amp;img, _T(\u0026#34;test.jpg\u0026#34;)); Rendering Images To draw the image on screen, EasyX provides the putimage function:\n1 2 3 4 5 6 7 // 绘制图像 void putimage( int dstX,\t// X coordinate on screen int dstY,\t// Y coordinate on screen IMAGE *pSrcImg,\t// Pointer to the IMAGE object to draw DWORD dwRop = SRCCOPY\t// Raster operation code (usually ignored) ); The first two parameters specify the position on screen.\nThe last parameter is a raster operation code, which we’ll ignore for this project.\nTo render the image we just loaded:\n1 2 3 4 5 6 // Load image IMAGE img; loadimage(\u0026amp;img, _T(\u0026#34;test.jpg\u0026#34;)); // Draw image putimage(100, 200, \u0026amp;img); If the image is 300 * 300 pixels, it will appear at position (100, 200) on the game screen like this:\nHandling 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.\n1 2 3 4 5 6 7 8 9 #pragma comment(lib, \u0026#34;MSIMG32.LIB\u0026#34;) void putimage_alpha(int x, int y, IMAGE* img) { int w = img-\u0026gt;getwidth(); int h = img-\u0026gt;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, \u0026quot;MSIMG32.LIB\u0026quot;) .\nImplementing 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.\nFrame-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.\nTo switch animation frames at fixed intervals, we use a timer-based counter:\n1 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;\t// 1. Index of the current animation frame const int PLAYER_ANIM_NUM = 6;\t// Total number of animation frames int main() { ..... while (is_running) { while (peekmessage(\u0026amp;msg)) { } static int counter = 0;\t// 2. Counts how many game frames have passed // \u0026#39;static\u0026#39; ensures it\u0026#39;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:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 const int PLAYER_ANIM_NUM = 6;\t// 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 \u0026lt; PLAYER_ANIM_NUM; i++) { std::wstring path = L\u0026#34;img/player_left_\u0026#34; + std::to_wstring(i) + L\u0026#34;.png\u0026#34;; loadimage(\u0026amp;img_player_left[i], path.c_str()); } for (size_t i = 0; i \u0026lt; PLAYER_ANIM_NUM; i++) { std::wstring path = L\u0026#34;img/player_right_\u0026#34; + std::to_wstring(i) + L\u0026#34;.png\u0026#34;; loadimage(\u0026amp;img_player_right[i], path.c_str()); } } Then, inside the main loop, draw the current frame:\n1 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)\t// Main game loop { DWORD start_time = GetTickCount(); //========= Handle Input ========= while (peekmessage(\u0026amp;msg)) { }\t//======== Update ========= cleardevice(); //======== Render ========= putimage_alpha(500, 500, \u0026amp;img_player_left[idx_current_anim]); FlushBatchDraw(); ...... } } This setup ensures smooth animation playback by cycling through frames at a consistent rate.\nEncapsulating Animation into a Class From a data structure perspective, we use a vector\u0026lt;IMAGE*\u0026gt; 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.\nWhen 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.\nWhy 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.\nHere’s the implementation:\n1 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)\t// Load animation frames { interval_ms = interval; TCHAR path_file[256]; for (size_t i = 0; i \u0026lt; 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)\t// Play animation { timer += delta; if (timer \u0026gt;= interval_ms) { idx_frame = (idx_frame + 1) % frame_list.size(); timer =0; } // Render current frame pitimage_alpha(x, y, frame_list[idx_frame]); } ~Animation()\t// Release resources { for (size_t i = 0; i \u0026lt; frame_list.size(); i++) { delete frame_list[i]; } } private: vector\u0026lt;IMAGE*\u0026gt; frame_list;\t// List of animation frames int interval_ms = 0;\t// 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.\nAdditionally, 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.\nTo ensure smooth and consistent movement across all frames, we treat movement as a state:\nWhen 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\u0026rsquo;s position is updated accordingly in each frame:\n1 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\u0026amp; 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; } }\t} 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:\n1 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:\n1 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\u0026amp; 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:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 void Move(const Player\u0026amp; player) { const POINT\u0026amp; 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 \u0026gt; 0) facing_left = false; else if (dir_x \u0026lt; 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.\nEnemy vs. Bullet 1 2 3 4 5 6 7 8 bool CheckBulletCollision(const Bullet\u0026amp; bullet) // \u0026#39;const\u0026#39; ensures the bullet won\u0026#39;t be modified { // Treat the bullet as a point and check if it\u0026#39;s inside the enemy\u0026#39;s rectangle bool is_overlap_x = bullet.position.x \u0026gt;= position.x \u0026amp;\u0026amp; bullet.position.x \u0026lt;= position.x + FRAME_WIDTH; bool is_overlap_y = bullet.position.y \u0026gt;= position.y \u0026amp;\u0026amp; bullet.position.y \u0026lt;= position.y + FRAME_HEIGHT; return is_overlap_x \u0026amp;\u0026amp; is_overlap_y; } Enemy vs. Player In most games, collision detection isn\u0026rsquo;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.\nHere, we treat the enemy’s center point as the collision point and check if it overlaps with the player’s rectangle:\n1 2 3 4 5 6 7 8 9 bool CheckPlayerCollision(const Player\u0026amp; player) { // Use the enemy\u0026#39;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 \u0026gt;= player.GetPosition().x \u0026amp;\u0026amp; check_position.x \u0026lt;= player.GetPosition().x + player.FRAME_WIDTH; bool is_overlap_y = check_position.y \u0026gt;= player.GetPosition().y \u0026amp;\u0026amp; check_position.y \u0026lt;= player.GetPosition().y + player.FRAME_HEIGHT; return is_overlap_x \u0026amp;\u0026amp; 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.\nTo create a dynamic visual effect, we animate the bullets by adjusting their angle (α) over time. All angles are calculated in radians for simplicity:\nHere’s the corresponding code:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 // Update bullet positions void UpdateBullets(vector\u0026lt;Bullet\u0026gt;\u0026amp; bullet_list, const Player\u0026amp; 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\u0026#39;s position based on the player\u0026#39;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 \u0026lt; 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 \u0026lt; enemy_list.size(); i++) // Avoid using iterators since the // container is modified during iteration { Enemy* enemy = enemy_list[i]; if (!enemy-\u0026gt;CheckAlive()) { // Swap with the last element and remove it // * This is an efficient deletion method when element order doesn\u0026#39;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:\n1 2 3 4 5 // Open the bgm.mp3 file located in the mus folder and assign it the alias \u0026#34;bgm\u0026#34; mciSendString(_T(\u0026#34;open mus/bgm.mp3 alias bgm\u0026#34;), NULL, 0, NULL);\t// Load sound // Play the sound with alias \u0026#34;bgm\u0026#34; in a loop starting from the beginning mciSendString(_T(\u0026#34;play bgm repeat from 0\u0026#34;), NULL, 0, NULL);\t// Remove \u0026#39;repeat\u0026#39; if looping is not needed\tPerformance 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.\nHere’s a comparison between a typical implementation and a Flyweight-based one:\n1 2 3 4 5 6 7 //========= Typical Implementation ========= struct Tree { Model model;\t// Tree model Texture texture;// Tree texture int x, y, z;\t// 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;\t// Tree model Texture texture;// Tree texture } // Tree instance structure struct Tree { TreeAsset* asset;\t// Pointer to shared asset int x, y, z;\t// Tree position } In this project, we refactor the Animation class to separate shared and instance-specific data. The shared data—std::vector\u0026lt;IMAGE*\u0026gt; frame_list—is stored in an Atlas class, while each enemy instance maintains its own animation state.\nShared image data is managed by the Atlas class:\n1 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 \u0026lt; 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 \u0026lt; frame_list.size(); i++) { delete frame_list[i]; } } public: vector\u0026lt;IMAGE*\u0026gt; frame_list; }; Instance-specific animation logic is encapsulated in the Animation class:\n1 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\u0026#39;t delete it here // It should be released at a higher level (e.g., in main) // Also, we didn\u0026#39;t allocate it with \u0026#39;new\u0026#39; here // Play animation void Play(int x, int y, int delta_time) { timer += delta_time; if (timer \u0026gt;= interval_ms) { idx_frame = (idx_frame + 1) % anim_atlas-\u0026gt;frame_list.size(); timer = 0; } putimage_alpha(x, y, anim_atlas-\u0026gt;frame_list[idx_frame]); } private: int interval_ms = 0;// Frame interval int timer = 0;\t// 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:\nAccordingly, we need to handle three types of input events: mouse movement, left mouse button press, and left mouse button release.\n1 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\u0026amp; msg) { switch (msg.message) { case WM_MOUSEMOVE: if (status == Status::Idle \u0026amp;\u0026amp; CheckCursorHit(msg.x, msg.y)) status = Status::Hovered; else if (status == Status::Idle \u0026amp;\u0026amp; !CheckCursorHit(msg.x, msg.y)) status = Status::Idle; else if (status == Status::Hovered \u0026amp;\u0026amp; !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 \u0026amp; 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:\n1 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.\nIn 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:\nIn 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(\u0026amp;image). Each DWORD element occupies 4 bytes and stores RGBA data (Red, Green, Blue, Alpha).\nImplementing 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.\nBefore 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.\nNext, 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.\nLoading left-facing animation frames:\n1 2 3 4 5 6 7 8 9 IMAGE img_player_left[6]; // Load player left-facing animation for (int i = 0; i \u0026lt; 6; i++) { static TCHAR img_path[256]; _stprintf_s(img_path, _T(\u0026#34;img/paimon_left_\u0026amp;d.png\u0026#34;), i); loadimage(\u0026amp;img_player_left[i], img_path); } Creating right-facing flipped frames:\n1 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 \u0026lt; 6; i++) { int width = img_player_left[i].getwidth(); int height = img_player_left[i].getheight(); Resize(\u0026amp;img_player_right[i], width, height);\t// Resize and allocate memory for right-facing image // Flip each row horizontally DWORD* color_buffer_left_img = GetImageBuffer(\u0026amp;img_player_left[i]); DWORD* color_buffer_right_img = GetImageBuffer(\u0026amp;img_player_right[i]); for (int y = 0; y \u0026lt; height; y++) { for (int x = 0; x \u0026lt; width; x++) { int idx_left_img = y * width + x;\t// Source pixel index int idx_right_img = y * width + (width - 1 - x);\t// 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:\n1 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.\nTo 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:\n1 2 3 4 // RGB(255, 255, 255) gives 0x00FFFFFF // Adding full opacity: (BYTE)(255) \u0026lt;\u0026lt; 24 = 0xFF000000 // Combined: 0xFFFFFFFF = fully opaque white DWORD white_pix = BGR(RGB(255, 255, 255)) | (((DWORD)(BYTE)(255)) \u0026lt;\u0026lt; 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：\n1 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 \u0026lt; 6; i++) { int width = img_player_left[i].getwidth(); int height = img_player_left[i].getheight(); Resize(\u0026amp;img_player_left_sketch[i], width, height);\t// Allocate memory and set size DWORD* color_buffer_raw_img = GetImageBuffer(\u0026amp;img_player_left[i]); DWORD* color_buffer_sketch_img = GetImageBuffer(\u0026amp;img_player_left_sketch[i]); for (int y = 0; y \u0026lt; height; y++) { for (int x = 0; x \u0026lt; width; x++) { int idx = y * width + x; if ((color_buffer_raw_img[idx] \u0026amp; 0xFF000000) \u0026gt;\u0026gt; 24)\t// If pixel is not fully transparent color_buffer_sketch_img[idx] = BGR(RGB(255, 255, 255)) | (((DWORD)(BYTE)(255)) \u0026lt;\u0026lt; 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:\nImage Overlay Without Alpha Blending 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(\u0026amp;img_ice); DWORD* color_buffer_frame_img = GetImageBuffer(\u0026amp;img_current_frame); // Traverse the color buffer of the current frame and blend the non-transparent regions for (int y = 0; y \u0026lt; height; y++) { for (int x = 0; x \u0026lt; width; x++) { int idx = y * width + x; static const float RATIO = 0.25f;\t// Blend ratio DWORD color_ice_img = color_buffer_ice_img[idx]; DWORD color_frame_img = color_buffer_frame_img[idx]; if ((color_frame_img \u0026amp; 0xFF000000) \u0026gt;\u0026gt; 24)\t// 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)) \u0026lt;\u0026lt; 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.\n1 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;\t// Animation frame index static int anim_timer = 0;\t// Animation timer static int frozen_timer = 0;\t// Freeze state timer static const int THICKNESS = 5;\t// Scanline thickness static int hightlight_pos_y = 0;// Vertical position of the scanline static bool is_frozen = false;\t// Whether the player is currently frozen // If not frozen, update animation timer if ((!is_frozen) \u0026amp;\u0026amp; (++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, \u0026amp;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(\u0026amp;img_ice); DWORD* color_buffer_frame_img = GetImageBuffer(\u0026amp;img_current_frame); for (int y = 0; y \u0026lt; height; y++) { for (int x = 0; x \u0026lt; width; x++) { int idx = y * width + x; static const float RATIO = 0.25f;\t// Blending ratio static const float THRESHOLD = 0.84f;\t// 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 \u0026amp; 0xFF000000) \u0026gt;\u0026gt; 24)\t// 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 \u0026gt;= hightlight_pos_y \u0026amp;\u0026amp; y \u0026lt; = highlight_pos_y + THICKNESS) \u0026amp;\u0026amp; ((r / 255.0f) * 0.2126f + (g / 255.0f) * 0.7152f + (b / 255.0f) * 0.0722f \u0026gt;= TRESHOLD)) { color_buffer_frame_img[idx] = (BGR(RGB(255, 255, 255)) | (((DWORD)(BYTE)(255)) \u0026lt;\u0026lt; 24); continue; } color_buffer_frame_img[idx] = (BGR(RGB(r, g, b)) | (((DWORD)(BYTE)(255)) \u0026lt;\u0026lt; 24); } } } putimage_alpha(position.x, position.y, \u0026amp;img_current_frame);\t} else putimage_alpha(position.x, position.y, \u0026amp;img_player_left[counter]); } The brightness coefficients (0.2126, 0.7152, 0.0722) are based on a standard formula for perceived luminance.\nFull 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 \u0026lt;graphics.h\u0026gt; #include \u0026lt;string\u0026gt; #include \u0026lt;vector\u0026gt; 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, \u0026#34;MSIMG32.LIB\u0026#34;) // Links the Windows GDI+ library for advanced image operations #pragma comment(lib, \u0026#34;Winmm.lib\u0026#34;) // 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 \u0026lt; 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 \u0026lt; frame_list.size(); i++) { delete frame_list[i]; } } public: vector\u0026lt;IMAGE*\u0026gt; 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;\t// The atlas is shared, so we don\u0026#39;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 \u0026gt;= interval_ms) { idx_frame = (idx_frame + 1) % anim_atlas-\u0026gt;frame_list.size(); timer = 0; } putimage_alpha(x, y, anim_atlas-\u0026gt;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;\t// Pointer to shared atlas }; class Player { public: Player() { loadimage(\u0026amp;img_shadow, _T(\u0026#34;img/shadow_player.png\u0026#34;)); 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\u0026amp; 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; } }\t} 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 \u0026lt; 0) position.x = 0; if (position.y \u0026lt; 0) position.y = 0; if (position.x + FRAME_WIDTH \u0026gt; WINDOW_WIDTH) position.x = WINDOW_WIDTH - FRAME_WIDTH; if (position.y + FRAME_HEIGHT \u0026gt; 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, \u0026amp;img_shadow); static bool facing_left = false; int dir_x = is_moving_right - is_moving_left; if (dir_x \u0026lt; 0) facing_left = true; else if (dir_x \u0026gt; 0) facing_left = false; if (facing_left) anim_left-\u0026gt;Play(position.x, position.y, delta_time); else anim_right-\u0026gt;Play(position.x, position.y, delta_time); } const POINT\u0026amp; 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;\tprivate:\tIMAGE 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 \u0026#39;const\u0026#39; 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(\u0026amp;img_shadow, _T(\u0026#34;img/shadow_enemy.png\u0026#34;)); 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\u0026amp; bullet) // Add \u0026#39;const\u0026#39; 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\u0026#39;s rectangle bool is_overlap_x = bullet.position.x \u0026gt;= position.x \u0026amp;\u0026amp; bullet.position.x \u0026lt;= position.x + FRAME_WIDTH; bool is_overlap_y = bullet.position.y \u0026gt;= position.y \u0026amp;\u0026amp; bullet.position.y \u0026lt;= position.y + FRAME_HEIGHT; return is_overlap_x \u0026amp;\u0026amp; is_overlap_y; } bool CheckPlayerCollision(const Player\u0026amp; 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 \u0026gt;= player.GetPosition().x \u0026amp;\u0026amp; check_position.x \u0026lt;= player.GetPosition().x + player.FRAME_WIDTH; bool is_overlap_y = check_position.y \u0026gt;= player.GetPosition().y \u0026amp;\u0026amp; check_position.y \u0026lt;= player.GetPosition().y + player.FRAME_HEIGHT; return is_overlap_x \u0026amp;\u0026amp; is_overlap_y; } void Move(const Player\u0026amp; player) { const POINT\u0026amp; 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 \u0026gt; 0) facing_left = false; else if (dir_x \u0026lt; 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, \u0026amp;img_shadow); if (facing_left) anim_left-\u0026gt;Play(position.x, position.y, delta_time); else anim_right-\u0026gt;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;\tprivate: 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(\u0026amp;img_idle, path_imag_idle); loadimage(\u0026amp;img_hovered, path_imag_hovered); loadimage(\u0026amp;img_pushed, path_imag_pushed); } ~Button() = default; void Draw() { switch (status) { case Status::Idle: putimage(region.left, region.top, \u0026amp;img_idle); break; case Status::Hovered: putimage(region.left, region.top, \u0026amp;img_hovered); break; case Status::Pushed: putimage(region.left, region.top, \u0026amp;img_pushed); break; } } void ProcessEvent(const ExMessage\u0026amp; msg) { switch (msg.message) { case WM_MOUSEMOVE: if (status == Status::Idle \u0026amp;\u0026amp; CheckCursorHit(msg.x, msg.y)) status = Status::Hovered; else if (status == Status::Idle \u0026amp;\u0026amp; !CheckCursorHit(msg.x, msg.y)) status = Status::Idle; else if (status == Status::Hovered \u0026amp;\u0026amp; !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 \u0026gt;= region.left \u0026amp;\u0026amp; x \u0026lt;= region.right \u0026amp;\u0026amp; y \u0026gt;= region.top \u0026amp;\u0026amp; y \u0026lt;= 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(\u0026#34;play bgm repeat from 0\u0026#34;), 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\u0026lt;Enemy*\u0026gt;\u0026amp; enemy_list); void UpdateBullets(vector\u0026lt;Bullet\u0026gt;\u0026amp; bullet_list, const Player\u0026amp; player); void DrawPlayerScore(int score); int main() { initgraph(WINDOW_WIDTH, WINDOW_HEIGHT); mciSendString(_T(\u0026#34;open mus/bgm.mp3 alias bgm\u0026#34;), NULL, 0, NULL); // load audio mciSendString(_T(\u0026#34;open mus/hit.wav alias hit\u0026#34;), 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(\u0026#34;img/player_left_%d.png\u0026#34;), PLAYER_ANIM_NUM); atlas_player_right = new Atlas(_T(\u0026#34;img/player_right_%d.png\u0026#34;), PLAYER_ANIM_NUM); atlas_enemy_left = new Atlas(_T(\u0026#34;img/enemy_left_%d.png\u0026#34;), ENEMY_ANIM_NUM); atlas_enemy_right = new Atlas(_T(\u0026#34;img/enemy_right_%d.png\u0026#34;), ENEMY_ANIM_NUM); Player player; vector\u0026lt;Enemy*\u0026gt; enemy_list; vector\u0026lt;Bullet\u0026gt; bullet_list(3); // Only three bullets are needed, so raw pointers are avoided to prevent memory leaks\tExMessage msg; IMAGE img_menu;\tIMAGE img_background;\tint 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(\u0026#34;img/ui_start_idle.png\u0026#34;), _T(\u0026#34;img/ui_start_hovered.png\u0026#34;), _T(\u0026#34;img/ui_start_pushed.png\u0026#34;)); QuitGameButton btn_quit_game = QuitGameButton(region_btn_quit_game, _T(\u0026#34;img/ui_quit_idle.png\u0026#34;), _T(\u0026#34;img/ui_quit_hovered.png\u0026#34;), _T(\u0026#34;img/ui_quit_pushed.png\u0026#34;)); loadimage(\u0026amp;img_menu, _T(\u0026#34;img/menu.png\u0026#34;)); loadimage(\u0026amp;img_background, _T(\u0026#34;img/background.png\u0026#34;)); BeginBatchDraw(); while (is_running) { DWORD start_time = GetTickCount(); while (peekmessage(\u0026amp;msg)) { if (is_game_started) { player.ProcessEvent(msg); } else { btn_start_game.ProcessEvent(msg); btn_quit_game.ProcessEvent(msg); } }\tif (is_game_started) { player.Move(); UpdateBullets(bullet_list, player); TryGenerateEnemy(enemy_list); for (Enemy* enemy : enemy_list) enemy-\u0026gt;Move(player); // Collision detection: enemies vs. player for (Enemy* enemy : enemy_list) { if (enemy-\u0026gt;CheckPlayerCollision(player)) { static TCHAR text[128]; _stprintf_s(text, _T(\u0026#34;最终得分：%d！\u0026#34;), score); MessageBox(GetHWnd(), text, _T(\u0026#34;游戏结束\u0026#34;), MB_OK); is_running = false; break; } } // Collision detection: enemies vs. bullets for (Enemy* enemy : enemy_list) { for (const Bullet\u0026amp; bullet : bullet_list) { if (enemy-\u0026gt;CheckBulletCollision(bullet)) { mciSendString(_T(\u0026#34;play hit from 0\u0026#34;), NULL, 0, NULL); enemy-\u0026gt;Hurt(); score++; } } } // Iterate through the enemy list and remove defeated enemies for (size_t i = 0; i \u0026lt; enemy_list.size(); i++) // Avoid using iterators since the container is modified during iteration { Enemy* enemy = enemy_list[i]; if (!enemy-\u0026gt;CheckAlive()) { // Swap with the last element and remove it // * This is an efficient deletion method when element order doesn\u0026#39;t matter swap(enemy_list[i], enemy_list.back()); enemy_list.pop_back(); delete enemy; } } } cleardevice(); // ======= Draw ======= if (is_game_started) { putimage(0, 0, \u0026amp;img_background); player.Draw(1000 / FPS); for (Enemy* enemy : enemy_list) enemy-\u0026gt;Draw(1000 / FPS); for (Bullet\u0026amp; bullet : bullet_list) bullet.Draw(); DrawPlayerScore(score); } else { putimage(0, 0, \u0026amp;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 \u0026lt; 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-\u0026gt;getwidth(); int h = img-\u0026gt;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\u0026lt;Enemy*\u0026gt;\u0026amp; 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\u0026lt;Bullet\u0026gt;\u0026amp; bullet_list, const Player\u0026amp; 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\u0026#39;s position based on the player\u0026#39;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 \u0026lt; 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(\u0026#34;当前玩家得分：%d\u0026#34;), 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.\nI 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.\nLooking 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.\n","date":"2025-10-08T10:47:30+02:00","image":"https://nullshowjl.github.io/en/p/c-game-dev-from-scratchteyvat-survivors/cover_hu_4ff13cd35e0f614d.webp","permalink":"https://nullshowjl.github.io/en/p/c-game-dev-from-scratchteyvat-survivors/","title":"【C++ Game Dev from Scratch】Teyvat Survivors"},{"content":"Table of Contents\nTerms Industry Expressions for Game \u0026amp; IT Developers Idiomatic Expressions For Interviews and Project Communication Terms Expression Meaning Usage Scenario polymorphism The ability of different objects to respond to the same function call in different ways. Core concept in object-oriented programming, especially with virtual functions. inheritance A mechanism where one class derives properties and behaviors from another. Used to create class hierarchies and reuse code. encapsulation Hiding internal details and exposing only necessary parts through interfaces. Helps protect data and maintain clean architecture. constructor / destructor Special functions that initialize and clean up objects. Used in C++ to manage object lifecycle. reference / pointer Ways to access or refer to memory locations. Essential in C++ for memory management and function arguments. overload / override Overload: same function name with different parameters; Override: redefine base class behavior. Used to extend or customize functionality. compile-time / runtime Compile-time: when code is translated to machine code; Runtime: when the program is executed. Important for debugging and performance analysis. stack / heap Two types of memory allocation: stack is fast and temporary, heap is dynamic and persistent. Used in variable storage and object creation. thread-safe Code that can safely run in multi-threaded environments without causing errors. Crucial in concurrent programming. undefined behavior Code that may produce unpredictable results due to language rules violations. Should be avoided to ensure stability and portability. template / generic Code structures that work with any data type. Used in C++ (templates) and TypeScript (generics) for reusable components. STL (Standard Template Library) A collection of pre-built classes and functions for common data structures and algorithms. Widely used in C++ for vectors, maps, sets, etc. lambda expression A concise way to define anonymous functions. Used in functional-style programming and callbacks. scope / lifetime Scope: where a variable is accessible; Lifetime: how long it exists in memory. Important for managing resources and avoiding bugs. Industry Expressions for Game \u0026amp; IT Developers Expression Meaning Usage Scenario MVP (Minimum Viable Product) The simplest version of a product that still delivers value. Used during early product development to test core features quickly. pivot A major change in strategy or direction. When a product or business shifts focus due to feedback or market changes. iteration One cycle of development and improvement. Common in agile workflows to gradually refine a product. sprint A short, focused period of development. Used in agile teams to deliver specific tasks within 1–2 weeks. backlog A prioritized list of tasks or features. Managed by product owners to track upcoming work. playtest Testing a game by letting users play it. Used to gather feedback on gameplay, balance, and user experience. asset pipeline The workflow for preparing and integrating visual/audio assets. Collaboration between artists and developers to get assets into the game. hitbox / collision detection The invisible area used to detect contact between objects. Essential for gameplay mechanics like combat or movement. frame rate / FPS The number of frames rendered per second. A key performance metric for smooth gameplay. latency / lag Delay between user action and system response. Critical in online games and real-time applications. live ops Ongoing updates and events after a game’s release. Used to retain players and keep content fresh. monetization Strategies to generate revenue from a product. Includes ads, in-app purchases, subscriptions, etc. sandbox environment A safe, isolated testing space. Used for experimentation without affecting production systems. scalability The ability of a system to handle growth. Important for systems expected to serve many users or large data loads. CI/CD (Continuous Integration / Deployment) Automated processes for building, testing, and releasing code. Core to modern DevOps workflows for fast and reliable delivery. Idiomatic Expressions Expression Meaning Usage “Let’s refactor this module.” Suggest improving the code structure to make it cleaner or more efficient. During a code review or when planning technical debt cleanup. “This function is too tightly coupled.” The function depends too much on other parts of the code, making it hard to reuse or test. When discussing code design problems. “Can we abstract this logic?” Suggest extracting common logic into a reusable function or class. While reviewing repetitive code or planning modularization. “This breaks the single responsibility principle.” The code does too many things and should be split into smaller parts. When evaluating class or function design. “Let’s keep it DRY.” Avoid repeating the same code in multiple places. During refactoring or team discussions. “This is a bit verbose.” The code or explanation is too long or detailed. When suggesting simplification. “It’s more idiomatic to use…” This way of writing is more natural or standard in this programming language. When giving style or best practice advice. “Let’s decouple the UI from the logic.” Separate the user interface from the underlying code logic. When designing front-end architecture. “This is a good candidate for a helper function.” This piece of code can be moved into a reusable function. During code cleanup or review. “We should avoid side effects here.” The function should not change external states unexpectedly. When writing pure or predictable functions. For Interviews and Project Communication Expression Meaning Usage Scenario I led the implementation of\u0026hellip; I was responsible for building or developing a specific feature or system. Used when describing your role in a project. We optimized the performance by\u0026hellip; We made the system faster or more efficient using a specific method. Used to highlight technical improvements. I collaborated with cross-functional teams. I worked with people from different departments (e.g., design, QA, product). Shows teamwork and communication skills. One challenge we faced was\u0026hellip; We encountered a problem during the project. Used to introduce problem-solving stories. I proposed a solution that\u0026hellip; I suggested a way to fix or improve something. Shows initiative and problem-solving ability. The project was deployed to production in\u0026hellip; The project went live and was used by real users. Used to show project completion and impact. I ensured code quality through\u0026hellip; I used specific practices to keep the code clean and reliable. Highlights engineering discipline and standards. I’m comfortable working in agile environments. I’m used to working in fast-paced, iterative teams. Shows adaptability and team experience. I’m currently exploring C++ for backend performance. I’m learning how to use C++ to make backend systems faster. Shows ongoing learning and technical curiosity. I’d love to contribute to scalable systems. I’m interested in building systems that can grow and handle more users. Expresses career goals and technical ambition. ","date":"2025-09-28T10:28:14+02:00","image":"https://nullshowjl.github.io/en/p/english-for-developersexpressions-for-dev-life/cover_hu_9a73549b2ea3aaf4.webp","permalink":"https://nullshowjl.github.io/en/p/english-for-developersexpressions-for-dev-life/","title":"【English for Developers】Expressions for Dev Life"},{"content":"Table of Contents\nOverview Timeline - Phase One Phase One Summary Overview Project Goal: Build a personal blog website from scratch to document and share my learning notes, development journey, and reflections. It also serves as part of my portfolio. Tech Stack: GitHub Pages + Hugo, with lightweight customizations based on the Stack theme. Start Date: 2025-09-14 Current Status: In progress Learning Resources: Hugo official docs, Stack theme docs, tutorials from Bilibili and other creators online. Repository: GitHub Timeline - Phase One 2025-09-14 Goals： Set up the site and enable auto-deployment Add a basic version of the animated character widget Issues： Page not found when switching between Chinese and English Website tab icon not showing Solutions: Used GitHub Copilot in VS Code to help write a JS file for language switching Tested with icons that worked on other websites to rule out image issues; the icon eventually appeared after I worked on other modules Thoughts: I need to learn web programming systematically while developing, so I can understand not just how things work, but why GitHub Pages deploys quickly, but changes may take time to show. If the repo is updated but the site hasn’t refreshed, just wait a bit 2025-09-16 Goals: Add an Update Log to the sidebar Upload the first blog post (Chinese version) Change fonts Use a custom cursor Issue: Custom cursor not showing Solution: Found in the tutorial’s comment section that the issue might be with the image. Tested with the blogger’s image and it worked Thoughts: Comments under articles or videos can be very helpful—others may have already solved the same problem 2025-09-18 Goal: Finish a round of basic visual improvements Issues: Unsure how to apply specific fonts to different languages and code blocks Syntax error during auto-deployment Solutions: Used Copilot’s suggestions to fix the font issue Syntax error was due to indentation. VS Code auto-formatted incorrectly, so I switched to Notepad to fix it Thoughts: Front-end code is sensitive to indentation. Better to use Notepad or other editors instead of VS Code for certain tasks 2025-09-19 Goals:\nContinue visual improvements Adjust icons and text for Color Scheme Add dynamic background Add blog view counter Issue:\nAfter fixing overlapping icons, text formatting still didn’t align with other sidebar items Notes：\nTried for quite a while without success \u0026ndash; I\u0026rsquo;ll leave it for now and revisit it after I’ve studied more front-end\n2025-09-20 Goals:\nContinue visual improvements Add homepage loading animation Add footer animation Add blog post heatmap Issues:\nUnrelated text appeared at the bottom of the site Tried adding animation to the icon below the avatar but it didn’t work Solution:\nAI helped identify the issue: I used C++ style comments in custom.html, which browsers treated as text Notes：\nWill revisit the small icon animation after learning more front-end (fixed on 9-23) Thoughts:\nComment syntax varies a lot between languages - important to follow the correct format strictly 2025-09-22 Goal：\nContinue visual improvements Issue：\nLink page card images couldn’t be resized uniformly Notes：\nStill couldn’t solve the issue, so I’ve manually adjusted each image for now. Will come back to unify the layout after I’ve learned more front-end 2025-09-23 Goals:\nAdd RSS feeds for language-specific subscriptions Upgrade character widget2D to moc3 version Add footer animation Add blog post heatmap Issue:\nCharacter model failed to load Solution:\nUsed browser dev tools to debug. AI helped identify a missing \\ in the model folder path Thoughts:\nAlways double-check file paths Dev tools are super useful for debugging 2025-09-24 Goals:\nAdd a new character model and customize all parameters Issues:\nNew model failed to load jsDelivr didn’t load newly pushed files Solutions:\nDev tools showed the model was too large, so I decided to abandon it Learned from AI that jsDelivr caches tags and doesn’t auto-update. Used precise loading to avoid cluttering the repo Thoughts:\nFirst time realizing how important file size is for web delivery. Just like managing game assets in later stages of Brotato, each programming domain has its own key concerns that you only grasp through hands-on experience 2025-09-29 Goals:\nAdd runtime statistics and article count to the bottom of the webpage Issues:\nChinese characters appeared as garbled text Solutions:\nFollowed AI’s suggestion to inspect the file using VS Code. Found that the encoding was incorrect under UTF-8. After adjusting the encoding, the characters displayed correctly Thoughts:\nOpening the file in VS 2022 did not reveal any encoding issues, but VS Code clearly exposed the problem However, after editing in VS Code, clicking Save would automatically change the file format, causing Hugo to fail during compilation Phase One Summary Progress:\nFinished initial setup of the tech blog, including auto-deployment and basic visual customization Takeaways:\nLearned the basics of static site generators (config → template → render) Got familiar with GitHub Pages deployment Didn’t study front-end formally during this phase—relied on tutorials and AI. Learned how to ask AI better questions to solve problems Next Steps:\nStudy front-end systematically and revisit unresolved issues Read and understand the customized parts of the code Keep updating the site based on my TODO list ","date":"2025-09-24T09:11:30+02:00","image":"https://nullshowjl.github.io/en/p/dev-logmy-tech-blog-website/cover_hu_c0f1e97ee737fddc.webp","permalink":"https://nullshowjl.github.io/en/p/dev-logmy-tech-blog-website/","title":"【Dev Log】My Tech Blog Website"},{"content":"Table of Contents\nOverview Environment Setup Demo1 - Circle Follows Mouse Demo2 - Tic-Tac-Toe Game Reflection and Summary Overview Tech Stack：C++ + EasyX\nProject Goal: Set up the EasyX environment and complete two small demos (a circle that follows the mouse, and a tic-tac-toe game). Understand the basic structure of a game loop.\nCourse Source：Bilibili-Voidmatrix\nEnvironment Setup EasyX: Just search for “EasyX” and download it directly from the official site.\nTo use EasyX functions, include \u0026lt;graphics.h\u0026gt; in your header file.\nDemo1 - Circle Follows Mouse Design Approach Create a window and set up the main game loop Draw a circle that follows the mouse Optimize rendering using double buffering Development Steps Initialize Window and Main Loop Use initgraph() to initialize the window, and a while (true) loop to prevent it from closing instantly:\n1 2 3 4 5 6 7 8 9 int main() { initgraph(1280, 720); while (true) { } return 0; } This infinite loop is the basic framework for all games. Input handling and screen updates happen inside it:\n1 2 3 4 5 while (true) { // Handle player input // Update screen } Draw the Circle Use solidcircle() to draw the circle.\nHandle Input Use peekmessage() to process input.\nIn EasyX, mouse movement, clicks, and keyboard input are all considered “messages.” These messages are stored in a queue. Each time peekmessage() is called, EasyX tries to pull one message from the queue. If successful, it returns true; otherwise, false. So we use another loop to keep pulling messages until the queue is empty.\nAccording to the docs, peekmessage() requires a parameter msg, which is a pointer to an ExMessage struct. One of its members, message, indicates the type of input (mouse, keyboard, etc.). So the input handling looks like this:\n1 2 3 4 5 6 7 8 9 10 11 while (true) { ExMessage msg; while (peekmessage(\u0026amp;msg)) { if (msg == WM_MOUSEMOVE) { // Handle mouse movement } } } Clear Screen If you don’t clear the screen, the ball will leave a trail as it follows the mouse. Use cleardevice() before each draw.\nOptimize Drawing with Double Buffering Use BeginBatchDraw(), FlushBatchDraw(), and EndBatchDraw():\n1 2 3 4 5 6 7 8 9 10 11 12 13 BeginBatchDraw(); while (true) // Main game loop { // Handle input // Update game state cleardevice(); // Clear screen // Draw FlushBatchDraw(); } EndBatchDraw(); Key Functions initgraph() – initialize graphics window peekmessage() – get mouse movement messages cleardevice() – clear screen solidcircle(x, y, r) – draw circle BeginBatchDraw(), FlushBatchDraw(), EndBatchDraw() – double buffering Concepts EasyX Coordinate System Origin is at the top-left of the screen. X increases to the right, Y increases downward.\nRender Buffer Think of the render buffer as a giant canvas. Drawing functions paint on it. Earlier drawings can be covered by later ones. cleardevice() fills the canvas with the current background color (default is black).\nBeginBatchDraw() creates a new invisible canvas. All drawing happens on this canvas until FlushBatchDraw() and EndBatchDraw() swap it with the visible one. This prevents flickering caused by frequent redraws.\nGame Loop A typical game loop repeatedly performs:\n1 2 3 4 5 6 while (true) { // Read input // Process data // Render screen } Initialize game data before the loop (before BeginBatchDraw()), and release resources after the loop ends.\nDemo2 - Tic-Tac-Toe Game Game Description Players take turns placing X or O on a 3 * 3 grid. If one player gets three in a row (horizontal, vertical, or diagonal), they win. If all nine cells are filled with no winner, it’s a draw. This demo supports local two-player mode only.\nDesign Approach Three Core Elements in the Game Loop Input: Handle mouse left-clicks. If a blank cell is clicked, place a piece.\nData Processing: Check for game-over conditions: three matching pieces in a line or full board. If the game ends, show a popup and exit the loop.\nRendering: Use line() to draw the grid and X pieces (diagonal lines), and circle() for O pieces. Display the current piece type in the top-left corner.\nData Structures Board and Pieces: Use a 2D array char board_data[3][3] for the board. Use 'X' and 'O' for pieces, and '-' for empty cells.\nGame Over Conditions Win: Check all 8 possible winning combinations for both X and O.\nDraw: If no '-' remains and no winner, it’s a draw.\nDevelopment Steps Top-Down Approach Start with the framework, then fill in details.\nGame Loop Skeleton Use bool running to control the loop. Use CheckWin() and CheckDraw() to determine game status.\n1 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 bool running = true; ExMessage msg; BeginBatchDraw(); while (running) // Main game loop { while (peekmessage(\u0026amp;msg)) { // Input } // Data processing if (CheckWin(\u0026#39;X\u0026#39;)) { // Pop up message and end the game MessageBox(GetHWnd(), _T(\u0026#34;X Player wins\u0026#34;), _T(\u0026#34;Game over\u0026#34;), MB_OK); running = false; } else if (CheckWin(\u0026#39;O\u0026#39;)) { // Similar logic to the above } else if (CheckDraw()) { // Similar logic to the above } cleardevice(); // Draw all the objects DrawBoard(); DrawPiece(); DrawPrompt(); FlushBatchDraw(); } EndBatchDraw(); Input Logic Mouse coordinates are in pixels. Convert them to grid indices:\n1 2 3 4 5 6 int x = msg.x; // Pixel indices of mouse int y = msg.y; int index_x = x / 200; // Grid indices of mouse int index_y = y / 200; Then place the piece and switch to the other type.\nData Processing Logic CheckWin() uses brute-force to check 8 patterns.\nCheckDraw() loops through all cells to check for '-'.\n1 2 3 4 5 6 7 8 9 10 for (int col = 0; col \u0026lt; 3; col++) { for (int row = 0; row \u0026lt; 3; row++) { if (board[row][col] == \u0026#39;-\u0026#39;) { } } } Rendering Logic Board: Use line() with pixel coordinates.\nX Pieces: Use diagonal line() calls.\nO Pieces: Use circle() with center offset by +100 pixels.\nDrawing the prompt message: To make it work in more general coding environments, used some less common types and functions \u0026ndash; but they work similarly to C’s printf().\n1 2 static TCHAR str[64]; _stprintf_s(str, _T(\u0026#34;Current piece type：%c\u0026#34;), current_piece); Some font styling functions：\n1 2 settextcolor(RGB(225, 175, 45)); // Set the text color to orange for better visual //\temphasis; outtextxy(0, 0, str); // Display the string at a specified position Optimization Last Piece Not Drawn: If win-check happens before drawing, the popup blocks rendering. So draw first, then check.\nHigh CPU Usage: When a computer runs a while loop, it executes extremely fast—our main game loop can complete thousands of iterations in an instant, consuming a large amount of CPU time. For most displays with a physical refresh rate of only 60Hz, this leads to unnecessary performance waste. A quick and crude solution is to use sleep(15) to force the program to pause for 15 milliseconds after each loop. However, this isn’t recommended. As the game grows in complexity, the amount of computation per loop can vary, depending on how the operating system allocates CPU resources. This means the actual time spent per loop may differ. So instead, we should calculate how long each frame takes to process and dynamically adjust the sleep time afterward. The recommended approach is to set a fixed frame rate manually. To do this, we use the GetTickCount() function, which returns the number of milliseconds since the program started running.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 while (running) { DWORD start_time = GetTickCount(); // Get the start time of the current loop // Read input // Process data // Render screen DWORD end_time = GetTickCount(); // Get the end time of the current loop DWORD delta_time = end_time - start_time; // Calculate the time interval // Dynamically assign sleep time based on the interval // Refresh the screen at 60 frames per second if (delta_time \u0026lt; 1000 / 60) // If the interval is less than the time for one frame, // sleep; Otherwise, no need to sleep { Sleep(1000 / 60 - delta_time); } } // Release resources } 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 #include \u0026lt;graphics.h\u0026gt; char board_data[3][3] = { {\u0026#39;-\u0026#39;, \u0026#39;-\u0026#39;, \u0026#39;-\u0026#39;}, {\u0026#39;-\u0026#39;, \u0026#39;-\u0026#39;, \u0026#39;-\u0026#39;}, {\u0026#39;-\u0026#39;, \u0026#39;-\u0026#39;, \u0026#39;-\u0026#39;} }; char current_piece = \u0026#39;O\u0026#39;; bool CheckWin(char c); bool CheckDraw(); void DrawBoard(); void DrawPiece(); void DrawPrompt(); int main() { //======= Initialization ======= initgraph(600, 600); ExMessage msg; bool running = true; // Double buffering to prevent screen flickering BeginBatchDraw(); //======= Main game loop ======= while (running) { DWORD start_time = GetTickCount(); while (peekmessage(\u0026amp;msg)) { //======= Handle input ======= // Detect mouse left-click messages if (msg.message == WM_LBUTTONDOWN) { // Calculate click position int x = msg.x; int y = msg.y; int index_x = y / 200; int index_y = x / 200; //========= Handle data processing ========= // Place piece if (board_data[index_y][index_x] == \u0026#39;-\u0026#39;) { board_data[index_y][index_x] = current_piece;\t// Switch piece type if (current_piece == \u0026#39;O\u0026#39;) { current_piece = \u0026#39;X\u0026#39;; } else if (current_piece == \u0026#39;X\u0026#39;) { current_piece = \u0026#39;O\u0026#39;; } } } }\tcleardevice(); //===== Handle rendering ===== DrawBoard();\tDrawPiece(); DrawPrompt(); FlushBatchDraw(); // Check for win condition -- placed after rendering to ensure the last piece // is drawn if (CheckWin(\u0026#39;X\u0026#39;)) { MessageBox(GetHWnd(), _T(\u0026#34;X player wins\u0026#34;), _T(\u0026#34;Game over\u0026#34;), MB_OK); running = false; } else if (CheckWin(\u0026#39;O\u0026#39;)) { MessageBox(GetHWnd(), _T(\u0026#34;O player wins\u0026#34;), _T(\u0026#34;Game over\u0026#34;), MB_OK); running = false; } else if (CheckDraw()) { MessageBox(GetHWnd(), _T(\u0026#34;Draw\u0026#34;), _T(\u0026#34;Game over\u0026#34;), MB_OK); running = false; } //======= Set frame rate（Optimization）======= DWORD end_time = GetTickCount(); DWORD delta_time = end_time - start_time; if (delta_time \u0026lt; 1000 / 60) { Sleep(1000 / 60 - delta_time); // Optimize performance by capping frame // rate at 60 FPS -- avoid running too fast } } EndBatchDraw(); return 0; } bool CheckWin(char c) { if (board_data[0][0] == c \u0026amp;\u0026amp; board_data[0][1] == c \u0026amp;\u0026amp; board_data[0][2] == c) return true; if (board_data[1][0] == c \u0026amp;\u0026amp; board_data[1][1] == c \u0026amp;\u0026amp; board_data[1][2] == c) return true; if (board_data[2][0] == c \u0026amp;\u0026amp; board_data[2][1] == c \u0026amp;\u0026amp; board_data[2][2] == c) return true; if (board_data[0][0] == c \u0026amp;\u0026amp; board_data[1][0] == c \u0026amp;\u0026amp; board_data[2][0] == c) return true; if (board_data[0][1] == c \u0026amp;\u0026amp; board_data[1][1] == c \u0026amp;\u0026amp; board_data[2][1] == c) return true; if (board_data[0][2] == c \u0026amp;\u0026amp; board_data[1][2] == c \u0026amp;\u0026amp; board_data[2][2] == c) return true; if (board_data[2][0] == c \u0026amp;\u0026amp; board_data[1][1] == c \u0026amp;\u0026amp; board_data[0][2] == c) return true; if (board_data[0][0] == c \u0026amp;\u0026amp; board_data[1][1] == c \u0026amp;\u0026amp; board_data[2][2] == c) return true; return false; } bool CheckDraw() { for (int col = 0; col \u0026lt; 3; col++) { for (int row = 0; row \u0026lt; 3; row++) { if (board_data[row][col] == \u0026#39;-\u0026#39;) { return false; } } } return true; } void DrawBoard() { line(0, 200, 600, 200); line(0, 400, 600, 400); line(200, 0, 200, 600); line(400, 0, 400, 600); } void DrawPiece() { for (int col = 0; col \u0026lt; 3; col++) { for (int row = 0; row \u0026lt; 3; row++) { switch (board_data[row][col]) { case \u0026#39;-\u0026#39;: break; case \u0026#39;O\u0026#39;: circle(200 * row + 100, 200 * col + 100, 100); break; case \u0026#39;X\u0026#39;: line(200 * row, 200 * col, 200 * (row + 1), 200 * (col + 1)); line(200 * (row + 1), 200 * col, 200 * row, 200 * (col + 1)); } } }\t} void DrawPrompt() { static TCHAR str[64]; _stprintf_s(str, _T(\u0026#34;Current piece type：%c\u0026#34;), current_piece); settextcolor(RGB(225, 175, 45)); outtextxy(0, 0, str); } Reflection and Summary This was my first time truly understanding the game loop, double buffering, coordinate systems, and frame rate control. I’ve used C++ and raylib before, but mostly by copying code without fully grasping it. This time, I followed the tutorial step by step, focusing on fast and simple implementation rather than object-oriented design. I used a top-down approach: build the framework first, then solve each problem one by one.\nI plan to finish all of VoidMatrix’s tutorials to improve my coding skills and deepen my understanding of game development. I also want to align my coding style with industry standards.\n","date":"2025-09-18T09:11:30+02:00","image":"https://nullshowjl.github.io/en/p/c-game-dev-from-scratchfundamental/cover_hu_bda5ab51651e4291.webp","permalink":"https://nullshowjl.github.io/en/p/c-game-dev-from-scratchfundamental/","title":"【C++ Game Dev from Scratch】Fundamental"}]