feat: make the game okish

This commit is contained in:
Krzysztof kuhy Rudnicki 2025-10-12 22:34:05 +02:00
parent 0e60cbd402
commit a9cfdb1245
12 changed files with 1161 additions and 127 deletions

View File

@ -44,7 +44,7 @@ namespace Magisterka.BulletHell
_body = gameObject.AddComponent<Rigidbody2D>();
_body.gravityScale = 0f;
_body.isKinematic = true;
_body.bodyType = RigidbodyType2D.Kinematic;
_body.interpolation = RigidbodyInterpolation2D.Interpolate;
_collider = gameObject.AddComponent<CircleCollider2D>();
@ -75,6 +75,22 @@ namespace Magisterka.BulletHell
private void OnTriggerEnter2D(Collider2D other)
{
if (other.TryGetComponent(out Bullet otherBullet))
{
if (otherBullet == this || otherBullet._ownerFaction == _ownerFaction)
{
return;
}
if (otherBullet.gameObject.activeInHierarchy)
{
otherBullet.Despawn();
}
Despawn();
return;
}
if (!other.TryGetComponent(out Health health))
{
return;

View File

@ -27,7 +27,6 @@ namespace Magisterka.BulletHell
}
Instance = this;
DontDestroyOnLoad(gameObject);
}
public void Initialize(Transform parent)
@ -54,6 +53,13 @@ namespace Magisterka.BulletHell
var color = hitFaction == Faction.Player ? playerColor : enemyColor;
var endSize = Mathf.Lerp(0.6f, 1.8f, Mathf.Clamp01(intensity + 0.2f));
PlayPulse(position, color, 0.25f, endSize, 0.18f);
if (hitFaction == Faction.Player)
{
float boosted = Mathf.Clamp01(intensity + 0.3f);
PlayPulse(position, Color.white, 0.18f, Mathf.Lerp(1.4f, 2.8f, boosted), 0.24f);
PlayPulse(position, playerColor, 0.45f, Mathf.Lerp(1.8f, 3.4f, boosted), 0.32f);
}
}
public void SpawnExplosion(Vector2 position, Faction faction, float size = 2.5f)
@ -66,8 +72,6 @@ namespace Magisterka.BulletHell
float duration = 0.22f + i * 0.06f;
PlayPulse(position, color, start, end, duration);
}
_cameraShaker?.Shake(0.18f, 0.45f * size);
}
public void SpawnScreenClear(Vector2 position, float radius)
@ -85,6 +89,11 @@ namespace Magisterka.BulletHell
_cameraShaker?.Shake(0.4f, 1.2f * radius);
}
public void ShakeCamera(float duration, float strength)
{
_cameraShaker?.Shake(duration, strength);
}
private void Warmup(int count)
{
for (int i = 0; i < count; i++)

View File

@ -0,0 +1,88 @@
using UnityEngine;
namespace Magisterka.BulletHell
{
public enum EnemyMovementStyle
{
ZigZag,
Straight,
Sine,
Orbit
}
public enum EnemyFireStyle
{
None,
Radial,
Stream,
Burst
}
/// <summary>
/// Runtime configuration for enemy archetypes.
/// </summary>
public sealed class EnemyBlueprint
{
public EnemyBlueprint(
string name,
Color color,
float scale,
EnemyMovementStyle movement,
EnemyFireStyle fire,
float moveSpeed,
float horizontalAmplitude,
float horizontalFrequency,
float fireInterval,
int bulletsPerVolley,
int aimedShots,
float bulletSpeed,
float bulletDamage,
float contactDamage,
float health,
int score,
float orbitRadius,
Sprite sprite,
float centeringBias)
{
Name = name;
Color = color;
Scale = scale;
Movement = movement;
Fire = fire;
MoveSpeed = moveSpeed;
HorizontalAmplitude = horizontalAmplitude;
HorizontalFrequency = horizontalFrequency;
FireInterval = fireInterval;
BulletsPerVolley = bulletsPerVolley;
AimedShots = aimedShots;
BulletSpeed = bulletSpeed;
BulletDamage = bulletDamage;
ContactDamage = contactDamage;
Health = health;
Score = score;
OrbitRadius = orbitRadius;
Sprite = sprite;
CenteringBias = centeringBias;
}
public string Name { get; }
public Color Color { get; }
public float Scale { get; }
public EnemyMovementStyle Movement { get; }
public EnemyFireStyle Fire { get; }
public float MoveSpeed { get; }
public float HorizontalAmplitude { get; }
public float HorizontalFrequency { get; }
public float FireInterval { get; }
public int BulletsPerVolley { get; }
public int AimedShots { get; }
public float BulletSpeed { get; }
public float BulletDamage { get; }
public float ContactDamage { get; }
public float Health { get; }
public int Score { get; }
public float OrbitRadius { get; }
public Sprite Sprite { get; }
public float CenteringBias { get; }
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3c02072540f72f170b8efc887586f480

View File

@ -1,4 +1,3 @@
using System.Collections;
using UnityEngine;
namespace Magisterka.BulletHell
@ -9,112 +8,206 @@ namespace Magisterka.BulletHell
[RequireComponent(typeof(Health))]
public class EnemyController : MonoBehaviour
{
[SerializeField] private float moveSpeed = 3f;
[SerializeField] private float bulletSpeed = 12f;
[SerializeField] private float bulletDamage = 8f;
[SerializeField] private float fireInterval = 0.6f;
[SerializeField] private int scoreValue = 100;
[SerializeField] private float despawnPadding = 2f;
private EnemySpawner _spawner;
private BulletPool _bulletPool;
private PlayerController _player;
private BulletPool _bulletPool;
private PlayerController _player;
private EnemyBlueprint _config;
private Health _health;
private float _phaseSeed;
private float _zigzagWidth = 2.8f;
private float _difficulty;
private Coroutine _fireRoutine;
private float _moveTime;
private float _phaseSeed;
private float _fireTimer;
private float _baseX;
private Vector2 _worldBounds;
public void Initialize(EnemySpawner spawner, BulletPool bulletPool, PlayerController player, float difficultyScale, float healthValue)
public void Initialize(EnemySpawner spawner, BulletPool bulletPool, PlayerController player, EnemyBlueprint blueprint, float difficultyScale, Vector2 worldBounds)
{
_spawner = spawner;
_bulletPool = bulletPool;
_player = player;
_config = blueprint;
_difficulty = difficultyScale;
_health = GetComponent<Health>();
_health.Configure(Faction.Enemy, healthValue);
_worldBounds = worldBounds;
_health.Configure(Faction.Enemy, blueprint.Health + difficultyScale * 12f);
_health.Died += HandleDeath;
_phaseSeed = Random.Range(0f, 360f);
moveSpeed += difficultyScale * 0.8f;
fireInterval = Mathf.Max(0.22f, fireInterval - difficultyScale * 0.08f);
bulletSpeed += difficultyScale * 1.4f;
bulletDamage += difficultyScale * 0.6f;
scoreValue += Mathf.RoundToInt(40f * difficultyScale);
if (_fireRoutine != null)
var collider = GetComponent<CircleCollider2D>();
if (collider != null)
{
StopCoroutine(_fireRoutine);
collider.radius = 0.4f * blueprint.Scale;
}
_fireRoutine = StartCoroutine(FireRoutine());
_phaseSeed = Random.Range(0f, Mathf.PI * 2f);
_baseX = transform.position.x;
_moveTime = 0f;
_fireTimer = GetFireDelay(true);
}
private void Update()
{
var pos = transform.position;
pos += Vector3.down * (moveSpeed * Time.deltaTime);
pos.x += Mathf.Sin(Time.time * (2f + _difficulty) + _phaseSeed) * (_zigzagWidth * Time.deltaTime);
transform.position = pos;
AdvanceMovement();
HandleFiring();
if (transform.position.y < -12f)
float bottomLimit = -_worldBounds.y - despawnPadding;
if (transform.position.y < bottomLimit)
{
_spawner?.DespawnEnemy(this);
}
}
private IEnumerator FireRoutine()
private void AdvanceMovement()
{
yield return new WaitForSeconds(Random.Range(0.15f, fireInterval));
_moveTime += Time.deltaTime;
var pos = transform.position;
float speed = _config.MoveSpeed + _difficulty * 0.2f;
while (true)
switch (_config.Movement)
{
FirePattern();
yield return new WaitForSeconds(fireInterval);
case EnemyMovementStyle.ZigZag:
pos += Vector3.down * (speed * Time.deltaTime);
pos.x += Mathf.Sin((_moveTime + _phaseSeed) * (1.4f + _config.HorizontalFrequency)) * (_config.HorizontalAmplitude * Time.deltaTime);
break;
case EnemyMovementStyle.Straight:
pos += Vector3.down * (speed * Time.deltaTime);
break;
case EnemyMovementStyle.Sine:
pos += Vector3.down * (speed * Time.deltaTime);
pos.x = _baseX + Mathf.Sin((_moveTime + _phaseSeed) * (1f + _config.HorizontalFrequency)) * _config.HorizontalAmplitude;
break;
case EnemyMovementStyle.Orbit:
float orbitSpeed = _config.HorizontalFrequency + 0.8f + _difficulty * 0.05f;
pos += Vector3.down * (speed * 0.6f * Time.deltaTime);
pos.x = _baseX + Mathf.Cos((_moveTime + _phaseSeed) * orbitSpeed) * _config.OrbitRadius;
pos.y += Mathf.Sin((_moveTime + _phaseSeed) * orbitSpeed) * (_config.OrbitRadius * 0.25f) * Time.deltaTime;
break;
}
if (_config.CenteringBias > 0f)
{
float lerpFactor = Mathf.Clamp01(_config.CenteringBias * Time.deltaTime);
pos.x = Mathf.Lerp(pos.x, 0f, lerpFactor);
}
ClampWithinBounds(ref pos);
transform.position = pos;
}
private void HandleFiring()
{
if (_config.Fire == EnemyFireStyle.None)
{
return;
}
_fireTimer -= Time.deltaTime;
if (_fireTimer > 0f)
{
return;
}
FirePattern();
_fireTimer = GetFireDelay(false);
}
private void FirePattern()
{
int radialBullets = Mathf.Clamp(Mathf.RoundToInt(16 + _difficulty * 14f), 12, 48);
float angleStep = 360f / radialBullets;
float angleOffset = Time.time * 60f + _phaseSeed;
for (int i = 0; i < radialBullets; i++)
switch (_config.Fire)
{
float angle = (angleStep * i + angleOffset) * Mathf.Deg2Rad;
var dir = new Vector2(Mathf.Cos(angle), Mathf.Sin(angle));
_bulletPool.Spawn(transform.position, dir, bulletSpeed, bulletDamage);
case EnemyFireStyle.Radial:
FireRadial();
break;
case EnemyFireStyle.Stream:
FireStream();
break;
case EnemyFireStyle.Burst:
FireBurst();
break;
}
}
// Additional forward stream for dense barrages.
var forward = Vector2.down;
for (int i = -2; i <= 2; i++)
private void FireRadial()
{
int bullets = Mathf.Clamp(_config.BulletsPerVolley + Mathf.RoundToInt(_difficulty), 6, 24);
float angleStep = 360f / bullets;
float offset = (_moveTime + _phaseSeed) * 60f;
for (int i = 0; i < bullets; i++)
{
var spreadDir = Quaternion.Euler(0f, 0f, i * 8f) * forward;
_bulletPool.Spawn(transform.position, spreadDir, bulletSpeed * 1.2f, bulletDamage * 0.8f);
float angle = (angleStep * i + offset) * Mathf.Deg2Rad;
var dir = new Vector2(Mathf.Cos(angle), Mathf.Sin(angle));
_bulletPool.Spawn(transform.position, dir, _config.BulletSpeed * 0.2f, _config.BulletDamage);
}
}
private void FireStream()
{
var forward = Vector2.down;
int count = Mathf.Clamp(_config.BulletsPerVolley, 2, 6);
for (int i = 0; i < count; i++)
{
float spread = (i - (count - 1) * 0.5f) * 7f;
var dir = Quaternion.Euler(0f, 0f, spread) * forward;
_bulletPool.Spawn(transform.position, dir, _config.BulletSpeed * 0.2f, _config.BulletDamage);
}
if (_player != null && _player.isActiveAndEnabled)
{
var toPlayer = ((Vector2)_player.transform.position - (Vector2)transform.position).normalized;
int aimedShots = Mathf.Clamp(3 + Mathf.RoundToInt(_difficulty), 3, 8);
for (int i = 0; i < aimedShots; i++)
_bulletPool.Spawn(transform.position, toPlayer, _config.BulletSpeed * 0.22f, _config.BulletDamage);
}
}
private void FireBurst()
{
int volleys = Mathf.Clamp(_config.BulletsPerVolley, 1, 3);
var baseDir = Vector2.down;
for (int v = 0; v < volleys; v++)
{
float spread = 12f + v * 4f;
for (int i = -1; i <= 1; i++)
{
float sway = (i - (aimedShots - 1) * 0.5f) * 4f;
var dir = Quaternion.Euler(0f, 0f, sway) * toPlayer;
_bulletPool.Spawn(transform.position, dir, bulletSpeed * 1.4f, bulletDamage * 0.9f);
var dir = Quaternion.Euler(0f, 0f, i * spread) * baseDir;
_bulletPool.Spawn(transform.position, dir, _config.BulletSpeed * (0.18f + v * 0.02f), _config.BulletDamage);
}
}
}
private float GetFireDelay(bool first)
{
if (_config.Fire == EnemyFireStyle.None)
{
return float.MaxValue;
}
float baseDelay = Mathf.Max(0.3f, _config.FireInterval - _difficulty * 0.04f);
return first ? Random.Range(0.2f, baseDelay) : baseDelay;
}
private void HandleDeath(Health _)
{
EffectManager.Instance?.SpawnExplosion(transform.position, Faction.Enemy, 3f + _difficulty);
ScoreManager.Instance?.AddScore(scoreValue);
EffectManager.Instance?.SpawnExplosion(transform.position, Faction.Enemy, 2.6f + _difficulty * 0.2f);
ScoreManager.Instance?.AddScore(_config.Score + Mathf.RoundToInt(_difficulty * 20f));
_spawner?.NotifyEnemyKilled(this);
Destroy(gameObject);
}
private void OnTriggerEnter2D(Collider2D other)
{
if (!other.TryGetComponent(out PlayerController player))
{
return;
}
player.ApplyDamage(Mathf.Max(10f, _config.ContactDamage));
EffectManager.Instance?.SpawnHitEffect(player.transform.position, Faction.Player, 0.9f);
_health?.Kill();
}
private void OnDestroy()
{
if (_health != null)
@ -122,5 +215,17 @@ namespace Magisterka.BulletHell
_health.Died -= HandleDeath;
}
}
private void ClampWithinBounds(ref Vector3 position)
{
if (_worldBounds == Vector2.zero)
{
return;
}
position.x = Mathf.Clamp(position.x, -_worldBounds.x + 0.6f, _worldBounds.x - 0.6f);
float upperLimit = _worldBounds.y + 4f;
position.y = Mathf.Min(position.y, upperLimit);
}
}
}

View File

@ -11,21 +11,26 @@ namespace Magisterka.BulletHell
{
public static EnemySpawner Instance { get; private set; }
[SerializeField] private float totalDuration = 300f;
[SerializeField] private float spawnDelayStart = 1.2f;
[SerializeField] private float spawnDelayEnd = 0.18f;
[SerializeField] private float spawnHeight = 10f;
public float TotalDuration => totalDuration;
public bool HasActiveEnemies => _liveEnemies.Count > 0;
[SerializeField] private float totalDuration = 90f;
[SerializeField] private float spawnDelayStart = 1.6f;
[SerializeField] private float spawnDelayEnd = 0.45f;
[SerializeField] private float spawnInset = 0.3f;
[SerializeField] private float horizontalPadding = 1.2f;
private readonly List<EnemyController> _liveEnemies = new List<EnemyController>();
private BulletPool _enemyBulletPool;
private Camera _camera;
private Sprite _enemySprite;
private float _elapsed;
private Vector2 _worldBounds;
private PlayerController _player;
private Vector2 _worldBounds;
private EnemyBlueprint[] _blueprints;
private Coroutine _spawnRoutine;
private float _elapsed;
private bool _spawningActive;
private int _introIndex;
public void Initialize(Camera camera, BulletPool enemyPool, PlayerController player, Vector2 worldBounds)
{
@ -41,9 +46,12 @@ namespace Magisterka.BulletHell
_enemyBulletPool = enemyPool;
_player = player;
_worldBounds = worldBounds;
_enemySprite = BuildEnemySprite();
_blueprints = BuildBlueprints();
StartCoroutine(SpawnRoutine());
_spawningActive = true;
_elapsed = 0f;
_introIndex = 0;
_spawnRoutine = StartCoroutine(SpawnRoutine());
}
public void NotifyEnemyKilled(EnemyController enemy)
@ -64,8 +72,9 @@ namespace Magisterka.BulletHell
public void ClearScreen()
{
foreach (var enemy in _liveEnemies.ToArray())
for (int i = _liveEnemies.Count - 1; i >= 0; i--)
{
var enemy = _liveEnemies[i];
if (enemy != null && enemy.TryGetComponent(out Health health))
{
health.Kill();
@ -75,17 +84,34 @@ namespace Magisterka.BulletHell
_enemyBulletPool?.ClearLiveBullets();
}
public void StopSpawning()
{
_spawningActive = false;
if (_spawnRoutine != null)
{
StopCoroutine(_spawnRoutine);
_spawnRoutine = null;
}
}
public void StopAndClear()
{
StopSpawning();
ClearScreen();
}
private IEnumerator SpawnRoutine()
{
yield return new WaitForSeconds(1.5f);
while (_elapsed < totalDuration)
while (_spawningActive && _elapsed < totalDuration)
{
SpawnWave();
float progress = Mathf.Clamp01(_elapsed / totalDuration);
float delay = Mathf.Lerp(spawnDelayStart, spawnDelayEnd, progress);
delay = Mathf.Max(0.08f, delay);
delay = Mathf.Max(0.4f, delay);
yield return new WaitForSeconds(delay);
_elapsed += delay;
}
@ -93,49 +119,161 @@ namespace Magisterka.BulletHell
private void SpawnWave()
{
int targetCount = Mathf.Clamp(Mathf.RoundToInt(3 + _elapsed * 0.12f), 4, 18);
float difficulty = Mathf.Clamp01(_elapsed / totalDuration) * 8f;
float progression = Mathf.Clamp01(_elapsed / totalDuration);
if (_introIndex < _blueprints.Length)
{
var introBlueprint = _blueprints[_introIndex];
_introIndex++;
float topEdge = _camera.transform.position.y + _camera.orthographicSize;
Vector2 spawnPos = new Vector2(0f, topEdge - Mathf.Max(0.02f, spawnInset));
SpawnEnemy(spawnPos, introBlueprint, Mathf.Lerp(0.5f, 2.5f, progression));
return;
}
int available = Mathf.Clamp(_introIndex, 1, _blueprints.Length);
int targetCount = Mathf.Clamp(Mathf.RoundToInt(1 + _elapsed * 0.05f), 2, 10);
float difficulty = Mathf.Lerp(1.2f, 6f, progression);
for (int i = 0; i < targetCount; i++)
{
Vector2 spawnPos = PickSpawnPosition(i, targetCount);
SpawnEnemy(spawnPos, difficulty);
var blueprint = _blueprints[Random.Range(0, available)];
Vector2 spawnPos = PickSpawnPosition(i, targetCount, blueprint.CenteringBias);
SpawnEnemy(spawnPos, blueprint, difficulty);
}
}
private Vector2 PickSpawnPosition(int index, int total)
private Vector2 PickSpawnPosition(int index, int total, float centeringBias)
{
float camWidth = _worldBounds.x;
float step = (camWidth * 2f - horizontalPadding * 2f) / Mathf.Max(1, total - 1);
float x = -camWidth + horizontalPadding + step * index + Random.Range(-0.6f, 0.6f);
float y = _camera.orthographicSize + spawnHeight;
if (centeringBias > 0f)
{
float bias = Mathf.Clamp01(centeringBias * 0.15f);
x = Mathf.Lerp(x, 0f, bias);
}
float top = _camera.transform.position.y + _camera.orthographicSize;
float y = top - Mathf.Max(0.02f, spawnInset);
return new Vector2(x, y);
}
private void SpawnEnemy(Vector2 position, float difficulty)
private void SpawnEnemy(Vector2 position, EnemyBlueprint blueprint, float difficulty)
{
var enemyGO = new GameObject("Enemy");
var enemyGO = new GameObject($"Enemy_{blueprint.Name}");
enemyGO.transform.SetParent(transform, false);
enemyGO.transform.position = position;
enemyGO.transform.localScale = Vector3.one * 1.6f;
enemyGO.transform.localScale = Vector3.one * blueprint.Scale;
var renderer = enemyGO.AddComponent<SpriteRenderer>();
renderer.sprite = _enemySprite;
renderer.sprite = blueprint.Sprite;
renderer.sortingOrder = 10;
renderer.color = Color.Lerp(new Color(1f, 0.6f, 0.3f, 1f), new Color(1f, 0.1f, 0.2f, 1f), Mathf.Clamp01(difficulty / 8f));
renderer.color = blueprint.Color;
var collider = enemyGO.AddComponent<CircleCollider2D>();
collider.isTrigger = true;
collider.radius = 0.7f;
collider.radius = 0.45f * blueprint.Scale;
var health = enemyGO.AddComponent<Health>();
enemyGO.AddComponent<Health>();
var controller = enemyGO.AddComponent<EnemyController>();
controller.Initialize(this, _enemyBulletPool, _player, difficulty, 30f + difficulty * 20f);
controller.Initialize(this, _enemyBulletPool, _player, blueprint, difficulty, _worldBounds);
_liveEnemies.Add(controller);
}
private EnemyBlueprint[] BuildBlueprints()
{
var blazeColor = new Color(1f, 0.55f, 0.25f, 1f);
var strikerColor = new Color(0.55f, 0.45f, 1f, 1f);
var cascadeColor = new Color(0.35f, 1f, 0.65f, 1f);
var interceptorColor = new Color(1f, 0.9f, 0.3f, 1f);
return new[]
{
new EnemyBlueprint(
"Blazer",
blazeColor,
1.6f,
EnemyMovementStyle.ZigZag,
EnemyFireStyle.Radial,
2.8f,
2.8f,
1.1f,
1.3f,
12,
3,
13f,
7f,
25f,
55f,
140,
0f,
GenerateSprite(EnemyShape.Diamond, blazeColor),
1.2f),
new EnemyBlueprint(
"Striker",
strikerColor,
1.4f,
EnemyMovementStyle.Straight,
EnemyFireStyle.Stream,
3.3f,
0f,
0f,
1f,
4,
2,
18f,
9f,
30f,
65f,
160,
0f,
GenerateSprite(EnemyShape.Triangle, strikerColor),
1.5f),
new EnemyBlueprint(
"Cascade",
cascadeColor,
1.7f,
EnemyMovementStyle.Sine,
EnemyFireStyle.Burst,
2.5f,
3.2f,
1.2f,
1.8f,
2,
0,
12f,
10f,
35f,
80f,
200,
0f,
GenerateSprite(EnemyShape.Disc, cascadeColor),
1.1f),
new EnemyBlueprint(
"Interceptor",
interceptorColor,
1.3f,
EnemyMovementStyle.Orbit,
EnemyFireStyle.None,
5.2f,
0f,
1.6f,
0f,
0,
0,
0f,
0f,
45f,
50f,
180,
1.9f,
GenerateSprite(EnemyShape.Arrow, interceptorColor),
1.8f)
};
}
private void OnDestroy()
{
if (Instance == this)
@ -144,7 +282,7 @@ namespace Magisterka.BulletHell
}
}
private Sprite BuildEnemySprite()
private Sprite GenerateSprite(EnemyShape shape, Color color)
{
const int size = 64;
var texture = new Texture2D(size, size, TextureFormat.RGBA32, false)
@ -152,18 +290,42 @@ namespace Magisterka.BulletHell
filterMode = FilterMode.Point
};
var center = new Vector2(size / 2f, size / 2f);
var pixels = new Color[size * size];
var body = new Color(1f, 0.35f, 0.35f, 1f);
var center = new Vector2(size * 0.5f, size * 0.5f);
for (int y = 0; y < size; y++)
{
for (int x = 0; x < size; x++)
{
int index = x + y * size;
float dist = Vector2.Distance(center, new Vector2(x, y));
float threshold = size * 0.35f + Mathf.Sin(y * 0.2f) * 2f;
pixels[index] = dist <= threshold ? body : new Color(0f, 0f, 0f, 0f);
bool fill = false;
switch (shape)
{
case EnemyShape.Disc:
fill = Vector2.Distance(center, new Vector2(x, y)) <= size * 0.32f;
break;
case EnemyShape.Triangle:
{
float ny = 1f - (y / (float)(size - 1));
float halfWidth = ny * size * 0.35f;
fill = Mathf.Abs(x - center.x) <= halfWidth && ny > 0.1f;
break;
}
case EnemyShape.Diamond:
fill = Mathf.Abs(x - center.x) + Mathf.Abs(y - center.y) <= size * 0.32f;
break;
case EnemyShape.Arrow:
{
float ny = y / (float)(size - 1);
float half = Mathf.Lerp(size * 0.1f, size * 0.35f, ny);
bool head = ny > 0.55f && Mathf.Abs(x - center.x) <= Mathf.Lerp(size * 0.05f, size * 0.4f, Mathf.Clamp01((ny - 0.55f) / 0.45f));
bool body = ny <= 0.55f && Mathf.Abs(x - center.x) <= half;
fill = head || body;
break;
}
}
pixels[index] = fill ? color : new Color(0f, 0f, 0f, 0f);
}
}
@ -172,5 +334,13 @@ namespace Magisterka.BulletHell
return Sprite.Create(texture, new Rect(0, 0, size, size), new Vector2(0.5f, 0.5f), 64f);
}
private enum EnemyShape
{
Disc,
Triangle,
Diamond,
Arrow
}
}
}

View File

@ -0,0 +1,103 @@
using UnityEngine;
using UnityEngine.UI;
namespace Magisterka.BulletHell
{
/// <summary>
/// Oversees overall game flow: timer, victory detection, and difficulty pacing hooks.
/// </summary>
public class GameDirector : MonoBehaviour
{
public static GameDirector Instance { get; private set; }
[SerializeField] private float totalDuration = 90f;
private EnemySpawner _spawner;
private PlayerController _player;
private ScoreManager _scoreManager;
private Text _timerText;
private float _timeRemaining;
private bool _timerRunning;
private bool _timerExpired;
private bool _victoryTriggered;
public float TotalDuration => totalDuration;
public void Initialize(EnemySpawner spawner, PlayerController player, ScoreManager scoreManager, Text timerText, float duration)
{
_spawner = spawner;
_player = player;
_scoreManager = scoreManager;
_timerText = timerText;
totalDuration = duration > 0f ? duration : totalDuration;
_timeRemaining = totalDuration;
_timerRunning = true;
_timerExpired = false;
_victoryTriggered = false;
UpdateTimerLabel();
}
private void Awake()
{
if (Instance != null && Instance != this)
{
Destroy(gameObject);
return;
}
Instance = this;
}
private void Update()
{
if (_timerRunning && !_timerExpired)
{
_timeRemaining -= Time.deltaTime;
if (_timeRemaining <= 0f)
{
_timeRemaining = 0f;
_timerExpired = true;
_timerRunning = false;
_spawner?.StopSpawning();
}
UpdateTimerLabel();
}
if (_timerExpired && !_victoryTriggered)
{
bool enemiesRemain = _spawner != null && _spawner.HasActiveEnemies;
if (!enemiesRemain)
{
TriggerVictory();
}
}
}
public void OnPlayerGameOver()
{
_timerRunning = false;
}
private void TriggerVictory()
{
_victoryTriggered = true;
_scoreManager?.ShowVictory();
_player?.HandleVictory();
}
private void UpdateTimerLabel()
{
if (_timerText == null)
{
return;
}
int seconds = Mathf.CeilToInt(_timeRemaining);
int minutes = seconds / 60;
seconds %= 60;
_timerText.text = $"{minutes:00}:{seconds:00}";
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 40b997f70689be4e1aa9b72fabe123ea

View File

@ -11,6 +11,15 @@ namespace Magisterka.BulletHell
{
[SerializeField] private float cameraSize = 6.5f;
private struct HudElements
{
public Text Score;
public Image[] Lives;
public Text Status;
public Text Timer;
public Image DamageOverlay;
}
private void Awake()
{
BuildCamera();
@ -49,25 +58,34 @@ namespace Magisterka.BulletHell
var effectManager = new GameObject("EffectManager").AddComponent<EffectManager>();
effectManager.Initialize(root);
var scoreManager = new GameObject("ScoreManager").AddComponent<ScoreManager>();
var scoreText = CreateScoreUI();
scoreManager.Initialize(scoreText);
var scoreManagerGO = new GameObject("ScoreManager");
scoreManagerGO.transform.SetParent(root, false);
var scoreManager = scoreManagerGO.AddComponent<ScoreManager>();
var pools = new GameObject("BulletPools").transform;
pools.SetParent(root, false);
var playerPool = BulletPool.Create(pools, "PlayerBullets", new Color(0.35f, 0.9f, 1f, 1f), Faction.Player, 600);
var enemyPool = BulletPool.Create(pools, "EnemyBullets", new Color(1f, 0.4f, 0.2f, 1f), Faction.Enemy, 1200);
var playerPool = BulletPool.Create(pools, "PlayerBullets", new Color(0.35f, 0.9f, 1f, 1f), Faction.Player, 400);
var enemyPool = BulletPool.Create(pools, "EnemyBullets", new Color(1f, 0.4f, 0.2f, 1f), Faction.Enemy, 900);
var player = PlayerController.CreatePlayer(root, playerPool, bounds);
var hud = CreateHud(player.MaxLives);
scoreManager.Initialize(hud.Score, hud.Lives, hud.Status, hud.DamageOverlay);
scoreManager.SetLives(player.CurrentLives);
var spawnerGO = new GameObject("EnemySpawner");
spawnerGO.transform.SetParent(root, false);
var spawner = spawnerGO.AddComponent<EnemySpawner>();
spawner.Initialize(cam, enemyPool, player, bounds);
var directorGO = new GameObject("GameDirector");
directorGO.transform.SetParent(root, false);
var director = directorGO.AddComponent<GameDirector>();
director.Initialize(spawner, player, scoreManager, hud.Timer, 90f);
}
private Text CreateScoreUI()
private HudElements CreateHud(int lives)
{
var canvasGO = new GameObject("Canvas");
var canvas = canvasGO.AddComponent<Canvas>();
@ -83,7 +101,7 @@ namespace Magisterka.BulletHell
{
label.font = Font.CreateDynamicFontFromOSFont("Arial", 32);
}
label.fontSize = 32;
label.fontSize = 24;
label.alignment = TextAnchor.UpperLeft;
label.color = Color.white;
label.text = "Score: 0";
@ -92,10 +110,116 @@ namespace Magisterka.BulletHell
rect.anchorMin = new Vector2(0f, 1f);
rect.anchorMax = new Vector2(0f, 1f);
rect.pivot = new Vector2(0f, 1f);
rect.anchoredPosition = new Vector2(20f, -20f);
rect.anchoredPosition = new Vector2(12f, -12f);
var livesGO = new GameObject("Lives");
livesGO.transform.SetParent(canvasGO.transform, false);
var livesRect = livesGO.AddComponent<RectTransform>();
livesRect.anchorMin = new Vector2(0f, 1f);
livesRect.anchorMax = new Vector2(0f, 1f);
livesRect.pivot = new Vector2(0f, 1f);
livesRect.anchoredPosition = new Vector2(12f, -40f);
var lifeSprite = BuildLifeSprite();
var lifeIcons = new Image[Mathf.Max(1, lives)];
for (int i = 0; i < lifeIcons.Length; i++)
{
var iconGO = new GameObject($"Life_{i}");
iconGO.transform.SetParent(livesGO.transform, false);
var icon = iconGO.AddComponent<Image>();
icon.sprite = lifeSprite;
icon.color = new Color(0.3f + i * 0.05f, 0.85f, 1f, 0.9f);
var iconRect = icon.rectTransform;
iconRect.anchorMin = new Vector2(0f, 1f);
iconRect.anchorMax = new Vector2(0f, 1f);
iconRect.pivot = new Vector2(0f, 1f);
iconRect.anchoredPosition = new Vector2(i * 26f, 0f);
iconRect.sizeDelta = new Vector2(22f, 22f);
lifeIcons[i] = icon;
}
var gameOverGO = new GameObject("GameOverLabel");
gameOverGO.transform.SetParent(canvasGO.transform, false);
var gameOver = gameOverGO.AddComponent<Text>();
gameOver.font = label.font;
gameOver.fontSize = 48;
gameOver.alignment = TextAnchor.MiddleCenter;
gameOver.color = new Color(1f, 0.55f, 0.55f, 1f);
gameOver.text = "GAME OVER";
gameOver.enabled = false;
var goRect = gameOver.rectTransform;
goRect.anchorMin = new Vector2(0.5f, 0.5f);
goRect.anchorMax = new Vector2(0.5f, 0.5f);
goRect.pivot = new Vector2(0.5f, 0.5f);
goRect.anchoredPosition = Vector2.zero;
var timerGO = new GameObject("TimerLabel");
timerGO.transform.SetParent(canvasGO.transform, false);
var timer = timerGO.AddComponent<Text>();
timer.font = label.font;
timer.fontSize = 24;
timer.alignment = TextAnchor.UpperRight;
timer.color = Color.white;
timer.text = "05:00";
var timerRect = timer.rectTransform;
timerRect.anchorMin = new Vector2(1f, 1f);
timerRect.anchorMax = new Vector2(1f, 1f);
timerRect.pivot = new Vector2(1f, 1f);
timerRect.anchoredPosition = new Vector2(-12f, -12f);
var overlayGO = new GameObject("DamageOverlay");
overlayGO.transform.SetParent(canvasGO.transform, false);
var overlay = overlayGO.AddComponent<Image>();
overlay.raycastTarget = false;
overlay.color = new Color(1f, 0.2f, 0.2f, 0f);
var overlayRect = overlay.rectTransform;
overlayRect.anchorMin = Vector2.zero;
overlayRect.anchorMax = Vector2.one;
overlayRect.pivot = new Vector2(0.5f, 0.5f);
overlayRect.offsetMin = Vector2.zero;
overlayRect.offsetMax = Vector2.zero;
overlayGO.transform.SetAsLastSibling();
canvasGO.transform.SetParent(transform, false);
return label;
return new HudElements
{
Score = label,
Lives = lifeIcons,
Status = gameOver,
Timer = timer,
DamageOverlay = overlay
};
}
private static Sprite BuildLifeSprite()
{
const int size = 32;
var texture = new Texture2D(size, size, TextureFormat.RGBA32, false)
{
filterMode = FilterMode.Bilinear
};
var center = new Vector2(size / 2f, size / 2f);
var pixels = new Color[size * size];
for (int y = 0; y < size; y++)
{
for (int x = 0; x < size; x++)
{
int index = x + y * size;
float dist = Vector2.Distance(center, new Vector2(x, y));
pixels[index] = dist <= size * 0.42f ? Color.white : new Color(0f, 0f, 0f, 0f);
}
}
texture.SetPixels(pixels);
texture.Apply();
return Sprite.Create(texture, new Rect(0, 0, size, size), new Vector2(0.5f, 0.5f), 64f);
}
}
}

View File

@ -14,6 +14,7 @@ namespace Magisterka.BulletHell
private float _current;
public event Action<Health> Died;
public event Action<float, Vector2> Damaged;
public Faction Faction => faction;
public float MaxHealth => maxHealth;
@ -40,6 +41,7 @@ namespace Magisterka.BulletHell
_current -= damage;
EffectManager.Instance?.SpawnHitEffect(hitPoint, faction, Mathf.Clamp01(damage / maxHealth));
Damaged?.Invoke(damage, hitPoint);
if (_current <= 0f)
{
@ -60,6 +62,7 @@ namespace Magisterka.BulletHell
}
_current = 0f;
Damaged?.Invoke(maxHealth, transform.position);
Die();
}

View File

@ -1,4 +1,5 @@
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespace Magisterka.BulletHell
@ -16,30 +17,43 @@ namespace Magisterka.BulletHell
[SerializeField] private float focusSpeedMultiplier = 0.6f;
[Header("Offense")]
[SerializeField] private float fireRate = 0.08f;
[SerializeField] private float bulletSpeed = 26f;
[SerializeField] private float bulletDamage = 16f;
[SerializeField] private int volleyWidth = 6;
[SerializeField] private float fireRate = 0.12f;
[SerializeField] private float bulletSpeed = 22f;
[SerializeField] private float bulletDamage = 14f;
[SerializeField] private int volleyWidth = 5;
[Header("Survivability")]
[SerializeField] private int maxLives = 5;
[SerializeField] private float respawnDelay = 1.4f;
[Header("Survivability")]
[SerializeField] private int maxLives = 1;
[SerializeField] private float respawnDelay = 0.7f;
[SerializeField] private float invulnerabilityDuration = 2f;
[Header("Progression")]
[SerializeField] private int baseLevelThreshold = 1500;
[Header("Controls")]
[SerializeField] private KeyCode fireKey = KeyCode.Z;
[SerializeField] private KeyCode modifierKey = KeyCode.LeftShift;
[SerializeField] private KeyCode bombKey = KeyCode.X;
private BulletPool _playerBulletPool;
private const int MaxLevel = 12;
private BulletPool _playerBulletPool;
private Health _health;
private SpriteRenderer _renderer;
private Vector2 _bounds = new Vector2(8f, 4.5f);
private float _fireCooldown;
private bool _bombAvailable = true;
private int _remainingLives;
private int _lives;
private bool _canControl = true;
private bool _isAlive = true;
private bool _isInvulnerable;
private bool _victoryAchieved;
private Color _baseColor;
private Coroutine _hitFlashRoutine;
private Coroutine _levelFlashRoutine;
private int _level;
private float _baseBulletSpeed;
private int _baseVolleyWidth;
public void Initialize(BulletPool bulletPool, Vector2 movementBounds)
{
@ -49,12 +63,29 @@ namespace Magisterka.BulletHell
_health = GetComponent<Health>();
_health.Configure(Faction.Player, 60f);
_health.Died += HandleDeath;
_health.Damaged += HandleDamageTaken;
_renderer = GetComponent<SpriteRenderer>();
_baseColor = _renderer != null ? _renderer.color : Color.cyan;
_remainingLives = Mathf.Max(0, maxLives - 1);
_baseBulletSpeed = bulletSpeed;
_baseVolleyWidth = volleyWidth;
_level = 0;
_lives = Mathf.Max(0, maxLives);
ScoreManager.Instance?.SetLives(_lives);
transform.position = new Vector3(0f, -_bounds.y + 1.5f, 0f);
_isAlive = true;
_canControl = true;
_bombAvailable = true;
_fireCooldown = 0f;
_victoryAchieved = false;
if (ScoreManager.Instance != null)
{
ScoreManager.Instance.ScoreChanged += HandleScoreChanged;
HandleScoreChanged(ScoreManager.Instance.CurrentScore);
}
}
public static PlayerController CreatePlayer(Transform parent, BulletPool playerBulletPool, Vector2 movementBounds)
@ -68,30 +99,48 @@ namespace Magisterka.BulletHell
renderer.color = new Color(0.25f, 0.85f, 1f, 1f);
var collider = playerGO.AddComponent<CircleCollider2D>();
collider.radius = 0.55f;
collider.radius = 1f / 64f;
collider.isTrigger = true;
var health = playerGO.AddComponent<Health>();
playerGO.AddComponent<Health>();
var controller = playerGO.AddComponent<PlayerController>();
controller.Initialize(playerBulletPool, movementBounds);
return controller;
}
public int MaxLives => Mathf.Max(1, maxLives);
public int CurrentLives => Mathf.Max(0, _lives);
private void Update()
{
HandleMovement();
HandleShooting();
HandleBomb();
if (_victoryAchieved)
{
return;
}
if (_fireCooldown > 0f)
{
_fireCooldown -= Time.deltaTime;
}
if (!_canControl)
{
return;
}
HandleMovement();
HandleShooting();
HandleBomb();
}
private void HandleMovement()
{
if (!_canControl)
{
return;
}
float x = 0f;
float y = 0f;
@ -111,6 +160,11 @@ namespace Magisterka.BulletHell
private void HandleShooting()
{
if (!_canControl || !_isAlive)
{
return;
}
bool wantsFire = Input.GetKey(fireKey) || Input.GetMouseButton(0);
if (!wantsFire || _fireCooldown > 0f)
{
@ -123,23 +177,34 @@ namespace Magisterka.BulletHell
private void FireVolley()
{
var origin = transform.position + Vector3.up * 0.4f;
int half = volleyWidth / 2;
var origin = transform.position + Vector3.up * 0.35f;
float speed = GetLevelAdjustedBulletSpeed();
int count = Mathf.Clamp(_baseVolleyWidth + Mathf.Max(0, _level), 1, 17);
float mid = (count - 1) * 0.5f;
float spreadStep = Mathf.Lerp(5f, 8f, Mathf.Clamp01(_level / 12f));
for (int i = -half; i <= half; i++)
for (int i = 0; i < count; i++)
{
float spread = i * 6f;
float offsetIndex = i - mid;
float spread = offsetIndex * spreadStep;
var direction = Quaternion.Euler(0f, 0f, spread) * Vector2.up;
var offset = direction * 0.25f * Mathf.Abs(i);
_playerBulletPool.Spawn(origin + (Vector3)offset, direction, bulletSpeed, bulletDamage);
var offset = new Vector3(offsetIndex * 0.18f, 0f, 0f);
_playerBulletPool.Spawn(origin + offset, direction, speed, bulletDamage);
}
EffectManager.Instance?.SpawnHitEffect(origin + Vector3.up * 0.2f, Faction.Player, 0.6f);
foreach (var extra in GetExtraDirectionsForLevel())
{
var dir = extra.normalized;
var spawnPos = transform.position + (Vector3)(dir * 0.4f);
_playerBulletPool.Spawn(spawnPos, dir, speed, bulletDamage);
}
EffectManager.Instance?.SpawnHitEffect(origin + Vector3.up * 0.1f, Faction.Player, 0.4f + _level * 0.05f);
}
private void HandleBomb()
{
if (!_bombAvailable)
if (!_bombAvailable || !_canControl || !_isAlive)
{
return;
}
@ -156,6 +221,7 @@ namespace Magisterka.BulletHell
private IEnumerator PerformBomb()
{
_canControl = false;
_isInvulnerable = true;
_renderer.color = Color.white;
EffectManager.Instance?.SpawnScreenClear(transform.position, 18f);
@ -186,10 +252,27 @@ namespace Magisterka.BulletHell
_isInvulnerable = false;
_renderer.color = _baseColor;
_canControl = true;
}
private void HandleDeath(Health _)
{
if (!_isAlive)
{
return;
}
_isAlive = false;
_canControl = false;
_bombAvailable = false;
if (_hitFlashRoutine != null)
{
StopCoroutine(_hitFlashRoutine);
_renderer.color = _baseColor;
_hitFlashRoutine = null;
}
_lives -= 1;
ScoreManager.Instance?.SetLives(Mathf.Max(0, _lives));
StartCoroutine(RespawnRoutine());
}
@ -199,16 +282,23 @@ namespace Magisterka.BulletHell
_renderer.enabled = false;
_isInvulnerable = true;
if (_remainingLives < 0)
if (_lives < 0)
{
yield return new WaitForSeconds(1f);
ScoreManager.Instance?.ShowGameOver();
EnemySpawner.Instance?.StopAndClear();
GameDirector.Instance?.OnPlayerGameOver();
gameObject.SetActive(false);
yield break;
}
yield return new WaitForSeconds(respawnDelay);
EnemySpawner.Instance?.ClearScreen();
transform.position = new Vector3(0f, -_bounds.y + 1.5f, 0f);
_renderer.enabled = true;
_health.RestoreFull();
_fireCooldown = fireRate;
_canControl = true;
float timer = invulnerabilityDuration;
while (timer > 0f)
@ -229,7 +319,9 @@ namespace Magisterka.BulletHell
}
_isInvulnerable = false;
_remainingLives--;
_renderer.color = _baseColor;
_isAlive = true;
_canControl = true;
}
private void OnDestroy()
@ -237,6 +329,12 @@ namespace Magisterka.BulletHell
if (_health != null)
{
_health.Died -= HandleDeath;
_health.Damaged -= HandleDamageTaken;
}
if (ScoreManager.Instance != null)
{
ScoreManager.Instance.ScoreChanged -= HandleScoreChanged;
}
if (Instance == this)
@ -249,7 +347,7 @@ namespace Magisterka.BulletHell
public void ApplyDamage(float value)
{
if (_isInvulnerable)
if (_isInvulnerable || !_isAlive || _victoryAchieved)
{
return;
}
@ -257,6 +355,178 @@ namespace Magisterka.BulletHell
_health.ApplyDamage(value, transform.position);
}
public void HandleVictory()
{
_victoryAchieved = true;
_canControl = false;
_isInvulnerable = true;
if (_renderer != null)
{
_renderer.color = Color.white;
}
}
private void HandleDamageTaken(float damage, Vector2 hitPoint)
{
if (_renderer == null || _isInvulnerable || !_isAlive)
{
return;
}
if (_hitFlashRoutine != null)
{
StopCoroutine(_hitFlashRoutine);
_renderer.color = _baseColor;
}
_hitFlashRoutine = StartCoroutine(HitFlashRoutine());
float strength = Mathf.Clamp01(damage / Mathf.Max(1f, _health.MaxHealth * 0.4f));
ScoreManager.Instance?.TriggerDamageFlash(strength);
EffectManager.Instance?.ShakeCamera(0.22f, 0.65f);
}
private IEnumerator HitFlashRoutine()
{
const float duration = 0.35f;
float elapsed = 0f;
while (elapsed < duration)
{
elapsed += Time.deltaTime;
float t = Mathf.PingPong(elapsed * 18f, 1f);
_renderer.color = Color.Lerp(_baseColor, Color.white, t);
yield return null;
}
_renderer.color = _baseColor;
_hitFlashRoutine = null;
}
private float GetLevelAdjustedBulletSpeed()
{
return _baseBulletSpeed;
}
private IEnumerable<Vector2> GetExtraDirectionsForLevel()
{
if (_level <= 0)
{
yield break;
}
if (_level >= 2)
{
yield return Vector2.left;
yield return Vector2.right;
}
if (_level >= 4)
{
yield return (Vector2.left + Vector2.up).normalized;
yield return (Vector2.right + Vector2.up).normalized;
}
if (_level >= 6)
{
yield return Vector2.down;
}
if (_level >= 8)
{
yield return (Vector2.left + Vector2.down * 0.7f).normalized;
yield return (Vector2.right + Vector2.down * 0.7f).normalized;
}
}
private void HandleScoreChanged(int score)
{
int newLevel = CalculateLevelFromScore(score);
if (newLevel > _level)
{
for (int next = _level + 1; next <= newLevel; next++)
{
_level = next;
HandleLevelUpFeedback();
}
}
else if (newLevel < _level)
{
_level = newLevel;
}
}
private int CalculateLevelFromScore(int score)
{
if (baseLevelThreshold <= 0)
{
return 0;
}
int level = 0;
int increment = baseLevelThreshold;
int threshold = baseLevelThreshold;
while (level < MaxLevel && score >= threshold)
{
level++;
increment *= 2;
threshold += increment;
}
return level;
}
private void HandleLevelUpFeedback()
{
EffectManager.Instance?.SpawnExplosion(transform.position, Faction.Player, 2f + _level * 0.2f);
EffectManager.Instance?.ShakeCamera(0.18f, 0.4f + _level * 0.05f);
if (_hitFlashRoutine != null)
{
StopCoroutine(_hitFlashRoutine);
_hitFlashRoutine = null;
}
if (_renderer != null)
{
_renderer.color = _baseColor;
}
if (_levelFlashRoutine != null)
{
StopCoroutine(_levelFlashRoutine);
}
_levelFlashRoutine = StartCoroutine(LevelUpFlashRoutine());
}
private IEnumerator LevelUpFlashRoutine()
{
float duration = 0.6f;
float elapsed = 0f;
var glowColor = Color.Lerp(_baseColor, new Color(1f, 0.95f, 0.35f, 1f), 0.85f);
while (elapsed < duration)
{
elapsed += Time.deltaTime;
float t = Mathf.PingPong(elapsed * 6f, 1f);
if (_renderer != null)
{
_renderer.color = Color.Lerp(glowColor, _baseColor, t);
}
yield return null;
}
if (_renderer != null)
{
_renderer.color = _baseColor;
}
_levelFlashRoutine = null;
}
private static Sprite BuildPlayerSprite()
{
const int size = 64;

View File

@ -1,3 +1,5 @@
using System;
using System.Collections;
using UnityEngine;
using UnityEngine.UI;
@ -11,7 +13,18 @@ namespace Magisterka.BulletHell
public static ScoreManager Instance { get; private set; }
private Text _scoreText;
private Image[] _lifeIcons;
private Text _statusText;
private Image _damageOverlay;
private int _score;
private int _queuedLives = -1;
private string _queuedStatus;
private Color _queuedStatusColor = Color.white;
private Coroutine _damageFlashRoutine;
public event Action<int> ScoreChanged;
public int CurrentScore => _score;
private void Awake()
{
@ -22,25 +35,101 @@ namespace Magisterka.BulletHell
}
Instance = this;
DontDestroyOnLoad(gameObject);
}
public void Initialize(Text scoreText)
public void Initialize(Text scoreText, Image[] lifeIcons, Text statusText, Image damageOverlay)
{
_scoreText = scoreText;
_lifeIcons = lifeIcons;
_statusText = statusText;
_damageOverlay = damageOverlay;
HideStatus();
ResetScore();
if (_queuedLives >= 0)
{
ApplyLives(_queuedLives);
_queuedLives = -1;
}
if (!string.IsNullOrEmpty(_queuedStatus))
{
ShowStatus(_queuedStatus, _queuedStatusColor);
_queuedStatus = null;
}
if (_damageOverlay != null)
{
var color = _damageOverlay.color;
color.a = 0f;
_damageOverlay.color = color;
}
ScoreChanged?.Invoke(_score);
}
public void AddScore(int value)
{
_score += value;
UpdateLabel();
ScoreChanged?.Invoke(_score);
}
public void ResetScore()
{
_score = 0;
UpdateLabel();
HideStatus();
ScoreChanged?.Invoke(_score);
}
public void SetLives(int lives)
{
if (_lifeIcons == null || _lifeIcons.Length == 0)
{
_queuedLives = lives;
return;
}
ApplyLives(lives);
}
public void ShowGameOver()
{
ShowStatus("GAME OVER", new Color(1f, 0.5f, 0.5f, 1f));
}
public void ShowVictory()
{
ShowStatus("YOU WIN", new Color(0.6f, 1f, 0.6f, 1f));
}
public void TriggerDamageFlash(float strength)
{
if (_damageOverlay == null)
{
return;
}
strength = Mathf.Clamp01(strength);
if (_damageFlashRoutine != null)
{
StopCoroutine(_damageFlashRoutine);
}
_damageFlashRoutine = StartCoroutine(DamageFlashRoutine(strength));
}
public void HideStatus()
{
if (_statusText == null)
{
return;
}
_statusText.enabled = false;
}
private void UpdateLabel()
@ -52,5 +141,58 @@ namespace Magisterka.BulletHell
_scoreText.text = $"Score: {_score:N0}";
}
private void ApplyLives(int lives)
{
if (_lifeIcons == null)
{
return;
}
for (int i = 0; i < _lifeIcons.Length; i++)
{
if (_lifeIcons[i] != null)
{
_lifeIcons[i].enabled = i < lives;
}
}
}
private void ShowStatus(string message, Color color)
{
if (_statusText == null)
{
_queuedStatus = message;
_queuedStatusColor = color;
return;
}
_statusText.text = message;
_statusText.color = color;
_statusText.enabled = true;
}
private IEnumerator DamageFlashRoutine(float strength)
{
float peakAlpha = Mathf.Lerp(0.35f, 0.75f, strength);
float duration = 0.45f;
float elapsed = 0f;
while (elapsed < duration)
{
elapsed += Time.deltaTime;
float normalized = Mathf.Clamp01(elapsed / duration);
float alpha = Mathf.Sin(normalized * Mathf.PI) * peakAlpha;
var color = _damageOverlay.color;
color.a = alpha;
_damageOverlay.color = color;
yield return null;
}
var reset = _damageOverlay.color;
reset.a = 0f;
_damageOverlay.color = reset;
_damageFlashRoutine = null;
}
}
}