commit b8feea2c749926a92f0d1c08c650dedd63cc3d81 Author: Ned Heller Date: Wed Jan 28 16:56:21 2026 -0800 Initial implementation of Yoda Stories NG engine - SDL2-based rendering with tile atlas - DTA file parser for tiles, zones, sounds, puzzles - Correct color palette from goda-stories project - 658 zones loading and rendering correctly - Basic game loop with zone navigation (N/P keys) - HUD with health bar and inventory slots Still needs: character parsing, IACT scripts, gameplay mechanics Co-Authored-By: Claude Opus 4.5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3d5d07c --- /dev/null +++ b/.gitignore @@ -0,0 +1,30 @@ +# Build outputs +bin/ +obj/ +*.dll +*.exe +*.pdb + +# IDE +.vs/ +.vscode/ +*.user +*.suo + +# Original game files (not part of the project) +Yoda/ +MAGIC/ +Themes/ +AUTORUN.INF +Yodaplay.exe +setup.exe + +# Temporary scripts +*.ps1 + +# Claude workspace +.claude/ + +# OS files +Thumbs.db +.DS_Store diff --git a/YodaStoriesNG.sln b/YodaStoriesNG.sln new file mode 100644 index 0000000..e2fb968 --- /dev/null +++ b/YodaStoriesNG.sln @@ -0,0 +1,27 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{F50EF6E8-6C0A-46AC-9F76-EE8B9BB5A564}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "YodaStoriesNG.Engine", "src\YodaStoriesNG.Engine\YodaStoriesNG.Engine.csproj", "{907444C4-9D6D-4425-9864-82285272B6EB}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {907444C4-9D6D-4425-9864-82285272B6EB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {907444C4-9D6D-4425-9864-82285272B6EB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {907444C4-9D6D-4425-9864-82285272B6EB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {907444C4-9D6D-4425-9864-82285272B6EB}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {907444C4-9D6D-4425-9864-82285272B6EB} = {F50EF6E8-6C0A-46AC-9F76-EE8B9BB5A564} + EndGlobalSection +EndGlobal diff --git a/src/YodaStoriesNG.Engine/Data/Action.cs b/src/YodaStoriesNG.Engine/Data/Action.cs new file mode 100644 index 0000000..50ebd19 --- /dev/null +++ b/src/YodaStoriesNG.Engine/Data/Action.cs @@ -0,0 +1,127 @@ +namespace YodaStoriesNG.Engine.Data; + +/// +/// Represents an IACT action/script from a zone. +/// Actions define game logic triggers and responses. +/// +public class Action +{ + public List Conditions { get; set; } = new(); + public List Instructions { get; set; } = new(); +} + +/// +/// A condition that must be met for an action to execute. +/// +public class Condition +{ + public ConditionOpcode Opcode { get; set; } + public List Arguments { get; set; } = new(); + public string? Text { get; set; } +} + +/// +/// An instruction to execute when conditions are met. +/// +public class Instruction +{ + public InstructionOpcode Opcode { get; set; } + public List Arguments { get; set; } = new(); + public string? Text { get; set; } +} + +/// +/// Condition opcodes from the IACT script system. +/// +public enum ConditionOpcode : ushort +{ + ZoneNotInitialized = 0x00, + ZoneEntered = 0x01, + Bump = 0x02, + PlacedItemIs = 0x03, + Standing = 0x04, + CounterIs = 0x05, + RandomIs = 0x06, + RandomIsGreaterThan = 0x07, + RandomIsLessThan = 0x08, + EnterByPlane = 0x09, + TileAtIs = 0x0A, + MonsterIsDead = 0x0B, + HasNoActiveMonsters = 0x0C, + HasItem = 0x0D, + RequiredItemIs = 0x0E, + EndingIs = 0x0F, + ZoneIsSolved = 0x10, + NoItemPlaced = 0x11, + ItemIsPlaced = 0x12, + HealthIsLessThan = 0x13, + HealthIsGreaterThan = 0x14, + Unused15 = 0x15, + FindItemIs = 0x16, + Unused17 = 0x17, + Unused18 = 0x18, + NpcIs = 0x19, + HasNpc = 0x1A, + RandomIsNot = 0x1B, + RandomIsGreaterOrEqual = 0x1C, + RandomIsLessOrEqual = 0x1D, + GamesWonIs = 0x1E, + DroppedItemIs = 0x1F, + HasBothItemsPlaced = 0x20, + HasAllQuestItems = 0x21, + CounterIsNot = 0x22, + CounterIsGreaterThan = 0x23, + CounterIsLessThan = 0x24, + VariableIsNot = 0x25, +} + +/// +/// Instruction opcodes from the IACT script system. +/// +public enum InstructionOpcode : ushort +{ + PlaceTile = 0x00, + RemoveTile = 0x01, + MoveTile = 0x02, + DrawTile = 0x03, + SpeakHero = 0x04, + SpeakNpc = 0x05, + SetTileNeedsDisplay = 0x06, + SetRectNeedsDisplay = 0x07, + Wait = 0x08, + Redraw = 0x09, + PlaySound = 0x0A, + StopSound = 0x0B, + RollDice = 0x0C, + SetCounter = 0x0D, + AddToCounter = 0x0E, + SetVariable = 0x0F, + HideHero = 0x10, + ShowHero = 0x11, + MoveHeroTo = 0x12, + MoveHeroBy = 0x13, + DisableAction = 0x14, + EnableHotspot = 0x15, + DisableHotspot = 0x16, + EnableMonster = 0x17, + DisableMonster = 0x18, + EnableAllMonsters = 0x19, + DisableAllMonsters = 0x1A, + DropItem = 0x1B, + AddItem = 0x1C, + RemoveItem = 0x1D, + MarkAsSolved = 0x1E, + WinGame = 0x1F, + LoseGame = 0x20, + ChangeZone = 0x21, + SetZoneType = 0x22, + Unknown23 = 0x23, + Unknown24 = 0x24, + SetNpc = 0x25, + AddHealth = 0x26, + SubtractHealth = 0x27, + SetHealth = 0x28, + Unknown29 = 0x29, + Unknown2A = 0x2A, + SpeakNpc2 = 0x2B, +} diff --git a/src/YodaStoriesNG.Engine/Data/Character.cs b/src/YodaStoriesNG.Engine/Data/Character.cs new file mode 100644 index 0000000..5c64db3 --- /dev/null +++ b/src/YodaStoriesNG.Engine/Data/Character.cs @@ -0,0 +1,62 @@ +namespace YodaStoriesNG.Engine.Data; + +/// +/// Represents a character definition from the CHAR section. +/// +public class Character +{ + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public CharacterType Type { get; set; } + + // Animation frame tile IDs + public CharacterFrames Frames { get; set; } = new(); + + // Weapon info from CHWP section + public CharacterWeapon? Weapon { get; set; } + + // Auxiliary data from CAUX section + public CharacterAux? AuxData { get; set; } +} + +/// +/// Character animation frames for different directions and states. +/// +public class CharacterFrames +{ + // Walking frames (3 frames per direction) + public ushort[] WalkUp { get; set; } = new ushort[3]; + public ushort[] WalkDown { get; set; } = new ushort[3]; + public ushort[] WalkLeft { get; set; } = new ushort[3]; + public ushort[] WalkRight { get; set; } = new ushort[3]; + + // Extension frames (if present) + public ushort[] ExtensionUp { get; set; } = Array.Empty(); + public ushort[] ExtensionDown { get; set; } = Array.Empty(); + public ushort[] ExtensionLeft { get; set; } = Array.Empty(); + public ushort[] ExtensionRight { get; set; } = Array.Empty(); +} + +public enum CharacterType : ushort +{ + Hero = 0x01, + Enemy = 0x02, + Friendly = 0x04, +} + +/// +/// Character weapon information from CHWP section. +/// +public class CharacterWeapon +{ + public ushort Reference { get; set; } + public ushort Health { get; set; } +} + +/// +/// Character auxiliary data from CAUX section. +/// +public class CharacterAux +{ + public ushort Damage { get; set; } +} diff --git a/src/YodaStoriesNG.Engine/Data/GameData.cs b/src/YodaStoriesNG.Engine/Data/GameData.cs new file mode 100644 index 0000000..5f6066f --- /dev/null +++ b/src/YodaStoriesNG.Engine/Data/GameData.cs @@ -0,0 +1,71 @@ +namespace YodaStoriesNG.Engine.Data; + +/// +/// Contains all game data loaded from the DTA file. +/// +public class GameData +{ + /// + /// Version information (typically 2.0). + /// + public Version Version { get; set; } = new(2, 0); + + /// + /// Startup screen data (STUP section). + /// + public byte[] StartupScreen { get; set; } = Array.Empty(); + + /// + /// Sound effect references. + /// + public List Sounds { get; set; } = new(); + + /// + /// All tile/sprite definitions. + /// + public List Tiles { get; set; } = new(); + + /// + /// All zone/map definitions. + /// + public List Zones { get; set; } = new(); + + /// + /// All puzzle definitions. + /// + public List Puzzles { get; set; } = new(); + + /// + /// All character definitions. + /// + public List Characters { get; set; } = new(); + + /// + /// Tile names from TNAM section. + /// + public Dictionary TileNames { get; set; } = new(); + + /// + /// Gets a tile by ID, or null if not found. + /// + public Tile? GetTile(int id) => + id >= 0 && id < Tiles.Count ? Tiles[id] : null; + + /// + /// Gets a zone by ID, or null if not found. + /// + public Zone? GetZone(int id) => + id >= 0 && id < Zones.Count ? Zones[id] : null; + + /// + /// Gets a character by ID, or null if not found. + /// + public Character? GetCharacter(int id) => + id >= 0 && id < Characters.Count ? Characters[id] : null; + + /// + /// Gets a sound by ID, or null if not found. + /// + public Sound? GetSound(int id) => + id >= 0 && id < Sounds.Count ? Sounds[id] : null; +} diff --git a/src/YodaStoriesNG.Engine/Data/Puzzle.cs b/src/YodaStoriesNG.Engine/Data/Puzzle.cs new file mode 100644 index 0000000..a8fb7ca --- /dev/null +++ b/src/YodaStoriesNG.Engine/Data/Puzzle.cs @@ -0,0 +1,30 @@ +namespace YodaStoriesNG.Engine.Data; + +/// +/// Represents a puzzle definition from the PUZ2 section. +/// +public class Puzzle +{ + public int Id { get; set; } + public PuzzleType Type { get; set; } + + // Items involved in this puzzle + public ushort Item1 { get; set; } + public ushort Item2 { get; set; } + + // Text strings for the puzzle + public List Strings { get; set; } = new(); + + // Raw data for unknown fields + public byte[] RawData { get; set; } = Array.Empty(); +} + +public enum PuzzleType : short +{ + None = -1, + Quest = 0, + Transport = 1, + Trade = 2, + Use = 3, + Goal = 4, +} diff --git a/src/YodaStoriesNG.Engine/Data/Sound.cs b/src/YodaStoriesNG.Engine/Data/Sound.cs new file mode 100644 index 0000000..b65a27c --- /dev/null +++ b/src/YodaStoriesNG.Engine/Data/Sound.cs @@ -0,0 +1,10 @@ +namespace YodaStoriesNG.Engine.Data; + +/// +/// Represents a sound effect reference from the SNDS section. +/// +public class Sound +{ + public int Id { get; set; } + public string FileName { get; set; } = string.Empty; +} diff --git a/src/YodaStoriesNG.Engine/Data/Tile.cs b/src/YodaStoriesNG.Engine/Data/Tile.cs new file mode 100644 index 0000000..3d42d77 --- /dev/null +++ b/src/YodaStoriesNG.Engine/Data/Tile.cs @@ -0,0 +1,73 @@ +namespace YodaStoriesNG.Engine.Data; + +/// +/// Represents a 32x32 pixel tile/sprite from the game. +/// +public class Tile +{ + public const int Width = 32; + public const int Height = 32; + public const int PixelCount = Width * Height; // 1024 bytes + + public int Id { get; set; } + public TileFlags Flags { get; set; } + public byte[] PixelData { get; set; } = new byte[PixelCount]; + + // Derived properties from flags + public bool IsTransparent => (Flags & TileFlags.Transparency) != 0; + public bool IsFloor => (Flags & TileFlags.Floor) != 0; + public bool IsObject => (Flags & TileFlags.Object) != 0; + public bool IsDraggable => (Flags & TileFlags.Draggable) != 0; + public bool IsRoof => (Flags & TileFlags.Roof) != 0; + public bool IsMap => (Flags & TileFlags.Map) != 0; + public bool IsWeapon => (Flags & TileFlags.Weapon) != 0; + public bool IsItem => (Flags & TileFlags.Item) != 0; + public bool IsCharacter => (Flags & TileFlags.Character) != 0; +} + +/// +/// Tile attribute flags from the DTA file. +/// +[Flags] +public enum TileFlags : uint +{ + None = 0, + + // Object classification (bits 0-8) + Transparency = 1 << 0, // Has transparent pixels + Floor = 1 << 1, // Non-colliding, draws behind player + Object = 1 << 2, // Colliding, middle layer + Draggable = 1 << 3, // Push/pull block + Roof = 1 << 4, // Non-colliding, draws above player + Map = 1 << 5, // Mini-map tile + Weapon = 1 << 6, // Weapon item + Item = 1 << 7, // Inventory item + Character = 1 << 8, // Character/NPC sprite + + // Weapon type flags (bits 16-19, when Weapon is set) + WeaponLightBlaster = 1 << 16, + WeaponHeavyBlaster = 1 << 17, + WeaponLightsaber = 1 << 18, + WeaponTheForce = 1 << 19, + + // Item type flags (bits 16-22, when Item is set) + ItemKeycard = 1 << 16, + ItemPuzzle1 = 1 << 17, + ItemPuzzle2 = 1 << 18, + ItemPuzzle3 = 1 << 19, + ItemLocator = 1 << 20, + ItemHealthPack = 1 << 22, + + // Character type flags (bits 16-18, when Character is set) + CharPlayer = 1 << 16, + CharEnemy = 1 << 17, + CharFriendly = 1 << 18, + + // Mini-map flags (bits 17-30, when Map is set) + MapHome = 1 << 17, + MapPuzzleSolved = 1 << 18, + MapPuzzleUnsolved = 1 << 19, + MapGateway = 1 << 20, + MapWall = 1 << 21, + MapObjective = 1 << 22, +} diff --git a/src/YodaStoriesNG.Engine/Data/Zone.cs b/src/YodaStoriesNG.Engine/Data/Zone.cs new file mode 100644 index 0000000..55f737f --- /dev/null +++ b/src/YodaStoriesNG.Engine/Data/Zone.cs @@ -0,0 +1,152 @@ +namespace YodaStoriesNG.Engine.Data; + +/// +/// Represents a game zone/map area. +/// +public class Zone +{ + public int Id { get; set; } + public int Width { get; set; } // 9 or 18 + public int Height { get; set; } // 9 or 18 + public ZoneFlags Flags { get; set; } + public Planet Planet { get; set; } + public ZoneType Type { get; set; } + + // Tile layers: [y, x, layer] - 3 layers per cell + public ushort[,,] TileGrid { get; set; } = null!; + + // Objects placed in this zone + public List Objects { get; set; } = new(); + + // Action scripts for this zone + public List Actions { get; set; } = new(); + + // Zone auxiliary data + public ZoneAuxData? AuxData { get; set; } + public ZoneAux2Data? Aux2Data { get; set; } + public ZoneAux3Data? Aux3Data { get; set; } + public ZoneAux4Data? Aux4Data { get; set; } + + /// + /// Gets the tile ID at the specified position and layer. + /// + public ushort GetTile(int x, int y, int layer) + { + if (x < 0 || x >= Width || y < 0 || y >= Height || layer < 0 || layer >= 3) + return 0xFFFF; + return TileGrid[y, x, layer]; + } + + /// + /// Sets the tile ID at the specified position and layer. + /// + public void SetTile(int x, int y, int layer, ushort tileId) + { + if (x >= 0 && x < Width && y >= 0 && y < Height && layer >= 0 && layer < 3) + TileGrid[y, x, layer] = tileId; + } +} + +[Flags] +public enum ZoneFlags : byte +{ + None = 0, + Unknown1 = 1 << 0, + Unknown2 = 1 << 1, + Unknown3 = 1 << 2, + Unknown4 = 1 << 3, +} + +public enum Planet : byte +{ + None = 0, + Desert = 1, // Tatooine + Snow = 2, // Hoth + Forest = 3, // Endor + Swamp = 5, // Dagobah +} + +public enum ZoneType +{ + None, + Empty, + BlockadeNorth, + BlockadeSouth, + BlockadeEast, + BlockadeWest, + TravelStart, + TravelEnd, + Room, + Load, + Goal, + Town, + Win, + Lose, + Trade, + Use, + Find, + FindTheForce, +} + +/// +/// An object placed within a zone. +/// +public class ZoneObject +{ + public ZoneObjectType Type { get; set; } + public int X { get; set; } + public int Y { get; set; } + public ushort Argument { get; set; } // Context-dependent (item ID, destination zone, etc.) +} + +public enum ZoneObjectType : ushort +{ + Trigger = 0x00, + SpawnLocation = 0x01, + ForceLocation = 0x02, + VehicleToSecondary = 0x03, + VehicleToPrimary = 0x04, + LocatorItem = 0x05, + CrateItem = 0x06, + PuzzleNPC = 0x07, + CrateWeapon = 0x08, + DoorEntrance = 0x09, + DoorExit = 0x0A, + Unused0B = 0x0B, + Lock = 0x0C, + Teleporter = 0x0D, + XWingFromDagobah = 0x0E, + XWingToDagobah = 0x0F, +} + +/// +/// IZAX auxiliary data structure. +/// +public class ZoneAuxData +{ + public byte[] RawData { get; set; } = Array.Empty(); +} + +/// +/// IZX2 auxiliary data structure. +/// +public class ZoneAux2Data +{ + public byte[] RawData { get; set; } = Array.Empty(); +} + +/// +/// IZX3 auxiliary data structure. +/// +public class ZoneAux3Data +{ + public byte[] RawData { get; set; } = Array.Empty(); +} + +/// +/// IZX4 auxiliary data structure. +/// +public class ZoneAux4Data +{ + public byte[] RawData { get; set; } = Array.Empty(); +} diff --git a/src/YodaStoriesNG.Engine/Game/ActionExecutor.cs b/src/YodaStoriesNG.Engine/Game/ActionExecutor.cs new file mode 100644 index 0000000..c602f74 --- /dev/null +++ b/src/YodaStoriesNG.Engine/Game/ActionExecutor.cs @@ -0,0 +1,292 @@ +using YodaStoriesNG.Engine.Data; + +namespace YodaStoriesNG.Engine.Game; + +/// +/// Executes zone action scripts (IACT). +/// +public class ActionExecutor +{ + private readonly GameData _gameData; + private readonly GameState _state; + private readonly Random _random = new(); + + // Action execution context + private int _lastRandomValue; + private Zone? _currentZone; + + public ActionExecutor(GameData gameData, GameState state) + { + _gameData = gameData; + _state = state; + } + + /// + /// Executes all applicable actions in the current zone. + /// + public void ExecuteZoneActions(ActionTrigger trigger) + { + _currentZone = _state.CurrentZone; + if (_currentZone == null) + return; + + foreach (var action in _currentZone.Actions) + { + if (EvaluateConditions(action.Conditions, trigger)) + { + ExecuteInstructions(action.Instructions); + } + } + } + + private bool EvaluateConditions(List conditions, ActionTrigger trigger) + { + foreach (var condition in conditions) + { + if (!EvaluateCondition(condition, trigger)) + return false; + } + return true; + } + + private bool EvaluateCondition(Condition condition, ActionTrigger trigger) + { + var args = condition.Arguments; + + switch (condition.Opcode) + { + case ConditionOpcode.ZoneNotInitialized: + // True on first entry to zone + return trigger == ActionTrigger.ZoneEnter && !_state.Variables.ContainsKey(_state.CurrentZoneId + 1000); + + case ConditionOpcode.ZoneEntered: + return trigger == ActionTrigger.ZoneEnter; + + case ConditionOpcode.Bump: + if (trigger != ActionTrigger.Bump || args.Count < 2) + return false; + return _state.PlayerX == args[0] && _state.PlayerY == args[1]; + + case ConditionOpcode.Standing: + if (args.Count < 2) + return false; + return _state.PlayerX == args[0] && _state.PlayerY == args[1]; + + case ConditionOpcode.CounterIs: + if (args.Count < 2) + return false; + return _state.GetCounter(args[0]) == args[1]; + + case ConditionOpcode.CounterIsNot: + if (args.Count < 2) + return false; + return _state.GetCounter(args[0]) != args[1]; + + case ConditionOpcode.CounterIsGreaterThan: + if (args.Count < 2) + return false; + return _state.GetCounter(args[0]) > args[1]; + + case ConditionOpcode.CounterIsLessThan: + if (args.Count < 2) + return false; + return _state.GetCounter(args[0]) < args[1]; + + case ConditionOpcode.RandomIs: + if (args.Count < 1) + return false; + return _lastRandomValue == args[0]; + + case ConditionOpcode.RandomIsNot: + if (args.Count < 1) + return false; + return _lastRandomValue != args[0]; + + case ConditionOpcode.RandomIsGreaterThan: + if (args.Count < 1) + return false; + return _lastRandomValue > args[0]; + + case ConditionOpcode.RandomIsLessThan: + if (args.Count < 1) + return false; + return _lastRandomValue < args[0]; + + case ConditionOpcode.HasItem: + if (args.Count < 1) + return false; + return _state.HasItem(args[0]); + + case ConditionOpcode.TileAtIs: + if (args.Count < 4 || _currentZone == null) + return false; + return _currentZone.GetTile(args[0], args[1], args[2]) == args[3]; + + case ConditionOpcode.ZoneIsSolved: + if (args.Count < 1) + return false; + return _state.IsZoneSolved(args[0]); + + case ConditionOpcode.HealthIsLessThan: + if (args.Count < 1) + return false; + return _state.Health < args[0]; + + case ConditionOpcode.HealthIsGreaterThan: + if (args.Count < 1) + return false; + return _state.Health > args[0]; + + case ConditionOpcode.GamesWonIs: + if (args.Count < 1) + return false; + return _state.GamesWon == args[0]; + + default: + // Unknown condition - assume true to allow script to continue + return true; + } + } + + private void ExecuteInstructions(List instructions) + { + foreach (var instruction in instructions) + { + ExecuteInstruction(instruction); + } + } + + private void ExecuteInstruction(Instruction instruction) + { + var args = instruction.Arguments; + + switch (instruction.Opcode) + { + case InstructionOpcode.PlaceTile: + if (args.Count >= 4 && _currentZone != null) + _currentZone.SetTile(args[0], args[1], args[2], (ushort)args[3]); + break; + + case InstructionOpcode.RemoveTile: + if (args.Count >= 3 && _currentZone != null) + _currentZone.SetTile(args[0], args[1], args[2], 0xFFFF); + break; + + case InstructionOpcode.RollDice: + if (args.Count >= 1) + _lastRandomValue = _random.Next(args[0]); + break; + + case InstructionOpcode.SetCounter: + if (args.Count >= 2) + _state.SetCounter(args[0], args[1]); + break; + + case InstructionOpcode.AddToCounter: + if (args.Count >= 2) + _state.AddToCounter(args[0], args[1]); + break; + + case InstructionOpcode.SetVariable: + if (args.Count >= 2) + _state.SetVariable(args[0], args[1]); + break; + + case InstructionOpcode.AddItem: + if (args.Count >= 1) + _state.AddItem(args[0]); + break; + + case InstructionOpcode.RemoveItem: + if (args.Count >= 1) + _state.RemoveItem(args[0]); + break; + + case InstructionOpcode.MarkAsSolved: + _state.MarkZoneSolved(_state.CurrentZoneId); + break; + + case InstructionOpcode.WinGame: + _state.IsGameWon = true; + _state.GamesWon++; + break; + + case InstructionOpcode.LoseGame: + _state.IsGameOver = true; + break; + + case InstructionOpcode.ChangeZone: + if (args.Count >= 3) + { + _state.CurrentZoneId = args[0]; + _state.PlayerX = args[1]; + _state.PlayerY = args[2]; + } + break; + + case InstructionOpcode.MoveHeroTo: + if (args.Count >= 2) + { + _state.PlayerX = args[0]; + _state.PlayerY = args[1]; + } + break; + + case InstructionOpcode.MoveHeroBy: + if (args.Count >= 2) + { + _state.PlayerX += args[0]; + _state.PlayerY += args[1]; + } + break; + + case InstructionOpcode.AddHealth: + if (args.Count >= 1) + _state.Health = Math.Min(_state.Health + args[0], _state.MaxHealth); + break; + + case InstructionOpcode.SubtractHealth: + if (args.Count >= 1) + _state.Health = Math.Max(_state.Health - args[0], 0); + break; + + case InstructionOpcode.SetHealth: + if (args.Count >= 1) + _state.Health = Math.Clamp(args[0], 0, _state.MaxHealth); + break; + + case InstructionOpcode.SpeakHero: + case InstructionOpcode.SpeakNpc: + case InstructionOpcode.SpeakNpc2: + // TODO: Display dialog text + if (!string.IsNullOrEmpty(instruction.Text)) + Console.WriteLine($"Dialog: {instruction.Text}"); + break; + + case InstructionOpcode.PlaySound: + // TODO: Play sound effect + if (args.Count >= 1) + Console.WriteLine($"Play sound: {args[0]}"); + break; + + case InstructionOpcode.Wait: + // TODO: Implement wait/delay + break; + + default: + // Unknown instruction - log and continue + Console.WriteLine($"Unknown instruction: {instruction.Opcode}"); + break; + } + } +} + +public enum ActionTrigger +{ + ZoneEnter, + ZoneLeave, + Bump, + Walk, + UseItem, + Attack, +} diff --git a/src/YodaStoriesNG.Engine/Game/GameEngine.cs b/src/YodaStoriesNG.Engine/Game/GameEngine.cs new file mode 100644 index 0000000..663d07f --- /dev/null +++ b/src/YodaStoriesNG.Engine/Game/GameEngine.cs @@ -0,0 +1,491 @@ +using Hexa.NET.SDL2; +using YodaStoriesNG.Engine.Data; +using YodaStoriesNG.Engine.Parsing; +using YodaStoriesNG.Engine.Rendering; + +namespace YodaStoriesNG.Engine.Game; + +/// +/// Main game engine that coordinates all systems. +/// +public class GameEngine : IDisposable +{ + private GameData? _gameData; + private GameState _state; + private GameRenderer? _renderer; + private ActionExecutor? _actionExecutor; + + private bool _isRunning; + private readonly string _dataPath; + + // Timing + private const double TargetFrameTime = 1.0 / 60.0; // 60 FPS + private const double AnimationFrameTime = 0.15; // 150ms per animation frame + + public GameEngine(string dataPath) + { + _dataPath = dataPath; + _state = new GameState(); + } + + /// + /// Loads game data and initializes the engine. + /// + public bool Initialize() + { + Console.WriteLine("Loading game data..."); + + // Parse the DTA file + var dtaPath = Path.Combine(_dataPath, "yodesk.dta"); + if (!File.Exists(dtaPath)) + { + Console.WriteLine($"Error: Could not find {dtaPath}"); + return false; + } + + var parser = new DtaParser(); + _gameData = parser.Parse(dtaPath); + + Console.WriteLine($"Game version: {_gameData.Version}"); + Console.WriteLine($"Loaded: {_gameData.Tiles.Count} tiles, {_gameData.Zones.Count} zones, {_gameData.Characters.Count} characters"); + + // Initialize renderer + _renderer = new GameRenderer(_gameData); + if (!_renderer.Initialize("Yoda Stories NG")) + { + Console.WriteLine("Failed to initialize renderer"); + return false; + } + + // Initialize action executor + _actionExecutor = new ActionExecutor(_gameData, _state); + + // Start new game + StartNewGame(); + + return true; + } + + /// + /// Starts a new game. + /// + public void StartNewGame() + { + _state.Reset(); + + // Find the starting zone (typically zone 0 or first non-empty zone) + for (int i = 0; i < _gameData!.Zones.Count; i++) + { + var zone = _gameData.Zones[i]; + if (zone.Width > 0 && zone.Height > 0) + { + LoadZone(i); + break; + } + } + } + + /// + /// Loads a zone by ID. + /// + public void LoadZone(int zoneId) + { + if (zoneId < 0 || zoneId >= _gameData!.Zones.Count) + { + Console.WriteLine($"Invalid zone ID: {zoneId}"); + return; + } + + _state.CurrentZoneId = zoneId; + _state.CurrentZone = _gameData.Zones[zoneId]; + + Console.WriteLine($"Loaded zone {zoneId}: {_state.CurrentZone.Width}x{_state.CurrentZone.Height}, planet: {_state.CurrentZone.Planet}"); + + // Debug: Print first few tile IDs + Console.WriteLine("Sample tile IDs from zone grid:"); + for (int y = 0; y < Math.Min(3, _state.CurrentZone.Height); y++) + { + for (int x = 0; x < Math.Min(3, _state.CurrentZone.Width); x++) + { + var bg = _state.CurrentZone.GetTile(x, y, 0); + var mid = _state.CurrentZone.GetTile(x, y, 1); + var fg = _state.CurrentZone.GetTile(x, y, 2); + Console.WriteLine($" [{x},{y}]: bg={bg}, mid={mid}, fg={fg}"); + } + } + + // Reset camera for zone + UpdateCamera(); + + // Execute zone entry actions + _actionExecutor?.ExecuteZoneActions(ActionTrigger.ZoneEnter); + + // Mark zone as initialized + _state.SetVariable(_state.CurrentZoneId + 1000, 1); + } + + /// + /// Main game loop. + /// + public void Run() + { + _isRunning = true; + var lastTime = DateTime.UtcNow; + + while (_isRunning) + { + var currentTime = DateTime.UtcNow; + var deltaTime = (currentTime - lastTime).TotalSeconds; + lastTime = currentTime; + + // Process input + ProcessInput(); + + // Update game state + Update(deltaTime); + + // Render + Render(); + + // Frame limiting + var frameTime = (DateTime.UtcNow - currentTime).TotalSeconds; + if (frameTime < TargetFrameTime) + { + var sleepMs = (int)((TargetFrameTime - frameTime) * 1000); + if (sleepMs > 0) + Thread.Sleep(sleepMs); + } + } + } + + private void ProcessInput() + { + while (_renderer!.PollEvent(out var evt)) + { + switch ((SDLEventType)evt.Type) + { + case SDLEventType.Quit: + _isRunning = false; + break; + + case SDLEventType.Keydown: + HandleKeyDown(evt.Key.Keysym.Sym); + break; + } + } + } + + private void HandleKeyDown(int keyCode) + { + // SDL key codes + const int SDLK_ESCAPE = 27; + const int SDLK_SPACE = 32; + const int SDLK_UP = 1073741906; + const int SDLK_DOWN = 1073741905; + const int SDLK_LEFT = 1073741904; + const int SDLK_RIGHT = 1073741903; + const int SDLK_1 = 49; + const int SDLK_8 = 56; + const int SDLK_a = 97; + const int SDLK_d = 100; + const int SDLK_n = 110; + const int SDLK_p = 112; + const int SDLK_r = 114; + const int SDLK_s = 115; + const int SDLK_w = 119; + + switch (keyCode) + { + case SDLK_ESCAPE: + _isRunning = false; + break; + + case SDLK_UP: + case SDLK_w: + TryMovePlayer(0, -1, Direction.Up); + break; + + case SDLK_DOWN: + case SDLK_s: + TryMovePlayer(0, 1, Direction.Down); + break; + + case SDLK_LEFT: + case SDLK_a: + TryMovePlayer(-1, 0, Direction.Left); + break; + + case SDLK_RIGHT: + case SDLK_d: + TryMovePlayer(1, 0, Direction.Right); + break; + + case SDLK_SPACE: + // Use item or attack + UseItem(); + break; + + case >= SDLK_1 and <= SDLK_8: + // Select inventory item (keys 1-8) + var slot = keyCode - SDLK_1; + if (slot < _state.Inventory.Count) + _state.SelectedItem = _state.Inventory[slot]; + break; + + case SDLK_r: + // Restart/new game + StartNewGame(); + break; + + case SDLK_n: + // Next zone (debug) + LoadZone((_state.CurrentZoneId + 1) % _gameData!.Zones.Count); + break; + + case SDLK_p: + // Previous zone (debug) + LoadZone((_state.CurrentZoneId - 1 + _gameData!.Zones.Count) % _gameData.Zones.Count); + break; + } + } + + private void TryMovePlayer(int dx, int dy, Direction direction) + { + _state.PlayerDirection = direction; + + var newX = _state.PlayerX + dx; + var newY = _state.PlayerY + dy; + + // Check bounds + if (newX < 0 || newX >= _state.CurrentZone!.Width || + newY < 0 || newY >= _state.CurrentZone.Height) + { + // Try to transition to adjacent zone + HandleZoneTransition(dx, dy); + return; + } + + // Check collision with middle layer tile + var middleTile = _state.CurrentZone.GetTile(newX, newY, 1); + if (middleTile != 0xFFFF && middleTile < _gameData!.Tiles.Count) + { + var tile = _gameData.Tiles[middleTile]; + if (tile.IsObject && !tile.IsDraggable) + { + // Collision - trigger bump action + _actionExecutor?.ExecuteZoneActions(ActionTrigger.Bump); + return; + } + } + + // Move player + _state.PlayerX = newX; + _state.PlayerY = newY; + + // Update camera + UpdateCamera(); + + // Execute walk actions + _actionExecutor?.ExecuteZoneActions(ActionTrigger.Walk); + + // Check for zone objects at new position + CheckZoneObjects(); + } + + private void HandleZoneTransition(int dx, int dy) + { + // Check for door objects at player position + foreach (var obj in _state.CurrentZone!.Objects) + { + if ((obj.Type == ZoneObjectType.DoorEntrance || obj.Type == ZoneObjectType.DoorExit) && + obj.X == _state.PlayerX && obj.Y == _state.PlayerY) + { + if (obj.Argument > 0 && obj.Argument < _gameData!.Zones.Count) + { + LoadZone(obj.Argument); + return; + } + } + } + + // TODO: Handle world map transitions + } + + private void CheckZoneObjects() + { + foreach (var obj in _state.CurrentZone!.Objects) + { + if (obj.X != _state.PlayerX || obj.Y != _state.PlayerY) + continue; + + switch (obj.Type) + { + case ZoneObjectType.CrateItem: + // Pick up item + if (obj.Argument > 0) + { + _state.AddItem(obj.Argument); + Console.WriteLine($"Picked up item: {obj.Argument}"); + } + break; + + case ZoneObjectType.CrateWeapon: + // Pick up weapon + if (obj.Argument > 0) + { + _state.SelectedWeapon = obj.Argument; + Console.WriteLine($"Picked up weapon: {obj.Argument}"); + } + break; + + case ZoneObjectType.DoorEntrance: + case ZoneObjectType.DoorExit: + if (obj.Argument > 0 && obj.Argument < _gameData!.Zones.Count) + { + LoadZone(obj.Argument); + } + break; + + case ZoneObjectType.Teleporter: + Console.WriteLine("Teleporter activated!"); + break; + + case ZoneObjectType.Trigger: + _actionExecutor?.ExecuteZoneActions(ActionTrigger.Walk); + break; + } + } + } + + private void UseItem() + { + if (_state.SelectedItem.HasValue) + { + _actionExecutor?.ExecuteZoneActions(ActionTrigger.UseItem); + } + else if (_state.SelectedWeapon.HasValue) + { + _actionExecutor?.ExecuteZoneActions(ActionTrigger.Attack); + } + } + + private void UpdateCamera() + { + if (_state.CurrentZone == null) + return; + + // Center camera on player for large zones + var viewportTiles = GameRenderer.ViewportTilesX; + var halfViewport = viewportTiles / 2; + + if (_state.CurrentZone.Width > viewportTiles) + { + _state.CameraX = Math.Clamp( + _state.PlayerX - halfViewport, + 0, + _state.CurrentZone.Width - viewportTiles); + } + else + { + _state.CameraX = 0; + } + + if (_state.CurrentZone.Height > viewportTiles) + { + _state.CameraY = Math.Clamp( + _state.PlayerY - halfViewport, + 0, + _state.CurrentZone.Height - viewportTiles); + } + else + { + _state.CameraY = 0; + } + } + + private void Update(double deltaTime) + { + if (_state.IsPaused) + return; + + // Update animation + _state.AnimationTimer += deltaTime; + if (_state.AnimationTimer >= AnimationFrameTime) + { + _state.AnimationTimer -= AnimationFrameTime; + _state.AnimationFrame = (_state.AnimationFrame + 1) % 3; + } + + // Check for game over conditions + if (_state.Health <= 0 && !_state.IsGameOver) + { + _state.IsGameOver = true; + Console.WriteLine("Game Over!"); + } + } + + private void Render() + { + if (_renderer == null || _state.CurrentZone == null) + return; + + // Render zone + _renderer.RenderZone(_state.CurrentZone, _state.CameraX, _state.CameraY); + + // Render player character + RenderPlayer(); + + // Render HUD + _renderer.RenderHUD(_state.Health, _state.MaxHealth, _state.Inventory, _state.SelectedWeapon); + + // Present frame + _renderer.Present(); + } + + private void RenderPlayer() + { + // Find hero character for rendering + Character? heroChar = null; + foreach (var character in _gameData!.Characters) + { + if (character.Type == CharacterType.Hero) + { + heroChar = character; + break; + } + } + + if (heroChar != null) + { + // Get animation frame based on direction + var frames = _state.PlayerDirection switch + { + Direction.Up => heroChar.Frames.WalkUp, + Direction.Down => heroChar.Frames.WalkDown, + Direction.Left => heroChar.Frames.WalkLeft, + Direction.Right => heroChar.Frames.WalkRight, + _ => heroChar.Frames.WalkDown + }; + + if (frames.Length > 0) + { + var frameIndex = Math.Min(_state.AnimationFrame, frames.Length - 1); + var tileId = frames[frameIndex]; + _renderer!.RenderSprite(tileId, _state.PlayerX, _state.PlayerY, _state.CameraX, _state.CameraY); + return; + } + } + + // Fallback: render a placeholder + // Use first character tile if available + if (_gameData.Tiles.Count > 800) + { + _renderer!.RenderSprite(800, _state.PlayerX, _state.PlayerY, _state.CameraX, _state.CameraY); + } + } + + public void Dispose() + { + _renderer?.Dispose(); + } +} diff --git a/src/YodaStoriesNG.Engine/Game/GameState.cs b/src/YodaStoriesNG.Engine/Game/GameState.cs new file mode 100644 index 0000000..532046f --- /dev/null +++ b/src/YodaStoriesNG.Engine/Game/GameState.cs @@ -0,0 +1,146 @@ +using YodaStoriesNG.Engine.Data; + +namespace YodaStoriesNG.Engine.Game; + +/// +/// Represents the current state of the game. +/// +public class GameState +{ + // Player state + public int PlayerX { get; set; } + public int PlayerY { get; set; } + public Direction PlayerDirection { get; set; } = Direction.Down; + public int Health { get; set; } = 100; + public int MaxHealth { get; set; } = 100; + + // Current zone + public int CurrentZoneId { get; set; } + public Zone? CurrentZone { get; set; } + + // Inventory + public List Inventory { get; set; } = new(); + public int? SelectedWeapon { get; set; } + public int? SelectedItem { get; set; } + + // Game variables (used by scripts) + public Dictionary Variables { get; set; } = new(); + public Dictionary Counters { get; set; } = new(); + + // Quest state + public HashSet SolvedZones { get; set; } = new(); + public int GamesWon { get; set; } + + // Game flags + public bool IsGameOver { get; set; } + public bool IsGameWon { get; set; } + public bool IsPaused { get; set; } + + // Animation state + public int AnimationFrame { get; set; } + public double AnimationTimer { get; set; } + + // Camera position (for large zones) + public int CameraX { get; set; } + public int CameraY { get; set; } + + /// + /// Resets the game state for a new game. + /// + public void Reset() + { + PlayerX = 4; + PlayerY = 4; + PlayerDirection = Direction.Down; + Health = MaxHealth; + CurrentZoneId = 0; + CurrentZone = null; + Inventory.Clear(); + SelectedWeapon = null; + SelectedItem = null; + Variables.Clear(); + Counters.Clear(); + SolvedZones.Clear(); + IsGameOver = false; + IsGameWon = false; + IsPaused = false; + AnimationFrame = 0; + AnimationTimer = 0; + CameraX = 0; + CameraY = 0; + } + + /// + /// Gets a game variable, returning 0 if not set. + /// + public int GetVariable(int id) => + Variables.TryGetValue(id, out var value) ? value : 0; + + /// + /// Sets a game variable. + /// + public void SetVariable(int id, int value) => + Variables[id] = value; + + /// + /// Gets a counter, returning 0 if not set. + /// + public int GetCounter(int id) => + Counters.TryGetValue(id, out var value) ? value : 0; + + /// + /// Sets a counter. + /// + public void SetCounter(int id, int value) => + Counters[id] = value; + + /// + /// Adds to a counter. + /// + public void AddToCounter(int id, int amount) + { + var current = GetCounter(id); + Counters[id] = current + amount; + } + + /// + /// Checks if the player has an item. + /// + public bool HasItem(int itemId) => + Inventory.Contains(itemId); + + /// + /// Adds an item to inventory. + /// + public void AddItem(int itemId) + { + if (!Inventory.Contains(itemId)) + Inventory.Add(itemId); + } + + /// + /// Removes an item from inventory. + /// + public void RemoveItem(int itemId) => + Inventory.Remove(itemId); + + /// + /// Marks a zone as solved. + /// + public void MarkZoneSolved(int zoneId) => + SolvedZones.Add(zoneId); + + /// + /// Checks if a zone is solved. + /// + public bool IsZoneSolved(int zoneId) => + SolvedZones.Contains(zoneId); +} + +public enum Direction +{ + Up, + Down, + Left, + Right +} diff --git a/src/YodaStoriesNG.Engine/Parsing/DtaParser.cs b/src/YodaStoriesNG.Engine/Parsing/DtaParser.cs new file mode 100644 index 0000000..0b5793a --- /dev/null +++ b/src/YodaStoriesNG.Engine/Parsing/DtaParser.cs @@ -0,0 +1,731 @@ +using YodaStoriesNG.Engine.Data; + +namespace YodaStoriesNG.Engine.Parsing; + +/// +/// Parser for YODESK.DTA game data files. +/// +public class DtaParser +{ + private BinaryReader _reader = null!; + private GameData _data = null!; + + /// + /// Parses a DTA file and returns the game data. + /// + public GameData Parse(string filePath) + { + using var stream = File.OpenRead(filePath); + return Parse(stream); + } + + /// + /// Parses a DTA file from a stream and returns the game data. + /// + public GameData Parse(Stream stream) + { + _reader = new BinaryReader(stream); + _data = new GameData(); + + while (_reader.BaseStream.Position < _reader.BaseStream.Length) + { + if (_reader.BaseStream.Position + 4 > _reader.BaseStream.Length) + break; + + var tagBytes = _reader.ReadBytes(4); + var tag = System.Text.Encoding.ASCII.GetString(tagBytes); + + // VERS section has no length field - just 4 bytes of version data + if (tag == "VERS") + { + ParseVersionSection(); + continue; + } + + // ENDF section has no length or data + if (tag == "ENDF") + { + Console.WriteLine("Reached end of file marker"); + break; + } + + // All other sections have a 4-byte length prefix + if (_reader.BaseStream.Position + 4 > _reader.BaseStream.Length) + break; + + var length = _reader.ReadUInt32(); + Console.WriteLine($"Section '{tag}' at position {_reader.BaseStream.Position - 8}, length: {length}"); + ParseSection(tag, length); + } + + return _data; + } + + private void ParseSection(string tag, uint length) + { + var startPos = _reader.BaseStream.Position; + + switch (tag) + { + case "VERS": + // VERS is handled separately in the main loop + break; + case "STUP": + ParseStartupSection(length); + break; + case "SNDS": + ParseSoundsSection(length); + break; + case "TILE": + ParseTilesSection(length); + break; + case "ZONE": + ParseZonesSection(); + break; + case "PUZ2": + ParsePuzzlesSection(length); + break; + case "CHAR": + ParseCharactersSection(); + break; + case "CHWP": + ParseCharacterWeaponsSection(); + break; + case "CAUX": + ParseCharacterAuxSection(); + break; + case "TNAM": + ParseTileNamesSection(); + break; + case "ENDF": + // End of file marker + break; + default: + // Skip unknown sections + Console.WriteLine($"Unknown section: {tag}, length: {length}"); + _reader.BaseStream.Seek(startPos + length, SeekOrigin.Begin); + break; + } + } + + private void ParseVersionSection() + { + // Version is stored as two 16-bit big-endian values + // BinaryReader reads little-endian, so we need to swap bytes + var majorBytes = _reader.ReadBytes(2); + var minorBytes = _reader.ReadBytes(2); + var major = (majorBytes[0] << 8) | majorBytes[1]; + var minor = (minorBytes[0] << 8) | minorBytes[1]; + _data.Version = new Version(major, minor); + } + + private void ParseStartupSection(uint length) + { + Console.WriteLine($"Reading {length} bytes for STUP section"); + _data.StartupScreen = _reader.ReadBytes((int)length); + Console.WriteLine($"After STUP, position: {_reader.BaseStream.Position}"); + } + + private void ParseSoundsSection(uint length) + { + var endPos = _reader.BaseStream.Position + length; + + // Skip the first 2 bytes (negative offset or header marker) + var header = _reader.ReadInt16(); + Console.WriteLine($"SNDS header: {header} (0x{header:X4})"); + + int soundId = 0; + while (_reader.BaseStream.Position < endPos - 1) + { + // Read filename length + var nameLength = _reader.ReadUInt16(); + + // 0xFFFF marks end of sounds + if (nameLength == 0xFFFF || nameLength == 0) + break; + + // Sanity check + if (nameLength > 256) + { + Console.WriteLine($"Invalid sound name length: {nameLength}, breaking"); + break; + } + + // Read filename (null-terminated) + var nameBytes = _reader.ReadBytes(nameLength); + var name = System.Text.Encoding.ASCII.GetString(nameBytes).TrimEnd('\0'); + + _data.Sounds.Add(new Sound + { + Id = soundId++, + FileName = name + }); + } + + // Ensure we're at the end of the section + _reader.BaseStream.Seek(endPos, SeekOrigin.Begin); + Console.WriteLine($"Loaded {_data.Sounds.Count} sounds"); + } + + private void ParseTilesSection(uint length) + { + var endPos = _reader.BaseStream.Position + length; + int tileId = 0; + + while (_reader.BaseStream.Position + 4 + Tile.PixelCount <= endPos) + { + var flags = (TileFlags)_reader.ReadUInt32(); + var pixels = _reader.ReadBytes(Tile.PixelCount); + + _data.Tiles.Add(new Tile + { + Id = tileId++, + Flags = flags, + PixelData = pixels + }); + } + + Console.WriteLine($"Loaded {_data.Tiles.Count} tiles"); + } + + private void ParseZonesSection() + { + // Zone count is 2 bytes + var zoneCount = _reader.ReadUInt16(); + Console.WriteLine($"Zone count header: {zoneCount}"); + + // The format appears to have 2 bytes padding after zone count, then zones start with IZON + // Skip 2 bytes padding + _reader.ReadUInt16(); + + int zonesLoaded = 0; + int zoneId = 0; + + // Parse zones by scanning for IZON markers + while (_reader.BaseStream.Position + 4 < _reader.BaseStream.Length) + { + var markerPos = _reader.BaseStream.Position; + + // Check for IZON marker + var marker = System.Text.Encoding.ASCII.GetString(_reader.ReadBytes(4)); + + if (marker == "IZON") + { + try + { + var zone = ParseIZONZone(zoneId, markerPos); + _data.Zones.Add(zone); + if (zone.Width > 0) + zonesLoaded++; + zoneId++; + } + catch (Exception ex) + { + Console.WriteLine($"Error parsing zone {zoneId}: {ex.Message}"); + _data.Zones.Add(new Zone { Id = zoneId, Width = 0, Height = 0 }); + zoneId++; + } + } + else if (marker == "PUZ2" || marker == "CHAR" || marker == "CHWP" || + marker == "CAUX" || marker == "TNAM" || marker == "ENDF") + { + // Hit another section - we're done with zones + _reader.BaseStream.Seek(markerPos, SeekOrigin.Begin); + break; + } + else + { + // Unknown data, try to find next IZON or section marker + _reader.BaseStream.Seek(markerPos + 1, SeekOrigin.Begin); + } + } + + Console.WriteLine($"Loaded {zonesLoaded} valid zones"); + } + + private Zone ParseIZONZone(int zoneId, long izonPos) + { + var zone = new Zone { Id = zoneId }; + + // IZON format (marker already read): + // 4 bytes: size info + // 2 bytes: width + // 2 bytes: height + // 1 byte: type/flags + // 5 bytes: padding + // 1 byte: planet + // 1 byte: unused + // Then tile data... + + var sizeInfo = _reader.ReadUInt32(); + zone.Width = _reader.ReadUInt16(); + zone.Height = _reader.ReadUInt16(); + zone.Flags = (ZoneFlags)_reader.ReadByte(); + _reader.ReadBytes(5); // padding + zone.Planet = (Planet)_reader.ReadByte(); + _reader.ReadByte(); // unused + + // Sanity check dimensions + if (zone.Width == 0 || zone.Height == 0 || zone.Width > 18 || zone.Height > 18) + { + return zone; + } + + // Read tile grid (3 layers per cell, 2 bytes per tile ID) + zone.TileGrid = new ushort[zone.Height, zone.Width, 3]; + for (int y = 0; y < zone.Height; y++) + { + for (int x = 0; x < zone.Width; x++) + { + zone.TileGrid[y, x, 0] = _reader.ReadUInt16(); + zone.TileGrid[y, x, 1] = _reader.ReadUInt16(); + zone.TileGrid[y, x, 2] = _reader.ReadUInt16(); + } + } + + // Read object count and objects + var objectCount = _reader.ReadUInt16(); + for (int j = 0; j < objectCount; j++) + { + var obj = new ZoneObject + { + Type = (ZoneObjectType)_reader.ReadUInt16(), + X = _reader.ReadUInt16(), + Y = _reader.ReadUInt16(), + Argument = _reader.ReadUInt16() + }; + _reader.ReadUInt32(); // padding/extra data + zone.Objects.Add(obj); + } + + // Parse auxiliary sections + while (_reader.BaseStream.Position + 4 < _reader.BaseStream.Length) + { + var auxPos = _reader.BaseStream.Position; + var auxTag = System.Text.Encoding.ASCII.GetString(_reader.ReadBytes(4)); + + switch (auxTag) + { + case "IZAX": + var izaxLen = _reader.ReadUInt16(); + zone.AuxData = new ZoneAuxData { RawData = _reader.ReadBytes(Math.Max(0, izaxLen - 6)) }; + break; + case "IZX2": + var izx2Len = _reader.ReadUInt16(); + zone.Aux2Data = new ZoneAux2Data { RawData = _reader.ReadBytes(Math.Max(0, izx2Len - 6)) }; + break; + case "IZX3": + var izx3Len = _reader.ReadUInt16(); + zone.Aux3Data = new ZoneAux3Data { RawData = _reader.ReadBytes(Math.Max(0, izx3Len - 6)) }; + break; + case "IZX4": + zone.Aux4Data = new ZoneAux4Data { RawData = _reader.ReadBytes(8) }; + break; + case "IACT": + var actLen = _reader.ReadUInt32(); + _reader.BaseStream.Seek(_reader.BaseStream.Position + actLen, SeekOrigin.Begin); + zone.Actions.Add(new Data.Action()); + break; + default: + // Not a zone subsection - go back and return + _reader.BaseStream.Seek(auxPos, SeekOrigin.Begin); + return zone; + } + } + + return zone; + } + + private Zone ParseZoneData(int zoneId, byte[] zoneData) + { + var zone = new Zone { Id = zoneId }; + + if (zoneData.Length < 22) + { + Console.WriteLine($"Zone {zoneId}: data too short ({zoneData.Length} bytes)"); + return zone; + } + + using var ms = new MemoryStream(zoneData); + using var reader = new BinaryReader(ms); + + // Zone data format: + // Bytes 0-1: Zone ID (from file) + // Bytes 2-5: "IZON" marker + // Bytes 6-9: Size/unknown + // Bytes 10-11: Width + // Bytes 12-13: Height + // Byte 14: Zone type/flags + // Bytes 15-19: Padding + // Byte 20: Planet + // Byte 21: Unused + // Bytes 22+: Tile data + + var fileZoneId = reader.ReadUInt16(); + var izonTag = System.Text.Encoding.ASCII.GetString(reader.ReadBytes(4)); + + if (izonTag != "IZON") + { + Console.WriteLine($"Zone {zoneId}: Expected IZON, got '{izonTag}'"); + return zone; + } + + var sizeInfo = reader.ReadUInt32(); + zone.Width = reader.ReadUInt16(); + zone.Height = reader.ReadUInt16(); + zone.Flags = (ZoneFlags)reader.ReadByte(); + reader.ReadBytes(5); // padding + zone.Planet = (Planet)reader.ReadByte(); + reader.ReadByte(); // unused + + // Sanity check dimensions + if (zone.Width == 0 || zone.Height == 0 || zone.Width > 18 || zone.Height > 18) + { + return zone; + } + + // Read tile grid (3 layers per cell, 2 bytes per tile ID) + zone.TileGrid = new ushort[zone.Height, zone.Width, 3]; + for (int y = 0; y < zone.Height; y++) + { + for (int x = 0; x < zone.Width; x++) + { + zone.TileGrid[y, x, 0] = reader.ReadUInt16(); // Background + zone.TileGrid[y, x, 1] = reader.ReadUInt16(); // Middle + zone.TileGrid[y, x, 2] = reader.ReadUInt16(); // Foreground + } + } + + // Read object count and objects (hotspots) + if (ms.Position + 2 <= ms.Length) + { + var objectCount = reader.ReadUInt16(); + for (int j = 0; j < objectCount && ms.Position + 12 <= ms.Length; j++) + { + zone.Objects.Add(ParseZoneObjectFromReader(reader)); + } + } + + // Parse auxiliary sections by looking for known tags + while (ms.Position + 4 <= ms.Length) + { + var auxTag = System.Text.Encoding.ASCII.GetString(reader.ReadBytes(4)); + + switch (auxTag) + { + case "IZAX": + zone.AuxData = ParseIZAXFromReader(reader); + break; + case "IZX2": + zone.Aux2Data = ParseIZX2FromReader(reader); + break; + case "IZX3": + zone.Aux3Data = ParseIZX3FromReader(reader); + break; + case "IZX4": + zone.Aux4Data = ParseIZX4FromReader(reader); + break; + case "IACT": + zone.Actions.Add(ParseActionFromReader(reader, ms)); + break; + default: + // Unknown tag or end of zone - stop parsing + return zone; + } + } + + return zone; + } + + private ZoneObject ParseZoneObjectFromReader(BinaryReader reader) + { + var type = (ZoneObjectType)reader.ReadUInt16(); + reader.ReadUInt16(); // padding + var x = reader.ReadUInt16(); + var y = reader.ReadUInt16(); + reader.ReadUInt16(); // padding + var argument = reader.ReadUInt16(); + + return new ZoneObject + { + Type = type, + X = x, + Y = y, + Argument = argument + }; + } + + private ZoneAuxData ParseIZAXFromReader(BinaryReader reader) + { + var length = reader.ReadUInt16(); + var dataLength = Math.Max(0, length - 6); + var data = reader.ReadBytes(dataLength); + return new ZoneAuxData { RawData = data }; + } + + private ZoneAux2Data ParseIZX2FromReader(BinaryReader reader) + { + var length = reader.ReadUInt16(); + var dataLength = Math.Max(0, length - 6); + var data = reader.ReadBytes(dataLength); + return new ZoneAux2Data { RawData = data }; + } + + private ZoneAux3Data ParseIZX3FromReader(BinaryReader reader) + { + var length = reader.ReadUInt16(); + var dataLength = Math.Max(0, length - 6); + var data = reader.ReadBytes(dataLength); + return new ZoneAux3Data { RawData = data }; + } + + private ZoneAux4Data ParseIZX4FromReader(BinaryReader reader) + { + // IZX4 has fixed 8-byte data + var data = reader.ReadBytes(8); + return new ZoneAux4Data { RawData = data }; + } + + private Data.Action ParseActionFromReader(BinaryReader reader, MemoryStream ms) + { + var action = new Data.Action(); + var length = reader.ReadUInt32(); + var endPos = ms.Position + length; + + // Skip IACT data for now + if (endPos <= ms.Length) + ms.Seek(endPos, SeekOrigin.Begin); + else + ms.Seek(0, SeekOrigin.End); + + return action; + } + + + private Condition ParseCondition() + { + var condition = new Condition(); + condition.Opcode = (ConditionOpcode)_reader.ReadUInt16(); + var argCount = _reader.ReadUInt16(); + var textLength = _reader.ReadUInt16(); + + for (int i = 0; i < argCount; i++) + { + condition.Arguments.Add(_reader.ReadInt16()); + } + + if (textLength > 0) + { + var textBytes = _reader.ReadBytes(textLength); + condition.Text = System.Text.Encoding.ASCII.GetString(textBytes).TrimEnd('\0'); + } + + return condition; + } + + private Instruction ParseInstruction() + { + var instruction = new Instruction(); + instruction.Opcode = (InstructionOpcode)_reader.ReadUInt16(); + var argCount = _reader.ReadUInt16(); + var textLength = _reader.ReadUInt16(); + + for (int i = 0; i < argCount; i++) + { + instruction.Arguments.Add(_reader.ReadInt16()); + } + + if (textLength > 0) + { + var textBytes = _reader.ReadBytes(textLength); + instruction.Text = System.Text.Encoding.ASCII.GetString(textBytes).TrimEnd('\0'); + } + + return instruction; + } + + private void ParsePuzzlesSection(uint length) + { + var endPos = _reader.BaseStream.Position + length; + int puzzleId = 0; + + while (_reader.BaseStream.Position < endPos - 4) + { + var puzzle = new Puzzle { Id = puzzleId++ }; + + // Read puzzle header (variable format, read cautiously) + var marker = _reader.ReadUInt32(); + if (marker == 0xFFFFFFFF || _reader.BaseStream.Position >= endPos) + break; + + _reader.BaseStream.Seek(-4, SeekOrigin.Current); + + // Read unknown header bytes + var unknown1 = _reader.ReadUInt16(); + puzzle.Item1 = _reader.ReadUInt16(); + puzzle.Item2 = _reader.ReadUInt16(); + var unknown2 = _reader.ReadUInt16(); + var unknown3 = _reader.ReadUInt16(); + + // Read puzzle strings (5 strings typically) + for (int i = 0; i < 5; i++) + { + var strLen = _reader.ReadUInt16(); + if (strLen > 0 && strLen < 1000) // Sanity check + { + var strBytes = _reader.ReadBytes(strLen); + puzzle.Strings.Add(System.Text.Encoding.ASCII.GetString(strBytes).TrimEnd('\0')); + } + else if (strLen >= 1000) + { + // Invalid length, likely parsing error + _reader.BaseStream.Seek(-2, SeekOrigin.Current); + break; + } + } + + _data.Puzzles.Add(puzzle); + } + + // Ensure we're at the end + _reader.BaseStream.Seek(endPos, SeekOrigin.Begin); + Console.WriteLine($"Loaded {_data.Puzzles.Count} puzzles"); + } + + private void ParseCharactersSection() + { + var charCount = _reader.ReadUInt16(); + + for (int i = 0; i < charCount; i++) + { + var character = ParseSingleCharacter(i); + _data.Characters.Add(character); + } + + Console.WriteLine($"Loaded {_data.Characters.Count} characters"); + } + + private Character ParseSingleCharacter(int charId) + { + var character = new Character { Id = charId }; + + // Read ICHA marker + var ichaTag = System.Text.Encoding.ASCII.GetString(_reader.ReadBytes(4)); + if (ichaTag != "ICHA") + { + Console.WriteLine($"Expected ICHA, got {ichaTag} at character {charId}"); + return character; + } + + var length = _reader.ReadUInt32(); + var endPos = _reader.BaseStream.Position + length; + + // Read character name (null-terminated with length prefix) + var nameLength = _reader.ReadUInt16(); + if (nameLength > 0) + { + var nameBytes = _reader.ReadBytes(nameLength); + character.Name = System.Text.Encoding.ASCII.GetString(nameBytes).TrimEnd('\0'); + } + + // Read character type + character.Type = (CharacterType)_reader.ReadUInt16(); + + // Read movement type + var movementType = _reader.ReadUInt16(); + + // Read frame count + var frameCount = _reader.ReadUInt16(); + + // Read animation frames + var frames = new CharacterFrames(); + if (frameCount >= 4) + { + // Read directional frames (up, down, left, right) + for (int dir = 0; dir < 4; dir++) + { + var dirFrames = new List(); + for (int f = 0; f < 3; f++) + { + dirFrames.Add(_reader.ReadUInt16()); + } + + switch (dir) + { + case 0: frames.WalkUp = dirFrames.ToArray(); break; + case 1: frames.WalkDown = dirFrames.ToArray(); break; + case 2: frames.WalkLeft = dirFrames.ToArray(); break; + case 3: frames.WalkRight = dirFrames.ToArray(); break; + } + } + } + + character.Frames = frames; + + // Seek to end of character data + if (_reader.BaseStream.Position != endPos) + { + _reader.BaseStream.Seek(endPos, SeekOrigin.Begin); + } + + return character; + } + + private void ParseCharacterWeaponsSection() + { + var count = _reader.ReadUInt16(); + + for (int i = 0; i < count; i++) + { + var reference = _reader.ReadUInt16(); + var health = _reader.ReadUInt16(); + + if (i < _data.Characters.Count) + { + _data.Characters[i].Weapon = new CharacterWeapon + { + Reference = reference, + Health = health + }; + } + } + } + + private void ParseCharacterAuxSection() + { + var count = _reader.ReadUInt16(); + + for (int i = 0; i < count; i++) + { + var damage = _reader.ReadUInt16(); + + if (i < _data.Characters.Count) + { + _data.Characters[i].AuxData = new CharacterAux + { + Damage = damage + }; + } + } + } + + private void ParseTileNamesSection() + { + var count = _reader.ReadUInt16(); + + for (int i = 0; i < count; i++) + { + var tileId = _reader.ReadUInt16(); + var nameLength = _reader.ReadUInt16(); + + if (nameLength > 0) + { + var nameBytes = _reader.ReadBytes(nameLength); + var name = System.Text.Encoding.ASCII.GetString(nameBytes).TrimEnd('\0'); + _data.TileNames[tileId] = name; + } + } + + Console.WriteLine($"Loaded {_data.TileNames.Count} tile names"); + } +} diff --git a/src/YodaStoriesNG.Engine/Program.cs b/src/YodaStoriesNG.Engine/Program.cs new file mode 100644 index 0000000..472b1cb --- /dev/null +++ b/src/YodaStoriesNG.Engine/Program.cs @@ -0,0 +1,81 @@ +using YodaStoriesNG.Engine.Game; + +namespace YodaStoriesNG.Engine; + +class Program +{ + static int Main(string[] args) + { + Console.WriteLine("========================================"); + Console.WriteLine(" Yoda Stories NG"); + Console.WriteLine(" An open-source reimplementation"); + Console.WriteLine("========================================"); + Console.WriteLine(); + + // Determine data path + string dataPath; + if (args.Length > 0 && Directory.Exists(args[0])) + { + dataPath = args[0]; + } + else + { + // Look for Yoda folder in common locations + var possiblePaths = new[] + { + Path.Combine(AppContext.BaseDirectory, "Yoda"), + Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", "Yoda"), + @"C:\YodaStoriesNG\Yoda", + "Yoda", + }; + + dataPath = possiblePaths.FirstOrDefault(Directory.Exists) ?? "Yoda"; + } + + var dtaFile = Path.Combine(dataPath, "yodesk.dta"); + if (!File.Exists(dtaFile)) + { + Console.WriteLine($"Error: Could not find game data file."); + Console.WriteLine($"Expected: {dtaFile}"); + Console.WriteLine(); + Console.WriteLine("Please ensure the Yoda Stories data files are in the 'Yoda' folder."); + Console.WriteLine("Usage: YodaStoriesNG.Engine [path-to-yoda-folder]"); + return 1; + } + + Console.WriteLine($"Data path: {dataPath}"); + Console.WriteLine(); + + Console.WriteLine("Controls:"); + Console.WriteLine(" Arrow keys / WASD - Move"); + Console.WriteLine(" Space - Use item / Attack"); + Console.WriteLine(" 1-8 - Select inventory item"); + Console.WriteLine(" N/P - Next/Previous zone (debug)"); + Console.WriteLine(" R - Restart game"); + Console.WriteLine(" ESC - Quit"); + Console.WriteLine(); + + try + { + using var engine = new GameEngine(dataPath); + + if (!engine.Initialize()) + { + Console.WriteLine("Failed to initialize game engine."); + return 1; + } + + Console.WriteLine("Starting game..."); + engine.Run(); + } + catch (Exception ex) + { + Console.WriteLine($"Error: {ex.Message}"); + Console.WriteLine(ex.StackTrace); + return 1; + } + + Console.WriteLine("Thanks for playing!"); + return 0; + } +} diff --git a/src/YodaStoriesNG.Engine/Rendering/GameRenderer.cs b/src/YodaStoriesNG.Engine/Rendering/GameRenderer.cs new file mode 100644 index 0000000..674c465 --- /dev/null +++ b/src/YodaStoriesNG.Engine/Rendering/GameRenderer.cs @@ -0,0 +1,327 @@ +using Hexa.NET.SDL2; +using YodaStoriesNG.Engine.Data; + +namespace YodaStoriesNG.Engine.Rendering; + +/// +/// SDL2-based renderer for the game. +/// +public unsafe class GameRenderer : IDisposable +{ + // SDL init flags + private const uint SDL_INIT_VIDEO = 0x00000020; + private const uint SDL_INIT_AUDIO = 0x00000010; + private const int SDL_WINDOWPOS_CENTERED = 0x2FFF0000; + + private SDLWindow* _window; + private SDLRenderer* _renderer; + private SDLTexture* _tileAtlas; + private int _atlasWidth; + private int _atlasHeight; + private int _tilesPerRow; + + private readonly GameData _gameData; + private readonly TileRenderer _tileRenderer; + + // Screen dimensions (9 tiles visible at once) + public const int ViewportTilesX = 9; + public const int ViewportTilesY = 9; + public const int Scale = 2; // 2x scaling for better visibility + public const int WindowWidth = ViewportTilesX * Tile.Width * Scale; + public const int WindowHeight = ViewportTilesY * Tile.Height * Scale + 100 * Scale; // Extra space for HUD + + public bool IsInitialized => _window != null; + + public GameRenderer(GameData gameData) + { + _gameData = gameData; + _tileRenderer = new TileRenderer(); + } + + public bool Initialize(string title = "Yoda Stories NG") + { + if (SDL.Init(SDL_INIT_VIDEO | SDL_INIT_AUDIO) < 0) + { + Console.WriteLine($"SDL_Init failed: {SDL.GetErrorS()}"); + return false; + } + + _window = SDL.CreateWindow( + title, + (int)SDL_WINDOWPOS_CENTERED, + (int)SDL_WINDOWPOS_CENTERED, + WindowWidth, + WindowHeight, + (uint)SDLWindowFlags.Shown); + + if (_window == null) + { + Console.WriteLine($"SDL_CreateWindow failed: {SDL.GetErrorS()}"); + return false; + } + + _renderer = SDL.CreateRenderer(_window, -1, + (uint)(SDLRendererFlags.Accelerated | SDLRendererFlags.Presentvsync)); + + if (_renderer == null) + { + Console.WriteLine($"SDL_CreateRenderer failed: {SDL.GetErrorS()}"); + return false; + } + + // Create tile atlas texture + CreateTileAtlas(); + + return true; + } + + private void CreateTileAtlas() + { + if (_gameData.Tiles.Count == 0) + return; + + // Calculate atlas dimensions (aim for roughly square) + _tilesPerRow = (int)Math.Ceiling(Math.Sqrt(_gameData.Tiles.Count)); + var (pixels, width, height) = _tileRenderer.CreateTileAtlas(_gameData.Tiles, _tilesPerRow); + _atlasWidth = width; + _atlasHeight = height; + + // Create SDL texture + _tileAtlas = SDL.CreateTexture( + _renderer, + (uint)SDLPixelFormatEnum.Argb8888, + (int)SDLTextureAccess.Static, + width, + height); + + if (_tileAtlas == null) + { + Console.WriteLine($"Failed to create tile atlas: {SDL.GetErrorS()}"); + return; + } + + // Enable alpha blending + SDL.SetTextureBlendMode(_tileAtlas, SDLBlendMode.Blend); + + // Upload pixel data + fixed (uint* pixelPtr = pixels) + { + SDL.UpdateTexture(_tileAtlas, null, pixelPtr, width * 4); + } + + Console.WriteLine($"Created tile atlas: {width}x{height} ({_gameData.Tiles.Count} tiles, {_tilesPerRow} per row)"); + } + + /// + /// Renders a zone at the specified camera offset. + /// + public void RenderZone(Zone zone, int cameraX, int cameraY) + { + // Clear screen + SDL.SetRenderDrawColor(_renderer, 0, 0, 0, 255); + SDL.RenderClear(_renderer); + + // Render tile layers + for (int layer = 0; layer < 3; layer++) + { + RenderTileLayer(zone, layer, cameraX, cameraY); + } + } + + private static bool _debugRendered = false; + + private void RenderTileLayer(Zone zone, int layer, int cameraX, int cameraY) + { + int tilesRendered = 0; + for (int screenY = 0; screenY < ViewportTilesY; screenY++) + { + for (int screenX = 0; screenX < ViewportTilesX; screenX++) + { + var worldX = cameraX + screenX; + var worldY = cameraY + screenY; + + if (worldX < 0 || worldX >= zone.Width || worldY < 0 || worldY >= zone.Height) + continue; + + var tileId = zone.GetTile(worldX, worldY, layer); + if (tileId == 0xFFFF || tileId >= _gameData.Tiles.Count) + continue; + + var tile = _gameData.Tiles[(int)tileId]; + + // Skip fully transparent tiles in upper layers + if (layer > 0 && !tile.IsTransparent && tile.PixelData[0] == 0) + continue; + + RenderTile(tileId, screenX * Tile.Width * Scale, screenY * Tile.Height * Scale); + tilesRendered++; + } + } + + if (!_debugRendered && layer == 0) + { + Console.WriteLine($"Layer {layer}: Rendered {tilesRendered} tiles"); + _debugRendered = true; + } + } + + private static bool _debugAtlas = false; + + /// + /// Renders a single tile at the specified screen position. + /// + public void RenderTile(int tileId, int x, int y) + { + if (_tileAtlas == null || tileId < 0 || tileId >= _gameData.Tiles.Count) + { + if (!_debugAtlas) + { + Console.WriteLine($"RenderTile skipped: atlas={_tileAtlas != null}, tileId={tileId}, tileCount={_gameData.Tiles.Count}"); + _debugAtlas = true; + } + return; + } + + // Calculate source rectangle in atlas + var atlasX = (tileId % _tilesPerRow) * Tile.Width; + var atlasY = (tileId / _tilesPerRow) * Tile.Height; + + var srcRect = new SDLRect + { + X = atlasX, + Y = atlasY, + W = Tile.Width, + H = Tile.Height + }; + + var dstRect = new SDLRect + { + X = x, + Y = y, + W = Tile.Width * Scale, + H = Tile.Height * Scale + }; + + SDL.RenderCopy(_renderer, _tileAtlas, &srcRect, &dstRect); + } + + /// + /// Renders a sprite (character/object) at the specified world position. + /// + public void RenderSprite(int tileId, int worldX, int worldY, int cameraX, int cameraY) + { + var screenX = (worldX - cameraX) * Tile.Width * Scale; + var screenY = (worldY - cameraY) * Tile.Height * Scale; + RenderTile(tileId, screenX, screenY); + } + + /// + /// Renders text on screen (placeholder - uses colored rectangles for now). + /// + public void RenderText(string text, int x, int y, byte r = 255, byte g = 255, byte b = 255) + { + // TODO: Implement proper text rendering with TTF + // For now, just draw a placeholder rectangle + SDL.SetRenderDrawColor(_renderer, r, g, b, 255); + var rect = new SDLRect { X = x, Y = y, W = text.Length * 8, H = 16 }; + SDL.RenderDrawRect(_renderer, &rect); + } + + /// + /// Renders the HUD (health, inventory, etc.). + /// + public void RenderHUD(int health, int maxHealth, List inventory, int? selectedWeapon) + { + var hudY = ViewportTilesY * Tile.Height * Scale; + + // Background + SDL.SetRenderDrawColor(_renderer, 40, 40, 40, 255); + var hudRect = new SDLRect { X = 0, Y = hudY, W = WindowWidth, H = 100 * Scale }; + SDL.RenderFillRect(_renderer, &hudRect); + + // Health bar + var healthWidth = (int)((float)health / maxHealth * 150); + SDL.SetRenderDrawColor(_renderer, 200, 0, 0, 255); + var healthRect = new SDLRect { X = 10, Y = hudY + 10, W = healthWidth, H = 20 }; + SDL.RenderFillRect(_renderer, &healthRect); + + // Health bar border + SDL.SetRenderDrawColor(_renderer, 255, 255, 255, 255); + var healthBorder = new SDLRect { X = 10, Y = hudY + 10, W = 150, H = 20 }; + SDL.RenderDrawRect(_renderer, &healthBorder); + + // Inventory slots + for (int i = 0; i < Math.Min(inventory.Count, 8); i++) + { + var slotX = 180 + i * (Tile.Width + 4); + var slotY = hudY + 5; + + // Slot background + SDL.SetRenderDrawColor(_renderer, 60, 60, 60, 255); + var slotRect = new SDLRect { X = slotX, Y = slotY, W = Tile.Width, H = Tile.Height }; + SDL.RenderFillRect(_renderer, &slotRect); + + // Item tile + if (inventory[i] > 0) + { + RenderTileUnscaled(inventory[i], slotX, slotY); + } + } + } + + private void RenderTileUnscaled(int tileId, int x, int y) + { + if (_tileAtlas == null || tileId < 0 || tileId >= _gameData.Tiles.Count) + return; + + var atlasX = (tileId % _tilesPerRow) * Tile.Width; + var atlasY = (tileId / _tilesPerRow) * Tile.Height; + + var srcRect = new SDLRect { X = atlasX, Y = atlasY, W = Tile.Width, H = Tile.Height }; + var dstRect = new SDLRect { X = x, Y = y, W = Tile.Width, H = Tile.Height }; + + SDL.RenderCopy(_renderer, _tileAtlas, &srcRect, &dstRect); + } + + /// + /// Presents the rendered frame. + /// + public void Present() + { + SDL.RenderPresent(_renderer); + } + + /// + /// Polls for SDL events. + /// + public bool PollEvent(out SDLEvent evt) + { + fixed (SDLEvent* evtPtr = &evt) + { + return SDL.PollEvent(evtPtr) != 0; + } + } + + public void Dispose() + { + if (_tileAtlas != null) + { + SDL.DestroyTexture(_tileAtlas); + _tileAtlas = null; + } + + if (_renderer != null) + { + SDL.DestroyRenderer(_renderer); + _renderer = null; + } + + if (_window != null) + { + SDL.DestroyWindow(_window); + _window = null; + } + + SDL.Quit(); + } +} diff --git a/src/YodaStoriesNG.Engine/Rendering/Palette.cs b/src/YodaStoriesNG.Engine/Rendering/Palette.cs new file mode 100644 index 0000000..5c4bc60 --- /dev/null +++ b/src/YodaStoriesNG.Engine/Rendering/Palette.cs @@ -0,0 +1,91 @@ +namespace YodaStoriesNG.Engine.Rendering; + +/// +/// Default 256-color palette for Yoda Stories. +/// This palette was extracted from the original game executable. +/// Format: RGBA (Red, Green, Blue, Alpha) +/// +public static class Palette +{ + /// + /// The color palette as an array of 256 ARGB values. + /// Extracted from the goda-stories project (correct Yoda Stories palette). + /// Index 0 (0x00) is transparent. + /// + public static readonly uint[] Colors = new uint[256] + { + // Palette from goda-stories project + // Row 0 (0x00-0x0F) + 0x00000000, 0xFF000000, 0xFF000000, 0xFF000000, 0xFF000000, 0xFF000000, 0xFF000000, 0xFF000000, + 0xFF000000, 0xFF000000, 0xFF8BFFFF, 0xFF4BCFC3, 0xFF1BA38B, 0xFF007757, 0xFF1BA38B, 0xFF4BCFC3, + // Row 1 (0x10-0x1F) + 0xFFFBFBFB, 0xFFE7E7EB, 0xFFD3D3DB, 0xFFC3C3CB, 0xFFB3B3BB, 0xFFA3A3AB, 0xFF8F8F9B, 0xFF7F7F8B, + 0xFF6F6F7B, 0xFF5B5B67, 0xFF4B4B57, 0xFF3B3B47, 0xFF2B2B33, 0xFF1B1B23, 0xFF0F0F13, 0xFF000000, + // Row 2 (0x20-0x2F) + 0xFF43C700, 0xFF43B700, 0xFF3FAB00, 0xFF3F9F00, 0xFF3F9300, 0xFF3B8700, 0xFF377B00, 0xFF336F00, + 0xFF336300, 0xFF2B5300, 0xFF274700, 0xFF233B00, 0xFF1B2F00, 0xFF132300, 0xFF0F1700, 0xFF070B00, + // Row 3 (0x30-0x3F) + 0xFFBB7B4B, 0xFFB37343, 0xFFAB6B43, 0xFFA3633B, 0xFF9B633B, 0xFF935B33, 0xFF8B5B33, 0xFF83532B, + 0xFF734B2B, 0xFF6B4B23, 0xFF5F4323, 0xFF533B1B, 0xFF47371B, 0xFF43331B, 0xFF3B2B13, 0xFF2B230B, + // Row 4 (0x40-0x4F) + 0xFFFFFFD7, 0xFFEFEFBB, 0xFFDFDFA3, 0xFFCFCF8B, 0xFFC3C377, 0xFFB3B363, 0xFFA3A353, 0xFF939343, + 0xFF878733, 0xFF777727, 0xFF67671B, 0xFF5B5B13, 0xFF4B4B0B, 0xFF3B3B07, 0xFF2B2B00, 0xFF1F1F00, + // Row 5 (0x50-0x5F) + 0xFFFBEBDB, 0xFFFBE3D3, 0xFFFBDBC3, 0xFFFBD3BB, 0xFFFBCBB3, 0xFFFBC3A3, 0xFFFBBB9B, 0xFFFBB78F, + 0xFFF7B383, 0xFFFBA773, 0xFFFB9B63, 0xFFF3935B, 0xFFEB8B5B, 0xFFDB8B53, 0xFFD38353, 0xFFCB7B4B, + // Row 6 (0x60-0x6F) + 0xFFFFC79B, 0xFFF7B78F, 0xFFEFB387, 0xFFF3A77F, 0xFFEF9F73, 0xFFCF8353, 0xFFB36B3B, 0xFFA35B2F, + 0xFF934F23, 0xFF83431B, 0xFF773B13, 0xFF672F0B, 0xFF572707, 0xFF471B00, 0xFF6D1300, 0xFF2B0F00, + // Row 7 (0x70-0x7F) + 0xFFE7FBFB, 0xFFD3F3F3, 0xFFC7E7EB, 0xFFB7DFE3, 0xFFA7D7DB, 0xFF97CFD3, 0xFF8BC7CB, 0xFF7FBBC3, + 0xFF73B3BB, 0xFF63A7AF, 0xFF47939B, 0xFF337B87, 0xFF1F676F, 0xFF0F535B, 0xFF004347, 0xFF003337, + // Row 8 (0x80-0x8F) + 0xFFF7F7FF, 0xFFDFDFEF, 0xFFC7C7DF, 0xFFB3B3CF, 0xFF9F9FBF, 0xFF8B8BB3, 0xFF7B7BA3, 0xFF6B6B93, + 0xFF575783, 0xFF4B4B73, 0xFF3B3B67, 0xFF2F2F57, 0xFF272747, 0xFF1B1B37, 0xFF131327, 0xFF0B0B1B, + // Row 9 (0x90-0x9F) + 0xFF37B3F7, 0xFF0793E7, 0xFF0B53FB, 0xFF0000FB, 0xFF0000CB, 0xFF00009F, 0xFF00006F, 0xFF000043, + 0xFFFBBBBF, 0xFFFB8B8F, 0xFFFB5B5F, 0xFFFFBB93, 0xFFF7975F, 0xFFEF7B3B, 0xFFC36323, 0xFFB35313, + // Row A (0xA0-0xAF) + 0xFFFF0000, 0xFFEF0000, 0xFFE30000, 0xFFD30000, 0xFFC30000, 0xFFB70000, 0xFFA70000, 0xFF9B0000, + 0xFF8B0000, 0xFF7F0000, 0xFF6F0000, 0xFF630000, 0xFF530000, 0xFF470000, 0xFF370000, 0xFF2B0000, + // Row B (0xB0-0xBF) + 0xFFFFFF00, 0xFFF7E300, 0xFFF3CF00, 0xFFEFB700, 0xFFEBA300, 0xFFE78B00, 0xFFDF7700, 0xFFDB6300, + 0xFFD74F00, 0xFFD33F00, 0xFFCF2F00, 0xFFFFFF97, 0xFFEFDF83, 0xFFDFC373, 0xFFCFA75F, 0xFFC38B53, + // Row C (0xC0-0xCF) + 0xFF002B2B, 0xFF002323, 0xFF001B1B, 0xFF001313, 0xFF000BFF, 0xFF4B00FF, 0xFFA300FF, 0xFFFF00FF, + 0xFF00FF00, 0xFF004B00, 0xFF00FFFF, 0xFF2F33FF, 0xFFFF0000, 0xFF971F00, 0xFFFF00DF, 0xFF770073, + // Row D (0xD0-0xDF) + 0xFFC37B6B, 0xFFAB5757, 0xFF934757, 0xFF7F3753, 0xFF67274F, 0xFF4F1B47, 0xFF3B133B, 0xFF777727, + 0xFF737323, 0xFF6F6F1F, 0xFF6B6B1B, 0xFF67671B, 0xFF6B6B1B, 0xFF6F6F1F, 0xFF737323, 0xFF777727, + // Row E (0xE0-0xEF) + 0xFFEFFFFF, 0xFFDBF7F7, 0xFFCBEFF3, 0xFFBBEBEF, 0xFFCBEFF3, 0xFF0793E7, 0xFF0F97E7, 0xFF179FEB, + 0xFF23A3EF, 0xFF2BABF3, 0xFF37B3F7, 0xFF27A7EF, 0xFF1B9FEB, 0xFF0F97E7, 0xFFFBCB0B, 0xFFFBA30B, + // Row F (0xF0-0xFF) + 0xFFFB730B, 0xFFFB4B0B, 0xFFFB230B, 0xFFFB730B, 0xFF931300, 0xFFD30B00, 0xFF000000, 0xFF000000, + 0xFF000000, 0xFF000000, 0xFF000000, 0xFF000000, 0xFF000000, 0xFF000000, 0xFF000000, 0xFFFFFFFF, + }; + + /// + /// Gets the color for a palette index as ARGB32. + /// + public static uint GetColor(byte index) => Colors[index]; + + /// + /// Gets the color components for a palette index. + /// + public static (byte r, byte g, byte b, byte a) GetColorComponents(byte index) + { + var color = Colors[index]; + return ( + (byte)((color >> 16) & 0xFF), // R + (byte)((color >> 8) & 0xFF), // G + (byte)(color & 0xFF), // B + (byte)((color >> 24) & 0xFF) // A + ); + } + + /// + /// Checks if the given palette index should be treated as transparent. + /// + public static bool IsTransparent(byte index) => index == 0; +} diff --git a/src/YodaStoriesNG.Engine/Rendering/TileRenderer.cs b/src/YodaStoriesNG.Engine/Rendering/TileRenderer.cs new file mode 100644 index 0000000..84d1be7 --- /dev/null +++ b/src/YodaStoriesNG.Engine/Rendering/TileRenderer.cs @@ -0,0 +1,93 @@ +using YodaStoriesNG.Engine.Data; + +namespace YodaStoriesNG.Engine.Rendering; + +/// +/// Converts tile pixel data to ARGB32 format for rendering. +/// +public class TileRenderer +{ + /// + /// Converts a tile's indexed pixel data to ARGB32 format. + /// + /// The tile to convert. + /// Array of ARGB32 pixel values (32x32 = 1024 pixels). + public uint[] ConvertTileToArgb32(Tile tile) + { + var result = new uint[Tile.PixelCount]; + + for (int i = 0; i < Tile.PixelCount; i++) + { + var paletteIndex = tile.PixelData[i]; + result[i] = Palette.GetColor(paletteIndex); + } + + return result; + } + + /// + /// Converts a tile's indexed pixel data to a raw byte array (RGBA format). + /// + /// The tile to convert. + /// Array of bytes in RGBA format (32x32x4 = 4096 bytes). + public byte[] ConvertTileToRgba(Tile tile) + { + var result = new byte[Tile.PixelCount * 4]; + + for (int i = 0; i < Tile.PixelCount; i++) + { + var paletteIndex = tile.PixelData[i]; + var (r, g, b, a) = Palette.GetColorComponents(paletteIndex); + + // Handle transparency (index 0) + if (Palette.IsTransparent(paletteIndex)) + { + a = 0; + } + + result[i * 4 + 0] = r; + result[i * 4 + 1] = g; + result[i * 4 + 2] = b; + result[i * 4 + 3] = a; + } + + return result; + } + + /// + /// Renders multiple tiles into a combined texture atlas. + /// + /// The tiles to combine. + /// Number of tiles per row in the atlas. + /// Combined ARGB32 pixel data and dimensions. + public (uint[] pixels, int width, int height) CreateTileAtlas(IList tiles, int tilesPerRow) + { + if (tiles.Count == 0) + return (Array.Empty(), 0, 0); + + var tilesPerColumn = (tiles.Count + tilesPerRow - 1) / tilesPerRow; + var atlasWidth = tilesPerRow * Tile.Width; + var atlasHeight = tilesPerColumn * Tile.Height; + var pixels = new uint[atlasWidth * atlasHeight]; + + for (int tileIndex = 0; tileIndex < tiles.Count; tileIndex++) + { + var tile = tiles[tileIndex]; + var tileX = (tileIndex % tilesPerRow) * Tile.Width; + var tileY = (tileIndex / tilesPerRow) * Tile.Height; + + for (int py = 0; py < Tile.Height; py++) + { + for (int px = 0; px < Tile.Width; px++) + { + var srcIndex = py * Tile.Width + px; + var dstIndex = (tileY + py) * atlasWidth + (tileX + px); + var paletteIndex = tile.PixelData[srcIndex]; + pixels[dstIndex] = Palette.GetColor(paletteIndex); + } + } + } + + return (pixels, atlasWidth, atlasHeight); + } +} diff --git a/src/YodaStoriesNG.Engine/YodaStoriesNG.Engine.csproj b/src/YodaStoriesNG.Engine/YodaStoriesNG.Engine.csproj new file mode 100644 index 0000000..e469a22 --- /dev/null +++ b/src/YodaStoriesNG.Engine/YodaStoriesNG.Engine.csproj @@ -0,0 +1,15 @@ + + + + Exe + net8.0 + enable + enable + true + + + + + + +