mirror of
https://github.com/Nighthawk42/YodaStoriesNG.git
synced 2026-08-30 08:02:28 +00:00
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:
+30
@@ -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
|
||||
@@ -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
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user