mirror of
https://github.com/Nighthawk42/YodaStoriesNG.git
synced 2026-08-30 08:02:28 +00:00
WebFun parity: combat, AI, health, scripts, and game loop fixes
Bring gameplay closer to the original Yoda Stories engine based on analysis of the WebFun reference implementation: Combat: - Monsters shoot projectiles at hero (same row/col, 4-tile range, 6/7 chance) - Melee attacks hit 3-tile spread (forward + 2 diagonals) - The Force stuns NPCs instead of dealing damage - Killed monsters drop loot items at death position Monster AI: - Add Patrol behavior (waypoint-following movement) - Add Animation behavior (frame cycling without movement) Health system: - Scale to 768 HP (3 lives x 256, matching original) - 3-segment color gradient health gauge (green/yellow/red) - Proportionally scaled weapon damage values Script engine: - Implement Wait instruction (pause/resume next tick) - Implement HideHero/ShowHero instructions - Fix RequiredItemIs, EndingIs, FindItemIs, HasAnyRequiredItem conditions - Fix IsVariable/SetVariable to use XOR addressing Game loop: - Double script evaluation per tick (before and after NPC updates) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
c893f00373
commit
34a101c6c0
@@ -768,7 +768,7 @@ public class MissionSolver
|
||||
X = obj.X,
|
||||
Y = obj.Y,
|
||||
IsEnabled = true,
|
||||
Health = 100 // IsAlive is computed from Health > 0
|
||||
Health = 256 // IsAlive is computed from Health > 0
|
||||
};
|
||||
return (dagobahZoneId, npc);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,14 @@ public class ActionExecutor
|
||||
public int BumpX { get; set; }
|
||||
public int BumpY { get; set; }
|
||||
|
||||
// World data for puzzle conditions (RequiredItemIs, EndingIs, FindItemIs, etc.)
|
||||
public WorldMap? CurrentWorld { get; set; }
|
||||
public Mission? CurrentMission { get; set; }
|
||||
|
||||
// Wait instruction support - remaining instructions to resume next tick
|
||||
public List<Instruction>? PendingInstructions { get; set; }
|
||||
public int PendingInstructionIndex { get; set; }
|
||||
|
||||
// Event for displaying dialogue
|
||||
public event Action<string, string>? OnDialogue;
|
||||
public event Action<string>? OnMessage;
|
||||
@@ -50,6 +58,23 @@ public class ActionExecutor
|
||||
if (_currentZone == null)
|
||||
return;
|
||||
|
||||
// Resume pending instructions from a previous Wait
|
||||
if (PendingInstructions != null)
|
||||
{
|
||||
var remaining = PendingInstructions;
|
||||
var startIdx = PendingInstructionIndex;
|
||||
PendingInstructions = null;
|
||||
PendingInstructionIndex = 0;
|
||||
|
||||
for (int i = startIdx; i < remaining.Count; i++)
|
||||
{
|
||||
ExecuteInstruction(remaining[i]);
|
||||
// Check if another Wait was hit
|
||||
if (PendingInstructions != null)
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var action in _currentZone.Actions)
|
||||
{
|
||||
if (EvaluateConditions(action.Conditions, trigger))
|
||||
@@ -225,17 +250,20 @@ public class ActionExecutor
|
||||
return _state.ZoneNPCs.All(n => !n.IsHostile || n.Health <= 0);
|
||||
|
||||
case ConditionOpcode.RequiredItemIs:
|
||||
// Check if zone's required item matches
|
||||
if (args.Count < 1 || _currentZone == null)
|
||||
return false;
|
||||
// This typically checks puzzle items - for now just check if we have the item
|
||||
return _state.HasItem(args[0]);
|
||||
|
||||
case ConditionOpcode.FindItemIs:
|
||||
// Similar to RequiredItemIs
|
||||
// Check if current sector's required item matches arg
|
||||
if (args.Count < 1)
|
||||
return false;
|
||||
return _state.HasItem(args[0]);
|
||||
if (CurrentMission?.CurrentPuzzleStep != null)
|
||||
return CurrentMission.CurrentPuzzleStep.RequiredItemId == args[0];
|
||||
return _state.HasItem(args[0]); // Fallback
|
||||
|
||||
case ConditionOpcode.FindItemIs:
|
||||
// Check if zone's find/provided item matches arg
|
||||
if (args.Count < 1)
|
||||
return false;
|
||||
if (CurrentMission?.CurrentPuzzleStep != null)
|
||||
return CurrentMission.CurrentPuzzleStep.RewardItemId == args[0];
|
||||
return _state.HasItem(args[0]); // Fallback
|
||||
|
||||
case ConditionOpcode.EnterByPlane:
|
||||
// Check if entered zone by X-Wing
|
||||
@@ -254,10 +282,15 @@ public class ActionExecutor
|
||||
return PlacedItemId != args[0];
|
||||
|
||||
case ConditionOpcode.EndingIs:
|
||||
// Check goal item - for now just check if we have the item
|
||||
// Check if arg matches current goal puzzle's item (last step's required item)
|
||||
if (args.Count < 1)
|
||||
return false;
|
||||
return _state.HasItem(args[0]);
|
||||
if (CurrentMission != null && CurrentMission.PuzzleChain.Count > 0)
|
||||
{
|
||||
var lastStep = CurrentMission.PuzzleChain[^1];
|
||||
return lastStep.RequiredItemId == args[0];
|
||||
}
|
||||
return _state.HasItem(args[0]); // Fallback
|
||||
|
||||
// Note: SectorCounterIs (0x19) shares value with NpcIs - handled above
|
||||
// Note: SectorCounterIsLessThan (0x1A) shares value with HasNpc - handled above
|
||||
@@ -280,9 +313,12 @@ public class ActionExecutor
|
||||
return BumpX == args[0] && BumpY == args[1] && DroppedItemId.HasValue;
|
||||
|
||||
case ConditionOpcode.HasAnyRequiredItem:
|
||||
// Check if has any required item for zone puzzles
|
||||
// For now, assume true if inventory is not empty
|
||||
return _state.Inventory.Count > 0;
|
||||
// Check if inventory contains any of the current sector's required items
|
||||
if (CurrentWorld?.RequiredItems != null && CurrentWorld.RequiredItems.Count > 0)
|
||||
{
|
||||
return CurrentWorld.RequiredItems.Any(itemId => _state.HasItem(itemId));
|
||||
}
|
||||
return _state.Inventory.Count > 0; // Fallback
|
||||
|
||||
case ConditionOpcode.GamesWonIsGreaterThan:
|
||||
if (args.Count < 1)
|
||||
@@ -290,10 +326,11 @@ public class ActionExecutor
|
||||
return _state.GamesWon > args[0];
|
||||
|
||||
case ConditionOpcode.IsVariable:
|
||||
// Same as TileAtIs internally
|
||||
if (args.Count < 4 || _currentZone == null)
|
||||
// XOR addressing: key = arg0 ^ arg1 ^ arg2, check if variable == arg3
|
||||
if (args.Count < 4)
|
||||
return false;
|
||||
return _currentZone.GetTile(args[0], args[1], args[2]) == args[3];
|
||||
int varKey = args[0] ^ args[1] ^ args[2];
|
||||
return _state.GetVariable(varKey) == args[3];
|
||||
|
||||
default:
|
||||
// Unknown condition - assume true to allow script to continue
|
||||
@@ -308,9 +345,16 @@ public class ActionExecutor
|
||||
Console.WriteLine($" Executing {instructions.Count} instructions");
|
||||
}
|
||||
|
||||
foreach (var instruction in instructions)
|
||||
for (int i = 0; i < instructions.Count; i++)
|
||||
{
|
||||
ExecuteInstruction(instruction);
|
||||
if (instructions[i].Opcode == InstructionOpcode.Wait)
|
||||
{
|
||||
// Save remaining instructions to resume next tick
|
||||
PendingInstructions = instructions;
|
||||
PendingInstructionIndex = i + 1;
|
||||
return;
|
||||
}
|
||||
ExecuteInstruction(instructions[i]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -346,8 +390,16 @@ public class ActionExecutor
|
||||
break;
|
||||
|
||||
case InstructionOpcode.SetVariable:
|
||||
if (args.Count >= 2)
|
||||
_state.SetVariable(args[0], args[1]);
|
||||
// XOR addressing: key = arg0 ^ arg1 ^ arg2, set to arg3
|
||||
if (args.Count >= 4)
|
||||
{
|
||||
int setVarKey = args[0] ^ args[1] ^ args[2];
|
||||
_state.SetVariable(setVarKey, args[3]);
|
||||
}
|
||||
else if (args.Count >= 2)
|
||||
{
|
||||
_state.SetVariable(args[0], args[1]); // Fallback for 2-arg form
|
||||
}
|
||||
break;
|
||||
|
||||
case InstructionOpcode.AddItem:
|
||||
@@ -446,8 +498,9 @@ public class ActionExecutor
|
||||
break;
|
||||
|
||||
case InstructionOpcode.Wait:
|
||||
// TODO: Implement wait/delay
|
||||
break;
|
||||
// Pause script execution for one tick - remaining instructions resume next frame
|
||||
// This is handled by saving remaining instructions in PendingInstructions
|
||||
break; // Caller (ExecuteInstructions) checks for Wait
|
||||
|
||||
case InstructionOpcode.DropItem:
|
||||
// Drop an item at specified location
|
||||
@@ -491,11 +544,11 @@ public class ActionExecutor
|
||||
break;
|
||||
|
||||
case InstructionOpcode.HideHero:
|
||||
// TODO: Make hero invisible
|
||||
_state.HeroVisible = false;
|
||||
break;
|
||||
|
||||
case InstructionOpcode.ShowHero:
|
||||
// TODO: Make hero visible
|
||||
_state.HeroVisible = true;
|
||||
break;
|
||||
|
||||
case InstructionOpcode.SetZoneType:
|
||||
|
||||
@@ -52,6 +52,13 @@ public unsafe class GameEngine : IDisposable
|
||||
private const double AnimationFrameTime = 0.15; // 150ms per animation frame
|
||||
private double _controllerMoveTimer = 0; // Rate limit controller movement
|
||||
|
||||
// Drag and drop state
|
||||
private bool _isDragging = false;
|
||||
private int? _draggedItemId = null;
|
||||
private int? _draggedFromSlot = null; // Inventory slot index, or -1 for weapon slot
|
||||
private int _dragStartX, _dragStartY;
|
||||
private int _dragCurrentX, _dragCurrentY;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the bot is currently running.
|
||||
/// </summary>
|
||||
@@ -108,13 +115,14 @@ public unsafe class GameEngine : IDisposable
|
||||
/// </summary>
|
||||
private (int maxAmmo, int damage, bool isSingleUse) GetWeaponConfig(int tileId)
|
||||
{
|
||||
// Check if it's The Force - unlimited ammo
|
||||
// Damage values scaled for 768 HP system (3 lives x 256 HP)
|
||||
// Check if it's The Force - unlimited ammo, stun duration
|
||||
if (tileId == TILE_THE_FORCE)
|
||||
return (-1, 75, false); // -1 = unlimited
|
||||
return (-1, 10, false); // -1 = unlimited, damage = stun duration in ticks
|
||||
|
||||
// Lightsabers - melee, no ammo
|
||||
if (tileId == TILE_BASIC_LIGHTSABER || tileId == TILE_UPGRADED_LIGHTSABER)
|
||||
return (-1, 50, false);
|
||||
return (-1, 200, false);
|
||||
|
||||
// Check tile flags for weapon type
|
||||
if (tileId < _gameData!.Tiles.Count)
|
||||
@@ -124,11 +132,11 @@ public unsafe class GameEngine : IDisposable
|
||||
|
||||
// Heavy blaster - more damage, less ammo
|
||||
if ((flags & TileFlags.WeaponHeavyBlaster) != 0)
|
||||
return (15, 75, false);
|
||||
return (15, 300, false);
|
||||
|
||||
// Light blaster - standard
|
||||
if ((flags & TileFlags.WeaponLightBlaster) != 0)
|
||||
return (30, 50, false);
|
||||
return (30, 200, false);
|
||||
|
||||
// Generic weapon flag (pistols, etc)
|
||||
if ((flags & TileFlags.Weapon) != 0)
|
||||
@@ -136,15 +144,15 @@ public unsafe class GameEngine : IDisposable
|
||||
// Check for grenade-like items (single use)
|
||||
var name = GetTileName(tileId).ToLower();
|
||||
if (name.Contains("grenade") || name.Contains("bomb") || name.Contains("thermal"))
|
||||
return (1, 100, true); // Single use, high damage
|
||||
return (1, 400, true); // Single use, high damage
|
||||
|
||||
// Default ranged weapon
|
||||
return (20, 50, false);
|
||||
return (20, 200, false);
|
||||
}
|
||||
}
|
||||
|
||||
// Default for unknown weapons
|
||||
return (20, 50, false);
|
||||
return (20, 200, false);
|
||||
}
|
||||
|
||||
|
||||
@@ -621,6 +629,13 @@ public unsafe class GameEngine : IDisposable
|
||||
var world = _worldGenerator.GenerateWorld(_selectedWorldSize);
|
||||
Console.WriteLine($"Generated {_selectedWorldSize} world ({world.GridWidth}x{world.GridHeight} grid)");
|
||||
|
||||
// Wire up world data to action executor for puzzle conditions
|
||||
if (_actionExecutor != null)
|
||||
{
|
||||
_actionExecutor.CurrentWorld = world;
|
||||
_actionExecutor.CurrentMission = _worldGenerator.CurrentMission;
|
||||
}
|
||||
|
||||
// Welcome message - minimal startup hints (mission given by Yoda)
|
||||
_messages.ShowMessage("Find Yoda to receive your mission.", MessageType.System);
|
||||
|
||||
@@ -1097,7 +1112,15 @@ public unsafe class GameEngine : IDisposable
|
||||
break;
|
||||
|
||||
case SDLEventType.Mousebuttondown:
|
||||
HandleMouseClick(evt.Button.X, evt.Button.Y, evt.Button.Button);
|
||||
HandleMouseDown(evt.Button.X, evt.Button.Y, evt.Button.Button);
|
||||
break;
|
||||
|
||||
case SDLEventType.Mousebuttonup:
|
||||
HandleMouseUp(evt.Button.X, evt.Button.Y, evt.Button.Button);
|
||||
break;
|
||||
|
||||
case SDLEventType.Mousemotion:
|
||||
HandleMouseMove(evt.Motion.X, evt.Motion.Y);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -1221,30 +1244,181 @@ public unsafe class GameEngine : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleMouseClick(int x, int y, byte button)
|
||||
private void HandleMouseDown(int x, int y, byte button)
|
||||
{
|
||||
// Only handle left click (button 1)
|
||||
if (button != 1) return;
|
||||
|
||||
if (_renderer == null) return;
|
||||
|
||||
// Check if click is on weapon slot
|
||||
if (_renderer.IsPointOverWeaponSlot(x, y) && _state.SelectedWeapon.HasValue)
|
||||
{
|
||||
// Start dragging the equipped weapon
|
||||
_isDragging = true;
|
||||
_draggedItemId = _state.SelectedWeapon.Value;
|
||||
_draggedFromSlot = -1; // -1 indicates weapon slot
|
||||
_dragStartX = x;
|
||||
_dragStartY = y;
|
||||
_dragCurrentX = x;
|
||||
_dragCurrentY = y;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if click is on inventory (in sidebar)
|
||||
if (_renderer != null && _renderer.IsPointOverSidebar(x, y))
|
||||
if (_renderer.IsPointOverSidebar(x, y))
|
||||
{
|
||||
// Check if clicking on an inventory slot
|
||||
int? clickedSlot = _renderer.GetInventorySlotAtPosition(x, y, _state.Inventory.Count);
|
||||
Console.WriteLine($"MouseDown on sidebar: x={x}, y={y}, inventoryCount={_state.Inventory.Count}, clickedSlot={clickedSlot}");
|
||||
if (clickedSlot.HasValue && clickedSlot.Value < _state.Inventory.Count)
|
||||
{
|
||||
var itemId = _state.Inventory[clickedSlot.Value];
|
||||
|
||||
// Select the item
|
||||
_state.SelectedItem = itemId;
|
||||
// Start dragging this item
|
||||
_isDragging = true;
|
||||
_draggedItemId = itemId;
|
||||
_draggedFromSlot = clickedSlot.Value;
|
||||
_dragStartX = x;
|
||||
_dragStartY = y;
|
||||
_dragCurrentX = x;
|
||||
_dragCurrentY = y;
|
||||
|
||||
// If it's R2D2/Locator, use it immediately
|
||||
if (IsLocatorTile(itemId) && _state.HasLocator)
|
||||
// Also select the item
|
||||
_state.SelectedItem = itemId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleMouseUp(int x, int y, byte button)
|
||||
{
|
||||
// Only handle left click (button 1)
|
||||
if (button != 1) return;
|
||||
|
||||
if (_isDragging && _draggedItemId.HasValue && _renderer != null)
|
||||
{
|
||||
int dragDist = Math.Abs(x - _dragStartX) + Math.Abs(y - _dragStartY);
|
||||
bool wasActualDrag = dragDist >= 10;
|
||||
|
||||
// Check if dropped on weapon slot
|
||||
if (_renderer.IsPointOverWeaponSlot(x, y))
|
||||
{
|
||||
if (wasActualDrag && IsWeaponTile(_draggedItemId.Value))
|
||||
{
|
||||
ShowLocatorHint();
|
||||
// Dragging from inventory to weapon slot
|
||||
if (_draggedFromSlot.HasValue && _draggedFromSlot.Value >= 0)
|
||||
{
|
||||
// Swap: put currently equipped weapon in the inventory slot
|
||||
if (_state.SelectedWeapon.HasValue)
|
||||
{
|
||||
_state.Inventory[_draggedFromSlot.Value] = _state.SelectedWeapon.Value;
|
||||
}
|
||||
else
|
||||
{
|
||||
// No weapon equipped, just remove from inventory
|
||||
_state.Inventory.RemoveAt(_draggedFromSlot.Value);
|
||||
}
|
||||
}
|
||||
// Equip the new weapon
|
||||
_state.SelectedWeapon = _draggedItemId.Value;
|
||||
_messages.ShowMessage("Equipped weapon", MessageType.Info);
|
||||
_sounds?.PlaySound(SoundManager.SoundPickup);
|
||||
}
|
||||
}
|
||||
// Check if dropped on inventory
|
||||
else if (_renderer.IsPointOverSidebar(x, y))
|
||||
{
|
||||
int? targetSlot = _renderer.GetInventorySlotAtPosition(x, y, _state.Inventory.Count);
|
||||
Console.WriteLine($"Drop on sidebar: targetSlot={targetSlot}, fromSlot={_draggedFromSlot}, wasActualDrag={wasActualDrag}");
|
||||
|
||||
if (wasActualDrag)
|
||||
{
|
||||
// Dragging from weapon slot to inventory - unequip
|
||||
if (_draggedFromSlot.HasValue && _draggedFromSlot.Value == -1)
|
||||
{
|
||||
// Add the weapon to inventory
|
||||
_state.Inventory.Add(_draggedItemId.Value);
|
||||
_state.SelectedWeapon = null;
|
||||
_messages.ShowMessage("Unequipped weapon", MessageType.Info);
|
||||
_sounds?.PlaySound(SoundManager.SoundPickup);
|
||||
}
|
||||
// Dragging between inventory slots - swap
|
||||
else if (_draggedFromSlot.HasValue && _draggedFromSlot.Value >= 0 && targetSlot.HasValue)
|
||||
{
|
||||
int fromSlot = _draggedFromSlot.Value;
|
||||
int toSlot = targetSlot.Value;
|
||||
|
||||
Console.WriteLine($"Drag: fromSlot={fromSlot}, toSlot={toSlot}, inventoryCount={_state.Inventory.Count}");
|
||||
|
||||
// Clamp target slot to valid range
|
||||
if (toSlot >= _state.Inventory.Count)
|
||||
toSlot = _state.Inventory.Count - 1;
|
||||
|
||||
if (toSlot >= 0 && toSlot < _state.Inventory.Count && fromSlot != toSlot)
|
||||
{
|
||||
// Swap the items
|
||||
var temp = _state.Inventory[toSlot];
|
||||
_state.Inventory[toSlot] = _state.Inventory[fromSlot];
|
||||
_state.Inventory[fromSlot] = temp;
|
||||
Console.WriteLine($"Swapped items: slot {fromSlot} <-> slot {toSlot}");
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// It was a click, not a drag - use item if it's R2D2
|
||||
if (IsLocatorTile(_draggedItemId.Value) && _state.HasLocator)
|
||||
{
|
||||
ShowLocatorHint();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// End drag
|
||||
_isDragging = false;
|
||||
_draggedItemId = null;
|
||||
_draggedFromSlot = null;
|
||||
}
|
||||
|
||||
private void HandleMouseMove(int x, int y)
|
||||
{
|
||||
if (_isDragging)
|
||||
{
|
||||
_dragCurrentX = x;
|
||||
_dragCurrentY = y;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a tile is a weapon that can be equipped.
|
||||
/// </summary>
|
||||
private bool IsWeaponTile(int tileId)
|
||||
{
|
||||
if (tileId < 0 || tileId >= _gameData!.Tiles.Count)
|
||||
return false;
|
||||
|
||||
var tile = _gameData.Tiles[tileId];
|
||||
|
||||
// Check for weapon flag
|
||||
if ((tile.Flags & TileFlags.Weapon) != 0)
|
||||
return true;
|
||||
|
||||
// Check known weapon tiles
|
||||
if (tileId == TILE_BASIC_LIGHTSABER || tileId == TILE_UPGRADED_LIGHTSABER || tileId == TILE_THE_FORCE)
|
||||
return true;
|
||||
|
||||
// Check by tile name
|
||||
var name = GetTileName(tileId);
|
||||
if (name != null)
|
||||
{
|
||||
var nameLower = name.ToLowerInvariant();
|
||||
if (nameLower.Contains("blaster") || nameLower.Contains("saber") || nameLower.Contains("force") ||
|
||||
nameLower.Contains("rifle") || nameLower.Contains("pistol") || nameLower.Contains("weapon"))
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private void HandleKeyDown(int keyCode)
|
||||
@@ -2860,7 +3034,7 @@ public unsafe class GameEngine : IDisposable
|
||||
}
|
||||
|
||||
// Get damage from ammo state or use default
|
||||
int damage = ammoState?.Damage ?? 50;
|
||||
int damage = ammoState?.Damage ?? 200;
|
||||
|
||||
// Determine projectile type
|
||||
var projType = ProjectileType.Blaster;
|
||||
@@ -2925,7 +3099,7 @@ public unsafe class GameEngine : IDisposable
|
||||
_state.IsAttacking = true;
|
||||
_state.AttackTimer = 0.3;
|
||||
|
||||
// Calculate attack position based on facing direction
|
||||
// Calculate primary attack position based on facing direction
|
||||
int targetX = _state.PlayerX;
|
||||
int targetY = _state.PlayerY;
|
||||
|
||||
@@ -2937,54 +3111,74 @@ public unsafe class GameEngine : IDisposable
|
||||
case Direction.Right: targetX++; break;
|
||||
}
|
||||
|
||||
Console.WriteLine($"Melee attack at ({targetX},{targetY}), {_state.ZoneNPCs.Count} NPCs in zone");
|
||||
// 3-tile spread pattern: primary target + 2 diagonal offsets
|
||||
// Example facing Right: (x+1,y), (x+1,y-1), (x+1,y+1)
|
||||
var attackPositions = new List<(int X, int Y)> { (targetX, targetY) };
|
||||
switch (_state.PlayerDirection)
|
||||
{
|
||||
case Direction.Up:
|
||||
case Direction.Down:
|
||||
attackPositions.Add((targetX - 1, targetY));
|
||||
attackPositions.Add((targetX + 1, targetY));
|
||||
break;
|
||||
case Direction.Left:
|
||||
case Direction.Right:
|
||||
attackPositions.Add((targetX, targetY - 1));
|
||||
attackPositions.Add((targetX, targetY + 1));
|
||||
break;
|
||||
}
|
||||
|
||||
// Check for NPC at or near target position (melee has some range)
|
||||
Console.WriteLine($"Melee attack at ({targetX},{targetY}) +spread, {_state.ZoneNPCs.Count} NPCs in zone");
|
||||
|
||||
// Calculate damage - scaled for 768 HP system
|
||||
int damage = _state.SelectedWeapon.HasValue ? 200 : 100;
|
||||
|
||||
bool hitAny = false;
|
||||
foreach (var npc in _state.ZoneNPCs)
|
||||
{
|
||||
if (!npc.IsEnabled || !npc.IsAlive)
|
||||
continue;
|
||||
|
||||
// Calculate distance to NPC
|
||||
var distX = Math.Abs(npc.X - targetX);
|
||||
var distY = Math.Abs(npc.Y - targetY);
|
||||
var dist = distX + distY;
|
||||
|
||||
Console.WriteLine($" NPC at ({npc.X},{npc.Y}), dist={dist}");
|
||||
|
||||
// Hit if within melee range (1 tile from target, 2 tiles from player)
|
||||
if (dist <= 1)
|
||||
// Check all 3 attack positions
|
||||
bool inRange = false;
|
||||
foreach (var pos in attackPositions)
|
||||
{
|
||||
// Calculate damage
|
||||
int damage = _state.SelectedWeapon.HasValue ? 50 : 25;
|
||||
|
||||
bool killed = npc.TakeDamage(damage);
|
||||
_state.AttackFlashTimer = 0.5;
|
||||
_sounds?.PlaySound(SoundManager.SoundAttack);
|
||||
|
||||
// Get NPC name for message
|
||||
var npcName = GetCharacterName(npc.CharacterId) ?? $"Target";
|
||||
|
||||
if (killed)
|
||||
if (npc.X == pos.X && npc.Y == pos.Y)
|
||||
{
|
||||
_messages.ShowCombat($"{npcName} defeated!");
|
||||
_sounds?.PlaySound(SoundManager.SoundDeath);
|
||||
Console.WriteLine($" Killed NPC!");
|
||||
}
|
||||
else
|
||||
{
|
||||
_messages.ShowCombat($"Hit! {npc.Health} HP left");
|
||||
Console.WriteLine($" Hit NPC for {damage} damage, {npc.Health} HP left");
|
||||
inRange = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
_actionExecutor?.ExecuteZoneActions(ActionTrigger.Attack);
|
||||
return;
|
||||
if (!inRange)
|
||||
continue;
|
||||
|
||||
hitAny = true;
|
||||
bool killed = npc.TakeDamage(damage);
|
||||
_state.AttackFlashTimer = 0.5;
|
||||
_sounds?.PlaySound(SoundManager.SoundAttack);
|
||||
|
||||
if (killed)
|
||||
{
|
||||
HandleNPCDeath(npc);
|
||||
}
|
||||
else
|
||||
{
|
||||
_messages.ShowCombat($"Hit! {npc.Health} HP left");
|
||||
Console.WriteLine($" Hit NPC for {damage} damage, {npc.Health} HP left");
|
||||
}
|
||||
}
|
||||
|
||||
// No NPC found - show swing/miss feedback
|
||||
_state.AttackFlashTimer = 0.2;
|
||||
_messages.ShowMessage("*swing*", MessageType.Combat);
|
||||
if (hitAny)
|
||||
{
|
||||
_actionExecutor?.ExecuteZoneActions(ActionTrigger.Attack);
|
||||
}
|
||||
else
|
||||
{
|
||||
// No NPC found - show swing/miss feedback
|
||||
_state.AttackFlashTimer = 0.2;
|
||||
_messages.ShowMessage("*swing*", MessageType.Combat);
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateCamera()
|
||||
@@ -3142,6 +3336,20 @@ public unsafe class GameEngine : IDisposable
|
||||
npc.MaxHealth = character.Weapon.Health;
|
||||
npc.Health = character.Weapon.Health;
|
||||
}
|
||||
|
||||
// Apply ranged attack from CHWP weapon reference
|
||||
if (character.Weapon != null && character.Weapon.Reference > 0 && character.Weapon.Reference != 0xFFFF)
|
||||
{
|
||||
npc.HasRangedAttack = true;
|
||||
npc.WeaponTileId = character.Weapon.Reference;
|
||||
}
|
||||
|
||||
// Enemy NPCs drop loot from their carried item
|
||||
if (character.Type == CharacterType.Enemy && npc.CarriedItemId.HasValue)
|
||||
{
|
||||
npc.DropsLoot = true;
|
||||
npc.LootItemId = npc.CarriedItemId.Value;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -3525,12 +3733,19 @@ public unsafe class GameEngine : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
// First script evaluation (before NPC/projectile updates)
|
||||
_actionExecutor?.ExecuteZoneActions(ActionTrigger.Walk);
|
||||
|
||||
// Update NPC AI
|
||||
UpdateNPCs(deltaTime);
|
||||
|
||||
// Update projectiles
|
||||
UpdateProjectiles(deltaTime);
|
||||
|
||||
// Second script evaluation (after NPC/projectile updates, before input)
|
||||
// This ensures scripts that depend on NPC state changes (like all-monsters-dead) trigger promptly
|
||||
_actionExecutor?.ExecuteZoneActions(ActionTrigger.Walk);
|
||||
|
||||
// Update bot AI
|
||||
if (_bot?.IsRunning == true)
|
||||
_bot.Update(deltaTime);
|
||||
@@ -3573,6 +3788,19 @@ public unsafe class GameEngine : IDisposable
|
||||
if (!npc.IsEnabled || !npc.IsAlive)
|
||||
continue;
|
||||
|
||||
// Decrement stun timer - stunned NPCs skip all actions
|
||||
if (npc.StunTimer > 0)
|
||||
{
|
||||
npc.StunTimer--;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Hostile NPCs with ranged weapons try to shoot before moving
|
||||
if (npc.IsHostile && npc.HasRangedAttack)
|
||||
{
|
||||
TryNPCRangedAttack(npc);
|
||||
}
|
||||
|
||||
// Update move timer
|
||||
npc.MoveTimer += deltaTime;
|
||||
|
||||
@@ -3593,6 +3821,12 @@ public unsafe class GameEngine : IDisposable
|
||||
case NPCBehavior.Fleeing:
|
||||
UpdateFleeingNPC(npc);
|
||||
break;
|
||||
case NPCBehavior.Patrol:
|
||||
UpdatePatrolNPC(npc);
|
||||
break;
|
||||
case NPCBehavior.Animation:
|
||||
// Animation-only NPCs just cycle frames (handled by global animation sync)
|
||||
break;
|
||||
case NPCBehavior.Stationary:
|
||||
default:
|
||||
// Face the player if nearby
|
||||
@@ -3604,7 +3838,7 @@ public unsafe class GameEngine : IDisposable
|
||||
break;
|
||||
}
|
||||
|
||||
// Hostile NPCs attack if adjacent to player
|
||||
// Hostile NPCs attack if adjacent to player (melee)
|
||||
if (npc.IsHostile)
|
||||
{
|
||||
npc.ActionTimer += deltaTime;
|
||||
@@ -3626,6 +3860,105 @@ public unsafe class GameEngine : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts a ranged attack from an NPC toward the hero.
|
||||
/// Monster checks if hero is within 4 tiles on same row/col, then rolls 1-in-7 chance to fire.
|
||||
/// </summary>
|
||||
private void TryNPCRangedAttack(NPC npc)
|
||||
{
|
||||
int dx = _state.PlayerX - npc.X;
|
||||
int dy = _state.PlayerY - npc.Y;
|
||||
|
||||
// Must be on same row or column
|
||||
bool sameRow = dy == 0 && Math.Abs(dx) > 0 && Math.Abs(dx) <= 4;
|
||||
bool sameCol = dx == 0 && Math.Abs(dy) > 0 && Math.Abs(dy) <= 4;
|
||||
|
||||
if (!sameRow && !sameCol)
|
||||
return;
|
||||
|
||||
// ~85% chance to fire per eligible tick (rand() % 7 != 3 fires, so 6/7 ~ 85%)
|
||||
if (_random.Next(7) == 3)
|
||||
return; // Miss this chance
|
||||
|
||||
// Determine direction to player
|
||||
double velX = 0, velY = 0;
|
||||
const double npcProjectileSpeed = 8.0; // Tiles per second (slower than player projectiles)
|
||||
|
||||
if (sameRow)
|
||||
{
|
||||
velX = dx > 0 ? npcProjectileSpeed : -npcProjectileSpeed;
|
||||
npc.Direction = dx > 0 ? Direction.Right : Direction.Left;
|
||||
}
|
||||
else
|
||||
{
|
||||
velY = dy > 0 ? npcProjectileSpeed : -npcProjectileSpeed;
|
||||
npc.Direction = dy > 0 ? Direction.Down : Direction.Up;
|
||||
}
|
||||
|
||||
var projectile = new Projectile
|
||||
{
|
||||
X = npc.X + (velX > 0 ? 0.5 : velX < 0 ? -0.5 : 0),
|
||||
Y = npc.Y + (velY > 0 ? 0.5 : velY < 0 ? -0.5 : 0),
|
||||
VelocityX = velX,
|
||||
VelocityY = velY,
|
||||
Damage = npc.Damage,
|
||||
LifeTime = 0.5, // Max 4 tiles at speed 8 = 0.5s
|
||||
Type = ProjectileType.Blaster,
|
||||
IsEnemyProjectile = true,
|
||||
TileId = npc.WeaponTileId
|
||||
};
|
||||
_state.Projectiles.Add(projectile);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates a patrol NPC that follows waypoints.
|
||||
/// </summary>
|
||||
private void UpdatePatrolNPC(NPC npc)
|
||||
{
|
||||
if (npc.Waypoints.Length == 0)
|
||||
{
|
||||
// No waypoints - fall back to wandering
|
||||
UpdateWanderingNPC(npc);
|
||||
return;
|
||||
}
|
||||
|
||||
var target = npc.Waypoints[npc.CurrentWaypoint];
|
||||
|
||||
// Skip waypoints at (0,0) - unused slots
|
||||
if (target.X == 0 && target.Y == 0)
|
||||
{
|
||||
npc.CurrentWaypoint = (npc.CurrentWaypoint + 1) % npc.Waypoints.Length;
|
||||
return;
|
||||
}
|
||||
|
||||
// Move toward current waypoint
|
||||
var dx = Math.Sign(target.X - npc.X);
|
||||
var dy = Math.Sign(target.Y - npc.Y);
|
||||
|
||||
if (dx != 0)
|
||||
{
|
||||
npc.Direction = dx > 0 ? Direction.Right : Direction.Left;
|
||||
if (IsValidNPCPosition(npc.X + dx, npc.Y, npc))
|
||||
{
|
||||
npc.X += dx;
|
||||
}
|
||||
}
|
||||
else if (dy != 0)
|
||||
{
|
||||
npc.Direction = dy > 0 ? Direction.Down : Direction.Up;
|
||||
if (IsValidNPCPosition(npc.X, npc.Y + dy, npc))
|
||||
{
|
||||
npc.Y += dy;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if reached waypoint
|
||||
if (npc.X == target.X && npc.Y == target.Y)
|
||||
{
|
||||
npc.CurrentWaypoint = (npc.CurrentWaypoint + 1) % npc.Waypoints.Length;
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateProjectiles(double deltaTime)
|
||||
{
|
||||
for (int i = _state.Projectiles.Count - 1; i >= 0; i--)
|
||||
@@ -3655,8 +3988,21 @@ public unsafe class GameEngine : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
// Check for NPC collision
|
||||
if (projectile.IsActive)
|
||||
// Enemy projectiles check collision with hero
|
||||
if (projectile.IsActive && projectile.IsEnemyProjectile)
|
||||
{
|
||||
if (tileX == _state.PlayerX && tileY == _state.PlayerY)
|
||||
{
|
||||
_state.Health -= projectile.Damage;
|
||||
_state.DamageFlashTimer = 1.0;
|
||||
_sounds?.PlaySound(SoundManager.SoundHurt);
|
||||
_messages.ShowCombat($"Shot! (-{projectile.Damage} HP)");
|
||||
projectile.IsActive = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Player projectiles check collision with NPCs
|
||||
if (projectile.IsActive && !projectile.IsEnemyProjectile)
|
||||
{
|
||||
foreach (var npc in _state.ZoneNPCs)
|
||||
{
|
||||
@@ -3665,19 +4011,30 @@ public unsafe class GameEngine : IDisposable
|
||||
|
||||
if (npc.X == tileX && npc.Y == tileY)
|
||||
{
|
||||
bool killed = npc.TakeDamage(projectile.Damage);
|
||||
projectile.IsActive = false;
|
||||
_state.AttackFlashTimer = 0.3;
|
||||
|
||||
var npcName = GetCharacterName(npc.CharacterId) ?? "Target";
|
||||
if (killed)
|
||||
// The Force stuns instead of dealing damage
|
||||
if (projectile.Type == ProjectileType.Force)
|
||||
{
|
||||
_messages.ShowCombat($"{npcName} defeated!");
|
||||
_sounds?.PlaySound(SoundManager.SoundDeath);
|
||||
npc.StunTimer = projectile.Damage;
|
||||
projectile.IsActive = false;
|
||||
_state.AttackFlashTimer = 0.3;
|
||||
var stunName = GetCharacterName(npc.CharacterId) ?? "Target";
|
||||
_messages.ShowCombat($"{stunName} stunned!");
|
||||
}
|
||||
else
|
||||
{
|
||||
_messages.ShowCombat($"Hit! {npc.Health} HP left");
|
||||
bool killed = npc.TakeDamage(projectile.Damage);
|
||||
projectile.IsActive = false;
|
||||
_state.AttackFlashTimer = 0.3;
|
||||
|
||||
var npcName = GetCharacterName(npc.CharacterId) ?? "Target";
|
||||
if (killed)
|
||||
{
|
||||
HandleNPCDeath(npc);
|
||||
}
|
||||
else
|
||||
{
|
||||
_messages.ShowCombat($"Hit! {npc.Health} HP left");
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -3692,6 +4049,47 @@ public unsafe class GameEngine : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles NPC death: shows message, plays sound, drops loot.
|
||||
/// </summary>
|
||||
private void HandleNPCDeath(NPC npc)
|
||||
{
|
||||
var npcName = GetCharacterName(npc.CharacterId) ?? "Target";
|
||||
_messages.ShowCombat($"{npcName} defeated!");
|
||||
_sounds?.PlaySound(SoundManager.SoundDeath);
|
||||
|
||||
// Drop loot if applicable
|
||||
if (npc.DropsLoot && _state.CurrentZone != null)
|
||||
{
|
||||
int lootId = npc.LootItemId;
|
||||
|
||||
// If loot is -1, use zone's DropQuestItem hotspot item
|
||||
if (lootId == -1 || lootId == 0xFFFF)
|
||||
{
|
||||
foreach (var obj in _state.CurrentZone.Objects)
|
||||
{
|
||||
// ZoneObjectType 0x0D (Teleporter) is also used for quest drops in some contexts
|
||||
// Look for CrateItem objects that could serve as quest item drops
|
||||
if (obj.Type == ZoneObjectType.CrateItem && obj.Argument != 0xFFFF && obj.Argument > 0)
|
||||
{
|
||||
lootId = obj.Argument;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (lootId > 0 && lootId != 0xFFFF)
|
||||
{
|
||||
// Place loot item on object layer at NPC position
|
||||
_state.CurrentZone.SetTile(npc.X, npc.Y, 1, (ushort)lootId);
|
||||
var lootName = GetTileName(lootId);
|
||||
Console.WriteLine($" Dropped loot: {lootName} (tile {lootId}) at ({npc.X},{npc.Y})");
|
||||
}
|
||||
}
|
||||
|
||||
_actionExecutor?.ExecuteZoneActions(ActionTrigger.Attack);
|
||||
}
|
||||
|
||||
private void UpdateWanderingNPC(NPC npc)
|
||||
{
|
||||
// Random movement within wander radius of start position
|
||||
@@ -3900,6 +4298,12 @@ public unsafe class GameEngine : IDisposable
|
||||
}
|
||||
_renderer.RenderHUD(_state.Health, _state.MaxHealth, _state.Inventory, _state.SelectedWeapon, _state.SelectedItem, currentAmmo, maxAmmo);
|
||||
|
||||
// Render dragged item if dragging
|
||||
if (_isDragging && _draggedItemId.HasValue)
|
||||
{
|
||||
_renderer.RenderDraggedItem(_draggedItemId.Value, _dragCurrentX, _dragCurrentY);
|
||||
}
|
||||
|
||||
// Render zone info
|
||||
_renderer.RenderZoneInfo(
|
||||
_state.CurrentZoneId,
|
||||
@@ -4069,6 +4473,10 @@ public unsafe class GameEngine : IDisposable
|
||||
|
||||
private void RenderPlayer()
|
||||
{
|
||||
// Skip rendering if hero is hidden (by HideHero script instruction)
|
||||
if (!_state.HeroVisible)
|
||||
return;
|
||||
|
||||
// Find hero character for rendering
|
||||
// In Yoda Stories, the hero (Luke) is typically character 0 or has Hero type
|
||||
Character? heroChar = null;
|
||||
|
||||
@@ -23,8 +23,8 @@ public class GameState
|
||||
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;
|
||||
public int Health { get; set; } = 768;
|
||||
public int MaxHealth { get; set; } = 768;
|
||||
|
||||
// Current zone
|
||||
public int CurrentZoneId { get; set; }
|
||||
@@ -57,6 +57,7 @@ public class GameState
|
||||
public bool IsGameWon { get; set; }
|
||||
public bool IsPaused { get; set; }
|
||||
public bool HasLocator { get; set; } // Has picked up R2D2/locator droid
|
||||
public bool HeroVisible { get; set; } = true; // For HideHero/ShowHero script instructions
|
||||
|
||||
// Animation state
|
||||
public int AnimationFrame { get; set; }
|
||||
@@ -115,6 +116,7 @@ public class GameState
|
||||
IsGameWon = false;
|
||||
IsPaused = false;
|
||||
HasLocator = false;
|
||||
HeroVisible = true;
|
||||
AnimationFrame = 0;
|
||||
AnimationTimer = 0;
|
||||
CameraX = 0;
|
||||
|
||||
@@ -8,7 +8,9 @@ public enum NPCBehavior
|
||||
Stationary, // Doesn't move
|
||||
Wandering, // Moves randomly
|
||||
Chasing, // Chases the player (enemy)
|
||||
Fleeing // Runs away from player
|
||||
Fleeing, // Runs away from player
|
||||
Patrol, // Follow waypoints in a cycle
|
||||
Animation // Cycle animation frames without moving
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -22,8 +24,8 @@ public class NPC
|
||||
public int StartX { get; set; } // Original spawn position
|
||||
public int StartY { get; set; }
|
||||
public Direction Direction { get; set; } = Direction.Down;
|
||||
public int Health { get; set; } = 100;
|
||||
public int MaxHealth { get; set; } = 100;
|
||||
public int Health { get; set; } = 256;
|
||||
public int MaxHealth { get; set; } = 256;
|
||||
public bool IsAlive => Health > 0;
|
||||
public bool IsEnabled { get; set; } = true;
|
||||
public bool IsHostile { get; set; } = false;
|
||||
@@ -41,10 +43,25 @@ public class NPC
|
||||
public double MoveCooldown { get; set; } = 0.5; // Time between moves
|
||||
public double AttackCooldown { get; set; } = 1.0; // Time between attacks
|
||||
|
||||
// Stun (from The Force weapon)
|
||||
public int StunTimer { get; set; }
|
||||
|
||||
// Combat
|
||||
public int Damage { get; set; } = 10;
|
||||
public int AttackRange { get; set; } = 1;
|
||||
|
||||
// Ranged attack (from CHWP weapon data)
|
||||
public bool HasRangedAttack { get; set; }
|
||||
public int WeaponTileId { get; set; } // Projectile visual tile from CHWP Reference
|
||||
|
||||
// Loot (from IZAX data)
|
||||
public bool DropsLoot { get; set; }
|
||||
public int LootItemId { get; set; } = -1; // -1 means use zone's DropQuestItem hotspot
|
||||
|
||||
// Patrol waypoints (from IZAX entity Data field)
|
||||
public (int X, int Y)[] Waypoints { get; set; } = Array.Empty<(int, int)>();
|
||||
public int CurrentWaypoint { get; set; }
|
||||
|
||||
// Item handoff (from IZAX data)
|
||||
public int? CarriedItemId { get; set; } // Item this NPC will give when interacted with
|
||||
public int CarriedItemQuantity { get; set; } = 1;
|
||||
@@ -66,8 +83,8 @@ public class NPC
|
||||
StartX = obj.X,
|
||||
StartY = obj.Y,
|
||||
Direction = Direction.Down,
|
||||
Health = 100,
|
||||
MaxHealth = 100,
|
||||
Health = 256,
|
||||
MaxHealth = 256,
|
||||
IsEnabled = true,
|
||||
Behavior = NPCBehavior.Wandering
|
||||
};
|
||||
|
||||
@@ -14,6 +14,7 @@ public class Projectile
|
||||
public bool IsActive { get; set; } = true;
|
||||
public int TileId { get; set; } // Visual representation
|
||||
public ProjectileType Type { get; set; } = ProjectileType.Blaster;
|
||||
public bool IsEnemyProjectile { get; set; } // True = fired by NPC, damages hero
|
||||
|
||||
/// <summary>
|
||||
/// Updates the projectile position.
|
||||
|
||||
@@ -129,16 +129,18 @@ public unsafe class GameRenderer : IDisposable
|
||||
int slotPadding = 2;
|
||||
int gridStartX = hudX + 10;
|
||||
|
||||
// Calculate grid start Y (after health, weapon sections)
|
||||
// Health: 15 + 18 + 45 = 78
|
||||
// Weapon section: 18 + 32 + 15 = 65
|
||||
// Calculate grid start Y (must match RenderHUD layout exactly)
|
||||
// Health section: 15 (start) + 18 (label) + 45 (bar) = 78
|
||||
// Weapon section: 18 (label) + 64 (slot: 32*2) + 15 (spacing) = 97
|
||||
// Inventory header: 16
|
||||
int gridStartY = 78 + 65 + 16;
|
||||
int gridStartY = 78 + 97 + 16; // = 191
|
||||
|
||||
// Check if click is in grid area
|
||||
int relX = logicalX - gridStartX;
|
||||
int relY = logicalY - gridStartY;
|
||||
|
||||
Console.WriteLine($"GetInventorySlotAtPosition: screen=({screenX},{screenY}), logical=({logicalX},{logicalY}), scale={_currentScale}, grid=({gridStartX},{gridStartY}), rel=({relX},{relY})");
|
||||
|
||||
if (relX < 0 || relY < 0) return null;
|
||||
|
||||
// Calculate which cell was clicked
|
||||
@@ -150,12 +152,59 @@ public unsafe class GameRenderer : IDisposable
|
||||
// Calculate slot index with scroll offset
|
||||
int slotIndex = (row * 4 + col) + _inventoryScrollOffset;
|
||||
|
||||
Console.WriteLine($" -> col={col}, row={row}, slotIndex={slotIndex}, inventoryCount={inventoryCount}");
|
||||
|
||||
if (slotIndex >= 0 && slotIndex < inventoryCount)
|
||||
return slotIndex;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the given screen position is over the weapon slot.
|
||||
/// </summary>
|
||||
public bool IsPointOverWeaponSlot(int screenX, int screenY)
|
||||
{
|
||||
// Convert screen coordinates to logical coordinates
|
||||
int logicalX = screenX * 2 / _currentScale;
|
||||
int logicalY = screenY * 2 / _currentScale;
|
||||
|
||||
// Check if over sidebar
|
||||
if (logicalX < GameAreaWidth) return false;
|
||||
|
||||
// Weapon slot layout (matches RenderHUD)
|
||||
int hudX = GameAreaWidth;
|
||||
int weaponSlotX = hudX + 10;
|
||||
int weaponSlotY = 15 + 18 + 45 + 18; // After health section + "WEAPON" label
|
||||
int weaponSlotSize = Tile.Width * Scale; // 32 * 2 = 64
|
||||
|
||||
// Check if click is within weapon slot bounds
|
||||
return logicalX >= weaponSlotX && logicalX < weaponSlotX + weaponSlotSize &&
|
||||
logicalY >= weaponSlotY && logicalY < weaponSlotY + weaponSlotSize;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders a dragged item at the current mouse position.
|
||||
/// </summary>
|
||||
public void RenderDraggedItem(int tileId, int screenX, int screenY)
|
||||
{
|
||||
if (_tileAtlas == null || tileId < 0 || tileId >= _gameData.Tiles.Count)
|
||||
return;
|
||||
|
||||
// Convert screen coordinates to logical coordinates
|
||||
int logicalX = screenX * 2 / _currentScale;
|
||||
int logicalY = screenY * 2 / _currentScale;
|
||||
|
||||
// Center the tile on the cursor
|
||||
int drawX = logicalX - Tile.Width / 2;
|
||||
int drawY = logicalY - Tile.Height / 2;
|
||||
|
||||
// Render with slight transparency
|
||||
SDL.SetTextureAlphaMod(_tileAtlas, 180);
|
||||
RenderTile(tileId, drawX, drawY);
|
||||
SDL.SetTextureAlphaMod(_tileAtlas, 255);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the window scale (1x, 2x or 4x).
|
||||
/// </summary>
|
||||
@@ -496,24 +545,61 @@ public unsafe class GameRenderer : IDisposable
|
||||
var borderRect = new SDLRect { X = hudX, Y = 0, W = 2, H = WindowHeight };
|
||||
SDL.RenderFillRect(_renderer, &borderRect);
|
||||
|
||||
// === HEALTH SECTION ===
|
||||
// === HEALTH SECTION (3-life gauge: green -> yellow -> red -> black) ===
|
||||
var sectionY = 15;
|
||||
_font.RenderText(_renderer, "HEALTH", hudX + 10, sectionY, 1, 200, 200, 200, 255);
|
||||
int lives = (health + 255) / 256; // 1-3 lives
|
||||
string livesLabel = lives > 1 ? $"HEALTH ({lives} lives)" : "HEALTH";
|
||||
_font.RenderText(_renderer, livesLabel, hudX + 10, sectionY, 1, 200, 200, 200, 255);
|
||||
sectionY += 18;
|
||||
|
||||
// Health bar background
|
||||
SDL.SetRenderDrawColor(_renderer, 60, 20, 20, 255);
|
||||
var healthBg = new SDLRect { X = hudX + 10, Y = sectionY, W = SidebarWidth - 20, H = 24 };
|
||||
// Health bar background (black = dead)
|
||||
SDL.SetRenderDrawColor(_renderer, 20, 20, 20, 255);
|
||||
int barWidth = SidebarWidth - 20;
|
||||
var healthBg = new SDLRect { X = hudX + 10, Y = sectionY, W = barWidth, H = 24 };
|
||||
SDL.RenderFillRect(_renderer, &healthBg);
|
||||
|
||||
// Health bar fill
|
||||
var healthWidth = (int)((float)health / maxHealth * (SidebarWidth - 20));
|
||||
SDL.SetRenderDrawColor(_renderer, 180, 40, 40, 255);
|
||||
var healthRect = new SDLRect { X = hudX + 10, Y = sectionY, W = healthWidth, H = 24 };
|
||||
SDL.RenderFillRect(_renderer, &healthRect);
|
||||
// Draw 3-segment color gradient gauge
|
||||
// Each segment = 256 HP. Segment 3 = green, Segment 2 = yellow, Segment 1 = red
|
||||
if (health > 0)
|
||||
{
|
||||
float ratio = (float)health / maxHealth;
|
||||
int fillWidth = (int)(ratio * barWidth);
|
||||
|
||||
// Determine color based on which life segment we're in
|
||||
byte hR, hG, hB;
|
||||
if (health > 512)
|
||||
{
|
||||
// Life 3: Green
|
||||
hR = 40; hG = 200; hB = 40;
|
||||
}
|
||||
else if (health > 256)
|
||||
{
|
||||
// Life 2: Yellow
|
||||
hR = 220; hG = 200; hB = 40;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Life 1: Red
|
||||
hR = 200; hG = 40; hB = 40;
|
||||
}
|
||||
|
||||
SDL.SetRenderDrawColor(_renderer, hR, hG, hB, 255);
|
||||
var healthRect = new SDLRect { X = hudX + 10, Y = sectionY, W = fillWidth, H = 24 };
|
||||
SDL.RenderFillRect(_renderer, &healthRect);
|
||||
|
||||
// Draw segment dividers at 1/3 and 2/3
|
||||
SDL.SetRenderDrawColor(_renderer, 0, 0, 0, 150);
|
||||
SDL.SetRenderDrawBlendMode(_renderer, SDLBlendMode.Blend);
|
||||
int seg1X = hudX + 10 + barWidth / 3;
|
||||
int seg2X = hudX + 10 + barWidth * 2 / 3;
|
||||
var div1 = new SDLRect { X = seg1X, Y = sectionY, W = 2, H = 24 };
|
||||
var div2 = new SDLRect { X = seg2X, Y = sectionY, W = 2, H = 24 };
|
||||
SDL.RenderFillRect(_renderer, &div1);
|
||||
SDL.RenderFillRect(_renderer, &div2);
|
||||
}
|
||||
|
||||
// Health bar border
|
||||
SDL.SetRenderDrawColor(_renderer, 200, 100, 100, 255);
|
||||
SDL.SetRenderDrawColor(_renderer, 100, 100, 100, 255);
|
||||
SDL.RenderDrawRect(_renderer, &healthBg);
|
||||
|
||||
// Health text centered
|
||||
|
||||
Reference in New Issue
Block a user