Some checks failed
Linux / Build Linux (push) Has been cancelled
Linux / Build Linux-1 (push) Has been cancelled
macOS / Build macOS (push) Has been cancelled
macOS / Build macOS-1 (push) Has been cancelled
Windows (MinGW) / Build MinGW (push) Has been cancelled
Windows (MinGW) / Build MinGW-1 (push) Has been cancelled
Windows (MSVC) / Build Windows (push) Has been cancelled
Windows (MSVC) / Build Windows-1 (push) Has been cancelled
1404 lines
54 KiB
C
1404 lines
54 KiB
C
#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 <stdlib.h>
|
|
|
|
/* ============================================================
|
|
* 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);
|
|
}
|