#include "rlc_turn_systems.h" #include "rlc_components.h" #include "rlc_equipment_spawner.h" #include "rlc_items.h" #include "rlc_log.h" #include "rlc_fov.h" #include "rlc_npc_ai.h" #include "rlc_pathfinding.h" #include "rlc_turn_manager.h" #include "rlc_tween.h" #include "rlc_tween_components.h" #include "dungeon_builder.h" #include /* ============================================================ * HELPER FUNCTIONS * ============================================================ */ /* Forward declarations */ static bool is_tile_occupied(ecs_world_t *world, int x, int y, ecs_entity_t ignore); /* Constants for Action Costs */ #define ATTACK_DELAY_BASE 20 /* Helper: Calculate attack delay */ static int calculate_attack_delay(int speed) { /* Higher speed = lower delay */ /* Base 20, reduce by speed/5. Min delay 5. */ int delay = ATTACK_DELAY_BASE - (speed / 5); if (delay < 5) delay = 5; return delay; } /* Helper: Find free inventory slot */ static int find_free_slot(ecs_world_t *world, ecs_entity_t player) { bool occupied[12] = {false}; ecs_query_t *q = ecs_query(world, { .terms = {{.id = ecs_id(InventorySlot)}, {.first.id = ContainedBy, .second.id = player}} }); ecs_iter_t it = ecs_query_iter(world, q); while (ecs_query_next(&it)) { InventorySlot *slots = ecs_field(&it, InventorySlot, 0); for (int i = 0; i < it.count; i++) { if (slots[i].slot_index >= 0 && slots[i].slot_index < 12) { occupied[slots[i].slot_index] = true; } } } ecs_query_fini(q); for (int i = 0; i < 12; i++) { if (!occupied[i]) return i; } return -1; } /* Helper: Unequip item of specific type */ static void unequip_slot_type(ecs_world_t *world, ecs_entity_t player, ecs_entity_t tag) { ecs_query_t *q = ecs_query(world, { .terms = {{.id = tag}, {.first.id = ContainedBy, .second.id = player}} }); ecs_iter_t it = ecs_query_iter(world, q); while (ecs_query_next(&it)) { for (int i = 0; i < it.count; i++) { ecs_remove_id(world, it.entities[i], tag); } } ecs_query_fini(q); } /* Helper: Execute Immediate Movement */ static bool execute_move_immediate(ecs_world_t *world, ecs_entity_t actor, int dx, int dy) { GridPosition *grid = ecs_get_mut(world, actor, GridPosition); if (!grid) return false; int new_x = grid->x + dx; int new_y = grid->y + dy; DungeonContext *dungeon = (DungeonContext *)ecs_get_ctx(world); if (!dungeon) return false; Tile t = dungeon_context_get_tile(dungeon, new_x, new_y); if (t != TILE_FLOOR) { /* Blocked by wall */ return false; } /* Check for entity collision */ if (is_tile_occupied(world, new_x, new_y, actor)) { return false; } /* Move successful */ grid->x = new_x; grid->y = new_y; ecs_modified(world, actor, GridPosition); RlcFOV *fov = ecs_get_mut(world, actor, RlcFOV); if (fov) { fov->needs_update = true; ecs_modified(world, actor, RlcFOV); } bool is_player = ecs_has(world, actor, Player); /* Update camera if player */ if (is_player) { ecs_query_t *q = ecs_query(world, { .terms = {{ .id = ecs_id(Camera) }} }); if (!q) return true; ecs_iter_t cam_it = ecs_query_iter(world, q); while (ecs_query_next(&cam_it)) { Camera *cam = ecs_field(&cam_it, Camera, 0); if (cam && cam[0].follow_player) { cam[0].target_x = (float)new_x; cam[0].target_y = (float)new_y; } ecs_iter_fini(&cam_it); /* Required for early exit */ break; } ecs_query_fini(q); } return true; } /* Helper: Find equipped weapon entity for an actor */ static ecs_entity_t find_equipped_weapon(ecs_world_t *world, ecs_entity_t actor) { ecs_query_t *q = ecs_query(world, { .terms = { { .id = ecs_id(EquippedWeapon) }, { .first.id = ContainedBy, .second.id = actor } } }); if (!q) return 0; ecs_entity_t weapon = 0; ecs_iter_t it = ecs_query_iter(world, q); if (ecs_query_next(&it)) { weapon = it.entities[0]; ecs_iter_fini(&it); } ecs_query_fini(q); return weapon; } /* Helper: Find equipped armor entity for an actor */ static ecs_entity_t find_equipped_armor(ecs_world_t *world, ecs_entity_t actor) { ecs_query_t *q = ecs_query(world, { .terms = { { .id = ecs_id(EquippedArmor) }, { .first.id = ContainedBy, .second.id = actor } } }); if (!q) return 0; ecs_entity_t armor = 0; ecs_iter_t it = ecs_query_iter(world, q); if (ecs_query_next(&it)) { armor = it.entities[0]; ecs_iter_fini(&it); } ecs_query_fini(q); return armor; } /* Helper: Queue Attack — uses weapon delay if equipped, else unarmed default */ static void queue_attack(ecs_world_t *world, TurnState *ts, ecs_entity_t actor, ecs_entity_t target, int speed) { int delay = 15; /* unarmed default */ ecs_entity_t weapon = find_equipped_weapon(world, actor); if (weapon) { const WeaponStats *wpn = ecs_get(world, weapon, WeaponStats); if (wpn) delay = wpn->delay; } else { /* Fallback: speed-based delay for monsters without equipment */ delay = calculate_attack_delay(speed); } QueuedAction qa = { .action_type = ACTION_ATTACK, .actor = actor, .target = target, .dx = 0, .dy = 0, .delay = delay, .priority = 100 }; rlc_action_queue_push(ts, qa); } /* Check if any entities have active tweens (animations in progress) * Returns true if any tween components exist in the world. * Tweens auto-remove on completion, so existence = active animation. */ static bool has_active_tweens(ecs_world_t *world) { /* Count entities with any tween component type */ int tween_pos_count = ecs_count(world, TweenPosition); int tween_alpha_count = ecs_count(world, TweenAlpha); int tween_scale_count = ecs_count(world, TweenScale); int tween_offset_count = ecs_count(world, TweenOffset); int total_tweens = tween_pos_count + tween_alpha_count + tween_scale_count + tween_offset_count; return total_tweens > 0; } /* System 1: Turn Initialization - Process delayed events */ static void TurnInitSystem(ecs_iter_t *it) { TurnState *ts = ecs_field(it, TurnState, 0); for (int i = 0; i < it->count; i++) { /* Process delayed event queue */ rlc_event_queue_process(it->world, &ts[i]); } } /* System 2: Timeline System - The Heartbeat of the Grand Integer System * Advances time until an entity is ready to act. */ static void TimelineSystem(ecs_iter_t *it) { /* Stop if visual effects (tweens) are playing */ if (has_active_tweens(it->world)) { return; } /* Stop if anyone currently has the MyTurn tag (waiting for them to act) */ if (ecs_count(it->world, MyTurn) > 0) { return; } /* Get all entities with Energy */ ecs_query_t *q_energy = ecs_query(it->world, { .terms = { { .id = ecs_id(Energy) }, { .id = ecs_id(Actor) } /* Only consider actors */ } }); if (!q_energy) return; /* Max ticks to simulate per frame to prevent infinite loops if no one can act */ int max_ticks = 1000; int ticks = 0; while (ticks < max_ticks) { /* 1. Check if anyone has enough energy to act RIGHT NOW */ int actors_ready = 0; ecs_iter_t energy_it = ecs_query_iter(it->world, q_energy); while (ecs_query_next(&energy_it)) { Energy *e = ecs_field(&energy_it, Energy, 0); for (int i = 0; i < energy_it.count; i++) { if (e[i].accumulator >= ACTION_COST) { actors_ready++; /* Mark them as "MyTurn" */ ecs_add(it->world, energy_it.entities[i], MyTurn); } } } /* 2. If we found someone, STOP. Let ECS systems run their logic. */ if (actors_ready > 0) { ecs_query_fini(q_energy); return; } /* 3. If no one is ready, advance the clock (Tick) */ /* This is the "Empty Time" where everyone regenerates */ energy_it = ecs_query_iter(it->world, q_energy); while (ecs_query_next(&energy_it)) { Energy *e = ecs_field(&energy_it, Energy, 0); for (int i = 0; i < energy_it.count; i++) { /* Apply interactions here! (e.g. status effects) */ int current_gain = e[i].gain_per_tick; /* Example: if (ecs_has(it->world, energy_it.entities[i], StatusFrozen)) current_gain /= 2; */ e[i].accumulator += current_gain; } } ticks++; } ecs_query_fini(q_energy); } /* System 3: Player Path Follow - Auto-follow mouse path when no keyboard input */ static void PlayerPathFollowSystem(ecs_iter_t *it) { ActionIntent *intent = ecs_field(it, ActionIntent, 0); AStarPath *path = ecs_field(it, AStarPath, 1); PathStatus *status = ecs_field(it, PathStatus, 2); /* Only run if player has turn */ if (!ecs_has(it->world, it->entities[0], MyTurn)) return; for (int i = 0; i < it->count; i++) { /* Skip if keyboard input is already pending */ if (intent[i].pending) continue; /* Check if we have a valid path to follow */ if (status[i].value != PATH_VALID || rlc_path_is_complete(path + i)) { continue; } /* Get next step from path */ int dx, dy; if (rlc_path_get_next_step(path + i, &dx, &dy)) { /* Set intent to move along path */ intent[i].action_type = ACTION_MOVE; intent[i].dx = dx; intent[i].dy = dy; intent[i].target = 0; intent[i].energy_cost = ACTION_COST_MOVE; intent[i].pending = true; /* Advance path for next turn */ rlc_path_advance(path + i); /* Clear path if complete */ if (rlc_path_is_complete(path + i)) { status[i].value = PATH_NONE; } // RLC_LOG_DEBUG("Player following path: (%d, %d)", dx, dy); ecs_modified(it->world, it->entities[i], ActionIntent); } } } /* System 4: Player Action System - Handle player input */ static void PlayerActionSystem(ecs_iter_t *it) { ActionIntent *intent = ecs_field(it, ActionIntent, 0); Energy *energy = ecs_field(it, Energy, 1); Actor *actor = ecs_field(it, Actor, 2); TurnState *ts = ecs_singleton_get_mut(it->world, TurnState); if (!ts) return; for (int i = 0; i < it->count; i++) { /* Ensure player has MyTurn tag */ if (!ecs_has(it->world, it->entities[i], MyTurn)) continue; if (!intent[i].pending) continue; /* If moving, check for Bump Attack */ if (intent[i].action_type == ACTION_MOVE) { GridPosition *pos = ecs_get_mut(it->world, it->entities[i], GridPosition); if (pos) { int target_x = pos->x + intent[i].dx; int target_y = pos->y + intent[i].dy; /* Check if blocked by entity */ if (is_tile_occupied(it->world, target_x, target_y, it->entities[i])) { /* Find WHO is blocking to see if we should attack */ ecs_query_t *q = ecs_query(it->world, { .terms = { { .id = ecs_id(GridPosition) }, { .id = ecs_id(Health) }, /* Only attack things with health */ { .id = ecs_id(Actor) } } }); if (q) { ecs_iter_t qit = ecs_query_iter(it->world, q); while (ecs_query_next(&qit)) { GridPosition *gp = ecs_field(&qit, GridPosition, 0); for (int k = 0; k < qit.count; k++) { if (qit.entities[k] != it->entities[i] && gp[k].x == target_x && gp[k].y == target_y) { /* Found target! Convert to Attack */ intent[i].action_type = ACTION_ATTACK; intent[i].target = qit.entities[k]; ecs_iter_fini(&qit); /* Required for early exit via goto */ goto found_blocker; } } } found_blocker: ecs_query_fini(q); } } } } /* Execute Action */ bool action_taken = false; if (intent[i].action_type == ACTION_MOVE) { /* Try to move */ bool moved = execute_move_immediate(it->world, it->entities[i], intent[i].dx, intent[i].dy); if (moved) action_taken = true; } else if (intent[i].action_type == ACTION_ATTACK) { /* Schedule Attack */ queue_attack(it->world, ts, it->entities[i], intent[i].target, actor[i].speed); action_taken = true; } else if (intent[i].action_type == ACTION_WAIT) { /* Explicit wait counts as taking a turn */ action_taken = true; } else { /* Other actions (Use Item, etc) */ QueuedAction qa = { .action_type = intent[i].action_type, .actor = it->entities[i], .target = intent[i].target, .item = intent[i].item, .dx = intent[i].dx, .dy = intent[i].dy, .delay = 0, .priority = 50 }; rlc_action_queue_push(ts, qa); action_taken = true; } if (action_taken) { /* Pay the cost */ energy[i].accumulator -= ACTION_COST; /* Remove MyTurn tag - turn ends */ ecs_remove(it->world, it->entities[i], MyTurn); } /* Clear intent */ intent[i].pending = false; } } /* --- Helper: Find player entity and position --- */ static bool find_player(ecs_world_t *world, ecs_entity_t *out_player, int *out_x, int *out_y) { ecs_query_t *player_q = ecs_query(world, { .terms = { { .id = ecs_id(GridPosition) }, { .id = ecs_id(Player) } } }); if (!player_q) return false; ecs_iter_t player_it = ecs_query_iter(world, player_q); bool found = false; if (ecs_query_next(&player_it)) { *out_player = player_it.entities[0]; const GridPosition *player_grid = ecs_field(&player_it, GridPosition, 0); if (player_grid) { *out_x = player_grid->x; *out_y = player_grid->y; found = true; } ecs_iter_fini(&player_it); /* Must finalize iterator before query_fini */ } ecs_query_fini(player_q); return found; } /* --- Helper: Check if any actor occupies a grid position --- */ static bool is_tile_occupied(ecs_world_t *world, int x, int y, ecs_entity_t ignore) { ecs_query_t *q = ecs_query(world, { .terms = { { .id = ecs_id(GridPosition) }, { .id = ecs_id(Actor) } } }); if (!q) return false; bool occupied = false; ecs_iter_t qit = ecs_query_iter(world, q); while (ecs_query_next(&qit)) { GridPosition *gp = ecs_field(&qit, GridPosition, 0); for (int i = 0; i < qit.count; i++) { if (qit.entities[i] != ignore && gp[i].x == x && gp[i].y == y) { occupied = true; break; } } if (occupied) { ecs_iter_fini(&qit); /* Required for early exit */ break; } } ecs_query_fini(q); return occupied; } /* --- Helper: Check line-of-sight using Bresenham's line algorithm --- */ static bool has_line_of_sight(DungeonContext *dungeon, int x0, int y0, int x1, int y1) { if (!dungeon) return false; int dx = abs(x1 - x0); int dy = abs(y1 - y0); int sx = (x0 < x1) ? 1 : -1; int sy = (y0 < y1) ? 1 : -1; int err = dx - dy; int x = x0, y = y0; while (1) { /* Don't check the start or end positions - only tiles in between */ if ((x != x0 || y != y0) && (x != x1 || y != y1)) { Tile t = dungeon_context_get_tile(dungeon, x, y); if (t == TILE_WALL || t == TILE_EMPTY) { return false; /* Blocked by wall or void */ } } if (x == x1 && y == y1) break; int e2 = 2 * err; if (e2 > -dy) { err -= dy; x += sx; } if (e2 < dx) { err += dx; y += sy; } } return true; } /* --- Helper: Check if entity can see target (range + line-of-sight) --- */ static bool can_see_entity(ecs_world_t *world, ecs_entity_t viewer, ecs_entity_t target) { const GridPosition *viewer_pos = ecs_get(world, viewer, GridPosition); const GridPosition *target_pos = ecs_get(world, target, GridPosition); if (!viewer_pos || !target_pos) return false; /* First: range check (early out if too far) */ const int MONSTER_PERCEPTION_RANGE = 8; int dx = target_pos->x - viewer_pos->x; int dy = target_pos->y - viewer_pos->y; int dist_sq = dx * dx + dy * dy; if (dist_sq > (MONSTER_PERCEPTION_RANGE * MONSTER_PERCEPTION_RANGE)) { return false; } /* Second: line-of-sight check (can't see through walls). * Use the world context DungeonContext* directly — it's fully populated * for all dungeon types (physics, BSP, loaded/imported, town, arena). */ DungeonContext *ctx = (DungeonContext *)ecs_get_ctx(world); if (!ctx) { return false; } return has_line_of_sight(ctx, viewer_pos->x, viewer_pos->y, target_pos->x, target_pos->y); } /* System 5a: AI Perception System - Update memory based on what AI can see */ static void AiPerceptionSystem(ecs_iter_t *it) { Actor *actor = ecs_field(it, Actor, 0); AiMemory *memory = ecs_field(it, AiMemory, 1); GridPosition *grid = ecs_field(it, GridPosition, 2); (void)actor; /* AI Systems now run whenever the AI entity has MyTurn */ /* This system updates memory for *all* AIs though? */ /* No, we should only update for AIs that are about to act or we update all per frame? */ /* Better to update only for the active entity to save perf, or all? */ /* Actually, perception is a passive thing. But in a strict turn system, */ /* it matters when they act. Let's filter by MyTurn for now to keep it simple. */ /* However, to catch "I see you", we usually check perception. */ /* Let's iterate only MyTurn entities */ // Note: The original system iterated all Actors. // We only want to process the entity whose turn it is. // So we add MyTurn to the query in registration. ecs_entity_t player = 0; int player_x = 0, player_y = 0; if (!find_player(it->world, &player, &player_x, &player_y)) return; for (int i = 0; i < it->count; i++) { ecs_entity_t monster = it->entities[i]; bool sees_player = can_see_entity(it->world, monster, player); if (sees_player) { memory[i].last_seen_player_x = player_x; memory[i].last_seen_player_y = player_y; memory[i].turns_since_player_seen = 0; /* Update hunting state if present */ AiStateHunting *hunting = ecs_get_mut(it->world, monster, AiStateHunting); if (hunting) { hunting->last_known_x = player_x; hunting->last_known_y = player_y; hunting->target_visible = true; } } else { memory[i].turns_since_player_seen++; /* Mark target not visible in hunting state */ AiStateHunting *hunting = ecs_get_mut(it->world, monster, AiStateHunting); if (hunting) { hunting->target_visible = false; } } (void)grid; } } /* System 5b: AI State Transition System */ static void AiStateTransitionSystem(ecs_iter_t *it) { Actor *actor = ecs_field(it, Actor, 0); AiMemory *memory = ecs_field(it, AiMemory, 1); (void)actor; ecs_entity_t player = 0; int player_x = 0, player_y = 0; find_player(it->world, &player, &player_x, &player_y); ecs_defer_begin(it->world); for (int i = 0; i < it->count; i++) { ecs_entity_t monster = it->entities[i]; bool sees_player = (memory[i].turns_since_player_seen == 0); /* Check current state and decide transitions */ bool is_idle = ecs_has(it->world, monster, AiStateIdle); bool is_wandering = ecs_has(it->world, monster, AiStateWandering); bool is_hunting = ecs_has(it->world, monster, AiStateHunting); bool is_investigating = ecs_has(it->world, monster, AiStateInvestigating); bool is_fleeing = ecs_has(it->world, monster, AiStateFleeing); /* Check health for fleeing decision */ const Health *hp = ecs_get(it->world, monster, Health); bool low_health = hp && hp->max > 0 && (hp->current * 100 / hp->max) < 30; /* Idle -> Hunting: Monster awakens when it sees the player */ if (is_idle) { if (sees_player && player) { ecs_remove(it->world, monster, AiStateIdle); ecs_set(it->world, monster, AiStateHunting, { .target = player, .last_known_x = memory[i].last_seen_player_x, .last_known_y = memory[i].last_seen_player_y, .target_visible = true, .stuck_turns = 0 }); RLC_LOG_DEBUG("Monster %lu: Idle -> Hunting (awakened!)", (unsigned long)monster); continue; } continue; } if (is_wandering) { if (sees_player && player) { ecs_remove(it->world, monster, AiStateWandering); ecs_set(it->world, monster, AiStateHunting, { .target = player, .last_known_x = memory[i].last_seen_player_x, .last_known_y = memory[i].last_seen_player_y, .target_visible = true, .stuck_turns = 0 }); } } else if (is_hunting) { AiStateHunting *hunting = ecs_get_mut(it->world, monster, AiStateHunting); if (low_health) { ecs_remove(it->world, monster, AiStateHunting); ecs_add(it->world, monster, AiStateFleeing); } else if (hunting && !hunting->target_visible && memory[i].turns_since_player_seen >= 2) { int target_x = hunting->last_known_x; int target_y = hunting->last_known_y; ecs_remove(it->world, monster, AiStateHunting); ecs_set(it->world, monster, AiStateInvestigating, { .target_x = target_x, .target_y = target_y, .turns_remaining = 5 }); } else if (hunting && hunting->stuck_turns >= 3) { ecs_remove(it->world, monster, AiStateHunting); ecs_set(it->world, monster, AiStateInvestigating, { .target_x = hunting->last_known_x, .target_y = hunting->last_known_y, .turns_remaining = 3 }); } } else if (is_investigating) { AiStateInvestigating *inv = ecs_get_mut(it->world, monster, AiStateInvestigating); if (sees_player && player) { ecs_remove(it->world, monster, AiStateInvestigating); ecs_set(it->world, monster, AiStateHunting, { .target = player, .last_known_x = memory[i].last_seen_player_x, .last_known_y = memory[i].last_seen_player_y, .target_visible = true, .stuck_turns = 0 }); } else if (inv && inv->turns_remaining <= 0) { ecs_remove(it->world, monster, AiStateInvestigating); ecs_add(it->world, monster, AiStateWandering); } } else if (is_fleeing) { if (memory[i].turns_since_player_seen >= 5) { ecs_remove(it->world, monster, AiStateFleeing); ecs_add(it->world, monster, AiStateWandering); } } else { ecs_add(it->world, monster, AiStateWandering); } } ecs_defer_end(it->world); } /* System 5c: AI Action - Idle behavior */ static void AiActionIdleSystem(ecs_iter_t *it) { Energy *energy = ecs_field(it, Energy, 1); for (int i = 0; i < it->count; i++) { /* Idle monsters pass their turn */ energy[i].accumulator -= ACTION_COST; ecs_remove(it->world, it->entities[i], MyTurn); } } /* System 5d: AI Action - Wandering behavior */ static void AiActionWanderingSystem(ecs_iter_t *it) { Energy *energy = ecs_field(it, Energy, 1); GridPosition *grid = ecs_field(it, GridPosition, 2); DungeonContext *dungeon = (DungeonContext *)ecs_get_ctx(it->world); if (!dungeon) return; for (int i = 0; i < it->count; i++) { ecs_entity_t monster = it->entities[i]; /* Pick random adjacent walkable tile */ int dx = 0, dy = 0; int directions[8][2] = {{-1,-1},{0,-1},{1,-1},{-1,0},{1,0},{-1,1},{0,1},{1,1}}; for (int tries = 0; tries < 8; tries++) { int dir_idx = rand() % 8; int try_dx = directions[dir_idx][0]; int try_dy = directions[dir_idx][1]; int new_x = grid[i].x + try_dx; int new_y = grid[i].y + try_dy; Tile t = dungeon_context_get_tile(dungeon, new_x, new_y); if (t == TILE_FLOOR) { dx = try_dx; dy = try_dy; break; } } if (dx != 0 || dy != 0) { execute_move_immediate(it->world, monster, dx, dy); } /* Pay cost */ energy[i].accumulator -= ACTION_COST; ecs_remove(it->world, monster, MyTurn); } } /* System 5e: AI Action - Hunting behavior */ static void AiActionHuntingSystem(ecs_iter_t *it) { Energy *energy = ecs_field(it, Energy, 1); GridPosition *grid = ecs_field(it, GridPosition, 2); AiStateHunting *hunting = ecs_field(it, AiStateHunting, 3); Actor *actor = ecs_field(it, Actor, 4); TurnState *ts = ecs_singleton_get_mut(it->world, TurnState); if (!ts) return; DungeonContext *dungeon = (DungeonContext *)ecs_get_ctx(it->world); if (!dungeon) return; ecs_entity_t player = 0; int player_x = 0, player_y = 0; find_player(it->world, &player, &player_x, &player_y); for (int i = 0; i < it->count; i++) { ecs_entity_t monster = it->entities[i]; if (!ecs_is_alive(it->world, hunting[i].target)) { energy[i].accumulator -= ACTION_COST; ecs_remove(it->world, monster, MyTurn); continue; } int target_x = hunting[i].last_known_x; int target_y = hunting[i].last_known_y; int gx = grid[i].x; int gy = grid[i].y; int dist_x = player_x - gx; int dist_y = player_y - gy; bool adjacent = (abs(dist_x) <= 1 && abs(dist_y) <= 1 && (dist_x != 0 || dist_y != 0)); bool acted = false; /* 1. Attack if adjacent */ if (adjacent && hunting[i].target_visible) { queue_attack(it->world, ts, monster, player, actor[i].speed); acted = true; } else { /* 2. Move */ AStarPath *path = ecs_get_mut(it->world, monster, AStarPath); PathStatus *status = ecs_get_mut(it->world, monster, PathStatus); int dx = 0, dy = 0; bool moved = false; if (path && status && status->value == PATH_VALID && !rlc_path_is_complete(path)) { int step_dx, step_dy; if (rlc_path_get_next_step(path, &step_dx, &step_dy)) { int new_x = grid[i].x + step_dx; int new_y = grid[i].y + step_dy; Tile t = dungeon_context_get_tile(dungeon, new_x, new_y); if (t == TILE_FLOOR) { dx = step_dx; dy = step_dy; rlc_path_advance(path); moved = execute_move_immediate(it->world, monster, dx, dy); if (moved) { hunting[i].stuck_turns = 0; acted = true; } } } } if (!moved) { int dist_sq = (target_x - grid[i].x) * (target_x - grid[i].x) + (target_y - grid[i].y) * (target_y - grid[i].y); if (dist_sq <= 400) { rlc_request_path_to_entity(it->world, monster, hunting[i].target); } hunting[i].stuck_turns++; /* Pass turn even if stuck to prevent freeze */ acted = true; } else { /* 3. Attack after move (bump) logic if we want, but here we just moved */ } } if (acted) { energy[i].accumulator -= ACTION_COST; ecs_remove(it->world, monster, MyTurn); } } } /* System 5f: AI Action - Fleeing behavior */ static void AiActionFleeingSystem(ecs_iter_t *it) { Energy *energy = ecs_field(it, Energy, 1); GridPosition *grid = ecs_field(it, GridPosition, 2); DungeonContext *dungeon = (DungeonContext *)ecs_get_ctx(it->world); if (!dungeon) return; ecs_entity_t player = 0; int player_x = 0, player_y = 0; find_player(it->world, &player, &player_x, &player_y); for (int i = 0; i < it->count; i++) { ecs_entity_t monster = it->entities[i]; int flee_dx = grid[i].x - player_x; int flee_dy = grid[i].y - player_y; int dx = (flee_dx > 0) ? 1 : ((flee_dx < 0) ? -1 : 0); int dy = (flee_dy > 0) ? 1 : ((flee_dy < 0) ? -1 : 0); int new_x = grid[i].x + dx; int new_y = grid[i].y + dy; Tile t = dungeon_context_get_tile(dungeon, new_x, new_y); if (t != TILE_FLOOR) { int alts[4][2] = {{dx, 0}, {0, dy}, {-dy, dx}, {dy, -dx}}; dx = 0; dy = 0; for (int j = 0; j < 4; j++) { new_x = grid[i].x + alts[j][0]; new_y = grid[i].y + alts[j][1]; t = dungeon_context_get_tile(dungeon, new_x, new_y); if (t == TILE_FLOOR) { dx = alts[j][0]; dy = alts[j][1]; break; } } } if (dx != 0 || dy != 0) { execute_move_immediate(it->world, monster, dx, dy); } energy[i].accumulator -= ACTION_COST; ecs_remove(it->world, monster, MyTurn); } } /* System 5g: AI Action - Investigating behavior */ static void AiActionInvestigatingSystem(ecs_iter_t *it) { Energy *energy = ecs_field(it, Energy, 1); GridPosition *grid = ecs_field(it, GridPosition, 2); AiStateInvestigating *inv = ecs_field(it, AiStateInvestigating, 3); DungeonContext *dungeon = (DungeonContext *)ecs_get_ctx(it->world); if (!dungeon) return; for (int i = 0; i < it->count; i++) { ecs_entity_t monster = it->entities[i]; inv[i].turns_remaining--; int dx = 0, dy = 0; int to_x = inv[i].target_x - grid[i].x; int to_y = inv[i].target_y - grid[i].y; if (to_x != 0 || to_y != 0) { dx = (to_x > 0) ? 1 : ((to_x < 0) ? -1 : 0); dy = (to_y > 0) ? 1 : ((to_y < 0) ? -1 : 0); int new_x = grid[i].x + dx; int new_y = grid[i].y + dy; Tile t = dungeon_context_get_tile(dungeon, new_x, new_y); if (t != TILE_FLOOR) { if (abs(to_x) >= abs(to_y)) { dy = 0; } else { dx = 0; } new_x = grid[i].x + dx; new_y = grid[i].y + dy; t = dungeon_context_get_tile(dungeon, new_x, new_y); if (t != TILE_FLOOR) { dx = 0; dy = 0; } } } if (dx != 0 || dy != 0) { execute_move_immediate(it->world, monster, dx, dy); } energy[i].accumulator -= ACTION_COST; ecs_remove(it->world, monster, MyTurn); } } /* System 6: Action Execution System - Execute queued actions (Attacks, Items) */ static void ActionExecutionSystem(ecs_iter_t *it) { TurnState *ts = ecs_field(it, TurnState, 0); for (int i = 0; i < it->count; i++) { if (ts[i].action_count == 0) continue; rlc_action_queue_sort(&ts[i]); for (size_t j = 0; j < ts[i].action_count; j++) { QueuedAction *qa = &ts[i].action_queue[j]; switch (qa->action_type) { case ACTION_ATTACK: { if (!ecs_is_alive(it->world, qa->target)) break; const GridPosition *attacker_pos = ecs_get(it->world, qa->actor, GridPosition); const GridPosition *target_pos = ecs_get(it->world, qa->target, GridPosition); if (!attacker_pos || !target_pos) break; int dx = target_pos->x - attacker_pos->x; int dy = target_pos->y - attacker_pos->y; if (abs(dx) > 1 || abs(dy) > 1) break; /* Attack lunge animation */ { float lunge_x = (float)dx * 4.0f; float lunge_y = (float)dy * 4.0f; if (!ecs_has(it->world, qa->actor, RenderOffset)) { ecs_set(it->world, qa->actor, RenderOffset, {0.0f, 0.0f}); } TweenBuilder lunge = TWEEN(it->world, qa->actor); rlc_tween_duration(&lunge, 0.15f); rlc_tween_easing(&lunge, EASE_OUT_QUAD); rlc_tween_repeat(&lunge, TWEEN_MODE_YOYO); rlc_tween_offset(lunge, lunge_x, lunge_y); } /* ── Deterministic weapon-based damage ── */ int raw_damage = 1; /* unarmed fallback */ const WeaponStats *wpn = NULL; ecs_entity_t weapon_ent = find_equipped_weapon(it->world, qa->actor); if (weapon_ent) { wpn = ecs_get(it->world, weapon_ent, WeaponStats); if (wpn) raw_damage = wpn->base_damage; } else { /* Unarmed: 1 + str/3 */ const Attributes *attr = ecs_get(it->world, qa->actor, Attributes); raw_damage = 1 + (attr ? attr->str / 3 : 0); } /* Flat defense from target's armor */ int defense = 0; ecs_entity_t armor_ent = find_equipped_armor(it->world, qa->target); if (armor_ent) { const ArmorStats *arm = ecs_get(it->world, armor_ent, ArmorStats); if (arm) defense += arm->defense; } /* Also check target's Attributes for innate defense (monsters) */ /* Monsters don't equip armor; their defense field IS their defense */ if (!armor_ent) { const Attributes *tgt_attr = ecs_get(it->world, qa->target, Attributes); /* con provides innate defense for unarmored entities */ if (tgt_attr) defense += tgt_attr->con / 3; } /* Final damage: deterministic, floor 1 */ int damage = raw_damage - defense; if (damage < 1) damage = 1; Health *target_hp = ecs_get_mut(it->world, qa->target, Health); if (target_hp) { target_hp->current -= damage; /* Enhanced combat log with damage breakdown */ rlc_log_combat_detailed(it->world, qa->actor, qa->target, raw_damage, defense, damage, weapon_ent); /* Stun mechanic: push target's queued attacks forward */ if (wpn && wpn->stun_delay > 0 && target_hp->current > 0) { for (size_t k = 0; k < ts[i].action_count; k++) { if (ts[i].action_queue[k].actor == qa->target) { ts[i].action_queue[k].delay += wpn->stun_delay; } } rlc_action_queue_sort(&ts[i]); const Name *tgt_name = ecs_get(it->world, qa->target, Name); rlc_log_message(it->world, LOG_MSG_COMBAT_HIT, "%s is stunned! (+%d delay)", tgt_name ? tgt_name->str : "Target", wpn->stun_delay); } if (target_hp->current <= 0) { bool is_target_player = ecs_has(it->world, qa->target, Player); if (!is_target_player) { ecs_delete(it->world, qa->target); } else { RLC_LOG_INFO("Player killed!"); } } } else { rlc_log_combat_detailed(it->world, qa->actor, qa->target, 0, 0, 0, 0); } break; } case ACTION_USE_ITEM: { if (!ecs_is_alive(it->world, qa->item)) break; const Consumable *cons = ecs_get(it->world, qa->item, Consumable); if (!cons) break; bool consumed = false; switch (cons->effect) { case CONSUMABLE_EFFECT_HEAL: { Health *hp = ecs_get_mut(it->world, qa->actor, Health); if (hp) { int old_hp = hp->current; hp->current += cons->power; if (hp->current > hp->max) hp->current = hp->max; ecs_modified(it->world, qa->actor, Health); int healed = hp->current - old_hp; rlc_log_message(it->world, LOG_MSG_ITEM_USE, "You use a healing potion and restore %d HP", healed); consumed = true; } break; } case CONSUMABLE_EFFECT_TELEPORT: { rlc_log_message(it->world, LOG_MSG_ITEM_USE, "You read a scroll of teleportation!"); consumed = true; break; } case CONSUMABLE_EFFECT_AIZAWA: { rlc_log_message(it->world, LOG_MSG_ITEM_USE, "You read the Scroll of Aizawa... reality unravels!"); ecs_add(it->world, qa->actor, AizawaPending); consumed = true; break; } default: break; } if (consumed) ecs_delete(it->world, qa->item); break; } case ACTION_DROP_ITEM: { if (!ecs_is_alive(it->world, qa->item)) break; /* Remove all equipment tags */ ecs_remove(it->world, qa->item, EquippedWeapon); ecs_remove(it->world, qa->item, EquippedArmor); ecs_remove(it->world, qa->item, EquippedAccessory); /* Remove inventory slot and containment */ ecs_remove(it->world, qa->item, InventorySlot); ecs_remove_pair(it->world, qa->item, ContainedBy, qa->actor); const GridPosition *pos = ecs_get(it->world, qa->actor, GridPosition); if (pos) { ecs_add(it->world, qa->item, WorldItem); ecs_set(it->world, qa->item, GridPosition, {pos->x, pos->y}); ecs_set(it->world, qa->item, RenderPosition, {(float)pos->x, (float)pos->y}); const Name *item_name = ecs_get(it->world, qa->item, Name); rlc_log_message(it->world, LOG_MSG_SYSTEM, "You drop %s", item_name ? item_name->str : "an item"); } break; } case ACTION_EQUIP_ITEM: { if (!ecs_is_alive(it->world, qa->item)) break; const Name *item_name = ecs_get(it->world, qa->item, Name); /* Check attribute requirements before equipping */ const Attributes *equip_attr = ecs_get(it->world, qa->actor, Attributes); const WeaponStats *equip_wpn = ecs_get(it->world, qa->item, WeaponStats); const ArmorStats *equip_arm = ecs_get(it->world, qa->item, ArmorStats); if (equip_wpn && equip_attr) { if (equip_attr->str < equip_wpn->req_str) { rlc_log_message(it->world, LOG_MSG_SYSTEM, "Need %d STR to wield %s (you have %d)", equip_wpn->req_str, item_name ? item_name->str : "weapon", equip_attr->str); break; } if (equip_attr->dex < equip_wpn->req_dex) { rlc_log_message(it->world, LOG_MSG_SYSTEM, "Need %d DEX to wield %s (you have %d)", equip_wpn->req_dex, item_name ? item_name->str : "weapon", equip_attr->dex); break; } } if (equip_arm && equip_attr) { if (equip_attr->str < equip_arm->req_str) { rlc_log_message(it->world, LOG_MSG_SYSTEM, "Need %d STR to wear %s (you have %d)", equip_arm->req_str, item_name ? item_name->str : "armor", equip_attr->str); break; } } /* Check item type and toggle equipment tag */ if (ecs_has(it->world, qa->item, IsWeapon)) { if (ecs_has(it->world, qa->item, EquippedWeapon)) { ecs_remove(it->world, qa->item, EquippedWeapon); rlc_log_message(it->world, LOG_MSG_SYSTEM, "You unequip %s", item_name ? item_name->str : "weapon"); } else { unequip_slot_type(it->world, qa->actor, ecs_id(EquippedWeapon)); ecs_add(it->world, qa->item, EquippedWeapon); rlc_log_message(it->world, LOG_MSG_SYSTEM, "You equip %s", item_name ? item_name->str : "weapon"); } } else if (ecs_has(it->world, qa->item, IsArmor)) { if (ecs_has(it->world, qa->item, EquippedArmor)) { ecs_remove(it->world, qa->item, EquippedArmor); rlc_log_message(it->world, LOG_MSG_SYSTEM, "You unequip %s", item_name ? item_name->str : "armor"); } else { unequip_slot_type(it->world, qa->actor, ecs_id(EquippedArmor)); ecs_add(it->world, qa->item, EquippedArmor); rlc_log_message(it->world, LOG_MSG_SYSTEM, "You equip %s", item_name ? item_name->str : "armor"); } } else if (ecs_has(it->world, qa->item, IsAccessory)) { if (ecs_has(it->world, qa->item, EquippedAccessory)) { ecs_remove(it->world, qa->item, EquippedAccessory); rlc_log_message(it->world, LOG_MSG_SYSTEM, "You unequip %s", item_name ? item_name->str : "accessory"); } else { unequip_slot_type(it->world, qa->actor, ecs_id(EquippedAccessory)); ecs_add(it->world, qa->item, EquippedAccessory); rlc_log_message(it->world, LOG_MSG_SYSTEM, "You equip %s", item_name ? item_name->str : "accessory"); } } break; } case ACTION_PICKUP_ITEM: { if (!ecs_is_alive(it->world, qa->item)) break; if (!ecs_has(it->world, qa->item, WorldItem)) break; /* Validate item isn't already in an inventory */ if (ecs_has(it->world, qa->item, InventorySlot)) break; if (ecs_has_pair(it->world, qa->item, ContainedBy, EcsWildcard)) break; /* Find free inventory slot */ int slot = find_free_slot(it->world, qa->actor); if (slot == -1) { rlc_log_message(it->world, LOG_MSG_SYSTEM, "Inventory full!"); break; } /* Add to inventory */ ecs_remove(it->world, qa->item, WorldItem); ecs_remove(it->world, qa->item, GridPosition); ecs_remove(it->world, qa->item, RenderPosition); ecs_add_pair(it->world, qa->item, ContainedBy, qa->actor); ecs_set(it->world, qa->item, InventorySlot, {.slot_index = slot}); const Name *item_name = ecs_get(it->world, qa->item, Name); rlc_log_message(it->world, LOG_MSG_ITEM_PICKUP, "You pick up %s", item_name ? item_name->str : "an item"); break; } default: break; } } rlc_action_queue_clear(&ts[i]); } } /* Register all turn systems */ void rlc_turn_systems_register(ecs_world_t *world) { /* 1. Turn Init */ ecs_entity_t sys_turn_init = ecs_system(world, { .entity = ecs_entity(world, { .name = "TurnInitSystem", .add = ecs_ids(ecs_dependson(EcsOnUpdate)) }), .query.terms = {{ .id = ecs_id(TurnState), .inout = EcsInOut }}, .callback = TurnInitSystem }); /* 2. Timeline (Time Loop) */ ecs_entity_t sys_timeline = ecs_system(world, { .entity = ecs_entity(world, { .name = "TimelineSystem", .add = ecs_ids(ecs_dependson(EcsOnUpdate)) }), /* Reads energy to manage turns, adds MyTurn tag */ .callback = TimelineSystem }); ecs_add_pair(world, sys_timeline, EcsDependsOn, sys_turn_init); /* 3. Player Path (Requires MyTurn) */ ecs_entity_t sys_player_path = ecs_system(world, { .entity = ecs_entity(world, { .name = "PlayerPathFollowSystem", .add = ecs_ids(ecs_dependson(EcsOnUpdate)) }), .query.terms = { { .id = ecs_id(ActionIntent), .inout = EcsInOut }, { .id = ecs_id(AStarPath), .inout = EcsInOut }, { .id = ecs_id(PathStatus), .inout = EcsInOut }, { .id = ecs_id(Player), .inout = EcsIn }, { .id = ecs_id(MyTurn), .inout = EcsIn } }, .callback = PlayerPathFollowSystem }); ecs_add_pair(world, sys_player_path, EcsDependsOn, sys_timeline); /* 4. Player Action (Requires MyTurn) */ ecs_entity_t sys_player_action = ecs_system(world, { .entity = ecs_entity(world, { .name = "PlayerActionSystem", .add = ecs_ids(ecs_dependson(EcsOnUpdate)) }), .query.terms = { { .id = ecs_id(ActionIntent), .inout = EcsIn }, { .id = ecs_id(Energy), .inout = EcsInOut }, { .id = ecs_id(Actor), .inout = EcsIn }, { .id = ecs_id(MyTurn), .inout = EcsIn } }, .callback = PlayerActionSystem }); ecs_add_pair(world, sys_player_action, EcsDependsOn, sys_player_path); /* 5. AI Systems (Require MyTurn) */ ecs_entity_t sys_ai_perception = ecs_system(world, { .entity = ecs_entity(world, { .name = "AiPerceptionSystem", .add = ecs_ids(ecs_dependson(EcsOnUpdate)) }), .query.terms = { { .id = ecs_id(Actor), .inout = EcsIn }, { .id = ecs_id(AiMemory), .inout = EcsInOut }, { .id = ecs_id(GridPosition), .inout = EcsIn }, { .id = ecs_id(MyTurn), .inout = EcsIn } }, .callback = AiPerceptionSystem }); ecs_add_pair(world, sys_ai_perception, EcsDependsOn, sys_timeline); ecs_entity_t sys_ai_state = ecs_system(world, { .entity = ecs_entity(world, { .name = "AiStateTransitionSystem", .add = ecs_ids(ecs_dependson(EcsOnUpdate)) }), .query.terms = { { .id = ecs_id(Actor), .inout = EcsIn }, { .id = ecs_id(AiMemory), .inout = EcsIn }, { .id = ecs_id(MyTurn), .inout = EcsIn } }, .callback = AiStateTransitionSystem }); ecs_add_pair(world, sys_ai_state, EcsDependsOn, sys_ai_perception); ecs_entity_t sys_ai_wandering = ecs_system(world, { .entity = ecs_entity(world, { .name = "AiActionWanderingSystem", .add = ecs_ids(ecs_dependson(EcsOnUpdate)) }), .query.terms = { { .id = ecs_id(Actor), .inout = EcsIn }, { .id = ecs_id(Energy), .inout = EcsInOut }, { .id = ecs_id(GridPosition), .inout = EcsIn }, { .id = ecs_id(AiStateWandering), .inout = EcsIn }, { .id = ecs_id(MyTurn), .inout = EcsIn } }, .callback = AiActionWanderingSystem }); ecs_add_pair(world, sys_ai_wandering, EcsDependsOn, sys_ai_state); ecs_entity_t sys_ai_idle = ecs_system(world, { .entity = ecs_entity(world, { .name = "AiActionIdleSystem", .add = ecs_ids(ecs_dependson(EcsOnUpdate)) }), .query.terms = { { .id = ecs_id(Actor), .inout = EcsIn }, { .id = ecs_id(Energy), .inout = EcsInOut }, { .id = ecs_id(AiStateIdle), .inout = EcsIn }, { .id = ecs_id(MyTurn), .inout = EcsIn } }, .callback = AiActionIdleSystem }); ecs_add_pair(world, sys_ai_idle, EcsDependsOn, sys_ai_state); ecs_entity_t sys_ai_hunting = ecs_system(world, { .entity = ecs_entity(world, { .name = "AiActionHuntingSystem", .add = ecs_ids(ecs_dependson(EcsOnUpdate)) }), .query.terms = { { .id = ecs_id(Actor), .inout = EcsIn }, { .id = ecs_id(Energy), .inout = EcsInOut }, { .id = ecs_id(GridPosition), .inout = EcsIn }, { .id = ecs_id(AiStateHunting), .inout = EcsInOut }, { .id = ecs_id(Actor), .inout = EcsIn }, { .id = ecs_id(MyTurn), .inout = EcsIn } }, .callback = AiActionHuntingSystem }); ecs_add_pair(world, sys_ai_hunting, EcsDependsOn, sys_ai_state); ecs_entity_t sys_ai_fleeing = ecs_system(world, { .entity = ecs_entity(world, { .name = "AiActionFleeingSystem", .add = ecs_ids(ecs_dependson(EcsOnUpdate)) }), .query.terms = { { .id = ecs_id(Actor), .inout = EcsIn }, { .id = ecs_id(Energy), .inout = EcsInOut }, { .id = ecs_id(GridPosition), .inout = EcsIn }, { .id = ecs_id(AiStateFleeing), .inout = EcsIn }, { .id = ecs_id(MyTurn), .inout = EcsIn } }, .callback = AiActionFleeingSystem }); ecs_add_pair(world, sys_ai_fleeing, EcsDependsOn, sys_ai_state); ecs_entity_t sys_ai_investigating = ecs_system(world, { .entity = ecs_entity(world, { .name = "AiActionInvestigatingSystem", .add = ecs_ids(ecs_dependson(EcsOnUpdate)) }), .query.terms = { { .id = ecs_id(Actor), .inout = EcsIn }, { .id = ecs_id(Energy), .inout = EcsInOut }, { .id = ecs_id(GridPosition), .inout = EcsIn }, { .id = ecs_id(AiStateInvestigating), .inout = EcsInOut }, { .id = ecs_id(MyTurn), .inout = EcsIn } }, .callback = AiActionInvestigatingSystem }); ecs_add_pair(world, sys_ai_investigating, EcsDependsOn, sys_ai_state); /* 6. Action Execution (Runs after everyone has had a chance to queue things) */ ecs_entity_t sys_action_exec = ecs_system(world, { .entity = ecs_entity(world, { .name = "ActionExecutionSystem", .add = ecs_ids(ecs_dependson(EcsOnUpdate)) }), .query.terms = {{ .id = ecs_id(TurnState), .inout = EcsInOut }}, .callback = ActionExecutionSystem }); /* Run after all AI and Player systems */ ecs_add_pair(world, sys_action_exec, EcsDependsOn, sys_ai_investigating); ecs_add_pair(world, sys_action_exec, EcsDependsOn, sys_player_action); /* Register NPC AI systems (schedule, movement, alert, flee) */ rlc_npc_ai_systems_register(world); }