8.9 - Exercise: Event System and LINQ
In this exercise, you'll apply the advanced C# concepts we've covered in this module to create a flexible event system for a game. You'll use delegates, events, lambda expressions, and LINQ to build a system that can handle various game events and allow different game systems to communicate without tight coupling.
Project Overview: Quest and Achievement System
You'll be building a quest and achievement system for an RPG game. This system will:
- Track player actions through events
- Manage quests with different objectives
- Award achievements based on player accomplishments
- Use LINQ to query and filter quests and achievements
Part 1: Setting Up the Event System
First, let's create the core event system that will track player actions:
using System;
using System.Collections.Generic;
// Step 1: Define event argument classes for different player actions
public class ItemCollectedEventArgs : EventArgs
{
public string ItemId { get; }
public string ItemName { get; }
public ItemRarity Rarity { get; }
public ItemCollectedEventArgs(string itemId, string itemName, ItemRarity rarity)
{
ItemId = itemId;
ItemName = itemName;
Rarity = rarity;
}
}
public class EnemyDefeatedEventArgs : EventArgs
{
public string EnemyId { get; }
public string EnemyName { get; }
public EnemyType Type { get; }
public int ExperienceGained { get; }
public EnemyDefeatedEventArgs(string enemyId, string enemyName, EnemyType type, int experienceGained)
{
EnemyId = enemyId;
EnemyName = enemyName;
Type = type;
ExperienceGained = experienceGained;
}
}
public class LocationDiscoveredEventArgs : EventArgs
{
public string LocationId { get; }
public string LocationName { get; }
public bool IsMainLocation { get; }
public LocationDiscoveredEventArgs(string locationId, string locationName, bool isMainLocation)
{
LocationId = locationId;
LocationName = locationName;
IsMainLocation = isMainLocation;
}
}
public class PlayerLevelUpEventArgs : EventArgs
{
public int NewLevel { get; }
public int PreviousLevel { get; }
public List<string> NewAbilities { get; }
public PlayerLevelUpEventArgs(int newLevel, int previousLevel, List<string> newAbilities)
{
NewLevel = newLevel;
PreviousLevel = previousLevel;
NewAbilities = newAbilities ?? new List<string>();
}
}
// Step 2: Define enums for item rarity and enemy type
public enum ItemRarity
{
Common,
Uncommon,
Rare,
Epic,
Legendary
}
public enum EnemyType
{
Normal,
Elite,
Boss,
Legendary
}
// Step 3: Create the event manager class
public class GameEventManager
{
// Singleton instance
private static GameEventManager _instance;
public static GameEventManager Instance => _instance ??= new GameEventManager();
// Events for different player actions
public event EventHandler<ItemCollectedEventArgs> OnItemCollected;
public event EventHandler<EnemyDefeatedEventArgs> OnEnemyDefeated;
public event EventHandler<LocationDiscoveredEventArgs> OnLocationDiscovered;
public event EventHandler<PlayerLevelUpEventArgs> OnPlayerLevelUp;
// Private constructor for singleton
private GameEventManager() { }
// Methods to raise events
public void ItemCollected(string itemId, string itemName, ItemRarity rarity)
{
OnItemCollected?.Invoke(this, new ItemCollectedEventArgs(itemId, itemName, rarity));
}
public void EnemyDefeated(string enemyId, string enemyName, EnemyType type, int experienceGained)
{
OnEnemyDefeated?.Invoke(this, new EnemyDefeatedEventArgs(enemyId, enemyName, type, experienceGained));
}
public void LocationDiscovered(string locationId, string locationName, bool isMainLocation)
{
OnLocationDiscovered?.Invoke(this, new LocationDiscoveredEventArgs(locationId, locationName, isMainLocation));
}
public void PlayerLevelUp(int newLevel, int previousLevel, List<string> newAbilities)
{
OnPlayerLevelUp?.Invoke(this, new PlayerLevelUpEventArgs(newLevel, previousLevel, newAbilities));
}
}
Part 2: Creating the Quest System
Now, let's create a quest system that uses the event system:
using System;
using System.Collections.Generic;
using System.Linq;
// Step 1: Define quest-related enums
public enum QuestStatus
{
NotStarted,
InProgress,
Completed,
Failed
}
public enum QuestType
{
Main,
Side,
Daily,
Hidden
}
// Step 2: Define the objective class
public class QuestObjective
{
public string Description { get; }
public int CurrentAmount { get; private set; }
public int RequiredAmount { get; }
public bool IsCompleted => CurrentAmount >= RequiredAmount;
// Delegate for the condition that completes this objective
private Func<EventArgs, bool> _completionCondition;
public QuestObjective(string description, int requiredAmount, Func<EventArgs, bool> completionCondition)
{
Description = description;
RequiredAmount = requiredAmount;
CurrentAmount = 0;
_completionCondition = completionCondition;
}
// Method to check if an event satisfies the objective
public bool CheckProgress(object sender, EventArgs e)
{
if (IsCompleted) return false;
if (_completionCondition(e))
{
CurrentAmount++;
return true;
}
return false;
}
// Method to manually update progress
public void UpdateProgress(int amount)
{
CurrentAmount = Math.Min(CurrentAmount + amount, RequiredAmount);
}
}
// Step 3: Define the quest class
public class Quest
{
public string Id { get; }
public string Title { get; }
public string Description { get; }
public QuestType Type { get; }
public QuestStatus Status { get; private set; }
public int ExperienceReward { get; }
public List<string> ItemRewards { get; }
public List<QuestObjective> Objectives { get; }
public bool IsCompleted => Status == QuestStatus.Completed;
public bool CanBeCompleted => Status == QuestStatus.InProgress && Objectives.All(o => o.IsCompleted);
public Quest(string id, string title, string description, QuestType type,
int experienceReward, List<string> itemRewards)
{
Id = id;
Title = title;
Description = description;
Type = type;
Status = QuestStatus.NotStarted;
ExperienceReward = experienceReward;
ItemRewards = itemRewards ?? new List<string>();
Objectives = new List<QuestObjective>();
}
// Add an objective to the quest
public void AddObjective(QuestObjective objective)
{
Objectives.Add(objective);
}
// Start the quest
public void Start()
{
if (Status == QuestStatus.NotStarted)
{
Status = QuestStatus.InProgress;
Console.WriteLine($"Quest started: {Title}");
}
}
// Complete the quest
public void Complete()
{
if (CanBeCompleted)
{
Status = QuestStatus.Completed;
Console.WriteLine($"Quest completed: {Title}");
Console.WriteLine($"Rewards: {ExperienceReward} XP and {string.Join(", ", ItemRewards)}");
}
}
// Fail the quest
public void Fail()
{
if (Status == QuestStatus.InProgress)
{
Status = QuestStatus.Failed;
Console.WriteLine($"Quest failed: {Title}");
}
}
// Check if any objectives are updated by an event
public bool CheckObjectives(object sender, EventArgs e)
{
if (Status != QuestStatus.InProgress) return false;
bool anyProgress = false;
foreach (var objective in Objectives)
{
if (objective.CheckProgress(sender, e))
{
Console.WriteLine($"Quest '{Title}' objective updated: {objective.Description} - {objective.CurrentAmount}/{objective.RequiredAmount}");
anyProgress = true;
}
}
return anyProgress;
}
}
// Step 4: Create the quest manager
public class QuestManager
{
private List<Quest> _quests = new List<Quest>();
public QuestManager()
{
// Subscribe to events
GameEventManager eventManager = GameEventManager.Instance;
eventManager.OnItemCollected += HandleEvent;
eventManager.OnEnemyDefeated += HandleEvent;
eventManager.OnLocationDiscovered += HandleEvent;
}
// Add a quest to the manager
public void AddQuest(Quest quest)
{
_quests.Add(quest);
}
// Get all quests
public List<Quest> GetAllQuests()
{
return _quests.ToList();
}
// Get quests by status
public List<Quest> GetQuestsByStatus(QuestStatus status)
{
return _quests.Where(q => q.Status == status).ToList();
}
// Get quests by type
public List<Quest> GetQuestsByType(QuestType type)
{
return _quests.Where(q => q.Type == type).ToList();
}
// Get completable quests
public List<Quest> GetCompletableQuests()
{
return _quests.Where(q => q.CanBeCompleted).ToList();
}
// Start a quest by ID
public void StartQuest(string questId)
{
Quest quest = _quests.FirstOrDefault(q => q.Id == questId);
quest?.Start();
}
// Complete a quest by ID
public void CompleteQuest(string questId)
{
Quest quest = _quests.FirstOrDefault(q => q.Id == questId);
quest?.Complete();
}
// Handle events from the event manager
private void HandleEvent(object sender, EventArgs e)
{
foreach (var quest in _quests.Where(q => q.Status == QuestStatus.InProgress))
{
quest.CheckObjectives(sender, e);
// Auto-complete quests if all objectives are done
if (quest.CanBeCompleted)
{
quest.Complete();
}
}
}
}
Part 3: Creating the Achievement System
Now, let's create an achievement system that also uses the event system:
using System;
using System.Collections.Generic;
using System.Linq;
// Step 1: Define achievement-related enums
public enum AchievementCategory
{
Combat,
Exploration,
Collection,
Character,
Special
}
// Step 2: Define the achievement class
public class Achievement
{
public string Id { get; }
public string Title { get; }
public string Description { get; }
public AchievementCategory Category { get; }
public bool IsUnlocked { get; private set; }
public DateTime? UnlockTime { get; private set; }
// Delegate for the condition that unlocks this achievement
private Func<EventArgs, bool> _unlockCondition;
public Achievement(string id, string title, string description,
AchievementCategory category, Func<EventArgs, bool> unlockCondition)
{
Id = id;
Title = title;
Description = description;
Category = category;
IsUnlocked = false;
_unlockCondition = unlockCondition;
}
// Check if an event unlocks this achievement
public bool CheckUnlock(object sender, EventArgs e)
{
if (IsUnlocked) return false;
if (_unlockCondition(e))
{
Unlock();
return true;
}
return false;
}
// Manually unlock the achievement
public void Unlock()
{
if (!IsUnlocked)
{
IsUnlocked = true;
UnlockTime = DateTime.Now;
Console.WriteLine($"Achievement unlocked: {Title} - {Description}");
}
}
}
// Step 3: Create the achievement manager
public class AchievementManager
{
private List<Achievement> _achievements = new List<Achievement>();
public AchievementManager()
{
// Subscribe to events
GameEventManager eventManager = GameEventManager.Instance;
eventManager.OnItemCollected += HandleEvent;
eventManager.OnEnemyDefeated += HandleEvent;
eventManager.OnLocationDiscovered += HandleEvent;
eventManager.OnPlayerLevelUp += HandleEvent;
}
// Add an achievement to the manager
public void AddAchievement(Achievement achievement)
{
_achievements.Add(achievement);
}
// Get all achievements
public List<Achievement> GetAllAchievements()
{
return _achievements.ToList();
}
// Get unlocked achievements
public List<Achievement> GetUnlockedAchievements()
{
return _achievements.Where(a => a.IsUnlocked).ToList();
}
// Get locked achievements
public List<Achievement> GetLockedAchievements()
{
return _achievements.Where(a => !a.IsUnlocked).ToList();
}
// Get achievements by category
public List<Achievement> GetAchievementsByCategory(AchievementCategory category)
{
return _achievements.Where(a => a.Category == category).ToList();
}
// Get recently unlocked achievements
public List<Achievement> GetRecentlyUnlockedAchievements(TimeSpan timeSpan)
{
DateTime cutoffTime = DateTime.Now - timeSpan;
return _achievements
.Where(a => a.IsUnlocked && a.UnlockTime.HasValue && a.UnlockTime.Value >= cutoffTime)
.OrderByDescending(a => a.UnlockTime)
.ToList();
}
// Handle events from the event manager
private void HandleEvent(object sender, EventArgs e)
{
foreach (var achievement in _achievements.Where(a => !a.IsUnlocked))
{
achievement.CheckUnlock(sender, e);
}
}
}
Part 4: Using the Systems Together
Now, let's create a simple game simulation that uses these systems:
using System;
using System.Collections.Generic;
using System.Threading;
public class GameSimulation
{
private GameEventManager _eventManager;
private QuestManager _questManager;
private AchievementManager _achievementManager;
public GameSimulation()
{
// Get the event manager instance
_eventManager = GameEventManager.Instance;
// Create quest and achievement managers
_questManager = new QuestManager();
_achievementManager = new AchievementManager();
// Set up quests
SetupQuests();
// Set up achievements
SetupAchievements();
}
private void SetupQuests()
{
// Create a "Goblin Hunter" quest
Quest goblinHunterQuest = new Quest(
"q001",
"Goblin Hunter",
"Clear the forest of goblins that have been terrorizing travelers.",
QuestType.Main,
100,
new List<string> { "GoblinSlayer_Sword", "Gold_50" }
);
// Add objectives to the quest
goblinHunterQuest.AddObjective(new QuestObjective(
"Defeat goblins",
5,
e => e is EnemyDefeatedEventArgs enemyArgs &&
enemyArgs.EnemyName.Contains("Goblin") &&
enemyArgs.Type == EnemyType.Normal
));
goblinHunterQuest.AddObjective(new QuestObjective(
"Defeat the goblin chief",
1,
e => e is EnemyDefeatedEventArgs enemyArgs &&
enemyArgs.EnemyName == "Goblin Chief" &&
enemyArgs.Type == EnemyType.Elite
));
// Create a "Treasure Hunter" quest
Quest treasureHunterQuest = new Quest(
"q002",
"Treasure Hunter",
"Find valuable treasures in the ancient ruins.",
QuestType.Side,
50,
new List<string> { "Lucky_Charm", "Gold_25" }
);
// Add objectives to the quest
treasureHunterQuest.AddObjective(new QuestObjective(
"Discover the ancient ruins",
1,
e => e is LocationDiscoveredEventArgs locArgs &&
locArgs.LocationName == "Ancient Ruins"
));
treasureHunterQuest.AddObjective(new QuestObjective(
"Collect rare artifacts",
3,
e => e is ItemCollectedEventArgs itemArgs &&
itemArgs.Rarity >= ItemRarity.Rare &&
itemArgs.ItemName.Contains("Artifact")
));
// Add quests to the manager
_questManager.AddQuest(goblinHunterQuest);
_questManager.AddQuest(treasureHunterQuest);
}
private void SetupAchievements()
{
// Create achievements
// Combat achievements
_achievementManager.AddAchievement(new Achievement(
"a001",
"First Blood",
"Defeat your first enemy.",
AchievementCategory.Combat,
e => e is EnemyDefeatedEventArgs
));
_achievementManager.AddAchievement(new Achievement(
"a002",
"Giant Slayer",
"Defeat a boss enemy.",
AchievementCategory.Combat,
e => e is EnemyDefeatedEventArgs enemyArgs &&
enemyArgs.Type == EnemyType.Boss
));
// Exploration achievements
_achievementManager.AddAchievement(new Achievement(
"a003",
"Explorer",
"Discover 3 different locations.",
AchievementCategory.Exploration,
e => {
static int GetDiscoveredLocationCount()
{
// In a real game, this would track locations from a persistent store
// For this example, we'll just return a value based on the event
return 3;
}
return e is LocationDiscoveredEventArgs && GetDiscoveredLocationCount() >= 3;
}
));
// Collection achievements
_achievementManager.AddAchievement(new Achievement(
"a004",
"Treasure Hunter",
"Collect a legendary item.",
AchievementCategory.Collection,
e => e is ItemCollectedEventArgs itemArgs &&
itemArgs.Rarity == ItemRarity.Legendary
));
// Character achievements
_achievementManager.AddAchievement(new Achievement(
"a005",
"Level Up",
"Reach level 5.",
AchievementCategory.Character,
e => e is PlayerLevelUpEventArgs levelArgs &&
levelArgs.NewLevel >= 5
));
}
public void RunSimulation()
{
Console.WriteLine("Starting game simulation...");
Console.WriteLine();
// Start quests
_questManager.StartQuest("q001");
_questManager.StartQuest("q002");
Console.WriteLine();
Console.WriteLine("Simulating player actions...");
Console.WriteLine();
// Simulate discovering a location
_eventManager.LocationDiscovered("loc001", "Forest Clearing", false);
Thread.Sleep(1000);
// Simulate defeating some enemies
_eventManager.EnemyDefeated("e001", "Goblin Scout", EnemyType.Normal, 10);
Thread.Sleep(1000);
_eventManager.EnemyDefeated("e002", "Goblin Warrior", EnemyType.Normal, 15);
Thread.Sleep(1000);
_eventManager.EnemyDefeated("e003", "Goblin Archer", EnemyType.Normal, 12);
Thread.Sleep(1000);
// Simulate discovering another location
_eventManager.LocationDiscovered("loc002", "Ancient Ruins", true);
Thread.Sleep(1000);
// Simulate collecting some items
_eventManager.ItemCollected("i001", "Ancient Artifact 1", ItemRarity.Rare);
Thread.Sleep(1000);
_eventManager.ItemCollected("i002", "Ancient Artifact 2", ItemRarity.Rare);
Thread.Sleep(1000);
// Simulate defeating more enemies
_eventManager.EnemyDefeated("e004", "Goblin Shaman", EnemyType.Normal, 20);
Thread.Sleep(1000);
_eventManager.EnemyDefeated("e005", "Goblin Brute", EnemyType.Normal, 25);
Thread.Sleep(1000);
// Simulate collecting a legendary item
_eventManager.ItemCollected("i003", "Ancient Crown", ItemRarity.Legendary);
Thread.Sleep(1000);
// Simulate defeating the goblin chief
_eventManager.EnemyDefeated("e006", "Goblin Chief", EnemyType.Elite, 50);
Thread.Sleep(1000);
// Simulate collecting the last artifact
_eventManager.ItemCollected("i004", "Ancient Artifact 3", ItemRarity.Epic);
Thread.Sleep(1000);
// Simulate player leveling up
_eventManager.PlayerLevelUp(5, 4, new List<string> { "Fireball", "Improved Stamina" });
Thread.Sleep(1000);
// Simulate defeating a boss
_eventManager.EnemyDefeated("e007", "Ancient Guardian", EnemyType.Boss, 100);
Thread.Sleep(1000);
// Display final status
DisplayStatus();
}
private void DisplayStatus()
{
Console.WriteLine();
Console.WriteLine("=== SIMULATION COMPLETE ===");
Console.WriteLine();
// Display quest status
Console.WriteLine("=== QUESTS ===");
var allQuests = _questManager.GetAllQuests();
foreach (var quest in allQuests)
{
Console.WriteLine($"{quest.Title} - {quest.Status}");
foreach (var objective in quest.Objectives)
{
Console.WriteLine($" * {objective.Description}: {objective.CurrentAmount}/{objective.RequiredAmount} {(objective.IsCompleted ? "[COMPLETE]" : "")}");
}
}
Console.WriteLine();
// Display achievement status
Console.WriteLine("=== ACHIEVEMENTS ===");
var unlockedAchievements = _achievementManager.GetUnlockedAchievements();
var lockedAchievements = _achievementManager.GetLockedAchievements();
Console.WriteLine("Unlocked:");
foreach (var achievement in unlockedAchievements)
{
Console.WriteLine($" * {achievement.Title} - {achievement.Description}");
}
Console.WriteLine("Locked:");
foreach (var achievement in lockedAchievements)
{
Console.WriteLine($" * {achievement.Title} - {achievement.Description}");
}
Console.WriteLine();
Console.WriteLine($"Progress: {unlockedAchievements.Count}/{unlockedAchievements.Count + lockedAchievements.Count} achievements unlocked");
}
}
// Main program to run the simulation
public class Program
{
public static void Main()
{
GameSimulation simulation = new GameSimulation();
simulation.RunSimulation();
Console.WriteLine();
Console.WriteLine("Press any key to exit...");
Console.ReadKey();
}
}
Challenge Tasks
Once you've implemented the basic system, try these challenge tasks to enhance it:
-
Add a Quest Dependency System: Make some quests require other quests to be completed first.
-
Implement a Reputation System: Track the player's reputation with different factions, which changes based on completed quests and defeated enemies.
-
Create a Quest Log UI: Design a simple console-based UI to display active quests, completed quests, and available quests.
-
Add Timed Quests: Implement quests that must be completed within a certain time frame.
-
Create a More Sophisticated Achievement System: Add achievement tiers (bronze, silver, gold) and progress tracking for achievements that require multiple steps.
Conclusion
This exercise has given you hands-on experience with many of the advanced C# concepts we've covered in this module:
-
Delegates and Events: Used to create a flexible event system that allows different game systems to communicate without tight coupling.
-
Lambda Expressions: Used to define conditions for quest objectives and achievements in a concise way.
-
LINQ: Used to query and filter quests and achievements based on various criteria.
-
Generics: Used implicitly in the collections that store quests and achievements.
-
Extension Methods: You could enhance the system by adding extension methods for common operations.
-
Enums: Used to define various game-related constants like quest status, enemy types, and item rarities.
These concepts are fundamental to creating flexible, maintainable game systems in C# and Unity. By mastering them, you'll be able to create more sophisticated and robust game code.
While this exercise uses console output for simplicity, the same patterns can be applied in Unity. The event system would work similarly, but you would integrate it with Unity's component-based architecture and use Unity's UI system instead of console output.
Remember that good game architecture is about creating systems that are:
- Decoupled: Systems should communicate without tight dependencies
- Extensible: Easy to add new features without modifying existing code
- Maintainable: Clear, readable, and well-organized
- Reusable: Components should be designed for potential reuse in other projects
The event-driven approach demonstrated in this exercise helps achieve all of these goals.