Add initial game systems and input handling for player interactions

This commit is contained in:
2025-12-09 22:20:38 +01:00
commit 5e0db113aa
182 changed files with 70557 additions and 0 deletions

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 72f24c63b26da6dde87590f51ac98e57
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,10 @@
using UnityEngine;
namespace Abstractions
{
public interface IInputService
{
float GetHorizontalMovement();
bool IsActionPressed();
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 2e152fbd10fb4ed5842228e7dca1f25c
timeCreated: 1765307263

8
Assets/Scripts/Core.meta Normal file
View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: a6e572b7a1cc355cd84f19dfb8400cd8
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,36 @@
using System;
namespace Core
{
public static class GameEvents
{
// Gameplay Events (Inputs to the systems)
public static event Action<int> PresentCaught;
public static event Action PresentDropped;
// State Changes (Outputs from the systems)
public static event Action<int> ScoreUpdated;
public static event Action<int> LivesUpdated;
public static event Action<float> TimeUpdated;
public static event Action GameOver;
// Invokers
public static void ReportPresentCaught(int value) => PresentCaught?.Invoke(value);
public static void ReportPresentDropped() => PresentDropped?.Invoke();
public static void ReportScoreUpdated(int score) => ScoreUpdated?.Invoke(score);
public static void ReportLivesUpdated(int lives) => LivesUpdated?.Invoke(lives);
public static void ReportTimeUpdated(float time) => TimeUpdated?.Invoke(time);
public static void ReportGameOver() => GameOver?.Invoke();
public static void Clear()
{
// Reset events when scene reloads to prevent memory leaks
PresentCaught = null;
PresentDropped = null;
ScoreUpdated = null;
LivesUpdated = null;
TimeUpdated = null;
GameOver = null;
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 26df539822f340e29cceac4ec34dd771
timeCreated: 1765312863

View File

@@ -0,0 +1,8 @@
namespace Core
{
public enum GameMode
{
Arcade,
TimeAttack,
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 53351add092b4030b6c58e9f72380f6d
timeCreated: 1765311817

View File

@@ -0,0 +1,97 @@
using System;
namespace Core
{
public class GameSession
{
public GameMode GameMode { get; private set; }
public int Score { get; private set; }
public int Lives { get; private set; }
public float TimeRemaining { get; private set; }
public bool IsGameOver { get; private set; }
public int HighScore { get; private set; }
public event Action<int> OnScoreChanged;
public event Action<int> OnLivesChanged;
public event Action<float> OnTimeChanged;
public event Action OnGameOver;
private readonly int _initialTimeOrLives;
public GameSession(GameMode gameMode, int initialValue, int currentHighScore)
{
GameMode = gameMode;
Score = 0;
IsGameOver = false;
HighScore = currentHighScore;
switch (gameMode)
{
case GameMode.TimeAttack:
TimeRemaining = initialValue;
Lives = 1;
break;
case GameMode.Arcade:
Lives = initialValue;
TimeRemaining = 0;
break;
}
}
public void Tick(float deltaTime)
{
if (IsGameOver || GameMode != GameMode.TimeAttack) return;
TimeRemaining -= deltaTime;
OnTimeChanged?.Invoke(TimeRemaining);
if (TimeRemaining <= 0)
{
TimeRemaining = 0;
EndGame();
}
}
public void AddScore(int points)
{
if (IsGameOver) return;
Score += points;
OnScoreChanged?.Invoke(Score);
}
public void LoseLife()
{
if (IsGameOver) return;
if (GameMode == GameMode.Arcade)
{
Lives--;
OnLivesChanged?.Invoke(Lives);
}
else
{
TimeRemaining -= _initialTimeOrLives;
OnTimeChanged?.Invoke(TimeRemaining);
}
if (Lives <= 0)
{
Lives = 0;
EndGame();
}
}
private void EndGame()
{
IsGameOver = true;
if (Score > HighScore)
{
HighScore = Score;
}
OnGameOver?.Invoke();
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 2e5b97c41204455ca44be18f18ddd3b3
timeCreated: 1765307365

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 0a13e7537abf404e9a3932ec3f9b4e58
timeCreated: 1765312897

View File

@@ -0,0 +1,35 @@
using System;
namespace Core.Systems
{
public class LivesSystem : IDisposable
{
private int _currentLives;
public LivesSystem(int initialLives)
{
_currentLives = initialLives;
GameEvents.PresentDropped += OnPresentDropped;
GameEvents.ReportLivesUpdated(_currentLives);
}
private void OnPresentDropped()
{
if (_currentLives <= 0) return;
_currentLives--;
GameEvents.ReportLivesUpdated(_currentLives);
if (_currentLives <= 0)
{
GameEvents.ReportGameOver();
}
}
public void Dispose()
{
GameEvents.PresentDropped -= OnPresentDropped;
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 694c3777489643ee843bf35f734ac060
timeCreated: 1765312961

View File

@@ -0,0 +1,46 @@
using System;
using Infrastructure;
namespace Core.Systems
{
public class PersistenceSystem : IDisposable
{
private readonly IPersistenceService _service;
private readonly string _saveKey;
private int _currentRunScore;
public PersistenceSystem(IPersistenceService service, string saveKey)
{
_service = service;
_saveKey = saveKey;
GameEvents.ScoreUpdated += OnScoreUpdated;
GameEvents.GameOver += OnGameOver;
}
public int GetHighScore()
{
return _service.LoadHighScore(_saveKey);
}
private void OnGameOver()
{
var existingHighScore = _service.LoadHighScore(_saveKey);
if (_currentRunScore > existingHighScore)
{
_service.SaveHighScore(_saveKey, _currentRunScore);
}
}
private void OnScoreUpdated(int newScore)
{
_currentRunScore = newScore;
}
public void Dispose()
{
GameEvents.ScoreUpdated -= OnScoreUpdated;
GameEvents.GameOver -= OnGameOver;
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 99a4c19937ce4a97881d2796dead20d4
timeCreated: 1765313464

View File

@@ -0,0 +1,26 @@
using System;
namespace Core.Systems
{
public class ScoreSystem : IDisposable
{
private int _currentScore;
public ScoreSystem()
{
_currentScore = 0;
GameEvents.PresentCaught += OnPresentCaught;
}
private void OnPresentCaught(int value)
{
_currentScore += value;
GameEvents.ReportScoreUpdated(_currentScore);
}
public void Dispose()
{
GameEvents.PresentCaught -= OnPresentCaught;
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 9724125bb1674f2e9f8150c290b71d1a
timeCreated: 1765312906

View File

@@ -0,0 +1,51 @@
using System;
namespace Core.Systems
{
public class TimeAttackSystem : IDisposable
{
private float _timeRemaining;
private readonly float _penaltyPerDrop;
public TimeAttackSystem(float initialTime, float penaltyPerDrop = 5f)
{
_timeRemaining = initialTime;
_penaltyPerDrop = penaltyPerDrop;
GameEvents.ReportTimeUpdated(_timeRemaining);
GameEvents.PresentDropped += OnPresentDropped;
}
public void Tick(float deltaTime)
{
if (_timeRemaining <= 0) return;
_timeRemaining -= deltaTime;
if (_timeRemaining <= 0)
{
_timeRemaining = 0;
GameEvents.ReportTimeUpdated(0f);
GameEvents.ReportGameOver();
}
else
{
GameEvents.ReportTimeUpdated(_timeRemaining);
}
}
private void OnPresentDropped()
{
if (_timeRemaining > 0)
{
_timeRemaining -= _penaltyPerDrop;
}
}
public void Dispose()
{
GameEvents.PresentDropped -= OnPresentDropped;
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 81bda016e43341779a714e971304d37d
timeCreated: 1765313331

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: ec1e68a28d45e3d17a951d9446f09d41
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
namespace Infrastructure
{
public interface IPersistenceService
{
void SaveHighScore(string key, int score);
int LoadHighScore(string key);
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 78982eb5342e49b5aa23fe22e84d4da8
timeCreated: 1765312139

View File

@@ -0,0 +1,32 @@
using System;
using Abstractions;
using UnityEngine;
namespace Infrastructure
{
public class PlayerOneInput : IInputService, IDisposable
{
private readonly Actions _input = new();
public PlayerOneInput ()
{
_input.Player.Enable();
}
public float GetHorizontalMovement()
{
return _input.Player.Move.ReadValue<Vector2>().x;
}
public bool IsActionPressed()
{
return _input.Player.Interact.triggered;
}
public void Dispose()
{
_input.Player.Disable();
_input?.Dispose();
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: eb373e773ac5427087b07b63c9accc90
timeCreated: 1765308257

View File

@@ -0,0 +1,18 @@
using UnityEngine;
namespace Infrastructure
{
public class PlayerPrefsPersistence : IPersistenceService
{
public void SaveHighScore(string key, int score)
{
PlayerPrefs.SetInt(key, score);
PlayerPrefs.Save();
}
public int LoadHighScore(string key)
{
return PlayerPrefs.GetInt(key, 0);
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: fed5bb3ae38d41dc9cc89464b1c611e9
timeCreated: 1765312168

View File

@@ -0,0 +1,23 @@
using System;
using Abstractions;
namespace Infrastructure
{
public class PlayerTwoInput : IInputService, IDisposable
{
public float GetHorizontalMovement()
{
throw new System.NotImplementedException();
}
public bool IsActionPressed()
{
throw new System.NotImplementedException();
}
public void Dispose()
{
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: c8e90fd115b7440b9ccff749d09b6bcd
timeCreated: 1765308287

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 51b062444819b810b960655ec67f6c70
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,59 @@
using System;
using Abstractions;
using UnityEngine;
namespace Presentation
{
public class ElfController : MonoBehaviour
{
[Header("Movement")] [SerializeField] private float moveSpeed = 10f;
[Header("Cane mechanics")] [SerializeField]
private Transform canePivot;
[SerializeField] private float maxTiltAngle = 30f;
[SerializeField] private float tiltSpeed = 15f;
private IInputService _inputService;
public void Configure(IInputService inputService)
{
_inputService = inputService;
}
private void Update()
{
if (_inputService == null) return;
var move = _inputService.GetHorizontalMovement();
transform.Translate(Vector3.right * (move * moveSpeed * Time.deltaTime));
HandleCaneRotation(move);
}
private void HandleCaneRotation(float inputDirection)
{
var targetAngle = 0f;
if (inputDirection > 0.1f)
{
targetAngle = -maxTiltAngle;
}
else if (inputDirection < -0.1f)
{
targetAngle = maxTiltAngle;
}
else
{
targetAngle = 0f;
}
var targetRotation = Quaternion.Euler(0f, 0f, targetAngle);
canePivot.localRotation = Quaternion.Lerp(
canePivot.localRotation,
targetRotation,
Time.deltaTime * tiltSpeed
);
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: ef7ae4dbcdba422dbfcf1c7ed761a311
timeCreated: 1765307632

View File

@@ -0,0 +1,88 @@
using System;
using Abstractions;
using Core;
using Core.Systems;
using Infrastructure;
using UnityEngine;
namespace Presentation
{
public class GameBootstrap : MonoBehaviour
{
private ScoreSystem _scoreSystem;
private LivesSystem _livesSystem;
private TimeAttackSystem _timeAttackSystem;
private PersistenceSystem _persistenceSystem;
private IDisposable _p1Input;
private IDisposable _p2Input;
[Header("Game Settings")]
[SerializeField] private int startValue= 3;
[SerializeField] private bool twoPlayerMode = false;
[SerializeField] private GameMode mode = GameMode.Arcade;
[Header("References")]
[SerializeField] private ElfController elfPrefab;
[SerializeField] private Transform spawnP1;
[SerializeField] private Transform spawnP2;
[SerializeField] private PresentSpawner spawner;
private void Start()
{
_scoreSystem = new ScoreSystem();
_livesSystem = new LivesSystem(startValue);
var saveKey = mode == GameMode.Arcade ? "HS_Arcade" : "HS_TimeAttack";
_persistenceSystem = new PersistenceSystem(new PlayerPrefsPersistence(), saveKey);
switch (mode)
{
case GameMode.Arcade:
_livesSystem = new LivesSystem(startValue);
break;
case GameMode.TimeAttack:
_timeAttackSystem = new TimeAttackSystem(startValue);
break;
}
_p1Input = new PlayerOneInput();
_p2Input = new PlayerTwoInput();
SpawnElf(_p1Input as IInputService, spawnP1.position, Color.white);
if (twoPlayerMode)
{
SpawnElf(_p2Input as IInputService, spawnP2.position, Color.green);
}
spawner.StartSpawning();
}
private void Update()
{
if (mode == GameMode.TimeAttack && _timeAttackSystem != null)
{
_timeAttackSystem.Tick(Time.deltaTime);
}
}
private void SpawnElf(IInputService input, Vector3 position, Color tint)
{
var elf = Instantiate(elfPrefab, position, Quaternion.identity);
elf.Configure(input);
elf.GetComponentInChildren<SpriteRenderer>().color = tint;
}
private void OnDestroy()
{
_scoreSystem?.Dispose();
_livesSystem?.Dispose();
_timeAttackSystem?.Dispose();
_persistenceSystem?.Dispose();
_p1Input?.Dispose();
_p2Input?.Dispose();
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: e824a9a954a6401395ad66ebb282accc
timeCreated: 1765307449

View File

@@ -0,0 +1,68 @@
using Core;
using TMPro;
using UnityEngine;
using UnityEngine.SceneManagement;
namespace Presentation
{
public class GameHud : MonoBehaviour
{
[Header("Text References")]
[SerializeField] private TextMeshProUGUI scoreText;
[SerializeField] private TextMeshProUGUI livesOrTimeText;
[SerializeField] private TextMeshProUGUI highScoreText;
[Header("Panels")]
[SerializeField] private GameObject gameOverPanel;
private void Awake()
{
if (gameOverPanel) gameOverPanel.SetActive(false);
}
private void OnEnable()
{
GameEvents.ScoreUpdated += UpdateScore;
GameEvents.LivesUpdated += UpdateLives;
GameEvents.TimeUpdated += UpdateTime;
GameEvents.GameOver += ShowGameOver;
}
private void OnDisable()
{
GameEvents.ScoreUpdated -= UpdateScore;
GameEvents.LivesUpdated -= UpdateLives;
GameEvents.TimeUpdated -= UpdateTime;
GameEvents.GameOver -= ShowGameOver;
}
private void UpdateScore(int score)
{
if(scoreText) scoreText.text = $"Score: {score}";
}
private void UpdateLives(int lives)
{
if(livesOrTimeText) livesOrTimeText.text = $"Lives: {lives}";
}
private void UpdateTime(float time)
{
if(livesOrTimeText) livesOrTimeText.text = $"Time: {time:F0}";
if (time <= 10f) livesOrTimeText.color = Color.red;
else livesOrTimeText.color = Color.white;
}
private void ShowGameOver()
{
if(gameOverPanel) gameOverPanel.SetActive(true);
}
public void RestartGame()
{
SceneManager.LoadScene(SceneManager.GetActiveScene().buildIndex);
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 420df631eed549fe9ab3dcddacb210c0
timeCreated: 1765314012

View File

@@ -0,0 +1,33 @@
using System;
using Core;
using UnityEngine;
namespace Presentation
{
public class Present : MonoBehaviour
{
[SerializeField] private int points = 1;
private PresentSpawner _spawner;
public void Configure(PresentSpawner spawner)
{
_spawner = spawner;
}
private void OnCollisionEnter2D(Collision2D other)
{
if (other.gameObject.CompareTag("Ground"))
{
GameEvents.ReportPresentDropped();
_spawner.ReturnToPool(this);
}
if (other.gameObject.CompareTag("Sleigh"))
{
GameEvents.ReportPresentCaught(points);
_spawner.ReturnToPool(this);
}
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: a93e3dd5e12b468089cd95e7566ece1d
timeCreated: 1765307547

View File

@@ -0,0 +1,112 @@
using System;
using System.Collections;
using System.Collections.Generic;
using Core;
using UnityEngine;
using Random = UnityEngine.Random;
namespace Presentation
{
public class PresentSpawner : MonoBehaviour
{
[Header("Pooling")]
[SerializeField] private Present presentPrefab;
[SerializeField] private int poolSize = 20;
[Header("Spawning")]
[SerializeField] private Transform[] spawnPoints;
[SerializeField] private float spawnInterval = 1.5f;
[SerializeField] private float burstChance = 0.3f;
[SerializeField] private Vector2 initialDirection = Vector2.right;
private Queue<Present> _pool = new();
private List<Present> _activePresents = new();
private bool _isSpawning;
private void Awake()
{
for (var i = 0; i < poolSize; i++)
{
CreateNewPresent();
}
}
private Present CreateNewPresent()
{
var p = Instantiate(presentPrefab, transform);
p.gameObject.SetActive(false);
p.Configure(this);
_pool.Enqueue(p);
return p;
}
private Present GetPresent()
{
if (_pool.Count == 0) CreateNewPresent();
var p = _pool.Dequeue();
p.gameObject.SetActive(true);
_activePresents.Add(p);
return p;
}
public void ReturnToPool(Present p)
{
if (!p.gameObject.activeSelf) return;
p.gameObject.SetActive(false);
_activePresents.Remove(p);
_pool.Enqueue(p);
}
public void StartSpawning()
{
_isSpawning = true;
GameEvents.GameOver += StopSpawning;
StartCoroutine(SpawnRoutine());
}
public void StopSpawning()
{
_isSpawning = false;
GameEvents.GameOver -= StopSpawning;
StopAllCoroutines();
foreach (var p in _activePresents.ToArray()) ReturnToPool(p);
_activePresents.Clear();
}
private IEnumerator SpawnRoutine()
{
while (_isSpawning)
{
SpawnOne();
if (Random.value < burstChance)
{
yield return new WaitForSeconds(0.2f);
SpawnOne();
}
//TODO: Difficulty scaling
yield return new WaitForSeconds(spawnInterval);
}
}
private void SpawnOne()
{
var p = GetPresent();
var rb = p.GetComponent<Rigidbody2D>();
rb.linearVelocity = Vector2.zero;
rb.angularVelocity = 0f;
var index = Random.Range(0, spawnPoints.Length);
p.transform.position = spawnPoints[index].position;
p.transform.rotation = Quaternion.identity;
rb.AddForce(initialDirection + new Vector2(Random.Range(-1f,1f), Random.Range(-1f,1f)), ForceMode2D.Impulse);
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 540dd15a8852481ab85229c9e3f928c8
timeCreated: 1765312250