Add hero runtime

This commit is contained in:
2026-04-21 22:54:14 +02:00
parent 67737f3ba8
commit 762c8969ab
29 changed files with 1327 additions and 12 deletions

View File

@@ -28,6 +28,7 @@ public partial class GameRoot : Node
DebugBootMode.Smoke => SmokeScene,
DebugBootMode.ContentBrowser => ContentBrowserScene,
DebugBootMode.DebugSandbox => DebugSandboxScene,
DebugBootMode.HeroSandbox => HeroSandboxScene,
_ => MenuScene
};
@@ -72,6 +73,9 @@ public partial class GameRoot : Node
[Export]
public PackedScene? DebugSandboxScene { get; set; }
[Export]
public PackedScene? HeroSandboxScene { get; set; }
private Node? m_LoadedScene;
private DebugSettings? m_Settings;
private DebugCommandNode? m_CommandNode;

View File

@@ -5,5 +5,6 @@ public enum DebugBootMode
Menu,
Smoke,
ContentBrowser,
DebugSandbox
DebugSandbox,
HeroSandbox
}

View File

@@ -32,6 +32,12 @@ public partial class DebugOverlay : CanvasLayer
Refresh();
}
public void SetHeroSummary(string summary)
{
m_HeroSummary = summary;
Refresh();
}
private void Refresh()
{
Label label = EnsureLabel();
@@ -42,7 +48,8 @@ public partial class DebugOverlay : CanvasLayer
DebugRuntimeState state = m_Service.State;
Visible = state.OverlayVisible;
label.Text = $"Debug boot: {m_Settings.BootMode}\n" + $"Scene: {m_LoadedSceneId}\n" + $"Seed: {state.Seed}\n" + $"Difficulty: {state.ActiveDifficultyId}\n" + $"Paused: {state.IsPaused}\n" + $"Time scale: {state.TimeScale.ToString(CultureInfo.InvariantCulture)}\n" + $"Marker: {DisplayOrNone(state.CurrentMarkerId)}\n" + $"Spawned: {state.SpawnedActorCount} ({DisplayOrNone(state.LastSpawnedActorId)})\n" + $"Flags: invuln={state.Invulnerable}, ammo={state.InfiniteSpecialAmmo}, nofire={state.NoEnemyFire}\n" + $"Debug draw: collisions={state.ShowCollisionShapes}, bounds={state.ShowGameplayBounds}";
string heroLine = string.IsNullOrWhiteSpace(m_HeroSummary) ? string.Empty : $"\nHero: {m_HeroSummary}";
label.Text = $"Debug boot: {m_Settings.BootMode}\n" + $"Scene: {m_LoadedSceneId}\n" + $"Seed: {state.Seed}\n" + $"Difficulty: {state.ActiveDifficultyId}\n" + $"Paused: {state.IsPaused}\n" + $"Time scale: {state.TimeScale.ToString(CultureInfo.InvariantCulture)}\n" + $"Marker: {DisplayOrNone(state.CurrentMarkerId)}\n" + $"Spawned: {state.SpawnedActorCount} ({DisplayOrNone(state.LastSpawnedActorId)})\n" + $"Flags: invuln={state.Invulnerable}, ammo={state.InfiniteSpecialAmmo}, nofire={state.NoEnemyFire}\n" + $"Debug draw: collisions={state.ShowCollisionShapes}, bounds={state.ShowGameplayBounds}" + heroLine;
}
private Label EnsureLabel()
@@ -74,4 +81,5 @@ public partial class DebugOverlay : CanvasLayer
private DebugCommandService? m_Service;
private DebugSettings? m_Settings;
private string m_LoadedSceneId = "none";
private string m_HeroSummary = string.Empty;
}

View File

@@ -0,0 +1,364 @@
#nullable enable
using System;
using System.Globalization;
using System.Linq;
using System.Threading.Tasks;
using Godot;
using SideScrollerGame.Content.Samples;
using SideScrollerGame.Debug.Commands;
using SideScrollerGame.Hero;
using SideScrollerGame.Hero.Rules;
namespace SideScrollerGame.Debug;
public partial class HeroSandboxController : Control
{
public override void _Ready()
{
ProcessMode = ProcessModeEnum.Always;
m_CommandNode = GetNodeOrNull<DebugCommandNode>("/root/GameRoot/DebugCommandNode");
m_Overlay = GetNodeOrNull<DebugOverlay>("/root/GameRoot/DebugOverlay");
if (m_CommandNode is null)
{
GD.PushError("Hero sandbox needs /root/GameRoot/DebugCommandNode.");
return;
}
BindSceneNodes();
RegisterHeroCommands();
CreateHeroRuntime();
CreateButtons();
m_CommandNode.Service.CommandExecuted += HandleCommandExecuted;
m_CommandNode.Service.RegisterRestartHandler(RestartSandbox);
if (ShouldRunHeroSmoke())
{
_ = RunHeroSmokeAsync();
}
}
public override void _UnhandledInput(InputEvent @event)
{
if (@event.IsActionPressed("debug_damage_hero"))
{
Execute(DebugCommandId.DamageHero);
}
else if (@event.IsActionPressed("debug_kill_hero"))
{
Execute(DebugCommandId.KillHero);
}
else if (@event.IsActionPressed("debug_rebirth_hero"))
{
Execute(DebugCommandId.RebirthHero);
}
else if (@event.IsActionPressed("debug_add_points"))
{
Execute(DebugCommandId.AddHeroPoints, "100");
}
else if (@event.IsActionPressed("debug_add_shield"))
{
Execute(DebugCommandId.AddHeroShield);
}
else if (@event.IsActionPressed("debug_remove_shield"))
{
Execute(DebugCommandId.RemoveHeroShield);
}
else if (@event.IsActionPressed("debug_toggle_primary_slot"))
{
Execute(DebugCommandId.TogglePrimaryWeaponSlot);
}
else if (@event.IsActionPressed("debug_clear_hero_inventory"))
{
Execute(DebugCommandId.ClearHeroInventory);
}
else if (@event.IsActionPressed("debug_toggle_invulnerability"))
{
Execute(DebugCommandId.ToggleInvulnerability);
}
else if (@event.IsActionPressed("pause_game") || @event.IsActionPressed("debug_pause"))
{
Execute(DebugCommandId.TogglePause);
}
else if (@event.IsActionPressed("debug_frame_step"))
{
Execute(DebugCommandId.FrameStep);
}
else if (@event.IsActionPressed("quick_restart"))
{
Execute(DebugCommandId.RestartMission);
}
}
private void BindSceneNodes()
{
m_Hero = GetNodeOrNull<HeroActor>("Playfield/Hero");
m_Hud = GetNodeOrNull<HeroStateHudController>("HudPanel");
m_LogLabel = GetNodeOrNull<Label>("LogPanel/LogScroll/LogLabel");
m_ButtonContainer = GetNodeOrNull<VBoxContainer>("ButtonPanel/Scroll/Buttons");
m_PlayBoundsRect = GetNodeOrNull<ColorRect>("Playfield/PlayBounds");
}
private void RegisterHeroCommands()
{
if (m_CommandNode is null)
{
return;
}
foreach (DebugCommandId commandId in s_HeroCommandIds)
{
m_CommandNode.Service.RegisterCommandHandler(commandId, argument => ExecuteHeroCommand(commandId, argument));
}
}
private void CreateHeroRuntime()
{
if (m_CommandNode is null)
{
return;
}
string difficultyId = m_CommandNode.Service.State.ActiveDifficultyId;
m_Runtime = new HeroRuntimeService(SampleContent.CreateRegistry(), HeroRuleConfig.CreateDefault(), difficultyId);
m_Runtime.StateChanged += _ => RefreshHeroViews();
m_Hero?.SetRuntime(m_Runtime);
m_Hero?.SetPlayBounds(PlayBounds);
if (m_Hero is not null)
{
m_Hero.GlobalPosition = PlayBounds.GetCenter();
}
if (m_PlayBoundsRect is not null)
{
m_PlayBoundsRect.GlobalPosition = PlayBounds.Position;
m_PlayBoundsRect.Size = PlayBounds.Size;
}
m_Hud?.Bind(m_Runtime);
RefreshHeroViews();
}
private void CreateButtons()
{
if (m_ButtonContainer is null || m_ButtonContainer.GetChildCount() > 0)
{
return;
}
AddHeader("Hero");
AddButton("Damage", DebugCommandId.DamageHero);
AddButton("Heal", DebugCommandId.HealHero);
AddButton("Kill", DebugCommandId.KillHero);
AddButton("Rebirth", DebugCommandId.RebirthHero);
AddButton("+100 pts", DebugCommandId.AddHeroPoints, "100");
AddButton("+500 pts", DebugCommandId.AddHeroPoints, "500");
AddButton("Level 1", DebugCommandId.SetHeroLevel, "1");
AddButton("Level 4", DebugCommandId.SetHeroLevel, "4");
AddButton("+Shield", DebugCommandId.AddHeroShield);
AddButton("-Shield", DebugCommandId.RemoveHeroShield);
AddButton("Retries 0", DebugCommandId.SetHeroRetries, "0");
AddButton("Retries 3", DebugCommandId.SetHeroRetries, "3");
AddButton("Primary slot", DebugCommandId.TogglePrimaryWeaponSlot);
AddButton("Give primary", DebugCommandId.GivePrimaryWeapon, "weapon.primary.basic");
AddButton("Give secondary", DebugCommandId.GiveSecondaryWeapon, "weapon.secondary.vertical");
AddButton("+3 special", DebugCommandId.GiveSpecialAmmo, "3");
AddButton("Give mate", DebugCommandId.GiveSquadronMate, "squadron.orbit");
AddButton("Clear inventory", DebugCommandId.ClearHeroInventory);
AddHeader("Debug");
AddButton("Invulnerable", DebugCommandId.ToggleInvulnerability);
AddButton("Pause", DebugCommandId.TogglePause);
AddButton("Step", DebugCommandId.FrameStep);
AddButton("Restart", DebugCommandId.RestartMission);
AddButton("Reload", DebugCommandId.ReloadScene);
}
private void AddHeader(string text)
{
m_ButtonContainer?.AddChild(new Label { Text = text });
}
private void AddButton(string text, DebugCommandId commandId, string? argument = null)
{
Button button = new() { Text = text };
button.Pressed += () => Execute(commandId, argument);
m_ButtonContainer?.AddChild(button);
}
private DebugCommandResult ExecuteHeroCommand(DebugCommandId commandId, string? argument)
{
if (m_Runtime is null)
{
return DebugCommandResult.Failure(commandId, "Hero runtime is not ready.", argument);
}
HeroRuleResult result = commandId switch
{
DebugCommandId.DamageHero => m_Runtime.ApplyHit(m_CommandNode?.Service.State.Invulnerable ?? false),
DebugCommandId.HealHero => m_Runtime.AddShieldCharge(1),
DebugCommandId.KillHero => m_Runtime.Kill(),
DebugCommandId.RebirthHero => m_Runtime.Rebirth(),
DebugCommandId.AddHeroPoints => m_Runtime.AddPoints(ParseInt(argument, 100)),
DebugCommandId.SetHeroLevel => m_Runtime.SetLevel(ParseInt(argument, 1)),
DebugCommandId.AddHeroShield => m_Runtime.AddShieldCharge(1),
DebugCommandId.RemoveHeroShield => m_Runtime.RemoveShieldCharge(1),
DebugCommandId.SetHeroRetries => m_Runtime.SetRetryCount(ParseInt(argument, 3)),
DebugCommandId.TogglePrimaryWeaponSlot => m_Runtime.TogglePrimaryWeaponSlot(),
DebugCommandId.ClearHeroInventory => m_Runtime.ClearInventory(),
DebugCommandId.GivePrimaryWeapon => m_Runtime.ApplyPrimaryWeaponPickup(RequireArgument(argument, "weapon.primary.basic")),
DebugCommandId.GiveSecondaryWeapon => m_Runtime.ApplySecondaryWeaponPickup(RequireArgument(argument, "weapon.secondary.vertical")),
DebugCommandId.GiveSpecialAmmo => m_Runtime.AddSpecialAmmo(ParseInt(argument, 1)),
DebugCommandId.GiveSquadronMate => m_Runtime.ApplySquadronMatePickup(RequireArgument(argument, "squadron.orbit")),
_ => HeroRuleResult.Failure($"Unsupported hero command '{commandId}'.", m_Runtime.State)
};
RefreshHeroViews();
return result.Succeeded ? DebugCommandResult.Success(commandId, result.Message, argument) : DebugCommandResult.Failure(commandId, result.Message, argument);
}
private DebugCommandResult RestartSandbox()
{
CreateHeroRuntime();
return DebugCommandResult.Success(DebugCommandId.RestartMission, "Hero sandbox restarted");
}
private void HandleCommandExecuted(DebugCommandResult result)
{
if (s_HeroCommandIds.Contains(result.CommandId))
{
string suffix = string.IsNullOrWhiteSpace(result.Argument) ? string.Empty : $" {result.Argument}";
AppendLog($"Hero command: {result.CommandId}{suffix}");
AppendLog($"Hero state: {BuildHeroSummary()}");
}
else if (result.CommandId is DebugCommandId.ToggleInvulnerability or DebugCommandId.TogglePause or DebugCommandId.FrameStep or DebugCommandId.RestartMission or DebugCommandId.ReloadScene)
{
string suffix = string.IsNullOrWhiteSpace(result.Argument) ? string.Empty : $" {result.Argument}";
AppendLog($"Command executed: {result.CommandId}{suffix}");
}
if (!result.Succeeded)
{
AppendLog($"Command failed: {result.Message}");
}
}
private void RefreshHeroViews()
{
m_Hud?.Refresh();
m_Overlay?.SetHeroSummary(BuildHeroSummary());
}
private string BuildHeroSummary()
{
if (m_Runtime is null)
{
return "unbound";
}
HeroRunState state = m_Runtime.State;
return $"{state.LifeState} level={state.Level} points={state.Points} shields={state.ShieldCharges} retries={state.RetryCount}";
}
private void Execute(DebugCommandId commandId, string? argument = null)
{
m_CommandNode?.Execute(commandId, argument);
}
private async Task RunHeroSmokeAsync()
{
await ToSignal(GetTree(), SceneTree.SignalName.ProcessFrame);
AppendLog("Hero sandbox smoke loaded");
bool succeeded = ExecuteAndRequire(DebugCommandId.SetDifficulty, "difficulty.normal") && ExecuteAndRequire(DebugCommandId.DamageHero) && ExecuteAndRequire(DebugCommandId.AddHeroPoints, "500") && ExecuteAndRequire(DebugCommandId.TogglePrimaryWeaponSlot) && ExecuteAndRequire(DebugCommandId.GivePrimaryWeapon, "weapon.primary.basic") && ExecuteAndRequire(DebugCommandId.GiveSpecialAmmo, "3") && ExecuteAndRequire(DebugCommandId.GiveSquadronMate, "squadron.orbit") && ExecuteAndRequire(DebugCommandId.ToggleInvulnerability) && ExecuteAndRequire(DebugCommandId.DamageHero) && ExecuteAndRequire(DebugCommandId.KillHero) && ExecuteAndRequire(DebugCommandId.RebirthHero) && ExecuteAndRequire(DebugCommandId.SetHeroRetries, "0") && ExecuteAndRequire(DebugCommandId.KillHero) && VerifyHeroSmokeState();
AppendLog(succeeded ? "Hero sandbox smoke succeeded" : "Hero sandbox smoke failed");
GetTree().Quit(succeeded ? 0 : 1);
}
private bool ExecuteAndRequire(DebugCommandId commandId, string? argument = null)
{
if (m_CommandNode is null)
{
return false;
}
DebugCommandResult result = m_CommandNode.Execute(commandId, argument);
if (result.Succeeded)
{
return true;
}
AppendLog(result.Message);
return false;
}
private bool VerifyHeroSmokeState()
{
if (m_Runtime is null)
{
return false;
}
HeroRunState state = m_Runtime.State;
return state.LifeState == HeroLifeState.GameOver && state.Level == 2 && state.Points == 500 && state.ShieldCharges == 0 && state.RetryCount == 0;
}
private void AppendLog(string message)
{
GD.Print(message);
if (m_LogLabel is null)
{
return;
}
m_LogLabel.Text = string.IsNullOrWhiteSpace(m_LogLabel.Text) ? message : $"{m_LogLabel.Text}\n{message}";
}
private static int ParseInt(string? value, int defaultValue)
{
return int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out int parsedValue) ? parsedValue : defaultValue;
}
private static string RequireArgument(string? argument, string defaultValue)
{
return string.IsNullOrWhiteSpace(argument) ? defaultValue : argument.Trim();
}
private static bool ShouldRunHeroSmoke()
{
return DisplayServer.GetName().Equals("headless", StringComparison.OrdinalIgnoreCase) && OS.GetCmdlineUserArgs().Any(argument => argument.Equals("--debug-script=hero-smoke", StringComparison.OrdinalIgnoreCase));
}
[Export]
public Rect2 PlayBounds { get; set; } = new(new Vector2(280.0f, 96.0f), new Vector2(640.0f, 420.0f));
private static readonly DebugCommandId[] s_HeroCommandIds =
[
DebugCommandId.DamageHero,
DebugCommandId.HealHero,
DebugCommandId.KillHero,
DebugCommandId.RebirthHero,
DebugCommandId.AddHeroPoints,
DebugCommandId.SetHeroLevel,
DebugCommandId.AddHeroShield,
DebugCommandId.RemoveHeroShield,
DebugCommandId.SetHeroRetries,
DebugCommandId.TogglePrimaryWeaponSlot,
DebugCommandId.ClearHeroInventory,
DebugCommandId.GivePrimaryWeapon,
DebugCommandId.GiveSecondaryWeapon,
DebugCommandId.GiveSpecialAmmo,
DebugCommandId.GiveSquadronMate
];
private DebugCommandNode? m_CommandNode;
private DebugOverlay? m_Overlay;
private HeroRuntimeService? m_Runtime;
private HeroActor? m_Hero;
private HeroStateHudController? m_Hud;
private Label? m_LogLabel;
private VBoxContainer? m_ButtonContainer;
private ColorRect? m_PlayBoundsRect;
}

View File

@@ -0,0 +1 @@
uid://dstsg1pgmok1o

View File

@@ -20,5 +20,20 @@ public enum DebugCommandId
ToggleGameplayBounds,
ToggleInvulnerability,
ToggleInfiniteSpecialAmmo,
ToggleNoEnemyFire
ToggleNoEnemyFire,
DamageHero,
HealHero,
KillHero,
RebirthHero,
AddHeroPoints,
SetHeroLevel,
AddHeroShield,
RemoveHeroShield,
SetHeroRetries,
TogglePrimaryWeaponSlot,
ClearHeroInventory,
GivePrimaryWeapon,
GiveSecondaryWeapon,
GiveSpecialAmmo,
GiveSquadronMate
}

View File

@@ -37,7 +37,7 @@ public sealed class DebugCommandService
DebugCommandId.ToggleInvulnerability => ToggleFlag(commandId, nameof(State.Invulnerable)),
DebugCommandId.ToggleInfiniteSpecialAmmo => ToggleFlag(commandId, nameof(State.InfiniteSpecialAmmo)),
DebugCommandId.ToggleNoEnemyFire => ToggleFlag(commandId, nameof(State.NoEnemyFire)),
_ => DebugCommandResult.Failure(commandId, $"Unsupported debug command '{commandId}'.", argument)
_ => ExecuteRegisteredCommand(commandId, argument)
};
CommandExecuted?.Invoke(result);
@@ -64,6 +64,16 @@ public sealed class DebugCommandService
m_RestartHandler = handler;
}
public void RegisterCommandHandler(DebugCommandId commandId, Func<string?, DebugCommandResult> handler)
{
m_CommandHandlers[commandId] = handler;
}
public void ClearCommandHandler(DebugCommandId commandId)
{
m_CommandHandlers.Remove(commandId);
}
public DebugRuntimeState State { get; }
public event Action<DebugRuntimeState>? StateChanged;
@@ -218,6 +228,11 @@ public sealed class DebugCommandService
return DebugCommandResult.Success(commandId, message);
}
private DebugCommandResult ExecuteRegisteredCommand(DebugCommandId commandId, string? argument)
{
return m_CommandHandlers.TryGetValue(commandId, out Func<string?, DebugCommandResult>? handler) ? handler(argument) : DebugCommandResult.Failure(commandId, $"Unsupported debug command '{commandId}'.", argument);
}
private string ToggleShowCollisionShapes()
{
State.ShowCollisionShapes = !State.ShowCollisionShapes;
@@ -276,6 +291,7 @@ public sealed class DebugCommandService
private static readonly HashSet<double> s_SupportedTimeScales = [0.25, 0.5, 1.0, 2.0, 4.0];
private readonly ContentRegistry m_Registry;
private readonly Dictionary<DebugCommandId, Func<string?, DebugCommandResult>> m_CommandHandlers = [];
private Func<string, DebugCommandResult>? m_SpawnHandler;
private Func<string, DebugCommandResult>? m_TimelineJumpHandler;
private Func<DebugCommandResult>? m_ReloadHandler;

View File

@@ -0,0 +1,51 @@
#nullable enable
using Godot;
using SideScrollerGame.Hero.Rules;
namespace SideScrollerGame.Hero;
public partial class HeroActor : Node2D
{
public override void _PhysicsProcess(double delta)
{
if (m_Runtime is not null && m_Runtime.State.LifeState != HeroLifeState.Alive)
{
return;
}
Vector2 direction = Input.GetVector("move_left", "move_right", "move_up", "move_down");
GlobalPosition = ClampToBounds(GlobalPosition + (direction * MoveSpeed * (float)delta));
}
public void SetRuntime(HeroRuntimeService runtime)
{
m_Runtime = runtime;
}
public void SetPlayBounds(Rect2 bounds)
{
PlayBounds = bounds;
GlobalPosition = ClampToBounds(GlobalPosition);
}
[Export]
public float MoveSpeed { get; set; } = 320.0f;
[Export]
public Rect2 PlayBounds { get; set; } = new(new Vector2(260.0f, 96.0f), new Vector2(640.0f, 420.0f));
private Vector2 ClampToBounds(Vector2 position)
{
if (PlayBounds.Size == Vector2.Zero)
{
return position;
}
float x = Mathf.Clamp(position.X, PlayBounds.Position.X, PlayBounds.Position.X + PlayBounds.Size.X);
float y = Mathf.Clamp(position.Y, PlayBounds.Position.Y, PlayBounds.Position.Y + PlayBounds.Size.Y);
return new Vector2(x, y);
}
private HeroRuntimeService? m_Runtime;
}

View File

@@ -0,0 +1 @@
uid://df8y8nyfawd51

View File

@@ -0,0 +1,75 @@
#nullable enable
using System.Globalization;
using System.Linq;
using Godot;
using SideScrollerGame.Hero.Rules;
namespace SideScrollerGame.Hero;
public partial class HeroStateHudController : Control
{
public override void _Ready()
{
EnsureLabel();
Refresh();
}
public void Bind(HeroRuntimeService runtime)
{
if (m_Runtime is not null)
{
m_Runtime.StateChanged -= HandleStateChanged;
}
m_Runtime = runtime;
runtime.StateChanged += HandleStateChanged;
Refresh();
}
public void Refresh()
{
Label label = EnsureLabel();
if (m_Runtime is null)
{
label.Text = "Hero: unbound";
return;
}
HeroRunState state = m_Runtime.State;
string slots = string.Join(", ", state.PrimaryWeaponSlots.Select((weaponId, index) => index == state.SelectedPrimaryWeaponSlotIndex ? $"[{Display(weaponId)}]" : Display(weaponId)));
string nextThreshold = m_Runtime.NextPointThreshold?.ToString(CultureInfo.InvariantCulture) ?? "max";
label.Text = $"Hero: {state.LifeState}\n" + $"Level: {state.Level} Points: {state.Points} Next: {nextThreshold}\n" + $"Shields: {state.ShieldCharges} Retries: {state.RetryCount}\n" + $"Primary: {slots}\n" + $"Secondary: {Display(state.CurrentSecondaryWeaponId)}\n" + $"Special: {Display(state.CurrentSpecialWeaponId)} ammo={state.SpecialAmmo}\n" + $"Squadron: {Display(state.SquadronMateTypeId)} x{state.SquadronMateCount}\n" + $"Last: {state.LastStateChange}";
}
private void HandleStateChanged(HeroRunState state)
{
Refresh();
}
private Label EnsureLabel()
{
if (m_StateLabel is not null)
{
return m_StateLabel;
}
m_StateLabel = GetNodeOrNull<Label>("StateLabel");
if (m_StateLabel is null)
{
m_StateLabel = new Label { Name = "StateLabel" };
AddChild(m_StateLabel);
}
m_StateLabel.AutowrapMode = TextServer.AutowrapMode.WordSmart;
return m_StateLabel;
}
private static string Display(string? value)
{
return string.IsNullOrWhiteSpace(value) ? "empty" : value;
}
private HeroRuntimeService? m_Runtime;
private Label? m_StateLabel;
}

View File

@@ -0,0 +1 @@
uid://dmqj2ouqe5cvq

View File

@@ -0,0 +1,10 @@
#nullable enable
namespace SideScrollerGame.Hero.Rules;
public enum HeroLifeState
{
Alive,
Dead,
GameOver
}

View File

@@ -0,0 +1 @@
uid://cr5kwlw3ovauj

View File

@@ -0,0 +1,7 @@
#nullable enable
using System.Collections.Generic;
namespace SideScrollerGame.Hero.Rules;
public sealed record HeroMissionSnapshot(string ActiveDifficultyId, HeroLifeState LifeState, int Level, int Points, int ShieldCharges, int RetryCount, IReadOnlyList<string?> PrimaryWeaponSlots, int SelectedPrimaryWeaponSlotIndex, string CurrentSecondaryWeaponId, string CurrentSpecialWeaponId, int SpecialAmmo, string SquadronMateTypeId, int SquadronMateCount);

View File

@@ -0,0 +1 @@
uid://chkspscrqv1mi

View File

@@ -0,0 +1,38 @@
#nullable enable
using System.Collections.Generic;
namespace SideScrollerGame.Hero.Rules;
public sealed class HeroRuleConfig
{
public HeroRuleConfig(int primaryWeaponSlotCount, string basePrimaryWeaponId, string baseSecondaryWeaponId, string defaultSpecialWeaponId, int defaultSpecialAmmo, int maxSquadronMateCount, IReadOnlyList<int> pointThresholds)
{
PrimaryWeaponSlotCount = primaryWeaponSlotCount;
BasePrimaryWeaponId = basePrimaryWeaponId;
BaseSecondaryWeaponId = baseSecondaryWeaponId;
DefaultSpecialWeaponId = defaultSpecialWeaponId;
DefaultSpecialAmmo = defaultSpecialAmmo;
MaxSquadronMateCount = maxSquadronMateCount;
PointThresholds = pointThresholds;
}
public static HeroRuleConfig CreateDefault()
{
return new HeroRuleConfig(2, "weapon.primary.basic", "weapon.secondary.vertical", "weapon.special.bomb", 12, 4, [500, 1500, 3000]);
}
public int PrimaryWeaponSlotCount { get; }
public string BasePrimaryWeaponId { get; }
public string BaseSecondaryWeaponId { get; }
public string DefaultSpecialWeaponId { get; }
public int DefaultSpecialAmmo { get; }
public int MaxSquadronMateCount { get; }
public IReadOnlyList<int> PointThresholds { get; }
}

View File

@@ -0,0 +1 @@
uid://dnk3adcvb0xma

View File

@@ -0,0 +1,16 @@
#nullable enable
namespace SideScrollerGame.Hero.Rules;
public sealed record HeroRuleResult(bool Succeeded, string Message, HeroRunState State)
{
public static HeroRuleResult Success(string message, HeroRunState state)
{
return new HeroRuleResult(true, message, state);
}
public static HeroRuleResult Failure(string message, HeroRunState state)
{
return new HeroRuleResult(false, message, state);
}
}

View File

@@ -0,0 +1 @@
uid://bvkra6itukg8p

View File

@@ -0,0 +1,79 @@
#nullable enable
using System.Collections.Generic;
using System.Linq;
namespace SideScrollerGame.Hero.Rules;
public sealed class HeroRunState
{
public HeroRunState(string activeDifficultyId, int shieldCharges, int retryCount, HeroRuleConfig config)
{
ActiveDifficultyId = activeDifficultyId;
ShieldCharges = shieldCharges;
RetryCount = retryCount;
m_PrimaryWeaponSlots = Enumerable.Repeat<string?>(null, config.PrimaryWeaponSlotCount).ToList();
ResetInventory(config);
LastStateChange = "Hero ready";
}
public int FindEmptyPrimarySlot()
{
return m_PrimaryWeaponSlots.FindIndex(string.IsNullOrWhiteSpace);
}
public void ReplacePrimaryWeaponSlot(int index, string? weaponId)
{
m_PrimaryWeaponSlots[index] = weaponId;
}
public void ResetInventory(HeroRuleConfig config)
{
for (int i = 0; i < m_PrimaryWeaponSlots.Count; i++)
{
m_PrimaryWeaponSlots[i] = null;
}
if (m_PrimaryWeaponSlots.Count > 0)
{
m_PrimaryWeaponSlots[0] = config.BasePrimaryWeaponId;
}
SelectedPrimaryWeaponSlotIndex = 0;
CurrentSecondaryWeaponId = config.BaseSecondaryWeaponId;
CurrentSpecialWeaponId = config.DefaultSpecialWeaponId;
SpecialAmmo = config.DefaultSpecialAmmo;
SquadronMateTypeId = string.Empty;
SquadronMateCount = 0;
}
public string ActiveDifficultyId { get; set; }
public HeroLifeState LifeState { get; set; } = HeroLifeState.Alive;
public int Level { get; set; } = 1;
public int Points { get; set; }
public int ShieldCharges { get; set; }
public int RetryCount { get; set; }
public IReadOnlyList<string?> PrimaryWeaponSlots => m_PrimaryWeaponSlots;
public int SelectedPrimaryWeaponSlotIndex { get; set; }
public string CurrentSecondaryWeaponId { get; set; } = string.Empty;
public string CurrentSpecialWeaponId { get; set; } = string.Empty;
public int SpecialAmmo { get; set; }
public string SquadronMateTypeId { get; set; } = string.Empty;
public int SquadronMateCount { get; set; }
public string LastStateChange { get; set; } = string.Empty;
private readonly List<string?> m_PrimaryWeaponSlots;
}

View File

@@ -0,0 +1 @@
uid://bet8wlbxg4xst

View File

@@ -0,0 +1,248 @@
#nullable enable
using System;
using System.Linq;
using SideScrollerGame.Content;
using SideScrollerGame.Content.Definitions;
namespace SideScrollerGame.Hero.Rules;
public sealed class HeroRuntimeService
{
public HeroRuntimeService(ContentRegistry registry, HeroRuleConfig config, string difficultyId)
{
m_Registry = registry;
Config = config;
m_ActiveDifficulty = ResolveDifficulty(difficultyId);
State = new HeroRunState(m_ActiveDifficulty.Id, m_ActiveDifficulty.HeroStartingShieldCharges, m_ActiveDifficulty.HeroRetryCount, config);
}
public HeroRuleResult ApplyHit(bool invulnerable)
{
if (State.LifeState != HeroLifeState.Alive)
{
return Fail("Hero is not alive");
}
if (invulnerable)
{
return Succeed("Hero ignored hit while invulnerable");
}
if (State.ShieldCharges > 0)
{
State.ShieldCharges--;
return Succeed($"Hero hit: shield {State.ShieldCharges}");
}
return EnterDeathState("Hero killed");
}
public HeroRuleResult Kill()
{
if (State.LifeState != HeroLifeState.Alive)
{
return Fail("Hero is not alive");
}
State.ShieldCharges = 0;
return EnterDeathState("Hero killed");
}
public HeroRuleResult Rebirth()
{
if (State.LifeState != HeroLifeState.Dead)
{
return Fail("Hero is not waiting for rebirth");
}
if (State.RetryCount <= 0)
{
State.LifeState = HeroLifeState.GameOver;
return Succeed("Game over");
}
State.RetryCount--;
State.LifeState = HeroLifeState.Alive;
State.ShieldCharges = m_ActiveDifficulty.HeroStartingShieldCharges;
State.ResetInventory(Config);
return Succeed("Hero reborn");
}
public HeroRuleResult AddPoints(int points)
{
if (points <= 0)
{
return Fail($"Invalid point amount {points}");
}
State.Points += points;
int targetLevel = CalculateLevel(State.Points);
int gainedLevels = Math.Max(0, targetLevel - State.Level);
State.Level = targetLevel;
State.ShieldCharges += gainedLevels;
return Succeed(gainedLevels == 0 ? $"Points added: {points}" : $"Points added: {points}; level {State.Level}");
}
public HeroRuleResult SetLevel(int level)
{
if (level < 1)
{
return Fail($"Invalid hero level {level}");
}
State.Level = level;
return Succeed($"Hero level set to {level}");
}
public HeroRuleResult AddShieldCharge(int amount)
{
if (amount <= 0)
{
return Fail($"Invalid shield amount {amount}");
}
State.ShieldCharges += amount;
return Succeed($"Shield added: {State.ShieldCharges}");
}
public HeroRuleResult RemoveShieldCharge(int amount)
{
if (amount <= 0)
{
return Fail($"Invalid shield amount {amount}");
}
State.ShieldCharges = Math.Max(0, State.ShieldCharges - amount);
return Succeed($"Shield removed: {State.ShieldCharges}");
}
public HeroRuleResult SetRetryCount(int retryCount)
{
if (retryCount < 0)
{
return Fail($"Invalid retry count {retryCount}");
}
State.RetryCount = retryCount;
return Succeed($"Retries set to {retryCount}");
}
public HeroRuleResult TogglePrimaryWeaponSlot()
{
if (State.PrimaryWeaponSlots.Count == 0)
{
return Fail("No primary weapon slots");
}
State.SelectedPrimaryWeaponSlotIndex = (State.SelectedPrimaryWeaponSlotIndex + 1) % State.PrimaryWeaponSlots.Count;
return Succeed($"Primary slot {State.SelectedPrimaryWeaponSlotIndex}");
}
public HeroRuleResult ApplyPrimaryWeaponPickup(string weaponId)
{
if (!m_Registry.Weapons.ContainsKey(weaponId))
{
return Fail($"Unknown primary weapon '{weaponId}'");
}
int targetSlot = State.FindEmptyPrimarySlot();
if (targetSlot < 0)
{
targetSlot = State.SelectedPrimaryWeaponSlotIndex;
}
State.ReplacePrimaryWeaponSlot(targetSlot, weaponId);
return Succeed($"Primary weapon {weaponId} in slot {targetSlot}");
}
public HeroRuleResult ApplySecondaryWeaponPickup(string weaponId)
{
if (!m_Registry.Weapons.ContainsKey(weaponId))
{
return Fail($"Unknown secondary weapon '{weaponId}'");
}
State.CurrentSecondaryWeaponId = weaponId;
return Succeed($"Secondary weapon {weaponId}");
}
public HeroRuleResult AddSpecialAmmo(int amount)
{
State.SpecialAmmo = Math.Max(0, State.SpecialAmmo + amount);
return Succeed($"Special ammo {State.SpecialAmmo}");
}
public HeroRuleResult ApplySquadronMatePickup(string squadronMateTypeId)
{
if (!m_Registry.SquadronMateTypes.ContainsKey(squadronMateTypeId))
{
return Fail($"Unknown squadron mate '{squadronMateTypeId}'");
}
State.SquadronMateTypeId = squadronMateTypeId;
State.SquadronMateCount = Math.Min(Config.MaxSquadronMateCount, State.SquadronMateCount + 1);
return Succeed($"Squadron mates {State.SquadronMateCount} {squadronMateTypeId}");
}
public HeroRuleResult ClearInventory()
{
State.ResetInventory(Config);
return Succeed("Hero inventory cleared");
}
public HeroMissionSnapshot CreateMissionSnapshot()
{
return new HeroMissionSnapshot(State.ActiveDifficultyId, State.LifeState, State.Level, State.Points, State.ShieldCharges, State.RetryCount, State.PrimaryWeaponSlots.ToList(), State.SelectedPrimaryWeaponSlotIndex, State.CurrentSecondaryWeaponId, State.CurrentSpecialWeaponId, State.SpecialAmmo, State.SquadronMateTypeId, State.SquadronMateCount);
}
public int? NextPointThreshold
{
get { return Config.PointThresholds.Cast<int?>().FirstOrDefault(threshold => threshold > State.Points); }
}
public HeroRunState State { get; }
public HeroRuleConfig Config { get; }
public event Action<HeroRunState>? StateChanged;
private HeroRuleResult EnterDeathState(string message)
{
State.ShieldCharges = 0;
State.ResetInventory(Config);
State.LifeState = State.RetryCount > 0 ? HeroLifeState.Dead : HeroLifeState.GameOver;
return Succeed(State.LifeState == HeroLifeState.GameOver ? "Game over" : message);
}
private HeroRuleResult Succeed(string message)
{
State.LastStateChange = message;
StateChanged?.Invoke(State);
return HeroRuleResult.Success(message, State);
}
private HeroRuleResult Fail(string message)
{
State.LastStateChange = message;
return HeroRuleResult.Failure(message, State);
}
private int CalculateLevel(int points)
{
return 1 + Config.PointThresholds.Count(threshold => points >= threshold);
}
private DifficultyDefinition ResolveDifficulty(string difficultyId)
{
if (m_Registry.Difficulties.TryGetValue(difficultyId, out DifficultyDefinition? difficulty))
{
return difficulty;
}
return m_Registry.DifficultyDefinitions.First();
}
private readonly ContentRegistry m_Registry;
private readonly DifficultyDefinition m_ActiveDifficulty;
}

View File

@@ -0,0 +1 @@
uid://hfyvh0qvd7uv