praca_magisterska/games/UNITY_CODE_DOCUMENTATION.md

20 KiB
Raw Permalink Blame History

Unity Bullet Hell — Complete Code Documentation

Project: magisterka_2/ (Unity 2D, C#) Namespace: Magisterka.BulletHell Purpose: Performance benchmark bullet-hell shooter for comparing Unity vs Unreal Engine. Duration: 90-second survival round with escalating difficulty.


Architecture Overview

The project follows a scene-free bootstrap pattern: the game builds itself entirely at runtime from C# code, with no dependency on a pre-configured Unity scene. This is done via:

  1. GameBootstrap — Static class with [RuntimeInitializeOnLoadMethod] that ensures GameInitializer exists.
  2. GameInitializer — MonoBehaviour that constructs the entire game hierarchy: camera, player, pools, HUD, spawner, director.

This architecture means the game can run from a completely empty scene — ideal for reproducible benchmarking across machines.

Dependency Graph

GameBootstrap → GameInitializer
                  ├── Camera (orthographic, 2D)
                  ├── BackgroundScroller (parallax starfield)
                  ├── EffectManager (VFX pooling)
                  ├── ScoreManager (HUD + score)
                  ├── BulletPool × 2 (player & enemy)
                  ├── PlayerController
                  ├── EnemySpawner
                  └── GameDirector (timer, victory, game-over)

Key Design Patterns

Pattern Where Used Why
Singleton GameDirector, EnemySpawner, ScoreManager, EffectManager, PlayerController Provides global access to managers via .Instance
Object Pooling BulletPool, EffectManager Eliminates per-frame allocation; critical for performance at 1000+ active bullets
Bootstrap/Factory GameBootstrap + GameInitializer Scene-independent initialization for benchmark reproducibility
Component Composition [RequireComponent(typeof(Health))] on EnemyController and PlayerController Ensures Health component always present; decouples damage logic from entity logic
Event-driven Communication Health.Died, Health.Damaged, ScoreManager.ScoreChanged Loose coupling between systems via C# events/delegates
Blueprint/Data-driven Configuration EnemyBlueprint class Runtime data objects define enemy archetypes without requiring ScriptableObjects

File-by-File Documentation

1. Faction.cs — Combat Side Identifier

What it does: Defines a simple enum with two values: Player and Enemy.

Why it works this way: Used throughout the collision and damage system to prevent friendly fire. When a bullet hits a Health component, factions are compared — bullets only damage entities of the opposite faction. This is the simplest possible friend-or-foe identification, avoiding complex layer setups.

Key detail: Stored as int (Player=0, Enemy=1), making comparison extremely cheap.


2. GameBootstrap.cs — Runtime Scene Initializer

What it does: A static class with [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] that runs automatically after any scene loads. It checks if a GameInitializer already exists; if not, it creates one and marks it DontDestroyOnLoad.

Why it works this way: This guarantees the game bootstraps correctly regardless of which scene is loaded or how the build is configured. No manual scene setup needed. The AfterSceneLoad timing ensures the scene hierarchy is ready before initialization begins.

Performance relevance: Zero runtime cost — runs exactly once at startup.


3. GameInitializer.cs — Full Scene Builder

What it does: A MonoBehaviour that constructs the entire game programmatically in Awake():

  • Creates an orthographic 2D camera (size 6.5, black background, positioned at z=-10)
  • Sets Application.targetFrameRate = 240 (uncapped for benchmarking)
  • Hides cursor
  • Creates BackgroundScroller, EffectManager, ScoreManager
  • Creates two BulletPool instances (player: 400 warm, cyan; enemy: 900 warm, orange-red)
  • Creates PlayerController with calculated movement bounds from camera aspect
  • Builds the entire HUD (canvas, score label, life icons, timer, game-over text, damage overlay)
  • Creates EnemySpawner and GameDirector, wiring them together

Why it works this way: Building everything in code means:

  1. No scene dependency — benchmarks are perfectly reproducible
  2. Single source of truth — all wiring is visible in one file
  3. No serialization bugs — no missing references from scene changes

HUD construction detail: The CreateHud() method builds the entire UI canvas from scratch including:

  • Score text (upper-left, 24pt)
  • Life icons (programmatically generated circular sprites via BuildLifeSprite())
  • Timer text (upper-right, 24pt)
  • Game over/victory text (center, 48pt, initially hidden)
  • Damage overlay (full-screen red flash, initially transparent)

BuildLifeSprite(): Generates a 32×32 pixel circular texture at runtime using distance-from-center calculation. No external assets needed.


4. GameDirector.cs — Game Flow Controller

What it does: Singleton MonoBehaviour that manages the overall game timer and end conditions:

  • Counts down from totalDuration (90s) to 0
  • When timer hits 0: stops the spawner, enters "cleanup phase"
  • During cleanup: waits until _spawner.HasActiveEnemies is false, then triggers victory
  • On player death (game over): stops the timer
  • Updates the timer UI label every frame in MM:SS format

Why it works this way: The two-phase end condition (timer expired → wait for enemies cleared) prevents an abrupt ending while enemies are still on screen. The player must survive AND clear all remaining enemies after time runs out.

State machine:

RUNNING → _timeRemaining <= 0 → EXPIRED (spawning stops)
EXPIRED → no active enemies  → VICTORY
RUNNING → player dies         → GAME OVER

5. Health.cs — Hit Point System

What it does: Tracks HP for any entity (player or enemy). Exposes:

  • Configure(faction, maxHP) — sets faction and max health
  • ApplyDamage(amount, hitPoint) — reduces HP, spawns hit effect, fires Damaged event, triggers death at 0
  • RestoreFull() — resets to max HP (used on player respawn)
  • Kill() — instant death (used by bomb screen-clear)
  • Events: Died (Action), Damaged (Action<float, Vector2>)

Why it works this way: Decoupled from entity logic. PlayerController and EnemyController both [RequireComponent(typeof(Health))] and subscribe to events. This means the damage system works identically regardless of who takes damage — the same ApplyDamage path is used for player health, enemy health, and bullet-vs-bullet collision.

Hit effect integration: Every ApplyDamage call automatically triggers EffectManager.SpawnHitEffect(), so visual feedback is always consistent.


6. BulletPool.cs — Object Pool for Bullets

What it does: Manages a pool of Bullet objects using a Queue<Bullet> (for O(1) get/return) and a HashSet<Bullet> for tracking live bullets:

  • Create(parent, name, color, faction, warmCount) — static factory that creates and pre-warms the pool
  • Spawn(position, direction, speed, damage) — activates a pooled bullet or creates a new one if pool is empty
  • Return(bullet) — deactivates and re-queues the bullet
  • ClearLiveBullets() — returns all active bullets to pool (used by bomb and screen clear)

Why it works this way: In a bullet-hell game, thousands of bullets are spawned and destroyed per second. Without pooling, Instantiate/Destroy would generate massive GC pressure. The Queue provides FIFO reuse, and the HashSet enables O(1) membership checking for ClearLiveBullets().

Warm capacity: Pre-allocates 400 player bullets and 900 enemy bullets at startup to avoid first-frame allocation spikes. These numbers were tuned empirically to the game's peak bullet counts.

Pool hierarchy: Inactive bullets are parented to a hidden _poolRoot transform. When spawned, bullets are unparented (SetParent(null)) to avoid transform hierarchy overhead.


7. Bullet.cs — Individual Bullet Behavior

What it does: Each bullet moves in a straight line at constant speed, checks bounds, and handles collisions:

  • Movement: transform.position += direction * speed * deltaTime (no physics engine)
  • Bounds check: despawns if beyond ±14 units from origin
  • Collision via OnTriggerEnter2D:
    • Bullet vs bullet (opposite faction): both despawn
    • Bullet vs Health (opposite faction): applies damage, despawns
    • Respects player invulnerability
  • Sprite caching: static Dictionary<Color, Sprite> SpriteCache — generates colored 16×16 pixel textures once and reuses them

Why it works this way: Using transform.position directly instead of Rigidbody2D.velocity gives deterministic control over movement. The Rigidbody2D is set to Kinematic with Interpolation — it exists only so Unity's physics system can generate trigger events, not for actual physics simulation.

Sprite generation: Bullets generate their own 16×16 solid-color sprites via a static cache. This avoids needing any sprite assets and ensures bullets of the same color share the same Sprite object.


8. EnemyBlueprint.cs — Enemy Archetype Configuration

What it does: A sealed immutable data class that defines all parameters for an enemy type:

  • Name, color, scale, sprite
  • Movement style (EnemyMovementStyle enum: ZigZag, Straight, Sine, Orbit)
  • Fire style (EnemyFireStyle enum: None, Radial, Stream, Burst)
  • Numerical stats: move speed, horizontal amplitude/frequency, fire interval, bullets per volley, bullet speed/damage, contact damage, health, score value, orbit radius, centering bias

Why it works this way: Using a plain C# class instead of ScriptableObject keeps everything in code (no Unity asset files). The 19-parameter constructor is intentionally explicit — it makes the 4 enemy definitions in EnemySpawner.BuildBlueprints() fully self-documenting.

Four archetypes:

Name Movement Fire Behavior
Blazer ZigZag Radial Orange, zig-zags down, fires radial bursts (12+ bullets)
Striker Straight Stream Purple, drops straight down, fires aimed stream + homing shots
Cascade Sine Burst Green, sine-waves, fires tight burst salvos
Interceptor Orbit None Yellow, fast orbital movement, no shooting, relies on collision

9. EnemyController.cs — Enemy AI and Behavior

What it does: Controls individual enemy behavior every frame:

  • Movement: Four movement patterns based on EnemyBlueprint.Movement:
    • ZigZag: Moves down + sinusoidal horizontal offset
    • Straight: Pure downward movement
    • Sine: Downward + absolute sine positioning (position-based, not velocity-based)
    • Orbit: Circular/elliptical path with slower vertical descent
  • Firing: Three firing patterns based on EnemyBlueprint.Fire:
    • Radial: N bullets evenly around 360° with a rotating offset
    • Stream: Downward spread + one homing shot aimed at the player
    • Burst: Triple salvos of 3 bullets each at increasing spread
  • Difficulty scaling: All stats scale with _difficulty parameter: health increases (+12 per difficulty unit), movement speeds up, fire rate decreases, radial bullet count increases
  • Death: Awards score, spawns explosion effect, notifies spawner, destroys self
  • Contact damage: If the enemy's collider touches the player, deals ContactDamage and the enemy self-destructs
  • Despawn: If the enemy falls below the camera view by despawnPadding, it's cleaned up

Why it works this way: The pattern-based movement system allows each enemy type to feel distinct while sharing the same update loop. Difficulty scaling is multiplicative — the same AdvanceMovement() code handles early weak enemies and late-game tough ones.

Centering bias: Some enemies (Blazer with 1.2 bias) tend to drift toward center X, making them cluster dangerously. This creates organic difficulty without specific AI pathfinding.


10. EnemySpawner.cs — Wave Generation System

What it does: Singleton that continuously spawns enemy waves with escalating difficulty:

  • Spawn routine: Coroutine that loops for totalDuration (90s), spawning waves at decreasing intervals
  • Spawn delay curve: Linearly interpolates from spawnDelayStart (1.6s) to spawnDelayEnd (0.45s) based on elapsed time
  • Introduction phase: First 4 enemies are spawned one at a time (one of each type) so the player sees all archetypes
  • Wave composition: After introduction, spawns 210 enemies per wave, scaling with elapsed time (1 + elapsed * 0.05)
  • Difficulty value: Linearly interpolates from 1.2 to 6.0 based on game progress
  • Spatial distribution: Enemies spawn at the top of the camera view, horizontally distributed with slight randomness and centering bias
  • Screen clear: ClearScreen() kills all active enemies and clears all enemy bullets (used by player bomb)
  • Procedural sprites: GenerateSprite() creates 64×64 pixel textures for four shapes: Disc, Triangle, Diamond, Arrow
  • Blueprint definitions: BuildBlueprints() creates all four enemy archetypes with hardcoded stats

Why it works this way: The coroutine-based spawn loop allows natural timing control without complex state machines. The escalating difficulty curve ensures the game gets progressively harder over 90 seconds — from manageable waves of 23 enemies to frantic swarms of 10.

Enemy tracking: A List<EnemyController> _liveEnemies tracks all active enemies. This is critical for:

  • HasActiveEnemies (checked by GameDirector for victory condition)
  • ClearScreen() (iterating all enemies to kill them)

11. PlayerController.cs — Player Input and Progression

What it does: Comprehensive player logic:

Movement (WASD/arrows):

  • Base speed 12 units/s, focus mode (LeftShift) at 0.6× speed
  • Clamped to camera bounds with 0.5 unit padding

Shooting (Z key or left mouse):

  • Auto-fire at 0.12s intervals (8.3 shots/sec)
  • Fires a volley of bullets in a fan pattern, size based on level
  • Base volley: 5 bullets with angular spread increasing by level
  • Extra directional shots unlock at levels 2, 4, 6, 8 (side, diagonal, back shots)

Bomb (X key or space or right mouse):

  • One-use screen clear: destroys all enemies, clears all bullets
  • Visual: player flashes white, dashes upward then returns
  • Grants temporary invulnerability during animation

Progression system:

  • Level calculated from score using exponential thresholds: baseLevelThreshold (1500), doubling each level
  • Max level: 12
  • Each level-up: visual flash, explosion effect, camera shake
  • Stats affected: volley width (more bullets), extra directional shots

Health/Lives:

  • Starts with maxLives (default 1)
  • On death: explosion VFX, renderer disabled, wait respawnDelay (0.7s), clear screen, reposition to bottom, flash invulnerability for 2s
  • If lives < 0: game over sequence

Test modes (command-line or runtime toggles):

  • --invincible / I key: permanent invulnerability (yellow color indicator)
  • --stationary / P key: disables all movement and shooting (for passive performance testing)

Why it works this way: The progression mechanic (score → level → more bullets) creates a natural performance escalation: as the player gets stronger, more player bullets are on screen, increasing GPU/CPU load. This is deliberate for benchmarking — the workload scales organically over the 90-second run.

Test modes rationale: --invincible prevents game-over during unattended benchmark runs. --stationary isolates enemy/spawner performance by removing player bullet generation.


12. ScoreManager.cs — Score and HUD Controller

What it does: Singleton that manages:

  • Score accumulation and display (Score: N)
  • Life icon visibility (enabling/disabling Image components)
  • Status messages ("GAME OVER" in red, "YOU WIN" in green)
  • Damage flash overlay (full-screen red pulse with sine-curve fade)
  • ScoreChanged event (consumed by PlayerController for level-up detection)

Why it works this way: The queued initialization pattern (_queuedLives, _queuedStatus) handles race conditions where SetLives() or status messages are called before the HUD is fully constructed. This is essential because PlayerController.Initialize() and ScoreManager.Initialize() may run in unpredictable order.

Damage flash: The DamageFlashRoutine uses a sine curve (sin(normalized * PI)) to create a smooth peak-and-fade effect. Strength parameter controls peak alpha (0.350.75).


13. EffectManager.cs — Visual Effects Hub

What it does: Singleton managing all visual feedback using pooled EffectPulse sprites:

  • SpawnHitEffect(position, faction, intensity) — 13 expanding circles (color by faction)
  • SpawnExplosion(position, faction, size) — 4 concentric expanding circles
  • SpawnScreenClear(position, radius) — 6 large expanding circles + camera shake
  • ShakeCamera(duration, strength) — delegates to CameraShaker
  • Internal pool of EffectPulse objects (warmed to 64, recycled via callbacks)

Why it works this way: Like BulletPool, effect objects are pooled to avoid GC spikes. The multi-pulse explosion technique (multiple expanding circles at different speeds) creates convincing explosion effects using only simple scaling sprites — no particle systems, no shaders.


14. EffectPulse.cs — Animated Sprite Effect

What it does: A single expanding, fading sprite:

  • Play(position, color, startSize, endSize, duration, callback) — configure and start
  • Each frame: interpolates scale from start to end, fades alpha from 1 to 0
  • On completion: deactivates self, invokes recycle callback

Sprite generation: BuildPulseSprite() creates a 32×32 radial gradient texture (alpha = (1 - distance)²) giving a soft, circular glow effect.

Why it works this way: The radial gradient sprite combined with scale animation creates the appearance of an expanding shockwave or explosion ring. The quadratic alpha falloff (alpha * alpha) ensures edges are smooth.


15. CameraShaker.cs — Screen Shake

What it does: Attached to the main camera, provides juicy screen shake:

  • Shake(duration, strength) — sets shake parameters (max of current and new)
  • Each frame during shake: random offset within _shakeStrength circle, strength decaying over time
  • After shake: smoothly lerps back to original position

Why it works this way: The "max of current and new" approach allows overlapping shakes without resetting, creating more organic screen feedback during intense combat.


16. BackgroundScroller.cs — Parallax Starfield

What it does: Creates two large starfield tiles and scrolls them downward at different speeds:

  • Tile A: scrolls at scrollSpeed (full speed)
  • Tile B: scrolls at scrollSpeed * parallaxFactor (0.6× speed, creating depth illusion)
  • When a tile scrolls off the bottom, it teleports back above the top (infinite loop)
  • GenerateStarSprite(): Creates a procedural starfield texture — dark background with random white pixels (star density: ~1 per 45 pixels)

Why it works this way: The parallax effect gives depth to a 2D game with zero external assets. Two tiles ensure seamless continuous scrolling. The procedural generation means no texture files are needed in the build.


Performance-Critical Design Decisions

Decision Rationale
Object pooling for bullets and effects Avoids GC allocation spikes from Instantiate/Destroy
transform.position movement (no physics forces) Deterministic movement, no physics solver overhead
Kinematic Rigidbody2D on bullets Enables trigger detection without full physics simulation
Procedural sprite/texture generation Zero asset loading, reproducible across machines
targetFrameRate = 240 Uncapped rendering for benchmarking
Scene-free bootstrap Eliminates scene deserialization variability
HashSet for live bullet tracking O(1) membership test during screen clears
Static sprite caching (Bullet.SpriteCache) Single shared texture per color