Add high scores, bot R2D2 priority, and menu updates

- Add HighScoreManager to save/load scores per game type
- Add HighScoreWindow with tabs for Force Factor and Indy Quotient
- Add "High Scores" menu item under About
- Save high score when completing 15-mission cycle
- Bot now prioritizes picking up R2D2/locator at game start
- Update About menu text to "Desktop Adventures NG"

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Ned Heller
2026-01-31 01:55:27 -08:00
co-authored by Claude Opus 4.5
parent 3e3f0b6823
commit 040652b267
5 changed files with 436 additions and 1 deletions
@@ -217,6 +217,22 @@ public class MissionSolver
/// </summary>
private BotObjective CreateExplorationObjective()
{
// Priority 0: Get the locator/R2D2 if we don't have it (highest priority after safety)
if (!_state.HasLocator)
{
var locator = FindLocatorItem();
if (locator != null)
{
return new BotObjective
{
Type = ObjectiveType.PickupItem,
Description = "Pick up R2D2 (Locator)",
TargetX = locator.X,
TargetY = locator.Y
};
}
}
// Priority 1: Kill nearby enemies (safety first!)
var enemy = FindNearestEnemy();
if (enemy != null)
@@ -492,6 +508,29 @@ public class MissionSolver
_usedItemsOnNpcs.Add((_state.CurrentZoneId, npc.X * 10000 + itemId, npc.Y));
}
/// <summary>
/// Finds the locator/R2D2 item in the current zone.
/// </summary>
private ZoneObject? FindLocatorItem()
{
if (_state.CurrentZone == null) return null;
foreach (var obj in _state.CurrentZone.Objects)
{
if (obj.Type == ZoneObjectType.LocatorItem)
{
// Skip if already collected
if (_collectedItems.Contains((_state.CurrentZoneId, obj.X, obj.Y)))
continue;
if (_state.IsObjectCollected(_state.CurrentZoneId, obj.X, obj.Y))
continue;
return obj;
}
}
return null;
}
/// <summary>
/// Finds an item we haven't picked up yet (either zone objects or tile items).
/// </summary>
@@ -30,6 +30,7 @@ public unsafe class GameEngine : IDisposable
private ControlsWindow? _controlsWindow;
private AboutWindow? _aboutWindow;
private ScoreWindow? _scoreWindow;
private UI.HighScoreWindow? _highScoreWindow;
private TitleScreen? _titleScreen;
private MenuBar? _menuBar;
@@ -210,11 +211,13 @@ public unsafe class GameEngine : IDisposable
_menuBar.OnShowControllerControls += ShowControllerControls;
_menuBar.OnSelectDataFile += SelectDataFile;
_menuBar.OnShowAbout += ShowAboutDialog;
_menuBar.OnShowHighScores += ShowHighScores;
// Initialize controls window, about window, and score window
_controlsWindow = new ControlsWindow();
_aboutWindow = new AboutWindow();
_scoreWindow = new ScoreWindow();
_highScoreWindow = new UI.HighScoreWindow();
// Show title screen
_showingTitleScreen = true;
@@ -227,6 +230,11 @@ public unsafe class GameEngine : IDisposable
_aboutWindow?.Open();
}
private void ShowHighScores()
{
_highScoreWindow?.Open();
}
private void SetGraphicsScale(int scale)
{
_graphicsScale = scale;
@@ -875,6 +883,12 @@ public unsafe class GameEngine : IDisposable
if (_scoreWindow.HandleEvent(&evtCopy))
continue;
}
if (_highScoreWindow != null && _highScoreWindow.IsOpen)
{
SDLEvent evtCopy = evt;
if (_highScoreWindow.HandleEvent(&evtCopy))
continue;
}
switch ((SDLEventType)evt.Type)
{
@@ -2360,6 +2374,12 @@ public unsafe class GameEngine : IDisposable
_messages.ShowMessage("CONGRATULATIONS! You've completed all 15 missions!", MessageType.System);
_messages.ShowMessage("Press R to start a new 15-mission cycle.", MessageType.Info);
// Calculate and save high score
var (total, _, _, _, _) = _state.CalculateScore();
var elapsedTime = DateTime.Now - _state.GameStartTime;
string rating = GetScoreRating(total, _gameData!.GameType);
HighScoreManager.AddScore(_gameData!.GameType, total, rating, _state.WorldSize, elapsedTime);
// Show score window
_scoreWindow?.Show(_state, _gameData!.GameType);
}
@@ -3481,6 +3501,7 @@ public unsafe class GameEngine : IDisposable
_controlsWindow?.Render();
_aboutWindow?.Render();
_scoreWindow?.Render();
_highScoreWindow?.Render();
}
/// <summary>
@@ -3894,6 +3915,33 @@ public unsafe class GameEngine : IDisposable
return null;
}
/// <summary>
/// Gets the rating string for a score.
/// </summary>
private string GetScoreRating(int score, Data.GameType gameType)
{
if (gameType == Data.GameType.IndianaJones)
{
if (score >= 450) return "Master Archaeologist";
if (score >= 400) return "Professor";
if (score >= 350) return "Seasoned Explorer";
if (score >= 300) return "Field Researcher";
if (score >= 250) return "Curator";
if (score >= 200) return "Student";
return "Amateur";
}
else
{
if (score >= 450) return "Legendary Hero";
if (score >= 400) return "Jedi Master";
if (score >= 350) return "Jedi Knight";
if (score >= 300) return "Padawan";
if (score >= 250) return "Force Sensitive";
if (score >= 200) return "Adventurer";
return "Beginner";
}
}
public void Dispose()
{
// Clean up controller
@@ -3906,6 +3954,7 @@ public unsafe class GameEngine : IDisposable
_debugMapWindow?.Dispose();
_scriptViewer?.Dispose();
_assetViewer?.Dispose();
_highScoreWindow?.Dispose();
_sounds?.Dispose();
_renderer?.Dispose();
}
@@ -0,0 +1,102 @@
using System.Text.Json;
using YodaStoriesNG.Engine.Data;
namespace YodaStoriesNG.Engine.Game;
/// <summary>
/// Manages high scores for Force Factor (Yoda) and Indy Quotient (Indy).
/// </summary>
public static class HighScoreManager
{
private const int MaxScoresPerGame = 10;
private static readonly string ScoreFilePath;
public class HighScore
{
public int Score { get; set; }
public string Rating { get; set; } = "";
public DateTime Date { get; set; }
public WorldSize WorldSize { get; set; }
public TimeSpan Time { get; set; }
}
public class HighScoreData
{
public List<HighScore> YodaScores { get; set; } = new();
public List<HighScore> IndyScores { get; set; } = new();
}
private static HighScoreData _scores = new();
static HighScoreManager()
{
var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
var gameDir = Path.Combine(appData, "YodaStoriesNG");
Directory.CreateDirectory(gameDir);
ScoreFilePath = Path.Combine(gameDir, "highscores.json");
Load();
}
public static void Load()
{
try
{
if (File.Exists(ScoreFilePath))
{
var json = File.ReadAllText(ScoreFilePath);
_scores = JsonSerializer.Deserialize<HighScoreData>(json) ?? new HighScoreData();
}
}
catch (Exception ex)
{
Console.WriteLine($"[HighScores] Failed to load: {ex.Message}");
_scores = new HighScoreData();
}
}
public static void Save()
{
try
{
var json = JsonSerializer.Serialize(_scores, new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(ScoreFilePath, json);
}
catch (Exception ex)
{
Console.WriteLine($"[HighScores] Failed to save: {ex.Message}");
}
}
public static void AddScore(GameType gameType, int score, string rating, WorldSize worldSize, TimeSpan time)
{
var newScore = new HighScore
{
Score = score,
Rating = rating,
Date = DateTime.Now,
WorldSize = worldSize,
Time = time
};
var list = gameType == GameType.IndianaJones ? _scores.IndyScores : _scores.YodaScores;
list.Add(newScore);
list.Sort((a, b) => b.Score.CompareTo(a.Score)); // Sort descending
// Keep only top scores
while (list.Count > MaxScoresPerGame)
list.RemoveAt(list.Count - 1);
Save();
}
public static List<HighScore> GetScores(GameType gameType)
{
return gameType == GameType.IndianaJones ? _scores.IndyScores : _scores.YodaScores;
}
public static int GetHighScore(GameType gameType)
{
var list = gameType == GameType.IndianaJones ? _scores.IndyScores : _scores.YodaScores;
return list.Count > 0 ? list[0].Score : 0;
}
}
@@ -0,0 +1,243 @@
using Hexa.NET.SDL2;
using YodaStoriesNG.Engine.Data;
using YodaStoriesNG.Engine.Game;
using YodaStoriesNG.Engine.Rendering;
namespace YodaStoriesNG.Engine.UI;
/// <summary>
/// Displays high scores for both Force Factor (Yoda) and Indy Quotient (Indy).
/// </summary>
public unsafe class HighScoreWindow : IDisposable
{
private SDLWindow* _window;
private SDLRenderer* _renderer;
private BitmapFont? _font;
private bool _isOpen = false;
private uint _windowId;
private int _selectedTab = 0; // 0 = Yoda, 1 = Indy
private const int WindowWidth = 450;
private const int WindowHeight = 400;
public bool IsOpen => _isOpen;
public void Open()
{
if (_isOpen)
{
if (_window != null)
SDL.RaiseWindow(_window);
return;
}
_window = SDL.CreateWindow(
"High Scores",
200, 150,
WindowWidth, WindowHeight,
(uint)SDLWindowFlags.Shown);
if (_window == null)
{
Console.WriteLine($"Failed to create high score window: {SDL.GetErrorS()}");
return;
}
_renderer = SDL.CreateRenderer(_window, -1,
(uint)(SDLRendererFlags.Accelerated | SDLRendererFlags.Presentvsync));
if (_renderer == null)
{
SDL.DestroyWindow(_window);
_window = null;
return;
}
_font = new BitmapFont();
_font.Initialize(_renderer);
_windowId = SDL.GetWindowID(_window);
_isOpen = true;
}
public void Close()
{
if (!_isOpen) return;
_font?.Dispose();
_font = null;
if (_renderer != null)
{
SDL.DestroyRenderer(_renderer);
_renderer = null;
}
if (_window != null)
{
SDL.DestroyWindow(_window);
_window = null;
}
_isOpen = false;
}
public bool HandleEvent(SDLEvent* evt)
{
if (!_isOpen) return false;
if (evt->Type == (uint)SDLEventType.Windowevent && evt->Window.WindowID == _windowId)
{
if (evt->Window.Event == (byte)SDLWindowEventID.Close)
{
Close();
return true;
}
}
if (evt->Type == (uint)SDLEventType.Keydown && evt->Key.WindowID == _windowId)
{
var keyCode = (int)evt->Key.Keysym.Sym;
if (keyCode == (int)SDLKeyCode.Escape)
{
Close();
return true;
}
if (keyCode == (int)SDLKeyCode.Tab || keyCode == (int)SDLKeyCode.Left || keyCode == (int)SDLKeyCode.Right)
{
_selectedTab = _selectedTab == 0 ? 1 : 0;
return true;
}
}
if (evt->Type == (uint)SDLEventType.Mousebuttondown && evt->Button.WindowID == _windowId)
{
int mx = evt->Button.X;
int my = evt->Button.Y;
// Check tab clicks
if (my >= 50 && my <= 80)
{
if (mx < WindowWidth / 2)
_selectedTab = 0;
else
_selectedTab = 1;
return true;
}
// Check close button
if (my >= WindowHeight - 50 && my <= WindowHeight - 20 &&
mx >= WindowWidth / 2 - 50 && mx <= WindowWidth / 2 + 50)
{
Close();
return true;
}
return true;
}
return false;
}
public void Render()
{
if (!_isOpen || _renderer == null || _font == null) return;
// Background
SDL.SetRenderDrawColor(_renderer, 25, 30, 40, 255);
SDL.RenderClear(_renderer);
// Header
SDL.SetRenderDrawColor(_renderer, 40, 45, 60, 255);
var headerRect = new SDLRect { X = 0, Y = 0, W = WindowWidth, H = 45 };
SDL.RenderFillRect(_renderer, &headerRect);
RenderTextCentered("HIGH SCORES", WindowWidth / 2, 15, 2, 255, 215, 0);
// Tabs
int tabWidth = WindowWidth / 2;
// Yoda tab
SDL.SetRenderDrawColor(_renderer, _selectedTab == 0 ? (byte)60 : (byte)40,
_selectedTab == 0 ? (byte)80 : (byte)45,
_selectedTab == 0 ? (byte)60 : (byte)55, 255);
var yodaTab = new SDLRect { X = 0, Y = 50, W = tabWidth, H = 30 };
SDL.RenderFillRect(_renderer, &yodaTab);
RenderTextCentered("Force Factor", tabWidth / 2, 57, 1,
_selectedTab == 0 ? (byte)100 : (byte)150,
_selectedTab == 0 ? (byte)255 : (byte)150,
_selectedTab == 0 ? (byte)100 : (byte)150);
// Indy tab
SDL.SetRenderDrawColor(_renderer, _selectedTab == 1 ? (byte)80 : (byte)40,
_selectedTab == 1 ? (byte)60 : (byte)45,
_selectedTab == 1 ? (byte)40 : (byte)55, 255);
var indyTab = new SDLRect { X = tabWidth, Y = 50, W = tabWidth, H = 30 };
SDL.RenderFillRect(_renderer, &indyTab);
RenderTextCentered("Indy Quotient", tabWidth + tabWidth / 2, 57, 1,
_selectedTab == 1 ? (byte)255 : (byte)150,
_selectedTab == 1 ? (byte)200 : (byte)150,
_selectedTab == 1 ? (byte)100 : (byte)150);
// Score list
var gameType = _selectedTab == 0 ? GameType.YodaStories : GameType.IndianaJones;
var scores = HighScoreManager.GetScores(gameType);
int y = 95;
int rank = 1;
// Column headers
_font.RenderText(_renderer, "#", 20, y, 1, 120, 120, 150, 255);
_font.RenderText(_renderer, "Score", 50, y, 1, 120, 120, 150, 255);
_font.RenderText(_renderer, "Rating", 120, y, 1, 120, 120, 150, 255);
_font.RenderText(_renderer, "Size", 280, y, 1, 120, 120, 150, 255);
_font.RenderText(_renderer, "Time", 340, y, 1, 120, 120, 150, 255);
y += 25;
if (scores.Count == 0)
{
RenderTextCentered("No scores yet!", WindowWidth / 2, y + 50, 1, 150, 150, 150);
RenderTextCentered("Complete a 15-mission cycle", WindowWidth / 2, y + 75, 1, 120, 120, 140);
}
else
{
foreach (var score in scores.Take(10))
{
byte r = rank == 1 ? (byte)255 : rank == 2 ? (byte)200 : rank == 3 ? (byte)180 : (byte)160;
byte g = rank == 1 ? (byte)215 : rank == 2 ? (byte)200 : rank == 3 ? (byte)150 : (byte)160;
byte b = rank == 1 ? (byte)0 : rank == 2 ? (byte)200 : rank == 3 ? (byte)100 : (byte)170;
_font.RenderText(_renderer, $"{rank}", 20, y, 1, r, g, b, 255);
_font.RenderText(_renderer, $"{score.Score}", 50, y, 1, 200, 200, 200, 255);
_font.RenderText(_renderer, score.Rating, 120, y, 1, 180, 180, 200, 255);
_font.RenderText(_renderer, score.WorldSize.ToString()[0].ToString(), 280, y, 1, 150, 150, 180, 255);
_font.RenderText(_renderer, $"{(int)score.Time.TotalMinutes}:{score.Time.Seconds:D2}", 340, y, 1, 150, 150, 180, 255);
y += 22;
rank++;
}
}
// Close button
int btnX = WindowWidth / 2 - 50;
int btnY = WindowHeight - 50;
SDL.SetRenderDrawColor(_renderer, 60, 80, 100, 255);
var btnRect = new SDLRect { X = btnX, Y = btnY, W = 100, H = 30 };
SDL.RenderFillRect(_renderer, &btnRect);
SDL.SetRenderDrawColor(_renderer, 80, 100, 130, 255);
SDL.RenderDrawRect(_renderer, &btnRect);
RenderTextCentered("Close", WindowWidth / 2, btnY + 8, 1, 200, 200, 200);
// Footer hint
_font.RenderText(_renderer, "Tab/Arrow: Switch game | ESC: Close", 90, WindowHeight - 18, 1, 100, 100, 120, 255);
SDL.RenderPresent(_renderer);
}
private void RenderTextCentered(string text, int centerX, int y, int scale, byte r, byte g, byte b)
{
int width = _font!.GetTextWidth(text) * scale;
_font.RenderText(_renderer, text, centerX - width / 2, y, scale, r, g, b, 255);
}
public void Dispose() => Close();
}
+3 -1
View File
@@ -25,7 +25,7 @@ public unsafe class MenuBar
new[] { "New Game: Small", "New Game: Medium", "New Game: Large", "New Game: X-tra Large", "-", "Save Game", "Save As...", "Load Game", "-", "Exit" },
new[] { "Asset Viewer (F2)", "Script Editor (F3)", "Map Viewer (F4)", "-", "Enable Bot", "Disable Bot" },
new[] { "Graphics: 1x Scale", "Graphics: 2x Scale", "Graphics: 4x Scale", "-", "Keyboard Controls", "Controller Controls", "-", "Select Data File..." },
new[] { "About Yoda Stories NG" }
new[] { "About Desktop Adventures NG", "High Scores" }
};
// Menu positions (consistent 5px gap between menus)
@@ -48,6 +48,7 @@ public unsafe class MenuBar
public event Action? OnShowControllerControls;
public event Action? OnSelectDataFile;
public event Action? OnShowAbout;
public event Action? OnShowHighScores;
public bool IsMenuOpen => _openMenu >= 0;
@@ -243,6 +244,7 @@ public unsafe class MenuBar
switch (item)
{
case 0: OnShowAbout?.Invoke(); break;
case 1: OnShowHighScores?.Invoke(); break;
}
break;
}