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 <noreply@anthropic.com>
This commit is contained in:
Ned Heller
2026-01-28 16:56:21 -08:00
co-authored by Claude Opus 4.5
commit b8feea2c74
18 changed files with 2849 additions and 0 deletions
+30
View File
@@ -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
+27
View File
@@ -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
+127
View File
@@ -0,0 +1,127 @@
namespace YodaStoriesNG.Engine.Data;
/// <summary>
/// Represents an IACT action/script from a zone.
/// Actions define game logic triggers and responses.
/// </summary>
public class Action
{
public List<Condition> Conditions { get; set; } = new();
public List<Instruction> Instructions { get; set; } = new();
}
/// <summary>
/// A condition that must be met for an action to execute.
/// </summary>
public class Condition
{
public ConditionOpcode Opcode { get; set; }
public List<short> Arguments { get; set; } = new();
public string? Text { get; set; }
}
/// <summary>
/// An instruction to execute when conditions are met.
/// </summary>
public class Instruction
{
public InstructionOpcode Opcode { get; set; }
public List<short> Arguments { get; set; } = new();
public string? Text { get; set; }
}
/// <summary>
/// Condition opcodes from the IACT script system.
/// </summary>
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,
}
/// <summary>
/// Instruction opcodes from the IACT script system.
/// </summary>
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,
}
@@ -0,0 +1,62 @@
namespace YodaStoriesNG.Engine.Data;
/// <summary>
/// Represents a character definition from the CHAR section.
/// </summary>
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; }
}
/// <summary>
/// Character animation frames for different directions and states.
/// </summary>
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<ushort>();
public ushort[] ExtensionDown { get; set; } = Array.Empty<ushort>();
public ushort[] ExtensionLeft { get; set; } = Array.Empty<ushort>();
public ushort[] ExtensionRight { get; set; } = Array.Empty<ushort>();
}
public enum CharacterType : ushort
{
Hero = 0x01,
Enemy = 0x02,
Friendly = 0x04,
}
/// <summary>
/// Character weapon information from CHWP section.
/// </summary>
public class CharacterWeapon
{
public ushort Reference { get; set; }
public ushort Health { get; set; }
}
/// <summary>
/// Character auxiliary data from CAUX section.
/// </summary>
public class CharacterAux
{
public ushort Damage { get; set; }
}
+71
View File
@@ -0,0 +1,71 @@
namespace YodaStoriesNG.Engine.Data;
/// <summary>
/// Contains all game data loaded from the DTA file.
/// </summary>
public class GameData
{
/// <summary>
/// Version information (typically 2.0).
/// </summary>
public Version Version { get; set; } = new(2, 0);
/// <summary>
/// Startup screen data (STUP section).
/// </summary>
public byte[] StartupScreen { get; set; } = Array.Empty<byte>();
/// <summary>
/// Sound effect references.
/// </summary>
public List<Sound> Sounds { get; set; } = new();
/// <summary>
/// All tile/sprite definitions.
/// </summary>
public List<Tile> Tiles { get; set; } = new();
/// <summary>
/// All zone/map definitions.
/// </summary>
public List<Zone> Zones { get; set; } = new();
/// <summary>
/// All puzzle definitions.
/// </summary>
public List<Puzzle> Puzzles { get; set; } = new();
/// <summary>
/// All character definitions.
/// </summary>
public List<Character> Characters { get; set; } = new();
/// <summary>
/// Tile names from TNAM section.
/// </summary>
public Dictionary<int, string> TileNames { get; set; } = new();
/// <summary>
/// Gets a tile by ID, or null if not found.
/// </summary>
public Tile? GetTile(int id) =>
id >= 0 && id < Tiles.Count ? Tiles[id] : null;
/// <summary>
/// Gets a zone by ID, or null if not found.
/// </summary>
public Zone? GetZone(int id) =>
id >= 0 && id < Zones.Count ? Zones[id] : null;
/// <summary>
/// Gets a character by ID, or null if not found.
/// </summary>
public Character? GetCharacter(int id) =>
id >= 0 && id < Characters.Count ? Characters[id] : null;
/// <summary>
/// Gets a sound by ID, or null if not found.
/// </summary>
public Sound? GetSound(int id) =>
id >= 0 && id < Sounds.Count ? Sounds[id] : null;
}
+30
View File
@@ -0,0 +1,30 @@
namespace YodaStoriesNG.Engine.Data;
/// <summary>
/// Represents a puzzle definition from the PUZ2 section.
/// </summary>
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<string> Strings { get; set; } = new();
// Raw data for unknown fields
public byte[] RawData { get; set; } = Array.Empty<byte>();
}
public enum PuzzleType : short
{
None = -1,
Quest = 0,
Transport = 1,
Trade = 2,
Use = 3,
Goal = 4,
}
+10
View File
@@ -0,0 +1,10 @@
namespace YodaStoriesNG.Engine.Data;
/// <summary>
/// Represents a sound effect reference from the SNDS section.
/// </summary>
public class Sound
{
public int Id { get; set; }
public string FileName { get; set; } = string.Empty;
}
+73
View File
@@ -0,0 +1,73 @@
namespace YodaStoriesNG.Engine.Data;
/// <summary>
/// Represents a 32x32 pixel tile/sprite from the game.
/// </summary>
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;
}
/// <summary>
/// Tile attribute flags from the DTA file.
/// </summary>
[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,
}
+152
View File
@@ -0,0 +1,152 @@
namespace YodaStoriesNG.Engine.Data;
/// <summary>
/// Represents a game zone/map area.
/// </summary>
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<ZoneObject> Objects { get; set; } = new();
// Action scripts for this zone
public List<Action> 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; }
/// <summary>
/// Gets the tile ID at the specified position and layer.
/// </summary>
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];
}
/// <summary>
/// Sets the tile ID at the specified position and layer.
/// </summary>
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,
}
/// <summary>
/// An object placed within a zone.
/// </summary>
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,
}
/// <summary>
/// IZAX auxiliary data structure.
/// </summary>
public class ZoneAuxData
{
public byte[] RawData { get; set; } = Array.Empty<byte>();
}
/// <summary>
/// IZX2 auxiliary data structure.
/// </summary>
public class ZoneAux2Data
{
public byte[] RawData { get; set; } = Array.Empty<byte>();
}
/// <summary>
/// IZX3 auxiliary data structure.
/// </summary>
public class ZoneAux3Data
{
public byte[] RawData { get; set; } = Array.Empty<byte>();
}
/// <summary>
/// IZX4 auxiliary data structure.
/// </summary>
public class ZoneAux4Data
{
public byte[] RawData { get; set; } = Array.Empty<byte>();
}
@@ -0,0 +1,292 @@
using YodaStoriesNG.Engine.Data;
namespace YodaStoriesNG.Engine.Game;
/// <summary>
/// Executes zone action scripts (IACT).
/// </summary>
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;
}
/// <summary>
/// Executes all applicable actions in the current zone.
/// </summary>
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<Condition> 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<Instruction> 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,
}
+491
View File
@@ -0,0 +1,491 @@
using Hexa.NET.SDL2;
using YodaStoriesNG.Engine.Data;
using YodaStoriesNG.Engine.Parsing;
using YodaStoriesNG.Engine.Rendering;
namespace YodaStoriesNG.Engine.Game;
/// <summary>
/// Main game engine that coordinates all systems.
/// </summary>
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();
}
/// <summary>
/// Loads game data and initializes the engine.
/// </summary>
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;
}
/// <summary>
/// Starts a new game.
/// </summary>
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;
}
}
}
/// <summary>
/// Loads a zone by ID.
/// </summary>
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);
}
/// <summary>
/// Main game loop.
/// </summary>
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();
}
}
+146
View File
@@ -0,0 +1,146 @@
using YodaStoriesNG.Engine.Data;
namespace YodaStoriesNG.Engine.Game;
/// <summary>
/// Represents the current state of the game.
/// </summary>
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<int> Inventory { get; set; } = new();
public int? SelectedWeapon { get; set; }
public int? SelectedItem { get; set; }
// Game variables (used by scripts)
public Dictionary<int, int> Variables { get; set; } = new();
public Dictionary<int, int> Counters { get; set; } = new();
// Quest state
public HashSet<int> 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; }
/// <summary>
/// Resets the game state for a new game.
/// </summary>
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;
}
/// <summary>
/// Gets a game variable, returning 0 if not set.
/// </summary>
public int GetVariable(int id) =>
Variables.TryGetValue(id, out var value) ? value : 0;
/// <summary>
/// Sets a game variable.
/// </summary>
public void SetVariable(int id, int value) =>
Variables[id] = value;
/// <summary>
/// Gets a counter, returning 0 if not set.
/// </summary>
public int GetCounter(int id) =>
Counters.TryGetValue(id, out var value) ? value : 0;
/// <summary>
/// Sets a counter.
/// </summary>
public void SetCounter(int id, int value) =>
Counters[id] = value;
/// <summary>
/// Adds to a counter.
/// </summary>
public void AddToCounter(int id, int amount)
{
var current = GetCounter(id);
Counters[id] = current + amount;
}
/// <summary>
/// Checks if the player has an item.
/// </summary>
public bool HasItem(int itemId) =>
Inventory.Contains(itemId);
/// <summary>
/// Adds an item to inventory.
/// </summary>
public void AddItem(int itemId)
{
if (!Inventory.Contains(itemId))
Inventory.Add(itemId);
}
/// <summary>
/// Removes an item from inventory.
/// </summary>
public void RemoveItem(int itemId) =>
Inventory.Remove(itemId);
/// <summary>
/// Marks a zone as solved.
/// </summary>
public void MarkZoneSolved(int zoneId) =>
SolvedZones.Add(zoneId);
/// <summary>
/// Checks if a zone is solved.
/// </summary>
public bool IsZoneSolved(int zoneId) =>
SolvedZones.Contains(zoneId);
}
public enum Direction
{
Up,
Down,
Left,
Right
}
@@ -0,0 +1,731 @@
using YodaStoriesNG.Engine.Data;
namespace YodaStoriesNG.Engine.Parsing;
/// <summary>
/// Parser for YODESK.DTA game data files.
/// </summary>
public class DtaParser
{
private BinaryReader _reader = null!;
private GameData _data = null!;
/// <summary>
/// Parses a DTA file and returns the game data.
/// </summary>
public GameData Parse(string filePath)
{
using var stream = File.OpenRead(filePath);
return Parse(stream);
}
/// <summary>
/// Parses a DTA file from a stream and returns the game data.
/// </summary>
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<ushort>();
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");
}
}
+81
View File
@@ -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;
}
}
@@ -0,0 +1,327 @@
using Hexa.NET.SDL2;
using YodaStoriesNG.Engine.Data;
namespace YodaStoriesNG.Engine.Rendering;
/// <summary>
/// SDL2-based renderer for the game.
/// </summary>
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)");
}
/// <summary>
/// Renders a zone at the specified camera offset.
/// </summary>
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;
/// <summary>
/// Renders a single tile at the specified screen position.
/// </summary>
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);
}
/// <summary>
/// Renders a sprite (character/object) at the specified world position.
/// </summary>
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);
}
/// <summary>
/// Renders text on screen (placeholder - uses colored rectangles for now).
/// </summary>
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);
}
/// <summary>
/// Renders the HUD (health, inventory, etc.).
/// </summary>
public void RenderHUD(int health, int maxHealth, List<int> 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);
}
/// <summary>
/// Presents the rendered frame.
/// </summary>
public void Present()
{
SDL.RenderPresent(_renderer);
}
/// <summary>
/// Polls for SDL events.
/// </summary>
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();
}
}
@@ -0,0 +1,91 @@
namespace YodaStoriesNG.Engine.Rendering;
/// <summary>
/// Default 256-color palette for Yoda Stories.
/// This palette was extracted from the original game executable.
/// Format: RGBA (Red, Green, Blue, Alpha)
/// </summary>
public static class Palette
{
/// <summary>
/// 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.
/// </summary>
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,
};
/// <summary>
/// Gets the color for a palette index as ARGB32.
/// </summary>
public static uint GetColor(byte index) => Colors[index];
/// <summary>
/// Gets the color components for a palette index.
/// </summary>
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
);
}
/// <summary>
/// Checks if the given palette index should be treated as transparent.
/// </summary>
public static bool IsTransparent(byte index) => index == 0;
}
@@ -0,0 +1,93 @@
using YodaStoriesNG.Engine.Data;
namespace YodaStoriesNG.Engine.Rendering;
/// <summary>
/// Converts tile pixel data to ARGB32 format for rendering.
/// </summary>
public class TileRenderer
{
/// <summary>
/// Converts a tile's indexed pixel data to ARGB32 format.
/// </summary>
/// <param name="tile">The tile to convert.</param>
/// <returns>Array of ARGB32 pixel values (32x32 = 1024 pixels).</returns>
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;
}
/// <summary>
/// Converts a tile's indexed pixel data to a raw byte array (RGBA format).
/// </summary>
/// <param name="tile">The tile to convert.</param>
/// <returns>Array of bytes in RGBA format (32x32x4 = 4096 bytes).</returns>
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;
}
/// <summary>
/// Renders multiple tiles into a combined texture atlas.
/// </summary>
/// <param name="tiles">The tiles to combine.</param>
/// <param name="tilesPerRow">Number of tiles per row in the atlas.</param>
/// <returns>Combined ARGB32 pixel data and dimensions.</returns>
public (uint[] pixels, int width, int height) CreateTileAtlas(IList<Tile> tiles, int tilesPerRow)
{
if (tiles.Count == 0)
return (Array.Empty<uint>(), 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);
}
}
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Hexa.NET.SDL2" Version="1.2.17" />
</ItemGroup>
</Project>