diff --git a/src/YodaStoriesNG.Engine/Bot/MissionSolver.cs b/src/YodaStoriesNG.Engine/Bot/MissionSolver.cs index e0beb3e..919fb9a 100644 --- a/src/YodaStoriesNG.Engine/Bot/MissionSolver.cs +++ b/src/YodaStoriesNG.Engine/Bot/MissionSolver.cs @@ -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); } diff --git a/src/YodaStoriesNG.Engine/Game/ActionExecutor.cs b/src/YodaStoriesNG.Engine/Game/ActionExecutor.cs index bb67f07..e846673 100644 --- a/src/YodaStoriesNG.Engine/Game/ActionExecutor.cs +++ b/src/YodaStoriesNG.Engine/Game/ActionExecutor.cs @@ -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? PendingInstructions { get; set; } + public int PendingInstructionIndex { get; set; } + // Event for displaying dialogue public event Action? OnDialogue; public event Action? 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: diff --git a/src/YodaStoriesNG.Engine/Game/GameEngine.cs b/src/YodaStoriesNG.Engine/Game/GameEngine.cs index 761d285..888968d 100644 --- a/src/YodaStoriesNG.Engine/Game/GameEngine.cs +++ b/src/YodaStoriesNG.Engine/Game/GameEngine.cs @@ -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; + /// /// Whether the bot is currently running. /// @@ -108,13 +115,14 @@ public unsafe class GameEngine : IDisposable /// 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; + } + } + + /// + /// Checks if a tile is a weapon that can be equipped. + /// + 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 } } + /// + /// 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. + /// + 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); + } + + /// + /// Updates a patrol NPC that follows waypoints. + /// + 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 } } + /// + /// Handles NPC death: shows message, plays sound, drops loot. + /// + 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; diff --git a/src/YodaStoriesNG.Engine/Game/GameState.cs b/src/YodaStoriesNG.Engine/Game/GameState.cs index 918a71b..67ad31d 100644 --- a/src/YodaStoriesNG.Engine/Game/GameState.cs +++ b/src/YodaStoriesNG.Engine/Game/GameState.cs @@ -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; diff --git a/src/YodaStoriesNG.Engine/Game/NPC.cs b/src/YodaStoriesNG.Engine/Game/NPC.cs index 566a627..0ed784f 100644 --- a/src/YodaStoriesNG.Engine/Game/NPC.cs +++ b/src/YodaStoriesNG.Engine/Game/NPC.cs @@ -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 } /// @@ -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 }; diff --git a/src/YodaStoriesNG.Engine/Game/Projectile.cs b/src/YodaStoriesNG.Engine/Game/Projectile.cs index d09065f..9425c89 100644 --- a/src/YodaStoriesNG.Engine/Game/Projectile.cs +++ b/src/YodaStoriesNG.Engine/Game/Projectile.cs @@ -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 /// /// Updates the projectile position. diff --git a/src/YodaStoriesNG.Engine/Rendering/GameRenderer.cs b/src/YodaStoriesNG.Engine/Rendering/GameRenderer.cs index e691038..20d96c9 100644 --- a/src/YodaStoriesNG.Engine/Rendering/GameRenderer.cs +++ b/src/YodaStoriesNG.Engine/Rendering/GameRenderer.cs @@ -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; } + /// + /// Checks if the given screen position is over the weapon slot. + /// + 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; + } + + /// + /// Renders a dragged item at the current mouse position. + /// + 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); + } + /// /// Sets the window scale (1x, 2x or 4x). /// @@ -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