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:
Ned Heller
2026-02-11 13:56:25 -08:00
co-authored by Claude Opus 4.6
parent c893f00373
commit 34a101c6c0
7 changed files with 680 additions and 113 deletions
@@ -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);
}
+78 -25
View File
@@ -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:
+473 -65
View File
@@ -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;
+4 -2
View File
@@ -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;
+22 -5
View File
@@ -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