diff --git a/.gitignore b/.gitignore index db2669d..2a38c30 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ build -*.tscn \ No newline at end of file +*.tscn +*.md \ No newline at end of file diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..3ea1ecf --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "data"] + path = data + url = https://github.com/OpenChamp/data.git diff --git a/.vscode/launch.json b/.vscode/launch.json index 13e4ab8..4aabf6c 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,17 +1,60 @@ { "version": "0.2.0", "configurations": [ - { - "name": "Win(x64) Debug", + "name": "Win(D) Visual", "type": "cppvsdbg", "request": "launch", "program": "${workspaceFolder}/build/_output/windows_x64/Debug/gameserver.exe", + "args": ["--visualize"], + "stopAtEntry": false, + "cwd": "${workspaceFolder}", + "environment": [{"name": "MAP_NAME", "value": "konda"}], + "console": "integratedTerminal" + }, + { + "name": "Win(D) Visual Autostart", + "type": "cppvsdbg", + "request": "launch", + "program": "${workspaceFolder}/build/_output/windows_x64/Debug/gameserver.exe", + "args": ["--visualize"], + "stopAtEntry": false, + "cwd": "${workspaceFolder}", + "environment": [{"name": "MAP_NAME", "value": "konda"}, {"name": "MAX_CLIENTS", "value": "0"}], + "console": "integratedTerminal" + }, + { + "name": "Win(D) Debug", + "type": "cppvsdbg", + "request": "launch", + "program": "${workspaceFolder}/build/_output/windows_x64/Debug/gameserver.exe", + "args": [], + "stopAtEntry": false, + "cwd": "${workspaceFolder}", + "environment": [{"name": "MAP_NAME", "value": "konda"}], + "console": "integratedTerminal" + }, + { + "name": "Win(R) Visual", + "type": "cppvsdbg", + "request": "launch", + "program": "${workspaceFolder}/build/_output/windows_x64/Release/gameserver.exe", + "args": ["--visualize"], + "stopAtEntry": false, + "cwd": "${workspaceFolder}", + "environment": [{"name": "MAP_NAME", "value": "konda"}], + "console": "integratedTerminal" + }, + { + "name": "Win(R) Debug", + "type": "cppvsdbg", + "request": "launch", + "program": "${workspaceFolder}/build/_output/windows_x64/Release/gameserver.exe", "args": [], "stopAtEntry": false, "cwd": "${workspaceFolder}", - "environment": [], - "console": "externalTerminal" + "environment": [{"name": "MAP_NAME", "value": "konda"}], + "console": "integratedTerminal" } ] } \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..d3c4667 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,80 @@ +{ + "files.associations": { + "algorithm": "cpp", + "atomic": "cpp", + "bit": "cpp", + "cctype": "cpp", + "charconv": "cpp", + "chrono": "cpp", + "clocale": "cpp", + "cmath": "cpp", + "compare": "cpp", + "concepts": "cpp", + "condition_variable": "cpp", + "csignal": "cpp", + "cstdarg": "cpp", + "cstddef": "cpp", + "cstdint": "cpp", + "cstdio": "cpp", + "cstdlib": "cpp", + "cstring": "cpp", + "ctime": "cpp", + "cwchar": "cpp", + "deque": "cpp", + "exception": "cpp", + "filesystem": "cpp", + "format": "cpp", + "forward_list": "cpp", + "fstream": "cpp", + "functional": "cpp", + "initializer_list": "cpp", + "iomanip": "cpp", + "ios": "cpp", + "iosfwd": "cpp", + "iostream": "cpp", + "istream": "cpp", + "iterator": "cpp", + "limits": "cpp", + "list": "cpp", + "locale": "cpp", + "map": "cpp", + "memory": "cpp", + "mutex": "cpp", + "new": "cpp", + "optional": "cpp", + "ostream": "cpp", + "queue": "cpp", + "random": "cpp", + "ratio": "cpp", + "sstream": "cpp", + "stdexcept": "cpp", + "stop_token": "cpp", + "streambuf": "cpp", + "string": "cpp", + "system_error": "cpp", + "thread": "cpp", + "tuple": "cpp", + "type_traits": "cpp", + "typeinfo": "cpp", + "unordered_map": "cpp", + "utility": "cpp", + "vector": "cpp", + "xfacet": "cpp", + "xhash": "cpp", + "xiosbase": "cpp", + "xlocale": "cpp", + "xlocbuf": "cpp", + "xlocinfo": "cpp", + "xlocmes": "cpp", + "xlocmon": "cpp", + "xlocnum": "cpp", + "xloctime": "cpp", + "xmemory": "cpp", + "xstring": "cpp", + "xtr1common": "cpp", + "xtree": "cpp", + "xutility": "cpp", + "variant": "cpp", + "array": "cpp" + } +} \ No newline at end of file diff --git a/CMakeLists.txt b/CMakeLists.txt index 5fcbdb4..daaa2be 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -51,48 +51,41 @@ set(GAMESERVER_SOURCES # Services src/services/navigation_service.cpp src/services/astar_pathfinding.cpp - # Systems - src/systems/combat_system.cpp + src/services/visualizer_service.cpp src/services/network_service.cpp - src/systems/data_loader.cpp + # Systems - Core src/systems/gameserver.cpp - src/systems/map_system.cpp - src/systems/combat_system.cpp - src/systems/movement_system.cpp - src/systems/serialization_system.cpp - src/systems/wave_system.cpp + src/systems/gameplay_coordinator.cpp + # Systems - Core Game Logic + src/systems/core/collision_system.cpp + src/systems/core/combat_system.cpp + src/systems/core/brain_system.cpp + src/systems/core/input_system.cpp + src/systems/core/movement_system.cpp + src/systems/core/network_sync_system.cpp + src/systems/core/npc_system.cpp + src/systems/core/spawning_system.cpp + src/systems/core/wave_system.cpp + # Systems - Utilities + src/systems/util/combat_calculator.cpp + src/systems/util/data_loader.cpp + src/systems/util/packet_handler.cpp + src/systems/util/player_manager.cpp + src/systems/util/serialization_system.cpp + src/systems/util/targeting_utility.cpp ) -# Header files (for IDE organization) -set(GAMESERVER_HEADERS - src/systems/gameserver.hpp - src/components/game_state.hpp - src/entities/player.hpp - src/systems/packet_validator.hpp - src/components/errors.hpp - src/systems/entity_manager.hpp - src/components/component.hpp - src/components/movement.hpp - src/components/stats.hpp - src/systems/math.hpp - src/components/map.hpp - src/components/navmesh.hpp - src/services/navigation_service.hpp - src/services/astar_pathfinding.hpp - src/services/network_service.hpp - src/systems/data_loader.hpp - src/systems/map_system.hpp - src/systems/combat_system.hpp - src/systems/movement_system.hpp - src/systems/serialization_system.hpp - src/systems/wave_system.hpp -) +# Header files are now included via consolidated headers: +# - for all component types +# - for all system types +# - for all service types +# No individual header listing needed. set(LIBRARY_SOURCES src/libs/pugixml.cpp ) -add_executable(gameserver ${GAMESERVER_SOURCES} ${GAMESERVER_HEADERS} ${LIBRARY_SOURCES}) +add_executable(gameserver ${GAMESERVER_SOURCES} ${LIBRARY_SOURCES}) # Include directories target_include_directories(gameserver PRIVATE @@ -116,7 +109,7 @@ enable_testing() # Add test executable add_executable(gameserver_tests tests/tests.cpp - src/systems/data_loader.cpp + src/systems/util/data_loader.cpp src/libs/pugixml.cpp ) diff --git a/count.sh b/count.sh new file mode 100644 index 0000000..95366c4 --- /dev/null +++ b/count.sh @@ -0,0 +1,155 @@ +#!/bin/bash + +# --- Configuration --- +OUTPUT_FILE="count.md" + +# Define common extensions for files to EXCLUDE from the main character/line count. +EXCLUDE_EXTENSIONS=("jpg" "jpeg" "png" "gif" "bmp" "ico" "mp3" "mp4" "mov" "avi" "zip" "rar" "gz" "tar" "bin" "exe" "dll" "so" "o" "class" "pdf" "doc" "docx" "xls" "xlsx") + +# Define common extensions for files you want to explicitly list in the breakdown table +# All other code/text files will be grouped under "Other Code/Text" +KNOWN_CODE_EXTENSIONS=("sh" "bash" "py" "js" "ts" "html" "css" "scss" "php" "c" "cpp" "h" "java" "go" "md" "txt" "yaml" "json" "xml") + + +# Convert the exclusion array to a regex pattern for use with 'find -not -regex' +EXCLUDE_PATTERN=$(printf "\.%s\\|" "${EXCLUDE_EXTENSIONS[@]}" | sed 's/|$/$/') +EXCLUDE_PATTERN="${EXCLUDE_PATTERN/\\|/\|}" +EXCLUDE_PATTERN=".*($EXCLUDE_PATTERN)" + +# Start by clearing the output file +echo "# 📁 Directory Content Analysis" > "$OUTPUT_FILE" +echo "" >> "$OUTPUT_FILE" +echo "Analysis started from: \`$(pwd)\`" >> "$OUTPUT_FILE" +echo "" >> "$OUTPUT_FILE" + +# --- 1. Total Code/Text Lines and Characters Count (Unchanged) --- + +echo "## 💻 Code/Text Lines & Characters" >> "$OUTPUT_FILE" +echo "*(Excluding files with extensions: ${EXCLUDE_EXTENSIONS[*]::$(( ${#EXCLUDE_EXTENSIONS[@]} > 5 ? 5 : ${#EXCLUDE_EXTENSIONS[@]} ))}[...] for brevity)*" >> "$OUTPUT_FILE" +echo "" >> "$OUTPUT_FILE" + +find . -type f -not -regex "$EXCLUDE_PATTERN" -print0 | xargs -0 wc -lcm | awk ' +BEGIN { + lines = 0; + chars = 0; +} +END { + if (NF >= 3) { + lines = $1; + chars = $2; + } + print "| Metric | Count |" + print "| :--- | :--- |" + print "| **Total Lines** | " lines " |" + print "| **Total Characters** | " chars " |" +}' >> "$OUTPUT_FILE" + +echo "" >> "$OUTPUT_FILE" + +# --- 2. File Classification and Summary Count (Updated Logic) --- + +declare -A EXTENSION_COUNTS +CODE_COUNT=0 +BINARY_COUNT=0 +TOTAL_FILES=0 +OTHER_CODE_COUNT=0 + +# Loop through all regular files to classify and count them +while IFS= read -r FILE; do + # Get the file's extension, defaulting to "None" if no extension is found + EXTENSION=$(echo "$FILE" | awk -F'.' '{if (NF>1) {print $NF} else {print "None"}}' | tr '[:upper:]' '[:lower:]') + + # Check if the extension is considered Binary/Media + IS_BINARY=0 + for EXT in "${EXCLUDE_EXTENSIONS[@]}"; do + if [[ "$EXTENSION" == "$EXT" ]]; then + IS_BINARY=1 + break + fi + done + + # Count based on classification for the top-level summary + if [[ $IS_BINARY -eq 1 ]]; then + ((BINARY_COUNT++)) + # Log binary files by their extension + EXTENSION_COUNTS["**Binary/Media**"]=$((${EXTENSION_COUNTS["**Binary/Media**"]:-0} + 1)) + else + ((CODE_COUNT++)) + + # Check if the code/text extension is one we want to explicitly list + IS_KNOWN_CODE=0 + for EXT in "${KNOWN_CODE_EXTENSIONS[@]}"; do + if [[ "$EXTENSION" == "$EXT" ]]; then + IS_KNOWN_CODE=1 + break + fi + done + + # Group Code/Text files: if known, list extension; if not, group as "Other Code/Text" + if [[ $IS_KNOWN_CODE -eq 1 ]]; then + EXTENSION_COUNTS["$EXTENSION"]=$((${EXTENSION_COUNTS["$EXTENSION"]:-0} + 1)) + elif [[ "$EXTENSION" == "None" ]]; then + # Files with no extension are still tracked under "None" + EXTENSION_COUNTS["None"]=$((${EXTENSION_COUNTS["None"]:-0} + 1)) + else + # All other (less common) code/text files are grouped here + ((OTHER_CODE_COUNT++)) + fi + fi + + ((TOTAL_FILES++)) + +done < <(find . -type f) + +# Add the cumulative "Other Code/Text" count to the tracking array if greater than zero +if [[ $OTHER_CODE_COUNT -gt 0 ]]; then + EXTENSION_COUNTS["**Other Code/Text**"]=$OTHER_CODE_COUNT +fi + + +# --- 3. Output Top-Level Summary Table (Unchanged) --- + +echo "## 📊 File Type Summary" >> "$OUTPUT_FILE" +echo "| File Type | Count | Percentage |" >> "$OUTPUT_FILE" +echo "| :--- | :--- | :--- |" >> "$OUTPUT_FILE" + +# Calculate percentages +if [[ $TOTAL_FILES -gt 0 ]]; then + CODE_PERCENT=$(awk "BEGIN {printf \"%.1f\", ($CODE_COUNT/$TOTAL_FILES)*100}") + BINARY_PERCENT=$(awk "BEGIN {printf \"%.1f\", ($BINARY_COUNT/$TOTAL_FILES)*100}") +else + CODE_PERCENT=0.0 + BINARY_PERCENT=0.0 +fi + +echo "| **Code/Text Files** | $CODE_COUNT | $CODE_PERCENT% |" >> "$OUTPUT_FILE" +echo "| Binary/Media Files | $BINARY_COUNT | $BINARY_PERCENT% |" >> "$OUTPUT_FILE" +echo "| **TOTAL** | **$TOTAL_FILES** | **100.0%** |" >> "$OUTPUT_FILE" + +echo "" >> "$OUTPUT_FILE" + +# --- 4. Output Extension Breakdown Table (Updated Logic) --- + +echo "## 🔍 File Count by Extension Breakdown" >> "$OUTPUT_FILE" +echo "| Extension | Count | Percentage |" >> "$OUTPUT_FILE" +echo "| :--- | :--- | :--- |" >> "$OUTPUT_FILE" + +# Sort the extensions by count in descending order +for EXTENSION in "${!EXTENSION_COUNTS[@]}"; do + echo "$EXTENSION ${EXTENSION_COUNTS[$EXTENSION]}" +done | sort -nr -k2 | while read EXTENSION COUNT; do + PERCENTAGE=$(awk "BEGIN {printf \"%.1f\", ($COUNT/$TOTAL_FILES)*100}") + + # Custom display logic for grouping headers + if [[ "$EXTENSION" == "**Binary/Media**" || "$EXTENSION" == "**Other Code/Text**" ]]; then + echo "| $EXTENSION | $COUNT | $PERCENTAGE% |" >> "$OUTPUT_FILE" + elif [[ "$EXTENSION" == "None" ]]; then + echo "| **$EXTENSION** | $COUNT | $PERCENTAGE% |" >> "$OUTPUT_FILE" + else + echo "| .$EXTENSION | $COUNT | $PERCENTAGE% |" >> "$OUTPUT_FILE" + fi +done + +echo "" >> "$OUTPUT_FILE" +echo "---" >> "$OUTPUT_FILE" +echo "Analysis complete. Output saved to \`$OUTPUT_FILE\`." \ No newline at end of file diff --git a/data b/data new file mode 160000 index 0000000..0882e21 --- /dev/null +++ b/data @@ -0,0 +1 @@ +Subproject commit 0882e214f1bec8e9d733aa01d48e69c6e8a30e11 diff --git a/data/entities/_entity.xsd b/data/entities/_entity.xsd deleted file mode 100644 index c93fce9..0000000 --- a/data/entities/_entity.xsd +++ /dev/null @@ -1,65 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/data/entities/minions/cannon_minion.xml b/data/entities/minions/cannon_minion.xml deleted file mode 100644 index 9227f00..0000000 --- a/data/entities/minions/cannon_minion.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - diff --git a/data/entities/minions/magic_minon.xml b/data/entities/minions/magic_minon.xml deleted file mode 100644 index fe96500..0000000 --- a/data/entities/minions/magic_minon.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/data/entities/minions/melee_minion.xml b/data/entities/minions/melee_minion.xml deleted file mode 100644 index 071a818..0000000 --- a/data/entities/minions/melee_minion.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/data/entities/minions/ranged_minion.xml b/data/entities/minions/ranged_minion.xml deleted file mode 100644 index 175284d..0000000 --- a/data/entities/minions/ranged_minion.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/src/components/attack.hpp b/src/components/attack.hpp new file mode 100644 index 0000000..b399e8d --- /dev/null +++ b/src/components/attack.hpp @@ -0,0 +1,53 @@ +#pragma once + +#include "component.hpp" +#include +#include + +/** + * AttackComponent - Tracks current attack state and cooldown (DATA ONLY) + * + * USAGE: Add to any entity that can attack (champions, minions, towers) and projectiles + * SYSTEMS: CombatSystem (for all update logic) + * COMPANIONS: Stats (for attack_speed, attack_range), TargetComponent + * + * PURPOSE: + * - Store attack state and cooldown + * - Cache attack parameters + * + * DESIGN: + * - Pure data component (no methods) + * - CombatSystem handles all update logic + * - Separates attack state from combat stats + */ +struct AttackComponent : public Component { + // The entity that this component currently wants to target + std::optional target; + + // === Attack State === + bool can_attack = true; // Can attack this frame + + // === Cooldown Tracking === + const float attack_cooldown_ms = 1000.0f; // Amount of ms to wait after attacking before being able to attack again (set once) + float cooldown_timer_ms = 0.0f; // Tracks remaining cooldown time + float cast_time_ms = 0.0f; // Time spent in current attack animation + float last_attack_time_ms = 0.0f; // Timestamp of last attack + float hit_timing_percent = 0.5f; // When in animation does damage occur (0.0-1.0) + + // === Current Attack === + bool attack_in_progress = false; // Currently executing attack animation/projectile + float attack_animation_progress = 0.0f; // 0.0 to 1.0, used for timing hit/effects + float attack_animation_duration_ms = 300.0f; // Total duration of attack animation + + // === Pending Attack Data (set by CombatSystem) === + float pending_damage = 0.0f; // Damage to apply when animation completes + EntityID pending_target = INVALID_ENTITY_ID; // Target of pending attack + DamageType pending_damage_type = DamageType::PHYSICAL; // Type of pending damage + + // === Attack Counters === + uint32_t total_attacks = 0; // Total attacks landed (for stats/quests) + uint32_t attacks_this_frame = 0; // Prevent multiple attacks per frame + + COMPONENT_TYPE_ID(AttackComponent, 2020) +}; + diff --git a/src/components/client_info.hpp b/src/components/client_info.hpp new file mode 100644 index 0000000..febde95 --- /dev/null +++ b/src/components/client_info.hpp @@ -0,0 +1,21 @@ +#pragma once + +#include "component.hpp" +#include + +/** + * ClientInfoComponent - Player network identification + * + * USAGE: Add to player entities only + * SYSTEMS: PlayerManager, NetworkService + * REQUIRED COMPANIONS: ReadinessComponent, NetworkMetadataComponent + * + * Maps network client_id to in-game player_id for identification + * across server systems. + */ +struct ClientInfoComponent : public Component { + std::string client_id; // Unique network identifier + uint32_t player_id; // Server-assigned player ID + + COMPONENT_TYPE_ID(ClientInfoComponent, 3001) +}; diff --git a/src/components/component_registry.hpp b/src/components/component_registry.hpp new file mode 100644 index 0000000..1ae9aed --- /dev/null +++ b/src/components/component_registry.hpp @@ -0,0 +1,45 @@ +#pragma once + +#include + +/** + * Central registry of all component type IDs. + * + * Organized by range: + * 0-999: Reserved (do not use) + * 1000-1999: Physics, Transform, Movement + * 2000-2999: Gameplay (Stats, Experience, Combat, AI) + * 3000-3999: Network, Player-specific + * 4000-4999: Client-side (visual, UI) + * 5000+: Custom, Modding + */ +namespace ComponentTypes { + // === Core Gameplay (2000-2099) === + constexpr uint32_t MOVEMENT = 2001; + constexpr uint32_t STATS = 2002; + constexpr uint32_t NPC = 2003; + constexpr uint32_t STRUCTURE = 2010; + + // === State Management (2010-2099) === + constexpr uint32_t ENTITY_STATE = 2010; + constexpr uint32_t PATHFINDING = 2009; + constexpr uint32_t INTENT = 2011; + constexpr uint32_t EXPERIENCE = 2012; + + // === Attack & Combat (2020-2099) === + constexpr uint32_t ATTACK = 2020; + constexpr uint32_t TARGET = 2021; + constexpr uint32_t AUTO_ATTACK = 2022; + constexpr uint32_t WAVE_STATE = 2050; + + // === Network & Player (3000-3099) === + constexpr uint32_t CLIENT_INFO = 3001; + constexpr uint32_t READINESS = 3002; + constexpr uint32_t NETWORK_METADATA = 3003; + constexpr uint32_t NETWORK_ENTITY = 3004; + constexpr uint32_t PLAYER_OWNED = 3005; + constexpr uint32_t TEMPLATE = 3006; + + // === Metadata & Visual (4000-4099) === + constexpr uint32_t METADATA = 4001; +} \ No newline at end of file diff --git a/src/components/errors.hpp b/src/components/engine_errors.hpp similarity index 100% rename from src/components/errors.hpp rename to src/components/engine_errors.hpp diff --git a/src/components/entity_state.hpp b/src/components/entity_state.hpp new file mode 100644 index 0000000..5a495f5 --- /dev/null +++ b/src/components/entity_state.hpp @@ -0,0 +1,36 @@ +#pragma once + +#include "component.hpp" +#include + +/** + * Entity state enumeration. + * Defines all possible states an entity can be in during its lifecycle. + */ +enum class EntityState { + SPAWNED, + IDLE, + // Idle is for players || If jungle is item and is attacked, it goes from SPAWNED -> IDLE -> ATTACKING + PATHFINDING_WAITING, + HOLDING_FOR_TARGET, + MOVING, + STOPPING, + STUCK, + ATTACKING, + DEAD +}; + +/** + * EntityState component for state machine management. + * Tracks the current state of an entity and enables state-based behaviors. + */ +struct EntityStateComponent : public Component { + + EntityState current_state = EntityState::SPAWNED; + EntityID target_entity_id = INVALID_ENTITY_ID; + std::vector target_positions = {}; + + float state_duration_ms = 0.0f; + + COMPONENT_TYPE_ID(EntityStateComponent, 2010) +}; diff --git a/src/components/experience.hpp b/src/components/experience.hpp index 90e8c08..32a4db0 100644 --- a/src/components/experience.hpp +++ b/src/components/experience.hpp @@ -1,11 +1,11 @@ #pragma once -#include "component.hpp" +#include struct ExperienceComponent : public Component { float current_exp = 0.0f; // Current experience points float required_exp = 1.0f; // Experience required for next level float exp_growth_rate = 1.2f; // Growth rate for required experience per level - COMPONENT_TYPE_ID(ExperienceComponent, 2010) + COMPONENT_TYPE_ID(ExperienceComponent, 2012) }; \ No newline at end of file diff --git a/src/components/intent.hpp b/src/components/intent.hpp new file mode 100644 index 0000000..f6aa0e8 --- /dev/null +++ b/src/components/intent.hpp @@ -0,0 +1,31 @@ +#pragma once + +#include +#include +#include + +/** + * Intent types define what entities want to do. + */ +enum class IntentType { + NONE, // No active intent + MOVE_TO_POSITION, // Move to a specific position (one-time) + ATTACK_TARGET, // Attack a specific entity +}; + +/** + * IntentComponent indicates what an entity currently wants to do. + * The BrainSystem encodes this intention into lower level components + * such as movement or attack. + * The Intent is controlled by, for example, the InputSystem (player controlled entity) + * or the NPCSystem (npc entity). + */ +struct IntentComponent : public Component { + IntentType type = IntentType::NONE; + + // Target data (usage depends on intent type) + uint32_t target_entity_id = INVALID_ENTITY_ID; // For ATTACK_TARGET + Vec2 target_position = Vec2(0.0f, 0.0f); // For MOVE_TO_POSITION + + COMPONENT_TYPE_ID(IntentComponent, 2011) +}; diff --git a/src/components/map.hpp b/src/components/map.hpp index ed7ff3d..ba7076f 100644 --- a/src/components/map.hpp +++ b/src/components/map.hpp @@ -1,13 +1,25 @@ #pragma once -#include "component.hpp" -#include "../systems/math.hpp" +#include +#include #include #include +/** + * Represents a single structure on the map (tower, core, etc.) (may become a full component later) -- cmkrist 4/12/2025 + */ +struct MapStructure { + std::string id; + std::string type; + uint32_t team = 0; + Vec3 position; + Vec3 rotation; + Vec3 scale; +}; + /** * Map component for the map entity. - * Stores map metadata including name, navmesh data, and spawnpoint information. + * Stores map metadata including name, navmesh data, spawnpoint information, and structures. */ struct Map : public Component { // Map name (e.g., "Konda") @@ -21,9 +33,23 @@ struct Map : public Component { // Minions path from spawnpoint[i] to spawnpoint[(i+1) % spawnpoints.size()] std::vector spawnpoints; + // Structures on the map (towers, cores, etc.) + std::vector structures; + // Grid size and offset Vec2 size = Vec2(0.0f, 0.0f); //width, height Vec2 offset = Vec2(0.0f, 0.0f); // 1/2 width, 1/2 height - cmkrist 15/11/2025 + // ===== Spatial Grid (computed at load time) ===== + // 2D grid of polygon IDs for fast point-in-polygon queries. + // Grid layout: grid_cells[y * grid_width + x] = {polygon_ids} + // Computed once at map load, never modified afterwards. + + std::vector> grid_cells; // grid_cells[index] = list of polygon IDs + Vec2 grid_origin; // World position of grid origin (bottom-left) + float grid_cell_size = 50.0f; // Size of each grid cell in world units + int grid_width = 0; // Number of cells in X direction + int grid_height = 0; // Number of cells in Y direction + COMPONENT_TYPE_ID(Map, 2005) }; diff --git a/src/components/metadata.hpp b/src/components/metadata.hpp new file mode 100644 index 0000000..7e87b8b --- /dev/null +++ b/src/components/metadata.hpp @@ -0,0 +1,17 @@ +#pragma once + +#include +#include + +/** + * Metadata component for UI/Visual Data. + * Stores metadata such as name, description, icon, and model for the entity. + */ +struct MetadataComponent : public Component { + std::string name; + std::string description; + std::string icon; + std::string model; + + COMPONENT_TYPE_ID(MetadataComponent, 4001) +}; diff --git a/src/components/movement.hpp b/src/components/movement.hpp index e394e72..a62b0b9 100644 --- a/src/components/movement.hpp +++ b/src/components/movement.hpp @@ -1,8 +1,9 @@ #pragma once -#include "component.hpp" -#include "../systems/math.hpp" +#include +#include #include +#include /** * Movement component for entities. @@ -15,6 +16,9 @@ struct Movement : public Component { // Collision radius for entity-to-entity collision avoidance float collision_radius{0.5f}; + + // Position that the entity currently wants to move to + std::optional target = std::nullopt; COMPONENT_TYPE_ID(Movement, 2001) }; \ No newline at end of file diff --git a/src/components/navmesh.hpp b/src/components/navmesh.hpp index a5a6fa5..f7f2999 100644 --- a/src/components/navmesh.hpp +++ b/src/components/navmesh.hpp @@ -1,7 +1,7 @@ #pragma once -#include "component.hpp" -#include "../systems/math.hpp" +#include +#include #include /** diff --git a/src/components/network_entity.hpp b/src/components/network_entity.hpp new file mode 100644 index 0000000..60e6711 --- /dev/null +++ b/src/components/network_entity.hpp @@ -0,0 +1,50 @@ +#pragma once + +#include "component.hpp" +#include "movement.hpp" +#include "entity_state.hpp" +#include "stats.hpp" +#include +#include + +/** + * NetworkEntityComponent - Tracks network synchronization state + * + * USAGE: Add to entities that need to be synchronized to clients. + * Presence of this component means the entity should always be synced. + * SYSTEMS: NetworkSyncSystem + * + * PURPOSE: + * - Cache last synced state to detect changes + * - Optimize bandwidth by only sending changed data + * - Track sync state per entity + * + * BENEFITS: + * - Efficient change detection (position, state, custom properties) + * - Per-entity sync control via component presence + * - Easy to extend with new sync requirements + * + * EXAMPLE: + * // Check if entity has changed since last sync + * if (entity.get_component()->has_changed(entity)) { + * network_sync_system.sync_entity(entity_id); + * } + */ +struct NetworkEntityComponent : public Component { + // === Sync Control === + bool force_full_sync_next_frame = false; // Send all data next frame, not just deltas + + // === Sync Tracking === + uint32_t last_sync_frame = 0; // Frame number when last synced + + // === Last Known State (for delta detection) === + Vec2 last_synced_position = Vec2(0.0f, 0.0f); // Position at last sync + EntityState last_synced_state = EntityState::SPAWNED; // State at last sync + std::optional last_synced_stats; // Stats at last sync (for change detection) + + // === Bandwidth Optimization === + static constexpr uint32_t MIN_POSITION_CHANGE = 1; // Only sync if moved 1 unit + static constexpr uint32_t SYNC_FRAME_INTERVAL = 1; // Sync every frame + + COMPONENT_TYPE_ID(NetworkEntityComponent, 3004) +}; diff --git a/src/components/network_metadata.hpp b/src/components/network_metadata.hpp new file mode 100644 index 0000000..4ac0aa7 --- /dev/null +++ b/src/components/network_metadata.hpp @@ -0,0 +1,20 @@ +#pragma once + +#include "component.hpp" +#include + +/** + * NetworkMetadataComponent - Player connection metadata + * + * USAGE: Track connection health and latency for player entities + * SYSTEMS: PlayerManager, NetworkService + * + * Replaces Player struct fields: last_activity, latency_ms + * Provides methods to check connection staleness and update activity. + */ +struct NetworkMetadataComponent : public Component { + std::chrono::steady_clock::time_point last_activity = std::chrono::steady_clock::now(); + unsigned int latency_ms = 0; // Last measured ping in milliseconds + + COMPONENT_TYPE_ID(NetworkMetadataComponent, 3003) +}; diff --git a/src/components/npc_component.hpp b/src/components/npc_component.hpp new file mode 100644 index 0000000..9b0aa26 --- /dev/null +++ b/src/components/npc_component.hpp @@ -0,0 +1,29 @@ +#pragma once + +#include "component.hpp" +#include "component_registry.hpp" + +enum class NPCType { + MINION +}; + + + +struct NPCComponent : public Component { + NPCType npc_type; + + + // === Behavior Configuration === + float aggression_range = 10.0f; // How far to search for enemies to attack + Vec2 objective; // Target position for minions to path towards + + // === Chase Behavior === + float chase_distance = 30.0f; + float chase_timeout_ms = 5000.0f; // How long to keep chasing without hitting the target + + // === State Cooldown === + float state_change_cooldown_ms = 250.0f; // Minimum time between state changes (prevents rapid flip-flopping) + float time_since_last_state_change_ms = 0.0f; // Tracks time since last state change + + COMPONENT_TYPE_ID(NPCComponent, ComponentTypes::NPC) +}; \ No newline at end of file diff --git a/src/components/pathfinding.hpp b/src/components/pathfinding.hpp index 3d705ab..6e966fd 100644 --- a/src/components/pathfinding.hpp +++ b/src/components/pathfinding.hpp @@ -1,27 +1,24 @@ #pragma once -#include "component.hpp" -#include "../systems/math.hpp" +#include +#include #include #include /** * Pathfinding component for entities. * Stores path waypoints and tracks progress along the path. + * Entity state is tracked in EntityStateComponent (PATHFINDING_WAITING, MOVING, STUCK). */ struct PathfindingComponent : public Component { - std::vector waypoints; - int current_waypoint_index = 0; - uint32_t target_spawnpoint_id = 0; - - bool is_waiting_for_path = false; + // Current path waypoints (2D positions) + std::vector waypoints; + // Request ID for tracking async pathfinding requests uint32_t path_request_id = 0; - // Track last position to detect if entity is stuck - Vec2 last_position = Vec2(0.0f, 0.0f); - float stuck_time_ms = 0.0f; - // Stuck Timeout Threshold - static constexpr float STUCK_THRESHOLD_MS = 2000.0f; - + + // True if the entity has requested a path and is currently waiting for it to be calculated + bool waiting_for_path = false; + COMPONENT_TYPE_ID(PathfindingComponent, 2009) }; \ No newline at end of file diff --git a/src/components/player_owned.hpp b/src/components/player_owned.hpp new file mode 100644 index 0000000..b3bbd0e --- /dev/null +++ b/src/components/player_owned.hpp @@ -0,0 +1,35 @@ +#pragma once + +#include "component.hpp" +#include + +/** + * PlayerOwnedComponent - Links a champion/entity to its owning player + * + * USAGE: Add to champion entities to track which player controls them + * SYSTEMS: PlayerManager, InputSystem + * + * PURPOSE: + * - Link champion entities to their player entity + * - Allow player inputs to control champion behavior + * - Track ownership for damage/kill credit + * + * BENEFITS: + * - Decouples player (input/connection) from champion (in-game avatar) + * - Multiple champions per player possible (future feature) + * - Clean architecture like League of Legends + * + * EXAMPLE: + * // Find the champion controlled by a player + * auto all_champions = entity_manager.get_entities_with_component(); + * for (auto* champion : all_champions) { + * if (champion->get_component()->owning_player_id == player_id) { + * // This is the player's champion + * } + * } + */ +struct PlayerOwnedComponent : public Component { + EntityID owning_player_id = INVALID_ENTITY_ID; // Entity ID of the owning player + + COMPONENT_TYPE_ID(PlayerOwnedComponent, 3005) +}; diff --git a/src/components/readiness.hpp b/src/components/readiness.hpp new file mode 100644 index 0000000..4f848aa --- /dev/null +++ b/src/components/readiness.hpp @@ -0,0 +1,18 @@ +#pragma once + +#include "component.hpp" + +/** + * ReadinessComponent - Player readiness state + * + * USAGE: Add to player entities for lobby/game readiness tracking + * SYSTEMS: PlayerManager, GameplayCoordinator + * + * Tracks whether a player has marked themselves as ready to begin + * the game. Used for lobby state management and game start validation. + */ +struct ReadinessComponent : public Component { + bool is_ready = false; // Player has marked themselves ready + + COMPONENT_TYPE_ID(ReadinessComponent, 3002) +}; diff --git a/src/components/state.hpp b/src/components/state.hpp deleted file mode 100644 index 1cd20e6..0000000 --- a/src/components/state.hpp +++ /dev/null @@ -1,23 +0,0 @@ -#pragma once - -#include "component.hpp" -#include "../systems/math.hpp" - -/** - * Component State Machine for entities. - * Manages the current state of an entity (e.g., idle, moving, attacking). - */ - -enum class EntityState { - IDLE, - MOVING, - ATTACKING, - CASTING, - STUNNED, - DEAD -}; - -struct State : public Component { - EntityState current_state = EntityState::IDLE; - COMPONENT_TYPE_ID(State, 2000) -}; \ No newline at end of file diff --git a/src/components/stats.hpp b/src/components/stats.hpp index f3a90c1..5c82676 100644 --- a/src/components/stats.hpp +++ b/src/components/stats.hpp @@ -19,7 +19,6 @@ struct Stats : public Component { float max_mana = 0.0f; float mana = 0.0f; float move_speed = 0.0f; - int level = 1; // === Offensive Stats === float attack_range = 1.0f; @@ -46,6 +45,15 @@ struct Stats : public Component { float leech = 0.0f; // Percentage of damage dealt returned as mana float vision_range = 1.0f; + // === Experience & Gold === + int level = 1; + float level_curve = 1.5f; + int total_experience = 0; + int experience = 0; + + int total_gold = 0; + int gold = 0; + // === Team & Faction === uint8_t team_id = 0; // [0 = Neutral, 1 = Team 1, 2 = Team 2] -- cmkrist 15/11/2025 diff --git a/src/components/structure.hpp b/src/components/structure.hpp new file mode 100644 index 0000000..758de3a --- /dev/null +++ b/src/components/structure.hpp @@ -0,0 +1,19 @@ +#pragma once +#include "component.hpp" + +enum class StructureType { + TOWER, + INHIBITOR, + NEXUS, + WARD, + CUSTOM +}; + +struct Structure : public Component { + StructureType type = StructureType::TOWER; + uint32_t level = 1; // Tower level (affects stats) + bool is_active = true; // Can attack/interact + float activation_range = 30.0f; // Range to detect enemies + + COMPONENT_TYPE_ID(Structure, 2010) +}; \ No newline at end of file diff --git a/src/components/target.hpp b/src/components/target.hpp new file mode 100644 index 0000000..329b5c8 --- /dev/null +++ b/src/components/target.hpp @@ -0,0 +1,60 @@ +#pragma once + +#include "component.hpp" +#include "../systems/entity_manager.hpp" +#include + +/** + * TargetComponent - Tracks current attack target + * + * USAGE: Add to entities that need target selection (minions, champions) + * SYSTEMS: CombatSystem + * COMPANIONS: AttackComponent, Movement + * + * PURPOSE: + * - Track current attack target + * - Store target priority and selection criteria + * - Support target switching and loss + * - Enable target-relative positioning + * + * DESIGN: + * - Separates target tracking from attack mechanics + * - Enables flexible targeting AI (priority targeting, defensive targeting, etc.) + * - Integrates with AttackComponent for actual attacks + * - Works with movement systems for approach/positioning + * + * EXAMPLES: + * - Minions auto-attack nearest enemy + * - Champions focus target selection + * - Towers attack closest enemy in range + */ +struct TargetComponent : public Component { + // === Target Tracking === + EntityID current_target = INVALID_ENTITY_ID; // Entity currently being attacked + + // === Target Selection === + enum class TargetPriority { + NEAREST, // Closest enemy + HIGHEST_THREAT, // Enemy dealing most damage + LOWEST_HEALTH, // Enemy with lowest HP + HIGHEST_DAMAGE, // Enemy with highest attack power + PRIORITY_TARGET // Player-selected or set target + }; + + TargetPriority targeting_mode = TargetPriority::NEAREST; + + // === Target Range === + float target_search_range = 10.0f; // How far to look for targets (game units) + float target_loss_range = 12.0f; // Range beyond which target is lost + + // === Target Timing === + float last_target_search_ms = 0.0f; // When last searched for new target + float target_search_interval_ms = 500.0f; // How often to search for targets (500ms) + + // === Target Status === + bool has_target = false; // Whether current_target is valid and in range + bool target_in_range = false; // Whether target is in attack range + float distance_to_target = 0.0f; // Cached distance to current target + + COMPONENT_TYPE_ID(TargetComponent, 2021) +}; diff --git a/src/components/template.hpp b/src/components/template.hpp new file mode 100644 index 0000000..91a8b71 --- /dev/null +++ b/src/components/template.hpp @@ -0,0 +1,17 @@ +#pragma once + +#include + +#include "component.hpp" + +/** + * This component indicates the template from which the entity was created. + * This is used to let client know what archetype to spawn. + */ +class TemplateComponent : public Component { +public: + TemplateComponent(std::string template_id) : template_id(template_id) {}; + std::string template_id; + + COMPONENT_TYPE_ID(TemplateComponent, 3006) +}; \ No newline at end of file diff --git a/src/components/wave_state.hpp b/src/components/wave_state.hpp new file mode 100644 index 0000000..11aa20d --- /dev/null +++ b/src/components/wave_state.hpp @@ -0,0 +1,61 @@ +#pragma once + +#include "component.hpp" +#include +#include +#include + +/** + * WaveStateComponent - Tracks wave progression state + * + * USAGE: Add to a "wave entity" that represents the current wave + * SYSTEMS: WaveSystem + * + * The WaveSystem creates one of these entities to manage wave state. + * Much cleaner than tracking multiple variables across the system. + * + * RESPONSIBILITIES: + * - Track which phase the wave is in (waiting, spawning, active, complete) + * - Count minions spawned vs remaining + * - Store minion composition for this wave + * - Track elapsed time within each phase + * + * BENEFITS: + * - Wave state is now queryable as a component + * - Can subscribe to wave changes via entity system + * - Clean separation of data from logic + */ +struct WaveStateComponent : public Component { + /** + * Wave phases - represents the lifecycle of a single wave + */ + enum class WavePhase { + WAITING, // Waiting for timer before spawn (initial delay between waves) + SPAWNING, // Currently spawning minions (with delay between each minion) + ACTIVE, // Minions spawned, wave in progress (minions are alive/fighting) + COMPLETE // Wave finished, waiting for next (all minions killed or despawned) + }; + + // === Wave Identity === + int wave_number = 0; // Which wave (0-indexed) + int wave_type = 0; // Special vs default (0=default, 1=special) + + // === Phase Tracking === + WavePhase phase = WavePhase::WAITING; // Current phase + float phase_elapsed_ms = 0.0f; // Time spent in current phase + + // === Minion Counting === + int minions_in_wave = 0; // Total minions for this wave + int minions_spawned = 0; // How many have been spawned so far + int minions_remaining = 0; // How many are still alive + + // === Composition === + std::vector minion_composition; // List of minion templates for this wave + + // === Timing === + static constexpr float WAVE_START_DELAY_MS = 3000.0f; // 3 seconds before first minion + static constexpr float MINION_SPAWN_DELAY_MS = 400.0f; // 400ms between minions + static constexpr float WAVE_COMPLETE_DELAY_MS = 2000.0f; // 2 seconds after last minion killed + + COMPONENT_TYPE_ID(WaveStateComponent, 2050) +}; diff --git a/src/systems/math.hpp b/src/libs/math.hpp similarity index 100% rename from src/systems/math.hpp rename to src/libs/math.hpp diff --git a/src/main.cpp b/src/main.cpp index 39362f4..c71da9c 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,4 +1,5 @@ #define ENET_IMPLEMENTATION +#define _CRT_SECURE_NO_WARNINGS /* Standard Libraries */ #include #include @@ -6,7 +7,7 @@ #include /* Project Headers */ -#include "gameserver.hpp" +#include // === Configuration Constants === constexpr int DEFAULT_PORT = 7000; @@ -39,6 +40,8 @@ static std::string get_map_path_from_env() { LOG_INFO("No map file path or name specified in environment, using default map"); return ""; } + + void signal_handler(int signal) { if (signal == SIGINT && g_server_instance) { LOG_INFO("Shutdown signal received. Cleaning up..."); @@ -47,10 +50,29 @@ void signal_handler(int signal) { } // === Main Entry Point === // -int main() { +int main(int argc, char* argv[]) { LOG_INFO("OpenChamp GameServer Starting"); LOG_INFO("=============================="); + // Parse command-line arguments + bool enable_visualizer = false; + uint16_t visualizer_port = 8080; + + for (int i = 1; i < argc; ++i) { + std::string arg(argv[i]); + if (arg == "--visualize") { + enable_visualizer = true; + } else if (arg == "--visualize-port" && i + 1 < argc) { + try { + visualizer_port = std::stoi(argv[++i]); + LOG_INFO("Visualizer port set to %u", visualizer_port); + } catch (...) { + LOG_ERROR("Invalid visualizer port, using default %u", visualizer_port); + } + } + } + LOG_INFO("Visualizer: %s ", enable_visualizer ? ("Enabled on port " + std::to_string(visualizer_port)).c_str() : "No"); + // Parse port from environment int env_port = DEFAULT_PORT; const char* port_env = std::getenv("SERVER_PORT"); @@ -60,16 +82,13 @@ int main() { if (env_port <= MIN_PORT || env_port > MAX_PORT) { LOG_ERROR("Invalid port number: %s. Using default %d", port_env, DEFAULT_PORT); env_port = DEFAULT_PORT; - } else { - LOG_INFO("Using port from environment: %d\n", env_port); - } + } } catch (const std::exception& e) { LOG_ERROR("Failed to parse SERVER_PORT: %s. Using default %d", e.what(), DEFAULT_PORT); env_port = DEFAULT_PORT; } - } else { - LOG_INFO("No port specified in environment, using default %d", DEFAULT_PORT); } + LOG_INFO("Game Port: %d", env_port); // Parse max clients from environment int max_clients = DEFAULT_MAX_CLIENTS; @@ -77,19 +96,16 @@ int main() { if (clients_env != nullptr) { try { max_clients = std::stoi(clients_env); - if (max_clients <= 0 || max_clients > MAX_CLIENTS_LIMIT) { + if (max_clients < 0 || max_clients > MAX_CLIENTS_LIMIT) { LOG_ERROR("Invalid MAX_CLIENTS: %s. Using default %d", clients_env, DEFAULT_MAX_CLIENTS); max_clients = DEFAULT_MAX_CLIENTS; - } else { - LOG_INFO("Using MAX_CLIENTS from environment: %d", max_clients); } } catch (const std::exception& e) { LOG_ERROR("Failed to parse MAX_CLIENTS: %s. Using default %d", e.what(), DEFAULT_MAX_CLIENTS); max_clients = DEFAULT_MAX_CLIENTS; } - } else { - LOG_INFO("No MAX_CLIENTS specified in environment, using default %d", DEFAULT_MAX_CLIENTS); - } + } + LOG_INFO("Max Clients: %d", max_clients); // Create and initialize server GameServer server(env_port, max_clients, get_map_path_from_env()); @@ -100,6 +116,11 @@ int main() { return (int)(init_result); } + // Start visualizer if requested + if (enable_visualizer) { + server.start_visualizer(visualizer_port); + } + // Register signal handler for graceful shutdown g_server_instance = &server; std::signal(SIGINT, signal_handler); diff --git a/src/services/astar_pathfinding.cpp b/src/services/astar_pathfinding.cpp index ded50bd..68ec00a 100644 --- a/src/services/astar_pathfinding.cpp +++ b/src/services/astar_pathfinding.cpp @@ -1,12 +1,13 @@ #include -#include +#include std::vector AStarPathfinder::FindPath( const Vec2& start_pos, const Vec2& goal_pos, const std::vector& vertices, const std::vector>& polygons, - float entity_radius + float entity_radius, + const NavGrid* grid ) { // Handle edge cases if (vertices.empty() || polygons.empty()) { @@ -22,88 +23,109 @@ std::vector AStarPathfinder::FindPath( return {start_pos, goal_pos}; } - // Find which polygon contains the start position - int start_polygon = -1; - for (int i = 0; i < static_cast(polygons.size()); ++i) { - std::vector poly_verts; - for (uint32_t vertex_idx : polygons[i]) { - if (vertex_idx < vertices.size()) { - poly_verts.push_back(vertices[vertex_idx]); - } + // Helper lambda to find polygon containing position using grid + auto FindPolygonContainingPoint = [&](const Vec2& pos) -> uint32_t { + if (!grid || !grid->IsBuilt()) { + return UINT32_MAX; } - if (PointInPolygon(start_pos, poly_verts)) { - start_polygon = i; - break; - } - } - - // Find which polygon contains the goal position - int goal_polygon = -1; - for (int i = 0; i < static_cast(polygons.size()); ++i) { - std::vector poly_verts; - for (uint32_t vertex_idx : polygons[i]) { - if (vertex_idx < static_cast(vertices.size())) { - poly_verts.push_back(vertices[vertex_idx]); + + Vec2 rel_pos = pos - grid->grid_origin; + int gx = static_cast(std::floor(rel_pos.x / grid->grid_cell_size)); + int gy = static_cast(std::floor(rel_pos.y / grid->grid_cell_size)); + + gx = std::max(0, std::min(gx, grid->grid_width - 1)); + gy = std::max(0, std::min(gy, grid->grid_height - 1)); + + int cell_index = gy * grid->grid_width + gx; + if (cell_index >= 0 && cell_index < static_cast(grid->grid_cells.size())) { + const auto& candidates = grid->grid_cells[cell_index]; + for (uint32_t poly_id : candidates) { + if (poly_id < polygons.size()) { + std::vector poly_verts; + for (uint32_t vertex_idx : polygons[poly_id]) { + if (vertex_idx < vertices.size()) { + poly_verts.push_back(vertices[vertex_idx]); + } + } + if (PointInPolygon(pos, poly_verts)) { + return poly_id; + } + } } } - if (PointInPolygon(goal_pos, poly_verts)) { - goal_polygon = i; - break; - } - } - - LOG_INFO("A*: start_polygon=%d, goal_polygon=%d for path (%.1f, %.1f) -> (%.1f, %.1f)", - start_polygon, goal_polygon, start_pos.x, start_pos.y, goal_pos.x, goal_pos.y); + return UINT32_MAX; + }; - // If either position is not in a polygon, try to find the closest polygon - if (start_polygon == -1) { + // Helper lambda to find closest polygon to a position + auto FindClosestPolygon = [&](const Vec2& pos) -> int { float closest_dist = std::numeric_limits::max(); + int closest_idx = -1; + for (int i = 0; i < static_cast(polygons.size()); ++i) { - Vec2 centroid = GetPolygonCentroid(std::vector{}); + std::vector poly_verts; for (uint32_t vertex_idx : polygons[i]) { if (vertex_idx < vertices.size()) { - centroid.x += vertices[vertex_idx].x; - centroid.y += vertices[vertex_idx].y; + poly_verts.push_back(vertices[vertex_idx]); } } - centroid.x /= polygons[i].size(); - centroid.y /= polygons[i].size(); - - float dist = start_pos.distance_to(centroid); + Vec2 centroid = GetPolygonCentroid(poly_verts); + float dist = pos.distance_to(centroid); if (dist < closest_dist) { closest_dist = dist; - start_polygon = i; + closest_idx = i; } } - } + return closest_idx; + }; - if (goal_polygon == -1) { - float closest_dist = std::numeric_limits::max(); - for (int i = 0; i < static_cast(polygons.size()); ++i) { - Vec2 centroid; - for (uint32_t vertex_idx : polygons[i]) { - if (vertex_idx < vertices.size()) { - centroid.x += vertices[vertex_idx].x; - centroid.y += vertices[vertex_idx].y; - } - } - centroid.x /= polygons[i].size(); - centroid.y /= polygons[i].size(); + // Find which polygons contain start and goal positions + uint32_t start_polygon = FindPolygonContainingPoint(start_pos); + uint32_t goal_polygon = FindPolygonContainingPoint(goal_pos); - float dist = goal_pos.distance_to(centroid); - if (dist < closest_dist) { - closest_dist = dist; - goal_polygon = i; - } + LOG_DEBUG("A*: start_polygon=%u, goal_polygon=%u for path (%.1f, %.1f) -> (%.1f, %.1f)", + start_polygon, goal_polygon, start_pos.x, start_pos.y, goal_pos.x, goal_pos.y); + + // If start position is not in a polygon, try to find the closest polygon + if (start_polygon == UINT32_MAX) { + int closest = FindClosestPolygon(start_pos); + start_polygon = (closest >= 0) ? static_cast(closest) : UINT32_MAX; + } + + // If goal position is not in a polygon, project it onto the navmesh + if (goal_polygon == UINT32_MAX) { + Vec2 projected_goal = ProjectToNavmesh(goal_pos, vertices, polygons, grid); + LOG_DEBUG("A*: Goal (%.1f, %.1f) projected to (%.1f, %.1f)", + goal_pos.x, goal_pos.y, projected_goal.x, projected_goal.y); + + // Try to find polygon containing the projected position + goal_polygon = FindPolygonContainingPoint(projected_goal); + + // If still not found, find the closest polygon + if (goal_polygon == UINT32_MAX) { + int closest = FindClosestPolygon(projected_goal); + goal_polygon = (closest >= 0) ? static_cast(closest) : UINT32_MAX; } } - if (start_polygon == -1 || goal_polygon == -1) { + if (start_polygon == UINT32_MAX || goal_polygon == UINT32_MAX) { // Fallback: return direct path since we couldn't locate in polygons // This can happen if spawnpoints are outside the navmesh or on edges return {start_pos, goal_pos}; } + // Pre-compute polygon centroids to avoid redundant calculations + std::vector polygon_centroids; + polygon_centroids.reserve(polygons.size()); + for (const auto& polygon : polygons) { + std::vector verts; + for (uint32_t idx : polygon) { + if (idx < vertices.size()) { + verts.push_back(vertices[idx]); + } + } + polygon_centroids.push_back(GetPolygonCentroid(verts)); + } + // A* algorithm on polygon graph std::priority_queue, std::greater> open_set; std::unordered_map g_costs; @@ -114,7 +136,7 @@ std::vector AStarPathfinder::FindPath( Node start_node; start_node.polygon_id = start_polygon; start_node.g_cost = 0.0f; - start_node.h_cost = Heuristic(start_pos, goal_pos); + start_node.h_cost = Heuristic(polygon_centroids[start_polygon], goal_pos); start_node.parent_polygon = UINT32_MAX; start_node.is_start = true; start_node.is_goal = (start_polygon == goal_polygon); @@ -146,7 +168,6 @@ std::vector AStarPathfinder::FindPath( vertices ); waypoints.push_back(edge_point); - current_poly = parent_poly; } @@ -162,7 +183,7 @@ std::vector AStarPathfinder::FindPath( } in_closed_set[current.polygon_id] = true; - // Check all neighbors + // Check only adjacent neighbors, not all polygons for (uint32_t neighbor_id = 0; neighbor_id < polygons.size(); ++neighbor_id) { if (in_closed_set[neighbor_id]) { continue; @@ -172,30 +193,9 @@ std::vector AStarPathfinder::FindPath( continue; } - // Calculate cost to neighbor - Vec2 current_center = GetPolygonCentroid( - [&]() { - std::vector verts; - for (uint32_t idx : polygons[current.polygon_id]) { - if (idx < vertices.size()) { - verts.push_back(vertices[idx]); - } - } - return verts; - }() - ); - - Vec2 neighbor_center = GetPolygonCentroid( - [&]() { - std::vector verts; - for (uint32_t idx : polygons[neighbor_id]) { - if (idx < vertices.size()) { - verts.push_back(vertices[idx]); - } - } - return verts; - }() - ); + // Use pre-computed centroids + const Vec2& current_center = polygon_centroids[current.polygon_id]; + const Vec2& neighbor_center = polygon_centroids[neighbor_id]; float move_cost = current_center.distance_to(neighbor_center); float new_g_cost = g_costs[current.polygon_id] + move_cost; @@ -267,14 +267,16 @@ bool AStarPathfinder::ArePolygonsAdjacent( const std::vector& poly2 ) { // Two polygons are adjacent if they share at least 2 vertices (an edge) + // Use early termination for faster rejection of non-adjacent polygons int shared_vertices = 0; for (uint32_t v1 : poly1) { for (uint32_t v2 : poly2) { if (v1 == v2) { shared_vertices++; if (shared_vertices >= 2) { - return true; + return true; // Early exit when adjacency confirmed } + break; // Found this vertex, move to next v1 } } } @@ -437,3 +439,93 @@ std::vector AStarPathfinder::SmoothPath( return smoothed; } + +Vec2 AStarPathfinder::ProjectToNavmesh( + const Vec2& pos, + const std::vector& vertices, + const std::vector>& polygons, + const NavGrid* nav_grid +) { + // Helper lambda to check if point is in polygon + auto IsInPolygon = [&](const Vec2& point, const std::vector& poly_verts) -> bool { + return PointInPolygon(point, poly_verts); + }; + + // Check if position is already inside a polygon + if (nav_grid && nav_grid->IsBuilt()) { + Vec2 rel_pos = pos - nav_grid->grid_origin; + int gx = static_cast(std::floor(rel_pos.x / nav_grid->grid_cell_size)); + int gy = static_cast(std::floor(rel_pos.y / nav_grid->grid_cell_size)); + + gx = std::max(0, std::min(gx, nav_grid->grid_width - 1)); + gy = std::max(0, std::min(gy, nav_grid->grid_height - 1)); + + int cell_index = gy * nav_grid->grid_width + gx; + if (cell_index >= 0 && cell_index < static_cast(nav_grid->grid_cells.size())) { + const auto& candidates = nav_grid->grid_cells[cell_index]; + for (uint32_t poly_id : candidates) { + if (poly_id < polygons.size()) { + std::vector poly_verts; + for (uint32_t vertex_idx : polygons[poly_id]) { + if (vertex_idx < vertices.size()) { + poly_verts.push_back(vertices[vertex_idx]); + } + } + if (IsInPolygon(pos, poly_verts)) { + return pos; // Already inside a polygon + } + } + } + } + } + + // Position is not inside a polygon, find closest point on any edge + float closest_dist = std::numeric_limits::max(); + Vec2 closest_point = pos; + + for (const auto& polygon : polygons) { + std::vector poly_verts; + for (uint32_t vertex_idx : polygon) { + if (vertex_idx < vertices.size()) { + poly_verts.push_back(vertices[vertex_idx]); + } + } + + if (poly_verts.size() < 2) continue; + + // Check each edge of this polygon + for (size_t i = 0; i < poly_verts.size(); ++i) { + const Vec2& v1 = poly_verts[i]; + const Vec2& v2 = poly_verts[(i + 1) % poly_verts.size()]; + + // Find closest point on this edge + Vec2 edge = v2 - v1; + float edge_len_sq = edge.x * edge.x + edge.y * edge.y; + + if (edge_len_sq < 0.0001f) { + // Degenerate edge, just use v1 + float dist = pos.distance_to(v1); + if (dist < closest_dist) { + closest_dist = dist; + closest_point = v1; + } + continue; + } + + Vec2 to_pos = pos - v1; + float t = (to_pos.x * edge.x + to_pos.y * edge.y) / edge_len_sq; + t = std::max(0.0f, std::min(1.0f, t)); // Clamp to edge + + Vec2 point_on_edge = v1 + edge * t; + float dist = pos.distance_to(point_on_edge); + + if (dist < closest_dist) { + closest_dist = dist; + closest_point = point_on_edge; + } + } + } + + return closest_point; +} + diff --git a/src/services/astar_pathfinding.hpp b/src/services/astar_pathfinding.hpp index c328fc7..25cd7fc 100644 --- a/src/services/astar_pathfinding.hpp +++ b/src/services/astar_pathfinding.hpp @@ -9,7 +9,7 @@ #include #include -#include +#include /** * A* Pathfinding for 2D polygon-based navmeshes. @@ -17,6 +17,21 @@ */ class AStarPathfinder { public: + /** + * Polygon grid data structure for spatial acceleration. + * This is pure data - no methods, just storage. + * Populated at map load time by MapSystem::build_polygon_grid(). + */ + struct NavGrid { + std::vector> grid_cells; // grid_cells[y * grid_width + x] = {polygon_ids} + Vec2 grid_origin; // World position of grid origin (bottom-left) + float grid_cell_size = 50.0f; // Size of each grid cell in world units + int grid_width = 0; // Number of cells in X direction + int grid_height = 0; // Number of cells in Y direction + + bool IsBuilt() const { return grid_width > 0 && grid_height > 0; } + }; + /** * Find a path from start to goal in the navmesh. * @@ -25,6 +40,7 @@ class AStarPathfinder { * @param vertices List of all navmesh vertices * @param polygons List of polygons (each polygon is a list of vertex indices) * @param entity_radius The radius of the entity for collision checking + * @param nav_grid Nav Grid for faster queries * @return Vector of 2D waypoints along the path, or empty if no path found */ static std::vector FindPath( @@ -32,7 +48,8 @@ class AStarPathfinder { const Vec2& goal_pos, const std::vector& vertices, const std::vector>& polygons, - float entity_radius = 0.0f + float entity_radius = 0.0f, + const NavGrid* nav_grid = nullptr ); private: @@ -116,4 +133,16 @@ class AStarPathfinder { const std::vector>& polygons, float entity_radius ); + + /** + * Project a position onto the closest point on the navmesh. + * If the position is already inside a polygon, returns the position unchanged. + * Otherwise, finds the closest point on any polygon edge. + */ + static Vec2 ProjectToNavmesh( + const Vec2& pos, + const std::vector& vertices, + const std::vector>& polygons, + const NavGrid* nav_grid = nullptr + ); }; diff --git a/src/services/navigation_service.cpp b/src/services/navigation_service.cpp index 698526d..b388857 100644 --- a/src/services/navigation_service.cpp +++ b/src/services/navigation_service.cpp @@ -8,22 +8,44 @@ #include #include -#include #include +// Comparator for priority queue: higher priority (player input) comes first +struct PathRequestComparator { + bool operator()(const PathRequest& a, const PathRequest& b) const { + // In priority_queue, returning true means 'a' should come AFTER 'b' + // So we return true if a has LOWER priority than b + return a.priority() < b.priority(); + } +}; + struct NavigationService::NavServiceBackend { std::thread worker; Map map; - std::atomic running{true}; - std::queue requests; + std::atomic running{false}; + std::priority_queue, PathRequestComparator> requests; std::queue results; std::queue waitingResults; std::mutex mtx; - // TODO what frame rate should this run at? infinite? - ploinky 14/11/2025 - FrameTimer frame_timer = FrameTimer(30); NavServiceBackend() { + // Don't start thread yet - wait for map to be assigned + } + + void start_worker() { + running = true; + + // Verify grid was built during map loading + if (map.grid_width > 0 && map.grid_height > 0) { + LOG_INFO("NavigationService: Using precomputed grid (size: %dx%d, cell_size: %.1f)", + map.grid_width, map.grid_height, map.grid_cell_size); + } else { + LOG_ERROR("NavigationService: Navmesh grid not precomputed. Closing navigation service."); + running = false; + return; + } + worker = std::thread([this]() { LOG_INFO("Spinning off NavigationService backend thread"); start_work(); @@ -54,7 +76,7 @@ struct NavigationService::NavServiceBackend { continue; } - PathRequest req = requests.front(); + PathRequest req = requests.top(); requests.pop(); // we have our request, we can unlock for now and do the pathing @@ -64,38 +86,40 @@ struct NavigationService::NavServiceBackend { // the lock is unlocked, take your time // ================================================================= - // Perform A* pathfinding on the 2D navmesh - Vec2 start_2d(req.current_position.x, req.current_position.z); - Vec2 goal_2d(req.destination.x, req.destination.z); - // Create local copies of the navmesh data for thread-safe pathfinding std::vector navmesh_vertices = this->map.vertices; std::vector> navmesh_polygons = this->map.polygons; + // Create grid structure reference from map component + AStarPathfinder::NavGrid grid; + grid.grid_cells = this->map.grid_cells; + grid.grid_origin = this->map.grid_origin; + grid.grid_cell_size = this->map.grid_cell_size; + grid.grid_width = this->map.grid_width; + grid.grid_height = this->map.grid_height; + std::vector path_2d = AStarPathfinder::FindPath( - start_2d, - goal_2d, + req.current_position, + req.destination, navmesh_vertices, navmesh_polygons, - req.entity_pathing_radius + req.entity_pathing_radius, + &grid // Pass grid from map component ); - // Convert the 2D path back to 3D (keeping Y from destination for now) + // Store the 2D path directly PathResult res = PathResult(); res.entity_id = req.entity_id; - res.path.reserve(path_2d.size()); - for (const auto& waypoint_2d : path_2d) { - res.path.push_back(Vec3(waypoint_2d.x, req.destination.y, waypoint_2d.y)); - } + res.path = path_2d; if (res.path.empty()) { LOG_WARN("Navigation: Entity %u path from (%.1f, %.1f) to (%.1f, %.1f) returned EMPTY PATH", - req.entity_id, start_2d.x, start_2d.y, goal_2d.x, goal_2d.y); + req.entity_id, req.current_position.x, req.current_position.y, req.destination.x, req.destination.y); } else { LOG_DEBUG("Navigation: Entity %u path with %zu waypoints", req.entity_id, res.path.size()); } - // we have our result, so let's hand it back to the main thread now + // we have our result, back to the main thread if(!lock.try_lock()) { // we couldn't lock, so cache this result for later // if we don't cache and submit this later, the request will be lost @@ -129,6 +153,7 @@ struct NavigationService::NavServiceBackend { NavigationService::NavigationService(Map map_object) { impl_ = std::make_unique(); impl_->map = std::move(map_object); + impl_->start_worker(); // Start thread after map is assigned } NavigationService::~NavigationService() = default; diff --git a/src/services/navigation_service.hpp b/src/services/navigation_service.hpp index cdab217..5424877 100644 --- a/src/services/navigation_service.hpp +++ b/src/services/navigation_service.hpp @@ -5,20 +5,26 @@ #include #include -#include +#include class PathRequest { public: uint32_t entity_id; float entity_pathing_radius; - Vec3 current_position; - Vec3 destination; + Vec2 current_position; + Vec2 destination; + bool is_player_input = false; + + // For priority queue ordering: higher priority value = processes first + int priority() const { + return is_player_input ? 1 : 0; + } }; class PathResult { public: uint32_t entity_id; - std::vector path; + std::vector path; }; class NavigationService { diff --git a/src/services/network_service.cpp b/src/services/network_service.cpp index 7cfa76d..db209b0 100644 --- a/src/services/network_service.cpp +++ b/src/services/network_service.cpp @@ -1,7 +1,7 @@ -#include "network_service.hpp" +#include -#include "systems/math.hpp" -#include "libs/log.hpp" +#include +#include struct NetworkService::NetworkBackend { NetworkBackend() { @@ -33,9 +33,7 @@ NetworkService::~NetworkService() { ERROR_CODE NetworkService::start_server() { LOG_INFO("Initializing GameServer on port %d with max %d clients", port_, max_clients_); // Initialize ENet - int enet_init_result = enet_initialize(); - LOG_INFO("enet_initialize() returned: %d", enet_init_result); - + int enet_init_result = enet_initialize(); if (enet_init_result != 0) { LOG_ERROR("Failed to initialize ENet"); return ERROR_CODE::ERROR_ENET_INIT_FAILED; @@ -84,6 +82,19 @@ void NetworkService::disconnect() { is_connected_ = false; } +void NetworkService::disconnect_client(const std::string& client_id) { + auto it = backend_->clients_.find(client_id); + if (it != backend_->clients_.end()) { + ENetPeer* peer = it->second; + if (peer) { + // Request graceful disconnection + enet_peer_disconnect(peer, 0); + } + // Remove from clients map + backend_->clients_.erase(it); + } +} + bool NetworkService::run_callbacks() { if (!backend_->enet_server_) { return false; @@ -287,7 +298,6 @@ void NetworkService::broadcast_packet(const PACKET_TYPE& packet_type) { // Broadcast to all connected peers if (backend_->enet_server_) { enet_host_broadcast(backend_->enet_server_, 0, packet); - LOG_DEBUG("Broadcasted packet type %d to all clients", (int)packet_type); } else { enet_packet_destroy(packet); } diff --git a/src/services/network_service.hpp b/src/services/network_service.hpp index e537be3..2b54e75 100644 --- a/src/services/network_service.hpp +++ b/src/services/network_service.hpp @@ -7,9 +7,9 @@ #include -#include -#include -#include "components/errors.hpp" +#include +#include +#include /** * Manages low level networking so that consumers can simply exchange packets @@ -36,6 +36,12 @@ class NetworkService { */ void disconnect(); + /** + * Disconnect a specific client. + * @param client_id ID of the client to disconnect + */ + void disconnect_client(const std::string& client_id); + /** * Send a packet of specified type to a peer. * @param packet_type Type of packet to send diff --git a/src/services/visualizer_service.cpp b/src/services/visualizer_service.cpp new file mode 100644 index 0000000..0511f91 --- /dev/null +++ b/src/services/visualizer_service.cpp @@ -0,0 +1,405 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// Windows socket headers +#ifdef _WIN32 + #include + #include + #pragma comment(lib, "ws2_32.lib") + #define SHUT_RDWR SD_BOTH +#endif + +// Helper functions to convert enums to strings +static std::string entity_state_to_string(EntityState state) { + switch (state) { + case EntityState::SPAWNED: return "SPAWNED"; + case EntityState::IDLE: return "IDLE"; + case EntityState::PATHFINDING_WAITING: return "PATHFINDING_WAITING"; + case EntityState::HOLDING_FOR_TARGET: return "HOLDING_FOR_TARGET"; + case EntityState::MOVING: return "MOVING"; + case EntityState::STOPPING: return "STOPPING"; + case EntityState::STUCK: return "STUCK"; + case EntityState::ATTACKING: return "ATTACKING"; + case EntityState::DEAD: return "DEAD"; + default: return "UNKNOWN"; + } +} + +static std::string intent_type_to_string(IntentType type) { + switch (type) { + case IntentType::NONE: return "NONE"; + case IntentType::MOVE_TO_POSITION: return "MOVE_TO_POSITION"; + case IntentType::ATTACK_TARGET: return "ATTACK_TARGET"; + default: return "UNKNOWN"; + } +} + +VisualizerService::VisualizerService(uint16_t port) + : port_(port) { +} + +VisualizerService::~VisualizerService() { + stop(); +} + +void VisualizerService::initialize(EntityManager* entity_manager, Map* map) { + entity_manager_ = entity_manager; + map_ = map; + LOG_INFO("VisualizerService initialized on port %u", port_); +} + +bool VisualizerService::start() { + if (running_) { + LOG_WARN("VisualizerService already running"); + return false; + } + + running_ = true; + server_thread_ = std::make_unique(&VisualizerService::run_server, this); + return true; +} + +void VisualizerService::stop() { + if (!running_) { + return; + } + + running_ = false; + if (server_thread_ && server_thread_->joinable()) { + server_thread_->join(); + } + + LOG_INFO("VisualizerService stopped"); +} + +void VisualizerService::run_server() { +#ifdef _WIN32 + // Initialize Winsock + WSADATA wsa_data; + if (WSAStartup(MAKEWORD(2, 2), &wsa_data) != 0) { + LOG_ERROR("WSAStartup failed"); + running_ = false; + return; + } + + // Create listening socket + SOCKET listen_socket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + if (listen_socket == INVALID_SOCKET) { + LOG_ERROR("socket() failed: %d", WSAGetLastError()); + WSACleanup(); + running_ = false; + return; + } + + // Set socket to reuse address + int reuse = 1; + if (setsockopt(listen_socket, SOL_SOCKET, SO_REUSEADDR, (const char*)&reuse, sizeof(reuse)) < 0) { + LOG_WARN("setsockopt(SO_REUSEADDR) failed"); + } + + // Bind to port + struct sockaddr_in server_addr; + server_addr.sin_family = AF_INET; + server_addr.sin_addr.s_addr = htonl(INADDR_ANY); + server_addr.sin_port = htons(port_); + + if (bind(listen_socket, (struct sockaddr*)&server_addr, sizeof(server_addr)) == SOCKET_ERROR) { + LOG_ERROR("bind() failed: %d", WSAGetLastError()); + closesocket(listen_socket); + WSACleanup(); + running_ = false; + return; + } + + // Listen for connections + if (listen(listen_socket, SOMAXCONN) == SOCKET_ERROR) { + LOG_ERROR("listen() failed: %d", WSAGetLastError()); + closesocket(listen_socket); + WSACleanup(); + running_ = false; + return; + } + + LOG_INFO("HTTP server listening on port %u", port_); + + // Accept connections loop + while (running_) { + // Set a timeout for accept so we can check running_ flag periodically + fd_set read_fds; + FD_ZERO(&read_fds); + FD_SET(listen_socket, &read_fds); + + struct timeval tv; + tv.tv_sec = 1; // 1 second timeout + tv.tv_usec = 0; + + int select_result = select((int)listen_socket + 1, &read_fds, nullptr, nullptr, &tv); + if (select_result < 0) { + LOG_ERROR("select() failed: %d", WSAGetLastError()); + break; + } + + if (select_result == 0) { + // Timeout - check if we should continue + continue; + } + + // Accept connection + struct sockaddr_in client_addr; + int client_addr_len = sizeof(client_addr); + SOCKET client_socket = accept(listen_socket, (struct sockaddr*)&client_addr, &client_addr_len); + + if (client_socket == INVALID_SOCKET) { + LOG_ERROR("accept() failed: %d", WSAGetLastError()); + continue; + } + + // Read HTTP request + char buffer[4096]; + int recv_len = recv(client_socket, buffer, sizeof(buffer) - 1, 0); + if (recv_len > 0) { + buffer[recv_len] = '\0'; + + // Parse request + std::string request(buffer); + std::string response; + + // Extract the request path + std::string path = "/"; + size_t path_start = request.find("GET "); + if (path_start != std::string::npos) { + path_start += 4; + size_t path_end = request.find(" ", path_start); + if (path_end != std::string::npos) { + path = request.substr(path_start, path_end - path_start); + } + } + + if (path.find("/api/gamestate") != std::string::npos) { + // Return JSON game state + std::string json = get_game_state_json(); + response = "HTTP/1.1 200 OK\r\n"; + response += "Content-Type: application/json\r\n"; + response += "Content-Length: " + std::to_string(json.length()) + "\r\n"; + response += "Access-Control-Allow-Origin: *\r\n"; + response += "Connection: close\r\n"; + response += "\r\n"; + response += json; + } else { + // Serve files from web_ui/ directory + std::string file_path = "web_ui"; + if (path == "/" || path.empty()) { + file_path += "/index.html"; + } else { + file_path += path; + } + + std::string content = get_file_content(file_path); + if (!content.empty()) { + // Determine content type based on file extension + std::string content_type = "text/plain"; + if (file_path.find(".html") != std::string::npos) { + content_type = "text/html"; + } else if (file_path.find(".css") != std::string::npos) { + content_type = "text/css"; + } else if (file_path.find(".js") != std::string::npos) { + content_type = "application/javascript"; + } else if (file_path.find(".json") != std::string::npos) { + content_type = "application/json"; + } + + response = "HTTP/1.1 200 OK\r\n"; + response += "Content-Type: " + content_type + "\r\n"; + response += "Content-Length: " + std::to_string(content.length()) + "\r\n"; + response += "Connection: close\r\n"; + response += "\r\n"; + response += content; + } else { + // 404 Not Found + response = "HTTP/1.1 404 Not Found\r\n"; + response += "Content-Type: text/plain\r\n"; + response += "Content-Length: 9\r\n"; + response += "Connection: close\r\n"; + response += "\r\n"; + response += "Not Found"; + } + } + + // Send response + send(client_socket, response.c_str(), (int)response.length(), 0); + } + + // Close client socket + shutdown(client_socket, SHUT_RDWR); + closesocket(client_socket); + } + + // Cleanup + closesocket(listen_socket); + WSACleanup(); + LOG_INFO("HTTP server stopped"); +#else + LOG_WARN("VisualizerService HTTP server not implemented for non-Windows platforms"); +#endif + running_ = false; +} + +std::string VisualizerService::get_html_page() const { + // Try to load from file first + std::ifstream file("web/index.html"); + if (file.is_open()) { + std::stringstream buffer; + buffer << file.rdbuf(); + return buffer.str(); + } + + // Fallback: return minimal HTML + return R"( + + +GameServer Debugger + +

GameServer Debugger

+

web/index.html not found. Please ensure visualizer files are in the correct directory.

+ + + )"; +} + +std::string VisualizerService::get_file_content(const std::string& path) const { + std::ifstream file(path, std::ios::binary); + if (!file.is_open()) { + return ""; + } + std::stringstream buffer; + buffer << file.rdbuf(); + return buffer.str(); +} + +std::string VisualizerService::get_game_state_json() const { + std::ostringstream json; + json << std::fixed << std::setprecision(2); + + json << "{"; + + // Map data + json << "\"map\":{"; + if (map_) { + json << "\"name\":\"" << map_->name << "\","; + json << "\"size\":{\"x\":" << map_->size.x << ",\"y\":" << map_->size.y << "},"; + json << "\"offset\":{\"x\":" << map_->offset.x << ",\"y\":" << map_->offset.y << "},"; + + // Vertices + json << "\"vertices\":["; + for (size_t i = 0; i < map_->vertices.size(); i++) { + if (i > 0) json << ","; + json << "{\"x\":" << map_->vertices[i].x << ",\"z\":" << map_->vertices[i].y << "}"; + } + json << "],"; + + // Spawnpoints + json << "\"spawnpoints\":["; + for (size_t i = 0; i < map_->spawnpoints.size(); i++) { + if (i > 0) json << ","; + json << "{\"x\":" << map_->spawnpoints[i].x << ",\"z\":" << map_->spawnpoints[i].y << "}"; + } + json << "],"; + + // Structures + json << "\"structures\":["; + for (size_t i = 0; i < map_->structures.size(); i++) { + if (i > 0) json << ","; + json << "{"; + json << "\"id\":\"" << map_->structures[i].id << "\","; + json << "\"type\":\"" << map_->structures[i].type << "\","; + json << "\"team\":" << map_->structures[i].team << ","; + json << "\"position\":{\"x\":" << map_->structures[i].position.x << ",\"y\":" << map_->structures[i].position.y << ",\"z\":" << map_->structures[i].position.z << "},"; + json << "\"rotation\":{\"x\":" << map_->structures[i].rotation.x << ",\"y\":" << map_->structures[i].rotation.y << ",\"z\":" << map_->structures[i].rotation.z << "},"; + json << "\"scale\":{\"x\":" << map_->structures[i].scale.x << ",\"y\":" << map_->structures[i].scale.y << ",\"z\":" << map_->structures[i].scale.z << "}"; + json << "}"; + } + json << "],"; + + + // Polygon Grid (for visualization) + json << "\"grid\":{"; + json << "\"origin\":{\"x\":" << map_->grid_origin.x << ",\"y\":" << map_->grid_origin.y << "},"; + json << "\"cell_size\":" << map_->grid_cell_size << ","; + json << "\"width\":" << map_->grid_width << ","; + json << "\"height\":" << map_->grid_height << ","; + json << "\"cells\":["; + if (map_->grid_width > 0 && map_->grid_height > 0) { + bool first_cell = true; + for (int y = 0; y < map_->grid_height; y++) { + for (int x = 0; x < map_->grid_width; x++) { + int index = y * map_->grid_width + x; + int poly_count = 0; + if (index >= 0 && index < (int)map_->grid_cells.size()) { + poly_count = (int)map_->grid_cells[index].size(); + } + + // Only include cells with polygons + if (poly_count > 0) { + if (!first_cell) json << ","; + first_cell = false; + json << "{\"x\":" << x << ",\"y\":" << y << ",\"polygon_count\":" << poly_count << "}"; + } + } + } + } + json << "]"; + json << "}"; + } + json << "},"; + + // Entities + json << "\"entities\":["; + if (entity_manager_) { + auto all_entities = entity_manager_->get_entities_with_component(); + bool first = true; + for (auto* entity : all_entities) { + if (!entity) continue; + + auto* move = entity->get_component(); + auto* stats = entity->get_component(); + auto* state = entity->get_component(); + auto* intent = entity->get_component(); + + if (!move || !stats || !state || !intent) continue; + + if (!first) json << ","; + first = false; + + json << "{"; + json << "\"id\":" << entity->get_id() << ","; + json << "\"position\":{\"x\":" << move->position.x << ",\"z\":" << move->position.y << "},"; + json << "\"team_id\":" << (int)(stats ? stats->team_id : 0) << ","; + json << "\"state\":\"" << (state ? entity_state_to_string(state->current_state) : "UNKNOWN") << "\","; + json << "\"intent\":\"" << (intent ? intent_type_to_string(intent->type) : "UNKNOWN") << "\""; + if (intent && intent->target_entity_id != INVALID_ENTITY_ID) { + json << ",\"target_id\":" << intent->target_entity_id; + } + json << "}"; + } + } + json << "],"; + + // State + json << "\"state\":{\"message\":\"Visualizer active\"}"; + + json << "}"; + return json.str(); +} diff --git a/src/services/visualizer_service.hpp b/src/services/visualizer_service.hpp new file mode 100644 index 0000000..7af8f41 --- /dev/null +++ b/src/services/visualizer_service.hpp @@ -0,0 +1,105 @@ +#pragma once + +#include +#include +#include +#include +#include + +// Forward declarations +class EntityManager; +struct SystemContext; + +/** + * VisualizerService - Optional web-based visualization of game state + * + * RESPONSIBILITIES: + * - Serve a web page for visualizing map and entities + * - Provide JSON API endpoints for game state + * - Run HTTP server in background thread + * - Display real-time entity positions and states + * + * USAGE (optional, debug only): + * VisualizerService visualizer(8080); // Port 8080 + * visualizer.initialize(&entity_manager, map_pointer_.get()); + * visualizer.start(); + * // Open http://localhost:8080 in browser + * + * NOTE: This is entirely optional and only for development debugging. + * Disable with VISUALIZER_ENABLED = false or by not initializing. + */ +class VisualizerService { +public: + VisualizerService(uint16_t port = 8080); + ~VisualizerService(); + + // Prevent copying + VisualizerService(const VisualizerService&) = delete; + VisualizerService& operator=(const VisualizerService&) = delete; + + // Allow moving + VisualizerService(VisualizerService&&) = default; + VisualizerService& operator=(VisualizerService&&) = default; + + /** + * Initialize visualizer with entity manager and map. + * Must be called before start(). + * @param entity_manager Pointer to entity manager + * @param map Map data for visualization + */ + void initialize(EntityManager* entity_manager, struct Map* map); + + /** + * Start the HTTP server in background thread. + * @return true if server started successfully + */ + bool start(); + + /** + * Stop the HTTP server and wait for thread to finish. + */ + void stop(); + + /** + * Check if server is running. + * @return true if HTTP server is active + */ + bool is_running() const { return running_; } + + /** + * Get the port the server is listening on. + * @return Port number + */ + uint16_t get_port() const { return port_; } + +private: + uint16_t port_; + std::atomic running_{false}; + std::unique_ptr server_thread_; + EntityManager* entity_manager_ = nullptr; + struct Map* map_ = nullptr; + + /** + * Run the HTTP server (called in background thread). + */ + void run_server(); + + /** + * Get HTML page content. + * @return HTML string for visualization + */ + std::string get_html_page() const; + + /** + * Get file content from disk. + * @param path Path to file relative to working directory + * @return File content or empty string if not found + */ + std::string get_file_content(const std::string& path) const; + + /** + * Get current game state as JSON. + * @return JSON string with map, entities, and state + */ + std::string get_game_state_json() const; +}; diff --git a/src/systems/core/brain_system.cpp b/src/systems/core/brain_system.cpp new file mode 100644 index 0000000..72b3efc --- /dev/null +++ b/src/systems/core/brain_system.cpp @@ -0,0 +1,81 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +void BrainSystem::update(const SystemContext& ctx) { + // Get all entities with IntentComponent + auto entities_with_intent = ctx.entity_manager.get_entities_with_component(); + + for (auto* entity : entities_with_intent) { + process_entity_intent(ctx, entity); + } +} + +void BrainSystem::process_entity_intent(const SystemContext& ctx, Entity* entity) { + auto* intent_comp = entity->get_component(); + + if(intent_comp->type == IntentType::ATTACK_TARGET) { + handle_attack_intent(ctx, entity); + } + + if(intent_comp->type == IntentType::MOVE_TO_POSITION) { + handle_move_intent(ctx, entity); + } +} + +void BrainSystem::handle_attack_intent(const SystemContext& ctx, Entity* entity) { + auto* intent = entity->get_component(); + auto* stats = entity->get_component(); + auto* attack = entity->get_component(); + auto* state = entity->get_component(); + + // Continue attacking if entity is in the middle of an attack + if(attack->target.has_value()) { + if(attack->target.value() == intent->target_entity_id) { + // Already in the middle of an attack on the correct target + return; + } + + // Attacking the wrong target, update attack target to new intent + LOG_DEBUG("Entity %d has changed target from %d to %d", entity->get_id(), state->target_entity_id, intent->target_entity_id); + } + + Entity* target = ctx.entity_manager.get_entity(intent->target_entity_id); + Movement* target_movement = target->get_component(); + Movement* entity_movement = entity->get_component(); + + // Move into range if too far away + if((target_movement->position - entity_movement->position).length() > stats->attack_range) { + state->current_state = EntityState::MOVING; + attack->target = std::nullopt; // Cancel any ongoing attack + entity_movement->target = target_movement->position; + return; + } + + // Start attack if in range + if(!attack->target.has_value() || attack->target.value() != target->get_id()) { + state->current_state = EntityState::ATTACKING; + entity_movement->target = std::nullopt; // Stop moving + attack->target = intent->target_entity_id; + return; + } +} + +void BrainSystem::handle_move_intent(const SystemContext& ctx, Entity* entity) { + Movement* movement = entity->get_component(); + IntentComponent* intent = entity->get_component(); + EntityStateComponent* state = entity->get_component(); + // Set the movement target if required + if(!movement->target.has_value() || movement->target.value() != intent->target_position) { + state->current_state = EntityState::MOVING; + movement->target = intent->target_position; + } +} \ No newline at end of file diff --git a/src/systems/core/brain_system.hpp b/src/systems/core/brain_system.hpp new file mode 100644 index 0000000..e56bc4f --- /dev/null +++ b/src/systems/core/brain_system.hpp @@ -0,0 +1,84 @@ +#pragma once + +#include "system_context.hpp" +#include +#include +#include + +/** + * BrainSystem - Intent management + * + * RESPONSIBILITIES: + * - Read IntentComponent to understand what the entity wants to do + * - Translate high-level intents into low-level component state + * - Set up CombatComponent and MovementComponent based on intents + * + * ARCHITECTURE: + * This system implements the Intent-based architecture: + * - InputSystem/NPCSystem = What the entity thinks it should do (ATTACK_TARGET, MOVE_TO_POSITION, etc.) + * - BrainSystem = How to translate intent into lower-level components + * - CombatSystem = Reads CombatComponent and executes combat logic + * - MovementSystem = Reads MovementComponent and executes movement + * + * FLOW: + * 1. Read IntentComponent.current_intent + * 2. Based on intent type, update CombatComponent and MovementComponent + * 3. CombatSystem will read CombatComponent and handle combat + * 4. MovementSystem will read MovementComponent and handle movement + * 5. BrainSystem manages cooldowns and decides when to clear/change intents + * + * USAGE: + * BrainSystem brain_system; + * // In game loop: + * brain_system.update(ctx); + * + * SYSTEM INTERACTION: + * - Runs early in the loop (like NPC input) + * - Reads: IntentComponent, Stats, Movement, EntityStateComponent, CombatComponent + * - Writes: CombatComponent, MovementComponent, EntityStateComponent + */ +class BrainSystem { +public: + BrainSystem() = default; + ~BrainSystem() = default; + + // Prevent copying + BrainSystem(const BrainSystem&) = delete; + BrainSystem& operator=(const BrainSystem&) = delete; + + // Allow moving + BrainSystem(BrainSystem&&) = default; + BrainSystem& operator=(BrainSystem&&) = default; + + /** + * Update NPC decision-making and intent management. + * Translates intents into component state for other systems to execute. + * + * @param ctx System context with entity manager and network service + */ + void update(const SystemContext& ctx); + +private: + /** + * Process a single entity's intent. + * + * @param ctx System context + * @param entity Entity with IntentComponent + */ + void process_entity_intent(const SystemContext& ctx, Entity* entity); + + /** + * Process a single entity's attack intent. + * + * @param ctx System context + * @param entity Entity with IntentComponent that intends to attack + */ + void handle_attack_intent(const SystemContext& ctx, Entity* entity); + + /** + * Process a single entity's move intent. + * + * @param ctx System context + * @param entity Entity with intends to move + */ + void handle_move_intent(const SystemContext& ctx, Entity* entity);}; diff --git a/src/systems/core/collision_system.cpp b/src/systems/core/collision_system.cpp new file mode 100644 index 0000000..82f2ba6 --- /dev/null +++ b/src/systems/core/collision_system.cpp @@ -0,0 +1,287 @@ +#include +#include +#include +#include +#include + +void CollisionSystem::update(const SystemContext& ctx) { + // Get all entities with movement components + auto moving_entities = ctx.entity_manager.get_entities_with_component(); + + // Check all pairs of entities for collisions + for (size_t i = 0; i < moving_entities.size(); ++i) { + Entity* entity_a = moving_entities[i]; + Movement* move_a = entity_a->get_component(); + + if (!move_a) continue; + + float radius_a = move_a->collision_radius; + + // Check against all other entities + for (size_t j = i + 1; j < moving_entities.size(); ++j) { + Entity* entity_b = moving_entities[j]; + Movement* move_b = entity_b->get_component(); + + if (!move_b) continue; + + float radius_b = move_b->collision_radius; + float min_distance = radius_a + radius_b; + + float actual_distance = distance(move_a->position, move_b->position); + + // If entities are overlapping, push them apart + if (actual_distance < min_distance) { + // Calculate separation direction + Vec2 separation = (move_b->position - move_a->position); + if (actual_distance > 0.001f) { + separation = separation.normalized(); + } else { + // Fallback if entities are at exact same position + separation = Vec2(1.0f, 0.0f); + } + + float overlap = min_distance - actual_distance; + float push_per_entity = overlap / 2.0f + 0.001f; // Split push equally, add small buffer + + // Push both entities apart + move_a->position = move_a->position - (separation * push_per_entity); + move_b->position = move_b->position + (separation * push_per_entity); + + LOG_DEBUG("Separated entities %u and %u by %.3f units", + entity_a->get_id(), entity_b->get_id(), overlap); + } + } + } +} + +bool CollisionSystem::would_collide(const Entity& entity, const Vec2& proposed_position, EntityManager& entity_manager) { + const Movement* entity_move = entity.get_component(); + if (!entity_move) { + return false; + } + + float entity_radius = entity_move->collision_radius; + + // Check collision with all other moving entities + auto moving_entities = entity_manager.get_entities_with_component(); + for (auto* other_entity : moving_entities) { + if (other_entity->get_id() == entity.get_id()) { + continue; // Skip self + } + + const Movement* other_move = other_entity->get_component(); + if (!other_move) { + continue; + } + + float other_radius = other_move->collision_radius; + float min_distance = entity_radius + other_radius; + + float actual_distance = distance(proposed_position, other_move->position); + + if (actual_distance < min_distance) { + return true; // Collision detected + } + } + + return false; // No collision +} + +Entity* CollisionSystem::get_colliding_entity(const Entity& entity, const Vec2& proposed_position, EntityManager& entity_manager) { + const Movement* entity_move = entity.get_component(); + if (!entity_move) { + return nullptr; + } + + float entity_radius = entity_move->collision_radius; + + // Check collision with all other moving entities + auto moving_entities = entity_manager.get_entities_with_component(); + for (auto* other_entity : moving_entities) { + if (other_entity->get_id() == entity.get_id()) { + continue; // Skip self + } + + const Movement* other_move = other_entity->get_component(); + if (!other_move) { + continue; + } + + float other_radius = other_move->collision_radius; + float min_distance = entity_radius + other_radius; + + float actual_distance = distance(proposed_position, other_move->position); + + if (actual_distance < min_distance) { + return other_entity; // Return first colliding entity + } + } + + return nullptr; // No collision +} + +void CollisionSystem::push_entity(Entity& pushed_entity, const Vec2& push_direction, float push_distance) { + Movement* move = pushed_entity.get_component(); + if (!move) { + return; + } + + move->position = move->position + (push_direction * push_distance); + LOG_DEBUG("Pushed entity %u by %.3f units", pushed_entity.get_id(), push_distance); +} + +void CollisionSystem::push_colliding_entities(const Entity& entity, const Vec2& proposed_position, EntityManager& entity_manager) { + const Movement* entity_move = entity.get_component(); + if (!entity_move) { + return; + } + + float entity_radius = entity_move->collision_radius; + Vec2 current_pos = entity_move->position; + Vec2 push_direction = (proposed_position - current_pos); + + if (push_direction.length() > 0.001f) { + push_direction = push_direction.normalized(); + } else { + return; // No movement, nothing to push + } + + // Find and push all colliding entities + auto moving_entities = entity_manager.get_entities_with_component(); + for (auto* other_entity : moving_entities) { + if (other_entity->get_id() == entity.get_id()) { + continue; // Skip self + } + + Movement* other_move = other_entity->get_component(); + if (!other_move) { + continue; + } + + float other_radius = other_move->collision_radius; + float min_distance = entity_radius + other_radius; + + // Check if this entity is colliding with the proposed position + float actual_distance = distance(proposed_position, other_move->position); + + if (actual_distance < min_distance) { + // Push the other entity in the direction of movement + float push_distance = min_distance - actual_distance + 0.01f; // Small buffer to separate them + push_entity(*other_entity, push_direction, push_distance); + } + } +} + +Vec2 CollisionSystem::find_free_space(const Vec2& position, float collision_radius, EntityManager& entity_manager, const struct Map* map) { + // Helper to check if a position is within grid bounds + auto is_within_grid_bounds = [map](const Vec2& pos) -> bool { + if (!map || map->grid_width == 0 || map->grid_height == 0) { + return true; // No map or invalid grid, allow any position + } + + // Calculate grid boundaries + Vec2 grid_min = map->grid_origin; + Vec2 grid_max = map->grid_origin + Vec2( + map->grid_width * map->grid_cell_size, + map->grid_height * map->grid_cell_size + ); + + // Check if position is within bounds + return pos.x >= grid_min.x && pos.x <= grid_max.x && + pos.y >= grid_min.y && pos.y <= grid_max.y; + }; + + // Get all entities with movement components to check for collisions + auto moving_entities = entity_manager.get_entities_with_component(); + + // Check if base position is free + bool position_free = true; + for (auto* entity : moving_entities) { + const Movement* other_move = entity->get_component(); + if (other_move) { + float dx = position.x - other_move->position.x; + float dy = position.y - other_move->position.y; + float dist = std::sqrt(dx * dx + dy * dy); + float min_distance = collision_radius + other_move->collision_radius; + + if (dist < min_distance) { + position_free = false; + break; + } + } + } + + // If base position is free AND within grid bounds, return it + if (position_free && is_within_grid_bounds(position)) { + return position; + } + + // If base position is occupied or outside bounds, try to find a free spot nearby + // Use expanding circles to search for free space + constexpr float SEARCH_RADIUS = 5.0f; + constexpr int SEARCH_SAMPLES = 16; // Number of angles to check + constexpr float PI = 3.14159265f; + + for (float search_distance = collision_radius * 2.0f; search_distance <= SEARCH_RADIUS; search_distance += 0.5f) { + for (int i = 0; i < SEARCH_SAMPLES; ++i) { + float angle = (2.0f * PI * i) / SEARCH_SAMPLES; + Vec2 candidate = position + Vec2(std::cos(angle) * search_distance, std::sin(angle) * search_distance); + + // Skip if candidate is outside grid bounds + if (!is_within_grid_bounds(candidate)) { + continue; + } + + // Check if this candidate position is free + bool candidate_free = true; + for (auto* entity : moving_entities) { + const Movement* other_move = entity->get_component(); + if (other_move) { + float dx = candidate.x - other_move->position.x; + float dy = candidate.y - other_move->position.y; + float dist = std::sqrt(dx * dx + dy * dy); + float min_distance = collision_radius + other_move->collision_radius; + + if (dist < min_distance) { + candidate_free = false; + break; + } + } + } + + if (candidate_free) { + return candidate; + } + } + } + + // If no free space found after search, log warning and return original position if in bounds + if (is_within_grid_bounds(position)) { + LOG_WARN("CollisionSystem: Could not find free space near (%.1f, %.1f) with radius %.1f, using base position", + position.x, position.y, collision_radius); + return position; + } else { + // Original position is outside grid bounds - return closest valid position + // Clamp to grid bounds + Vec2 grid_min = map->grid_origin; + Vec2 grid_max = map->grid_origin + Vec2( + map->grid_width * map->grid_cell_size, + map->grid_height * map->grid_cell_size + ); + + Vec2 clamped_position( + std::max(grid_min.x, std::min(position.x, grid_max.x)), + std::max(grid_min.y, std::min(position.y, grid_max.y)) + ); + + LOG_WARN("CollisionSystem: Spawn position (%.1f, %.1f) is outside grid bounds, clamping to (%.1f, %.1f)", + position.x, position.y, clamped_position.x, clamped_position.y); + return clamped_position; + } +} + +float CollisionSystem::distance(const Vec2& a, const Vec2& b) { + float dx = a.x - b.x; + float dy = a.y - b.y; + return std::sqrt(dx * dx + dy * dy); +} diff --git a/src/systems/core/collision_system.hpp b/src/systems/core/collision_system.hpp new file mode 100644 index 0000000..9884066 --- /dev/null +++ b/src/systems/core/collision_system.hpp @@ -0,0 +1,115 @@ +#pragma once + +#include "system_context.hpp" +#include + +/** + * CollisionSystem - Handles entity collision detection and response + * + * RESPONSIBILITIES: + * - Detect collisions between moving entities + * - Push entities out of collision when they intersect + * - Report collision events + * - Prevent entities from overlapping + * + * FEATURES: + * - Circle-based collision detection (using collision_radius) + * - Elastic collision response (pushing entities apart) + * - Works with Movement component's collision_radius + * + * USAGE: + * CollisionSystem collision_system; + * // In game loop: + * collision_system.update(ctx); + * + * SYSTEM INTERACTION: + * - Runs before MovementSystem to prevent overlaps before they happen + * - Can be used independently or as part of GameplayCoordinator + * - Respects entity movement and collision radius + */ +class CollisionSystem { +public: + CollisionSystem() = default; + ~CollisionSystem() = default; + + // Prevent copying + CollisionSystem(const CollisionSystem&) = delete; + CollisionSystem& operator=(const CollisionSystem&) = delete; + + // Allow moving + CollisionSystem(CollisionSystem&&) = default; + CollisionSystem& operator=(CollisionSystem&&) = default; + + /** + * Update collision detection and response for all entities. + * Detects overlapping entities and pushes them apart. + * + * @param ctx System context containing entity manager + */ + void update(const SystemContext& ctx); + + /** + * Check if a position would collide with any other entity. + * Does not perform response, only detection. + * + * @param entity The entity to check collisions for + * @param proposed_position The position to check + * @param entity_manager Reference to entity manager + * @return true if collision would occur at proposed position + */ + static bool would_collide(const Entity& entity, const Vec2& proposed_position, EntityManager& entity_manager); + + /** + * Get the first entity that would collide at a position. + * Useful for checking what specifically is blocking movement. + * + * @param entity The entity to check collisions for + * @param proposed_position The position to check + * @param entity_manager Reference to entity manager + * @return Entity pointer if collision found, nullptr otherwise + */ + static Entity* get_colliding_entity(const Entity& entity, const Vec2& proposed_position, EntityManager& entity_manager); + + /** + * Push an entity away from another entity. + * Moves the pushed entity along the separation direction. + * + * @param entity The entity doing the pushing + * @param pushed_entity The entity being pushed + * @param push_direction Direction to push (usually direction of pushing entity's movement) + * @param push_distance How far to push + */ + static void push_entity(Entity& pushed_entity, const Vec2& push_direction, float push_distance); + + /** + * Push all entities colliding at a position away from it. + * When an entity moves into other entities, pushes all of them along the movement direction. + * + * @param entity The entity doing the pushing + * @param proposed_position The position being moved into + * @param entity_manager Reference to entity manager for finding colliding entities + */ + static void push_colliding_entities(const Entity& entity, const Vec2& proposed_position, EntityManager& entity_manager); + + /** + * Find a free spawn position near a given location. + * Searches for a collision-free position using expanding circle pattern. + * Ensures the spawn position remains within the valid navigation grid bounds. + * + * @param position The desired spawn position + * @param collision_radius The collision radius of the entity to spawn (default 1.0) + * @param entity_manager Reference to entity manager to check existing entities + * @param map Pointer to map for grid boundary validation (optional, nullptr skips boundary check) + * @return A nearby collision-free position within grid bounds, or the original position if none found + */ + static Vec2 find_free_space(const Vec2& position, float collision_radius, EntityManager& entity_manager, const struct Map* map = nullptr); + +private: + /** + * Calculate distance between two 2D points. + * @param a First point + * @param b Second point + * @return Distance + */ + static float distance(const Vec2& a, const Vec2& b); +}; diff --git a/src/systems/core/combat_system.cpp b/src/systems/core/combat_system.cpp new file mode 100644 index 0000000..e968b4e --- /dev/null +++ b/src/systems/core/combat_system.cpp @@ -0,0 +1,182 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +void CombatSystem::update(const SystemContext& ctx) { + // Process all entities with auto-attack behavior + auto attacking_entities = ctx.entity_manager.get_entities_with_component(); + for (auto* entity : attacking_entities) { + if (entity) { + // Update auto-attack (target search, cooldown, attack initiation) + update_auto_attack(ctx, *entity, ctx.delta_time_ms); + } + } +} + +void CombatSystem::update_auto_attack(const SystemContext& ctx, Entity& entity, float delta_time_ms) { + auto* attack = entity.get_component(); + auto* target = entity.get_component(); + auto* stats = entity.get_component(); + auto* entity_state = entity.get_component(); + auto* intent = entity.get_component(); + + if (!attack || !target || !stats) { + return; // Missing required components + } + + // Don't process if entity is dead + if (is_dead(entity)) { + if (ComponentUtility::is_target_valid(*target)) { + ComponentUtility::clear_target(*target); + } + return; + } + + // Check if entity should initiate an attack + if (attack-> target.has_value() && attack->can_attack && !attack->attack_in_progress) { + + // Get the target entity + auto* target_entity = ctx.entity_manager.get_entity(intent->target_entity_id); + auto* target_stats = target_entity ? target_entity->get_component() : nullptr; + + if (target_stats && target_stats->health > 0.0f && intent->target_entity_id != INVALID_ENTITY_ID) { + // Calculate damage based on attacker's auto damage type and appropriate power stat + float damage = 0.0f; + DamageType damage_type = stats->auto_damage_type; + + switch (damage_type) { + case DamageType::MAGICAL: + damage = stats->magic_power * 1.0f; + break; + case DamageType::PHYSICAL: + case DamageType::TRUE_DAMAGE: + default: + damage = stats->physical_power * 1.0f; + break; + } + + // Initiate attack + attack->pending_target = intent->target_entity_id; + attack->pending_damage = damage; + attack->pending_damage_type = damage_type; + attack->attack_in_progress = true; + attack->attack_animation_progress = 0.0f; + attack->can_attack = false; + + LOG_DEBUG("Entity %u: CombatSystem initiating attack on target %u with damage %.1f", + entity.get_id(), intent->target_entity_id, damage); + } + } + + float hit_timing_percent = attack ? attack->hit_timing_percent : 0.5f; + float animation_duration = attack->attack_animation_duration_ms > 0.0f + ? attack->attack_animation_duration_ms + : 300.0f; + + // Store old progress to detect threshold crossing + float old_progress = attack->attack_animation_progress; + + // Update animation progress + attack->attack_animation_progress += delta_time_ms / animation_duration; + + // Check if we should apply damage (crossed the hit timing threshold) + bool damage_applied = false; + if (old_progress < hit_timing_percent && attack->attack_animation_progress >= hit_timing_percent) { + // It's time to apply damage! + // First check if attacker is still alive + auto* attacker_stats = entity.get_component(); + if (attacker_stats && attacker_stats->health > 0.0f && attack->pending_target != INVALID_ENTITY_ID) { + auto damage_events = combat_calculator_.apply_damage( + ctx.entity_manager, + entity.get_id(), + attack->pending_target, + attack->pending_damage, + attack->pending_damage_type + ); + + if (!damage_events.empty()) { + // Broadcast combat event to all clients + if (ctx.network_service) { + const auto& event = damage_events[0]; + auto combat_packet = SerializationSystem::serialize_combat_event( + entity.get_id(), + attack->pending_target, + event.damage_dealt, + static_cast(event.damage_type), + event.was_critical_hit + ); + ctx.network_service->broadcast_packet(combat_packet); + } + damage_applied = true; + } + } else if (attacker_stats && attacker_stats->health <= 0.0f) { + LOG_DEBUG("Cancelling attack: attacker %u is dead", entity.get_id()); + } + } + + // Check if animation is complete + if (attack->attack_animation_progress >= 1.0f) { + attack->attack_animation_progress = 0.0f; + attack->attack_in_progress = false; + attack->can_attack = true; + + // Clear pending attack data + attack->pending_damage = 0.0f; + attack->pending_target = INVALID_ENTITY_ID; + attack->pending_damage_type = DamageType::PHYSICAL; + } +} + +bool CombatSystem::is_dead(const Entity& entity) const { + const Stats* stats = entity.get_component(); + return stats && stats->health <= 0.0f; +} + +bool CombatSystem::is_valid_target(const SystemContext& ctx, const Entity& attacker, + const Entity& potential_target) const { + return TargetingUtility::can_engage_in_combat( + const_cast(ctx.entity_manager), + attacker.get_id(), + potential_target.get_id() + ); +} + +void CombatSystem::update_attack_cooldown(AttackComponent& attack, float delta_time_ms) const { + if (attack.cooldown_timer_ms > 0.0f) { + attack.cooldown_timer_ms -= delta_time_ms; + if (attack.cooldown_timer_ms < 0.0f) { + attack.cooldown_timer_ms = 0.0f; + } + } +} + +void CombatSystem::start_attack_cooldown(AttackComponent& attack, float attack_speed_stat) const { + // attack_speed_stat is attacks per second + if (attack_speed_stat > 0.0f) { + attack.cooldown_timer_ms = 1000.0f / attack_speed_stat; + } +} + +bool CombatSystem::is_attack_ready(const AttackComponent& attack) const { + return attack.can_attack && attack.cooldown_timer_ms <= 0.0f; +} + +void CombatSystem::begin_attack_animation(AttackComponent& attack) const { + attack.attack_in_progress = true; + attack.attack_animation_progress = 0.0f; +} + +bool CombatSystem::is_damage_apply_time(float attack_progress, float hit_timing_percent) const { + // This is now handled in update_auto_attack by comparing old_progress vs new_progress + return false; +} diff --git a/src/systems/core/combat_system.hpp b/src/systems/core/combat_system.hpp new file mode 100644 index 0000000..bc08256 --- /dev/null +++ b/src/systems/core/combat_system.hpp @@ -0,0 +1,114 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +/** + * CombatSystem - Unified combat management system + * + * RESPONSIBILITIES: + * - Attack cooldown and readiness tracking + * - Combat timeout management + * - Attack animation management + * - Attack execution and damage application + * - Coordinate between auto-attack logic and damage calculation + * + * COMPONENTS USED: + * - AttackComponent (attack state and cooldown) + * - Stats (attack speed, health, damage stats) + * - Movement (position for range calculations) + * + * UTILITIES USED: + * - TargetingUtility: Pure utility functions for targeting calculations and validation + * + * SYSTEM INTERACTION: + * - Uses CombatCalculator for damage calculation and application + * - Uses TargetingUtility for range and eligibility checks + * - Works with NetworkSyncSystem to broadcast combat effects + * - Feeds attack information to entity state tracking + * + * DATA-DRIVEN DESIGN: + * This system reads those properties and executes accordingly without hardcoded logic. + * Targeting behavior is entirely data-driven via AutoAttackComponent and Stats properties. + */ +class CombatSystem { +public: + /** + * Update all combat for this frame. + * Handles auto-attacks, cooldowns, target search, and attack execution. + * @param ctx System context with entity manager, services, and delta time + */ + void update(const SystemContext& ctx); + +private: + CombatCalculator combat_calculator_; + + /** + * Update and process auto-attacks for an entity. + * Handles cooldowns, target selection, and attack initiation. + * @param ctx System context + * @param entity Entity to update + * @param delta_time_ms Time since last frame in milliseconds + */ + void update_auto_attack(const SystemContext& ctx, Entity& entity, float delta_time_ms); + + /** + * Check if a potential target is valid (not dead, correct team, etc). + * NOTE: Use TargetingUtility validation functions instead - all targeting is now in utility! + * @param ctx System context + * @param attacker Entity doing attacking + * @param potential_target Entity to validate + * @return true if valid target + * @deprecated Use TargetingUtility::can_engage_in_combat() or is_target_valid() directly + */ + bool is_valid_target(const SystemContext& /*ctx*/, const Entity& attacker, + const Entity& potential_target) const; + + /** + * Check if an entity is dead (health <= 0). + * @param entity Entity to check + * @return true if entity is dead + */ + bool is_dead(const Entity& entity) const; + + /** + * Update attack cooldown for an entity. + * Called each frame to reduce the cooldown timer. + * @param attack Attack component to update + * @param delta_time_ms Time elapsed since last frame + */ + void update_attack_cooldown(AttackComponent& attack, float delta_time_ms) const; + + /** + * Start attack cooldown based on attack speed stat. + * @param attack Attack component to update + * @param attack_speed_stat Attack speed from Stats component (attacks per second) + */ + void start_attack_cooldown(AttackComponent& attack, float attack_speed_stat) const; + + /** + * Check if an entity is ready to attack. + * @param attack Attack component to check + * @return true if cooldown is finished and entity can attack + */ + bool is_attack_ready(const AttackComponent& attack) const; + + /** + * Begin attack animation. + * @param attack Attack component to update + */ + void begin_attack_animation(AttackComponent& attack) const; + + /** + * Check if it's time to apply damage based on animation progress and hit timing. + * @param attack_progress Current animation progress (0.0-1.0) + * @param hit_timing_percent When damage should occur (0.0-1.0) + * @return true if we just crossed the hit timing threshold + */ + bool is_damage_apply_time(float attack_progress, float hit_timing_percent) const; +}; diff --git a/src/systems/core/input_system.cpp b/src/systems/core/input_system.cpp new file mode 100644 index 0000000..1170dc4 --- /dev/null +++ b/src/systems/core/input_system.cpp @@ -0,0 +1,84 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +void InputSystem::queue_movement_input(EntityID entity_id, const Vec2& target_position) { + movement_inputs_.push({entity_id, target_position}); + LOG_DEBUG("Queued movement input for entity %u to (%.2f, %.2f)", entity_id, target_position.x, target_position.y); +} + +void InputSystem::update(const SystemContext& ctx) { + // Process all queued inputs + while (!movement_inputs_.empty()) { + const MovementInput& input = movement_inputs_.front(); + + Entity* entity = ctx.entity_manager.get_entity(input.entity_id); + if (!entity) { + LOG_WARN("Movement input for non-existent entity %u", input.entity_id); + movement_inputs_.pop(); + continue; + } + + if (process_movement_input(*entity, input.target_position, ctx)) { + LOG_DEBUG("Successfully processed movement input for entity %u", input.entity_id); + } else { + LOG_WARN("Failed to process movement input for entity %u", input.entity_id); + } + + movement_inputs_.pop(); + } +} + +bool InputSystem::process_movement_input(Entity& entity, const Vec2& target_position, const SystemContext& ctx) { + EntityStateComponent* ent_state = entity.get_component(); + Movement* movement = entity.get_component(); + IntentComponent* intent = entity.get_component(); + + if (!ent_state) { + LOG_WARN("Entity %u missing EntityStateComponent", entity.get_id()); + return false; + } + + if (!movement) { + LOG_WARN("Entity %u missing Movement component", entity.get_id()); + return false; + } + + // Transform input coordinates from map space to entity space + // Input comes in as absolute map coordinates, needs to be adjusted to map-relative coordinates + Vec2 transformed_position = target_position; + if (ctx.map) { + float half_width = ctx.map->size.x / 2.0f; + float half_height = ctx.map->size.y / 2.0f; + + // Calculate map minimum (where the map starts) + float map_min_x = ctx.map->offset.x - half_width; + float map_min_y = ctx.map->offset.y - half_height; + + // Check bounds first + float max_x = ctx.map->offset.x + half_width; + float max_y = ctx.map->offset.y + half_height; + + if (target_position.x < map_min_x || target_position.x > max_x || + target_position.y < map_min_y || target_position.y > max_y) { + LOG_WARN("Entity %u movement target (%.2f, %.2f) is out of bounds [%.1f-%.1f, %.1f-%.1f]", + entity.get_id(), target_position.x, target_position.y, + map_min_x, max_x, map_min_y, max_y); + return false; + } + + // Transform to centered coordinates (offset from map center) + transformed_position = target_position - ctx.map->offset; + } + + + intent->type = IntentType::MOVE_TO_POSITION; + intent->target_position = transformed_position; + + return true; +} diff --git a/src/systems/core/input_system.hpp b/src/systems/core/input_system.hpp new file mode 100644 index 0000000..9fc6a8d --- /dev/null +++ b/src/systems/core/input_system.hpp @@ -0,0 +1,87 @@ +#pragma once + +#include "system_context.hpp" +#include "math.hpp" +#include +#include + +class Entity; +typedef uint32_t EntityID; + +/** + * InputSystem - Processes player input and translates it into entity actions + * + * RESPONSIBILITIES: + * - Queue and process movement input from players + * - Translate input into entity state changes and target positions + * - Validate input against entity and game state + * - Coordinate with MovementSystem for execution + * + * ARCHITECTURE: + * PacketHandler -> InputSystem -> MovementSystem + * + * - PacketHandler: Validates network packets + * - InputSystem: Processes input logic and queues actions + * - MovementSystem: Executes movement and pathfinding + * + * USAGE: + * InputSystem input_system; + * // When packet arrives: + * input_system.queue_movement_input(entity_id, target_position); + * // In game loop: + * input_system.update(ctx); + */ +class InputSystem { +public: + InputSystem() = default; + ~InputSystem() = default; + + // Prevent copying + InputSystem(const InputSystem&) = delete; + InputSystem& operator=(const InputSystem&) = delete; + + // Allow moving + InputSystem(InputSystem&&) = default; + InputSystem& operator=(InputSystem&&) = default; + + /** + * Queue a movement input for an entity. + * Called by PacketHandler when receiving PLAYER_MOVE packets. + * + * @param entity_id ID of the entity to move + * @param target_position Target position to move to + */ + void queue_movement_input(EntityID entity_id, const Vec2& target_position); + + /** + * Process all queued inputs and apply them to entities. + * Called once per game frame by GameplayCoordinator. + * + * @param ctx System context containing entity manager + */ + void update(const SystemContext& ctx); + +private: + /** + * Movement input request + */ + struct MovementInput { + EntityID entity_id; + Vec2 target_position; + }; + + // Queue of pending movement inputs + std::queue movement_inputs_; + + /** + * Process a single movement input request. + * Uses pathfinding (A*) to find a navmesh-aware path to avoid holes. + * Falls back to direct movement if pathfinding fails. + * + * @param entity Entity to move + * @param target_position Target position + * @param ctx System context with NavigationService and Map + * @return true if input was processed successfully + */ + static bool process_movement_input(Entity& entity, const Vec2& target_position, const SystemContext& ctx); +}; diff --git a/src/systems/core/movement_system.cpp b/src/systems/core/movement_system.cpp new file mode 100644 index 0000000..a3ea7c7 --- /dev/null +++ b/src/systems/core/movement_system.cpp @@ -0,0 +1,188 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// ============================================================================ +// Constants +// ============================================================================ +namespace { + constexpr float TARGET_THRESHOLD = 0.5f; + constexpr float WAYPOINT_THRESHOLD = 0.5f; + constexpr float STUCK_DETECTION_THRESHOLD = 0.01f; +} + +// ============================================================================ +// Movement System Implementation +// ============================================================================ + +void MovementSystem::update(const SystemContext& ctx) { + process_completed_paths(ctx); + + auto moving_entities = ctx.entity_manager.get_entities_with_component(); + for (auto* entity : moving_entities) { + update_entity_movement(ctx, *entity); + } +} + +void MovementSystem::update_entity_movement(const SystemContext& ctx, Entity& entity) { + Movement* movement = entity.get_component(); + PathfindingComponent* pathfinding = entity.get_component(); + Stats* stats = entity.get_component(); + EntityStateComponent* state_comp = entity.get_component(); + + if (!movement || !stats) return; + + // No movement -> nothing to do + if (!movement->target.has_value()) { + return; + } + + // Entity has arrived at target -> stop moving + if((movement->position - movement->target.value()).length() <= TARGET_THRESHOLD) { + movement->target.reset(); + return; + } + + // Entities without pathfinding move straight towards the target + if(!pathfinding) { + update_direct_movement(ctx, entity, *movement, movement->target.value(), *stats, *state_comp); + return; + } + + // Pathfinding unit, so find a path + + // Entity has already requested a path and is waiting for a response, so no movement this frame + if(pathfinding->waiting_for_path) { + return; + } + + // No path, or path to incorrect target + if(pathfinding->waypoints.empty() || (pathfinding->waypoints[pathfinding->waypoints.size() - 1] - movement->target.value()).length() > REPATH_DISTANCE) { + request_new_path(entity, movement->target.value(), ctx.navigation_service); + return; // Path request submitted, will process next frame + } + + // Entity is on correct path, keep moving + Vec2 next_stop = pathfinding->waypoints.front(); + + if(next_stop.distance_to(movement->position) < WAYPOINT_THRESHOLD) { + pathfinding->waypoints.erase(pathfinding->waypoints.begin()); + } + // Update direct movement targets (players, point-based movement) + update_direct_movement(ctx, entity, *movement, next_stop, *stats, *state_comp); +} + +void MovementSystem::update_direct_movement(const SystemContext& ctx, Entity& entity, + Movement& movement, Vec2 target, Stats& stats, EntityStateComponent& state_comp) { + Vec2 current_pos = movement.position; + Vec2 target_pos = target; + Vec2 direction = target_pos - current_pos; + float distance = direction.length(); + + // Calculate new position based on speed + float move_speed = stats.move_speed; + float distance_to_move = move_speed * (ctx.delta_time_ms / 1000.0f); + + Vec2 new_position = (distance_to_move >= distance) + ? target_pos + : current_pos + (direction.normalized() * distance_to_move); + + // Validate that new position stays within grid bounds + if (ctx.map && ctx.map->grid_width > 0 && ctx.map->grid_height > 0) { + Vec2 grid_min = ctx.map->grid_origin; + Vec2 grid_max = ctx.map->grid_origin + Vec2( + ctx.map->grid_width * ctx.map->grid_cell_size, + ctx.map->grid_height * ctx.map->grid_cell_size + ); + + // Clamp position to grid bounds + new_position.x = std::max(grid_min.x, std::min(new_position.x, grid_max.x)); + new_position.y = std::max(grid_min.y, std::min(new_position.y, grid_max.y)); + } + + movement.position = new_position; +} + +// ============================================================================ +// Utility Functions +// ============================================================================ + +bool MovementSystem::has_collision(const Entity& entity, const Vec2& proposed_position, EntityManager& entity_manager) { + const Movement* entity_move = entity.get_component(); + if (!entity_move) { + return false; + } + + float entity_radius = entity_move->collision_radius; + + // Check collision with all other moving entities + auto moving_entities = entity_manager.get_entities_with_component(); + for (auto* other_entity : moving_entities) { + if (other_entity->get_id() == entity.get_id()) { + continue; // Skip self + } + + const Movement* other_move = other_entity->get_component(); + if (!other_move) { + continue; + } + + float other_radius = other_move->collision_radius; + float min_distance = entity_radius + other_radius; + + float actual_distance = (proposed_position - other_move->position).length(); + + if (actual_distance < min_distance) { + return true; // Collision detected + } + } + + return false; // No collision +} + +void MovementSystem::process_completed_paths(const SystemContext& ctx) { + std::optional result = ctx.navigation_service->GetResult(); + while (result) { + Entity* entity = ctx.entity_manager.get_entity(result->entity_id); + if (!entity || !entity->has_component()) { + result = ctx.navigation_service->GetResult(); + continue; + } + + PathfindingComponent* pathfinding = entity->get_component(); + pathfinding->waypoints = result->path; + pathfinding->waiting_for_path = false; + + result = ctx.navigation_service->GetResult(); + } +} + +void MovementSystem::request_new_path(Entity& entity, Vec2 target, NavigationService* navigation_service) { + if (!navigation_service) { + return; + } + + Movement* movement = entity.get_component(); + PathfindingComponent* pathfinding = entity.get_component(); + + PathRequest request; + request.entity_id = entity.get_id(); + request.current_position = movement->position; + request.destination = target; + request.entity_pathing_radius = 0.5f; + // Prioritize if this entity is player-controlled + request.is_player_input = entity.has_component(); + + if (navigation_service->MakeRequest(request)) { + pathfinding->waypoints.clear(); + pathfinding->waiting_for_path = true; + } +} \ No newline at end of file diff --git a/src/systems/core/movement_system.hpp b/src/systems/core/movement_system.hpp new file mode 100644 index 0000000..082c8ed --- /dev/null +++ b/src/systems/core/movement_system.hpp @@ -0,0 +1,77 @@ +#pragma once + +#include "entity_manager.hpp" +#include "components/stats.hpp" +#include "components/movement.hpp" +#include "components/map.hpp" +#include "components/pathfinding.hpp" +#include "components/entity_state.hpp" +#include "math.hpp" +#include "system_context.hpp" +#include + +// Determines how far the end point of the current path may be +// off the current movement target before recalculating the path +#define REPATH_DISTANCE 1.0f + +/** + * System to handle entity movement. + * Updates positions based on movement speed and follows path waypoints + * for pathfinding units. + */ +class MovementSystem { +public: + /** + * Update all moving entities. + * Moves pathfinding entities toward their current waypoints and handles path progression. + * Moves entites without pathfinding directly towards their target positions + * @param ctx System context containing entity manager, navigation service, and map + */ + void update(const SystemContext& ctx); + +private: + // ======================================================================== + // Phase processors + // ======================================================================== + + /** + * Process completed pathfinding results from NavigationService. + */ + static void process_completed_paths(const SystemContext& ctx); + + // ======================================================================== + // Entity movement handlers + // ======================================================================== + + /** + * Update a single entity's movement based on its type. + */ + void update_entity_movement(const SystemContext& ctx, Entity& entity); + + /** + * Update direct movement (player movement to target positions). + */ + static void update_direct_movement(const SystemContext& ctx, Entity& entity, Movement& movement, Vec2 target, Stats& stats, EntityStateComponent& state_comp); + + // ======================================================================== + // Utility functions + // ======================================================================== + + /** + * Request a new path for an entity to a target position. + * @param entity The entity requesting a path + * @param target Target position + * @param navigation_service Navigation service to queue the request + */ + static void request_new_path(Entity& entity, Vec2 target, NavigationService* navigation_service); + + /** + * Check for collision with other entities. + * @param entity The entity to check collisions for + * @param proposed_position The position the entity wants to move to + * @param entity_manager Reference to entity manager for checking other entities + * @return true if collision detected, false if path is clear + */ + static bool has_collision(const Entity& entity, const Vec2& proposed_position, EntityManager& entity_manager); +}; + diff --git a/src/systems/core/network_sync_system.cpp b/src/systems/core/network_sync_system.cpp new file mode 100644 index 0000000..79fd304 --- /dev/null +++ b/src/systems/core/network_sync_system.cpp @@ -0,0 +1,260 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// Helper function to create stat change packets only for changed fields +static std::vector> create_stat_change_packets(uint32_t entity_id, const Stats& current_stats, const Stats& last_synced_stats) { + std::vector> packets; + + // Helper lambda to create a stat packet if value changed + auto add_if_changed = [&](const std::string& stat_name, float current_val, float last_val) { + if (current_val != last_val) { + std::ostringstream oss; + oss << std::fixed << std::setprecision(6) << current_val; + packets.push_back(SerializationSystem::serialize_entity_stat_change(entity_id, stat_name, oss.str())); + } + }; + + auto add_if_changed_int = [&](const std::string& stat_name, int current_val, int last_val) { + if (current_val != last_val) { + packets.push_back(SerializationSystem::serialize_entity_stat_change(entity_id, stat_name, std::to_string(current_val))); + } + }; + + // Check each stat and only send packets for changed values + add_if_changed("health", current_stats.health, last_synced_stats.health); + add_if_changed("max_health", current_stats.max_health, last_synced_stats.max_health); + add_if_changed("mana", current_stats.mana, last_synced_stats.mana); + add_if_changed("max_mana", current_stats.max_mana, last_synced_stats.max_mana); + add_if_changed_int("level", current_stats.level, last_synced_stats.level); + + return packets; +} + +void NetworkSyncSystem::update(const SystemContext& ctx) { + if(current_frame_ % 30 == 0) { + LOG_INFO("NetworkSyncSystem::update - Frame %u", current_frame_); + } + + if (!ctx.network_service || !ctx.has_network_service()) { + return; + } + + current_frame_++; + + // Get all Network Entities + auto network_entities = ctx.entity_manager.get_entities_with_component(); + + for (auto* entity : network_entities) { + if (!entity) continue; + + auto* net_comp = entity->get_component(); + if (!net_comp) { + continue; // Should never happen since we queried for this component + } + + // For newly spawned entities (last_sync_frame == 0), always sync + bool is_first_sync = (net_comp->last_sync_frame == 0); + + // Check if this frame should sync this entity + if (!is_first_sync && !force_full_sync_next_frame_ && + (current_frame_ - net_comp->last_sync_frame) < SYNC_INTERVAL) { + LOG_DEBUG("Skipping sync for entity %u (frame %u)", entity->get_id(), current_frame_); + continue; // Skip this frame for this entity + } + + // Check if entity has actually changed + if (!has_entity_changed(*entity, current_frame_) && !force_full_sync_next_frame_ && !is_first_sync) { + LOG_DEBUG("Skipping sync for entity %u (frame %u)", entity->get_id(), current_frame_); + continue; // No changes to sync + } + + if(is_first_sync) { + Vec2 spawn_position = Vec2(0, 0); + if(Movement* movement = entity->get_component()) { + spawn_position = movement->position; + } + // TODO what to do with team if there is no stats component? - ploinky 17/11/2025 + uint8_t team_id = 0; + if(Stats* stats = entity->get_component()) { + team_id = stats->team_id; + } + std::string template_id = ""; + if(TemplateComponent* templateComponent = entity->get_component()) { + template_id = templateComponent->template_id; + } + ctx.network_service->broadcast_packet(SerializationSystem::serialize_entity_spawn(entity->get_id(), spawn_position, team_id, template_id)); + } + + // Serialize entity update - returns vector of packets (one per changed component) + bool send_full_state = force_full_sync_next_frame_ || net_comp->force_full_sync_next_frame || is_first_sync; + std::vector> packets = serialize_entity_update(entity->get_id(), *entity, send_full_state); + + if (!packets.empty()) { + // Broadcast all component update packets + // TODO: Implement Fog of War to avoid sending packets to unseen clients + for (const auto& packet : packets) { + if (!packet.empty()) { + ctx.network_service->broadcast_packet(packet); + } + } + + // Update last synced state + auto* move = entity->get_component(); + auto* state = entity->get_component(); + auto* stats = entity->get_component(); + + Vec2 pos = move ? move->position : Vec2(0, 0); + EntityState state_val = state ? state->current_state : EntityState::SPAWNED; + + ComponentUtility::mark_network_entity_synced(*net_comp, pos, state_val, stats, current_frame_); + LOG_DEBUG("Synced entity %u (is_first_sync=%s) at position (%.1f, %.1f), state=%d, packets=%zu", + entity->get_id(), is_first_sync ? "true" : "false", pos.x, pos.y, (int)state_val, packets.size()); + } + } + + // Clear forced sync flag after processing + force_full_sync_next_frame_ = false; +} + +bool NetworkSyncSystem::has_entity_changed(const Entity& entity, uint32_t current_frame) const { + auto* net_comp = entity.get_component(); + if (!net_comp) { + return false; + } + + auto* state = entity.get_component(); + auto* move = entity.get_component(); + auto* stats = entity.get_component(); + + // Always sync entities in MOVING state + if (state && state->current_state == EntityState::MOVING) { + return true; + } + + // Check position change + if (move && ComponentUtility::has_network_position_changed(*net_comp, move->position)) { + return true; + } + + // Check state change (for state transitions) + if (state && ComponentUtility::has_network_state_changed(*net_comp, state->current_state)) { + return true; + } + + // Check for stat changes (health, mana, level, etc.) + if (stats && ComponentUtility::has_network_stats_changed(*net_comp, *stats)) { + return true; + } + + // Force sync if requested + if (net_comp->force_full_sync_next_frame) { + return true; + } + + // Debug: log why entity isn't changing (first 50 frames only) + static uint32_t logged_frames = 0; + if (logged_frames < 50 && state) { + LOG_INFO("Frame %u: Entity %u state=%d, sync_check: moving=%s, pos_changed=%s, state_changed=%s, stats_changed=%s, force=%s", + current_frame, entity.get_id(), (int)state->current_state, + (state->current_state == EntityState::MOVING) ? "yes" : "no", + move && ComponentUtility::has_network_position_changed(*net_comp, move->position) ? "yes" : "no", + state && ComponentUtility::has_network_state_changed(*net_comp, state->current_state) ? "yes" : "no", + stats && ComponentUtility::has_network_stats_changed(*net_comp, *stats) ? "yes" : "no", + net_comp->force_full_sync_next_frame ? "yes" : "no"); + logged_frames++; + } + + return false; +} + +std::vector> NetworkSyncSystem::serialize_entity_update(EntityID entity_id, const Entity& entity, + bool send_full_state) const { + std::vector> packets; + + // Get entity components + auto* move = entity.get_component(); + auto* state = entity.get_component(); + auto* stats = entity.get_component(); + auto* net_comp = entity.get_component(); + + if (!move || !state) { + if (!move) { + LOG_DEBUG("Entity %u missing Movement component", entity_id); + } + if (!state) { + LOG_DEBUG("Entity %u missing EntityStateComponent", entity_id); + } + return packets; // Return empty vector + } + + // ======================================================================== + // Position Update Packet + // ======================================================================== + if (send_full_state || !net_comp) { + // Full state sync - always send position + packets.push_back(SerializationSystem::serialize_entity_position(entity_id, move->position)); + } else { + // Delta sync - only if position changed + if (ComponentUtility::has_network_position_changed(*net_comp, move->position)) { + packets.push_back(SerializationSystem::serialize_entity_position(entity_id, move->position)); + } + } + + // ======================================================================== + // Stats Update Packets (Each stat sent individually) + // ======================================================================== + if (stats) { + if (send_full_state || !net_comp) { + // Full state sync - send each stat as individual packet + std::ostringstream oss; + oss << std::fixed << std::setprecision(6) << stats->health; + packets.push_back(SerializationSystem::serialize_entity_stat_change(entity_id, "health", oss.str())); + LOG_DEBUG("Entity %u: Syncing health = %s", entity_id, oss.str().c_str()); + + oss.str(""); + oss.clear(); + oss << std::fixed << std::setprecision(6) << stats->max_health; + packets.push_back(SerializationSystem::serialize_entity_stat_change(entity_id, "max_health", oss.str())); + + oss.str(""); + oss.clear(); + oss << std::fixed << std::setprecision(6) << stats->mana; + packets.push_back(SerializationSystem::serialize_entity_stat_change(entity_id, "mana", oss.str())); + + oss.str(""); + oss.clear(); + oss << std::fixed << std::setprecision(6) << stats->max_mana; + packets.push_back(SerializationSystem::serialize_entity_stat_change(entity_id, "max_mana", oss.str())); + + packets.push_back(SerializationSystem::serialize_entity_stat_change(entity_id, "level", std::to_string(stats->level))); + } else if (ComponentUtility::has_network_stats_changed(*net_comp, *stats)) { + // Delta sync - only send packets for stats that changed + auto stat_packets = create_stat_change_packets(entity_id, *stats, *net_comp->last_synced_stats); + if (!stat_packets.empty()) { + LOG_DEBUG("Entity %u: Syncing %zu stat changes (health: %.1f)", entity_id, stat_packets.size(), stats->health); + } + packets.insert(packets.end(), stat_packets.begin(), stat_packets.end()); + } + } + + // ======================================================================== + // Entity State Update Packet + // ======================================================================== + if (send_full_state || !net_comp) { + // Full state sync - always send state + packets.push_back(SerializationSystem::serialize_entity_state(entity_id, static_cast(state->current_state))); + } else if (ComponentUtility::has_network_state_changed(*net_comp, state->current_state)) { + // Delta sync - only send state if it changed + packets.push_back(SerializationSystem::serialize_entity_state(entity_id, static_cast(state->current_state))); + } + + return packets; +} diff --git a/src/systems/core/network_sync_system.hpp b/src/systems/core/network_sync_system.hpp new file mode 100644 index 0000000..5614234 --- /dev/null +++ b/src/systems/core/network_sync_system.hpp @@ -0,0 +1,102 @@ +#pragma once + +#include +#include +#include +#include +#include + +/** + * NetworkSyncSystem - Synchronizes entity state to connected clients + * + * RESPONSIBILITIES: + * - Detect which entities changed this frame (position, state, etc.) + * - Serialize changed data into network packets + * - Broadcast updates to all clients who can see the entity + * - Track last-synced state for each entity + * - Optimize bandwidth by sending only changed data + * + * OPTIMIZATIONS: + * - Delta compression: Only sends changed components, not full state + * - Frame rate limiting: Syncs every N frames instead of every frame + * - Position thresholding: Only syncs if entity moved significant distance + * - Network visibility: Respects NetworkEntityComponent visibility flags + * - Batching: Multiple entity updates per packet + * + * COMPONENTS USED: + * - Movement (position, velocity) + * - EntityStateComponent (current state: spawned, alive, dead, etc.) + * - NetworkEntityComponent (sync tracking, visibility, change detection) + * + * USAGE: + * SystemContext ctx{...}; + * network_sync_system.update(ctx); + */ +class NetworkSyncSystem { +public: + NetworkSyncSystem() = default; + ~NetworkSyncSystem() = default; + + // Prevent copying + NetworkSyncSystem(const NetworkSyncSystem&) = delete; + NetworkSyncSystem& operator=(const NetworkSyncSystem&) = delete; + + // Allow moving + NetworkSyncSystem(NetworkSyncSystem&&) = default; + NetworkSyncSystem& operator=(NetworkSyncSystem&&) = default; + + /** + * Update and broadcast entity state to all clients. + * + * Process: + * 1. Iterate all entities with NetworkEntityComponent + * 2. Check if entity is visible and has changed + * 3. Serialize changed data + * 4. Broadcast to network service + * 5. Update last-synced state in NetworkEntityComponent + * + * @param ctx System context with entity manager and network service + */ + void update(const SystemContext& ctx); + + /** + * Force next sync to send all entity data (not just deltas). + * Useful when clients disconnect/reconnect or state becomes inconsistent. + */ + void force_full_sync() { + force_full_sync_next_frame_ = true; + } + +private: + uint32_t current_frame_ = 0; // Frame counter for sync interval checking + bool force_full_sync_next_frame_ = false; // Flag to force delta-free sync + + // Optimization: only sync every N frames for non-critical entities + static constexpr uint32_t SYNC_INTERVAL = 1; // Every frame for responsive movement + + /** + * Check if an entity has changed since last sync. + * Considers position, state, and health. + * @param entity Entity to check + * @param current_frame Current frame number + * @return true if position, state, or other tracked data has changed + */ + bool has_entity_changed(const Entity& entity, uint32_t current_frame) const; + + /** + * Serialize changed entity data into multiple packets (one per component type). + * Only includes data that has actually changed (delta compression). + * + * Returns one packet per component that changed: + * - Position packet if entity moved + * - Stats packet if health/mana/level changed + * - Additional packets as new components are added + * + * @param entity_id ID of entity that changed + * @param entity The entity + * @param send_full_state If true, sends all data instead of just changes + * @return Vector of serialized packets, one per changed component + */ + std::vector> serialize_entity_update(EntityID entity_id, const Entity& entity, + bool send_full_state) const; +}; diff --git a/src/systems/core/npc_system.cpp b/src/systems/core/npc_system.cpp new file mode 100644 index 0000000..a3656cc --- /dev/null +++ b/src/systems/core/npc_system.cpp @@ -0,0 +1,85 @@ +#include "npc_system.hpp" + +#include "components/npc_component.hpp" +#include "intent.hpp" + +void NPCSystem::update(const SystemContext& ctx) { + auto npc_entities = ctx.entity_manager.get_entities_with_component(); + + for(auto entity : npc_entities) { + if(entity->get_component()->npc_type == NPCType::MINION) { + handle_minion_update(ctx, entity); + } + } +} + + +void NPCSystem::handle_minion_update(const SystemContext& ctx, Entity* entity) { + NPCComponent* npc_component = entity->get_component(); + IntentComponent* intent = entity->get_component(); + Movement* movement = entity->get_component(); + Stats* stats = entity->get_component(); + + // Currently attacking an enemy unit + if(intent->type == IntentType::ATTACK_TARGET) { + Entity* target = ctx.entity_manager.get_entity(intent->target_entity_id); + + if(!target || !target->get_component() || target->get_component()->health <= 0.0f) { + // Current target is dead, continue pathing + intent->type = IntentType::MOVE_TO_POSITION; + intent->target_position = npc_component->objective; + } + + // Make sure entity is not chasing too far away + if((npc_component->objective - movement->position).length() >= npc_component->chase_distance) { + intent->type = IntentType::MOVE_TO_POSITION; + intent->target_position = npc_component->objective; + return; + } + + // Not chasing too far, keep attacking + return; + } + + // Not attacking anything, so keep following the path but aggress on targets in range + if(intent->type == IntentType::MOVE_TO_POSITION) { + float distance_to_target = npc_component->aggression_range; + EntityID id = INVALID_ENTITY_ID; + + // If an enemy entity is in range, attack them instead of running further + // TODO get entities in vicinity, not all of them... - ploinky 27.11.2025 + for (const auto& entity_pair : ctx.entity_manager.get_all_entities()) { + const Entity& other_entity = entity_pair.second; + + // Do not attack allies, entities without stats or self + if(other_entity.get_id() == entity->get_id() + || !other_entity.get_component() + || (other_entity.get_component()->team_id == stats->team_id)) { + continue; + } + + const Movement* other_movement = other_entity.get_component(); + float distance_to_other = (other_movement->position - movement->position).length(); + + // Found enemy, check range + if(distance_to_other <= distance_to_target) { + id = other_entity.get_id(); + distance_to_target = distance_to_other; + } + } + + // Found a target in aggression range, attack + if(id != INVALID_ENTITY_ID) { + intent->type = IntentType::ATTACK_TARGET; + intent->target_entity_id = id; + return; + } + } + + // If nothing else, back to objective + if(intent->type == IntentType::NONE) { + intent->type = IntentType::MOVE_TO_POSITION; + intent->target_position = npc_component->objective; + return; + } +} \ No newline at end of file diff --git a/src/systems/core/npc_system.hpp b/src/systems/core/npc_system.hpp new file mode 100644 index 0000000..675b59b --- /dev/null +++ b/src/systems/core/npc_system.hpp @@ -0,0 +1,28 @@ +#pragma once + +#include "system_context.hpp" + +/** + * NPC System - handles NPC entity behavior. + * + * Handles all entities with NPCComponents. Analyses their + * type and determines what they should do next. + */ +class NPCSystem { +public: + NPCSystem() = default; + ~NPCSystem() = default; + + // Prevent copying + NPCSystem(const NPCSystem&) = delete; + NPCSystem& operator=(const NPCSystem&) = delete; + + // Allow moving + NPCSystem(NPCSystem&&) = default; + NPCSystem& operator=(NPCSystem&&) = default; + + void update(const SystemContext& ctx); + +private: + void handle_minion_update(const SystemContext& ctx, Entity* entity); +}; \ No newline at end of file diff --git a/src/systems/core/spawning_system.cpp b/src/systems/core/spawning_system.cpp new file mode 100644 index 0000000..453f06a --- /dev/null +++ b/src/systems/core/spawning_system.cpp @@ -0,0 +1,100 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +EntityID SpawningSystem::spawn_entity_from_template(const SystemContext& ctx, + const std::string& template_id, + const Vec2& position, + uint8_t team_id) { + // Get collision radius from template to find free space + float collision_radius = ctx.entity_manager.get_template_collision_radius(template_id); + + // Find collision-free spawn position within grid bounds + Vec2 spawn_position = CollisionSystem::find_free_space(position, collision_radius, const_cast(ctx.entity_manager), ctx.map); + + // Create entity from template + Entity& entity = ctx.entity_manager.create_entity_from_template(template_id); + EntityID entity_id = entity.get_id(); + + if (entity_id == INVALID_ENTITY_ID) { + LOG_ERROR("Failed to spawn entity from template: %s", template_id.c_str()); + return INVALID_ENTITY_ID; + } + + // Set position (collision-checked position) + auto* move = entity.get_component(); + if (move) { + move->position = spawn_position; + } + + // Set team ID + auto* stats = entity.get_component(); + if (stats) { + stats->team_id = team_id; + LOG_INFO("Spawned minion (ID %u) with health=%f, max_health=%f, team=%u", + entity_id, stats->health, stats->max_health, team_id); + } + // Adjust intent objective target based on team + // Minions should target the enemy core (using structure data from map) + auto* npc = entity.get_component(); + if (npc && npc->npc_type == NPCType::MINION) { + // Find enemy core position from map structures + Vec2 enemy_core_position = Vec2(0, 0); // Default fallback + + if (ctx.map) { + uint8_t enemy_team = (team_id == 1) ? 2 : 1; + + // Search for enemy core in map structures + for (const auto& structure : ctx.map->structures) { + if (structure.type == "core" && structure.team == enemy_team) { + // Convert 3D position to 2D (use X and Z, ignore Y) + enemy_core_position = Vec2(structure.position.x, structure.position.z); + LOG_DEBUG("Found enemy core for team %u at position (%.1f, %.1f)", + team_id, enemy_core_position.x, enemy_core_position.y); + break; + } + } + + if (enemy_core_position.x == 0.0f && enemy_core_position.y == 0.0f) { + LOG_WARN("Could not find enemy core for team %u in %zu structures. Minion %u will default to (0,0)", + enemy_team, ctx.map->structures.size(), entity_id); + } + } else { + LOG_ERROR("Map context is null! Minion %u objective cannot be set properly", entity_id); + } + + npc->objective = enemy_core_position; + LOG_DEBUG("Entity %u (team %u): Set objective target to <%f, %f>", + entity_id, team_id, npc->objective.x, npc->objective.y); + } + + + // Add network entity component for syncing + if (!entity.has_component()) { + auto net_comp = std::make_unique(); + net_comp->force_full_sync_next_frame = true; // Ensure initial spawn is sent to clients + entity.add_component(std::move(net_comp)); + } + + // Add entity state component if not present + if (!entity.has_component()) { + auto entity_state = std::make_unique(); + entity_state->current_state = EntityState::SPAWNED; + entity.add_component(std::move(entity_state)); + } + + LOG_INFO("Spawned entity (ID %u) from template '%s' at (%.1f, %.1f), team %u", + entity_id, template_id.c_str(), spawn_position.x, spawn_position.y, team_id); + + // NetworkSyncSystem will broadcast spawn to clients on first sync (when last_sync_frame == 0) + // This centralizes all network communication through the SyncManager + + return entity_id; +} + diff --git a/src/systems/core/spawning_system.hpp b/src/systems/core/spawning_system.hpp new file mode 100644 index 0000000..54d5ab0 --- /dev/null +++ b/src/systems/core/spawning_system.hpp @@ -0,0 +1,61 @@ +#pragma once + +#include "entity_manager.hpp" +#include "system_context.hpp" +#include + +/** + * SpawningSystem - Handles entity spawning and initialization + * + * RESPONSIBILITIES: + * - Spawn entities from templates + * - Initialize entity components + * - Find collision-free spawn positions + * - Link spawned entities to their controllers (players, waves, etc.) + * + * COMPONENTS USED: + * - Movement (position tracking) + * - NetworkEntityComponent (for network synchronization) + * - PlayerOwnedComponent (for linking champions to players) + * - Stats (for entity attributes) + * + * SYSTEM INTERACTION: + * - Called by WaveSystem, other spawning sources + * - Works with CollisionSystem to find free space + * - Integrates with EntityManager for entity creation + * - NetworkSyncSystem handles broadcasting spawned entities to clients + * + * NETWORK COMMUNICATION: + * SpawningSystem does NOT directly broadcast to clients. Instead: + * 1. SpawningSystem creates entity and adds NetworkEntityComponent + * 2. Sets force_full_sync_next_frame = true on NetworkEntityComponent + * 3. NetworkSyncSystem detects new entity (last_sync_frame == 0) + * 4. NetworkSyncSystem broadcasts spawn packet to all clients + * + * This centralizes all client communication through NetworkSyncSystem. + * + * USAGE: + * SpawningSystem spawning_system; + * EntityID minion_id = spawning_system.spawn_entity_from_template( + * ctx, "melee_minion", position, team_id + * ); + */ +class SpawningSystem { +public: + /** + * Spawn an entity from a template at a specific position. + * Automatically finds a collision-free position if the requested position is occupied. + * The spawned entity will be automatically synchronized to clients by NetworkSyncSystem + * on the next frame. + * + * @param ctx System context with entity manager and network service + * @param template_id Template name (e.g., "melee_minion", "champion") + * @param position Starting position for the entity + * @param team_id Team assignment for the entity + * @return EntityID of spawned entity, or INVALID_ENTITY_ID on failure + */ + EntityID spawn_entity_from_template(const SystemContext& ctx, + const std::string& template_id, + const Vec2& position, + uint8_t team_id); +}; diff --git a/src/systems/core/wave_system.cpp b/src/systems/core/wave_system.cpp new file mode 100644 index 0000000..fe2247c --- /dev/null +++ b/src/systems/core/wave_system.cpp @@ -0,0 +1,102 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +void WaveSystem::initialize(EntityManager* entity_manager, NetworkService* network_service, + NavigationService* navigation_service, const Map* map) { + entity_manager_ = entity_manager; + network_service_ = network_service; + navigation_service_ = navigation_service; + map_ = map; + elapsed_time_ms = 0.0f; + last_spawn_timestamp = 0.0f; + minion_index = 0; + wave_index = 0; + special_wave_offset = 5; + + // TODO: Load from config file -- cmkrist 11/16/2025 + default_minion_wave = { + "melee_minion", + "melee_minion", + "melee_minion", + "ranged_minion", + "magic_minion" + }; + special_minion_wave = { + "melee_minion", + "melee_minion", + "melee_minion", + "cannon_minion", + "ranged_minion", + "magic_minion", + "ranged_minion" + }; +} + +void WaveSystem::update(const SystemContext& ctx) { + if (!entity_manager_) { + return; + } + + float delta_time_ms = ctx.delta_time_ms; + elapsed_time_ms += delta_time_ms; + + // Minion Spawning + if(minion_index > 0) { + if (elapsed_time_ms - last_spawn_timestamp >= wave_delay_ms) { + for(uint8_t team_id = 1; team_id < 3; team_id++) { + create_minion( + ctx, + (wave_index % special_wave_offset == 0 ? special_minion_wave : default_minion_wave)[minion_index - 1], + team_id, + team_id - 1 // Spawn from team-specific spawnpoint + ); + } + last_spawn_timestamp = elapsed_time_ms; + // Overflow check + if (minion_index >= (wave_index % special_wave_offset == 0 ? special_minion_wave.size() : default_minion_wave.size())) { + minion_index = 0; + } else { + minion_index++; + } + } + } + // Wave Spawning + if (elapsed_time_ms >= (wave_index == 0 ? first_wave_delay_ms : wave_interval_ms)) { + LOG_INFO("Spawning new wave"); + minion_index = 1; + wave_index++; + elapsed_time_ms = 0; + last_spawn_timestamp = 0; + } +} + +bool WaveSystem::create_minion(const SystemContext& ctx, const std::string& minion_template, uint8_t team_id, uint32_t spawn_point_id) { + if (!map_ || spawn_point_id >= map_->spawnpoints.size()) { + LOG_WARN("Invalid spawn point %u for minion", spawn_point_id); + return false; + } + + // Get spawn position from map + Vec2 spawn_pos = map_->spawnpoints[spawn_point_id]; + + // Spawn entity using SpawningSystem + // (SpawningSystem will handle collision checking and finding free space) + EntityID minion_id = spawning_system_.spawn_entity_from_template(ctx, minion_template, spawn_pos, team_id); + if (minion_id == INVALID_ENTITY_ID) { + LOG_ERROR("Failed to spawn minion of type %s", minion_template.c_str()); + return false; + } + return true; +} + diff --git a/src/systems/core/wave_system.hpp b/src/systems/core/wave_system.hpp new file mode 100644 index 0000000..4ad48da --- /dev/null +++ b/src/systems/core/wave_system.hpp @@ -0,0 +1,82 @@ +#pragma once +#include + +#include +#include +#include +#include +#include +#include + +/** + * WaveSystem - Manages enemy wave spawning and progression + * + * RESPONSIBILITIES: + * - Spawn minions at regular intervals + * - Track wave progression + * - Process pathfinding results for spawned minions + * - Coordinate with SpawningSystem for entity creation + * + * SYSTEMS USED: + * - SpawningSystem (for entity spawning) + * - NavigationService (for pathfinding) + * + * SYSTEM INTERACTION: + * - Called by GameplayCoordinator via update() + * - Spawns entities at regular intervals + * - Requests paths from NavigationService + * + * USAGE: + * WaveSystem wave_system; + * wave_system.initialize(entity_manager, network_service, navigation_service, map); + * // In game loop: + * wave_system.update(ctx); + */ +class WaveSystem { +public: + WaveSystem() = default; + + /** + * Initialize the wave system with required services. + * Must be called before update(). + * @param entity_manager Entity manager for spawning + * @param network_service Network service for broadcasting spawns + * @param navigation_service Navigation service for pathfinding + * @param map Map data for spawnpoints + */ + void initialize(EntityManager* entity_manager, NetworkService* network_service, + NavigationService* navigation_service, const Map* map); + + /** + * Update wave system - spawn minions and process pathfinding results. + * @param ctx System context + */ + void update(const SystemContext& ctx); + +private: + EntityManager* entity_manager_ = nullptr; + NetworkService* network_service_ = nullptr; + NavigationService* navigation_service_ = nullptr; + const Map* map_ = nullptr; + SpawningSystem spawning_system_; + float wave_interval_ms = 30000.0f; // 30 seconds between waves (debug timing) + float first_wave_delay_ms = 1000.0f; // 1 second before first wave spawns (debug timing - reduced for testing) + float wave_delay_ms = 100.0f; // 100ms delay between minions in a wave (debug timing - reduced for testing) + float elapsed_time_ms; + float last_spawn_timestamp; + int minion_index; + int wave_index; + int special_wave_offset; + std::vector default_minion_wave; + std::vector special_minion_wave; + + /** + * Try to spawn a minion of the given type. + * @param ctx System context (contains entity manager and network service) + * @param minion_template The template name of the minion to spawn + * @param team_id The team this minion belongs to + * @param spawn_point_id Starting spawnpoint ID (0-indexed) + * @return true if minion spawned successfully + */ + bool create_minion(const SystemContext& ctx, const std::string& minion_template, uint8_t team_id, uint32_t spawn_point_id); +}; \ No newline at end of file diff --git a/src/systems/data_loader.cpp b/src/systems/data_loader.cpp deleted file mode 100644 index 66859c9..0000000 --- a/src/systems/data_loader.cpp +++ /dev/null @@ -1,121 +0,0 @@ -#include - -#include - -#include - -/** - * Convenience macro. Loads the attribute from the node if the node has it, and sets it in the component. - * @param node A pugi::xml_node which (maybe) has the attribute - * @param comp The Component in which to set the value of the attribute - * @param attr The attribute to load. Must match exactly both the attribute name in the xml and the property in the Component. - * @param as pugixml can load attributes as a number of different types, for example string, float or int - */ -#define LOAD_ATTRIBUTE(node, comp, attr, as) \ - { \ - pugi::xml_attribute loaded = node.attribute(#attr); \ - if(!loaded.empty()) { \ - comp->attr = loaded.as_##as(); \ - } \ - } - - -EntityTemplate DataLoader::load_entity_template(std::string file_name) { - EntityTemplate temp; - - // TODO should probably not let pugi load the file for us; thinking .pak files etc... - ploinky 14/11/2025 - pugi::xml_document doc; - pugi::xml_parse_status status = doc.load_file(file_name.c_str()).status; - if(status != pugi::xml_parse_status::status_ok) { - LOG_ERROR("Failed to load entity from data file: %s, pugixml status is %d", file_name.c_str(), status); - return temp; - } - - pugi::xml_node rootNode = doc.child("entity"); - if(rootNode == NULL) { - LOG_ERROR("Failed to load entity from data file: %s, rootNode 'entity' is missing", file_name.c_str()); - return temp; - } - - std::string id = rootNode.attribute("id").as_string(); - if(id.empty()) { - LOG_ERROR("Failed to load entity from data file: %s, rootNode 'entity' is missing id attribute", file_name.c_str()); - return temp; - } - temp.id = id; - - pugi::xml_node movementNode = rootNode.child("movement"); - if(movementNode != NULL) { - std::shared_ptr movement = std::make_shared(); - LOAD_ATTRIBUTE(movementNode, movement, move_speed, float) - temp.component_templates.push_back(movement); - } - - pugi::xml_node pathfindingNode = rootNode.child("pathfinding"); - if(pathfindingNode != NULL) { - std::shared_ptr pathfinding = std::make_shared(); - temp.component_templates.push_back(pathfinding); - } - - pugi::xml_node statsNode = rootNode.child("stats"); - if(statsNode != NULL) { - std::shared_ptr stats = std::make_shared(); - - LOAD_ATTRIBUTE(statsNode, stats, max_health, float) - LOAD_ATTRIBUTE(statsNode, stats, health, float) - LOAD_ATTRIBUTE(statsNode, stats, max_mana, float) - LOAD_ATTRIBUTE(statsNode, stats, mana, float) - LOAD_ATTRIBUTE(statsNode, stats, move_speed, float) - LOAD_ATTRIBUTE(statsNode, stats, level, int) - - LOAD_ATTRIBUTE(statsNode, stats, attack_range, float) - LOAD_ATTRIBUTE(statsNode, stats, attack_speed, float) - // TODO what to do about enums (like DamageType auto_damage_type)? - ploinky 14/11/2025 - LOAD_ATTRIBUTE(statsNode, stats, crit_chance, float) - LOAD_ATTRIBUTE(statsNode, stats, crit_bonus, int) - LOAD_ATTRIBUTE(statsNode, stats, true_bonus, int) - LOAD_ATTRIBUTE(statsNode, stats, magic_power, float) - LOAD_ATTRIBUTE(statsNode, stats, physical_power, float) - LOAD_ATTRIBUTE(statsNode, stats, projectile_speed, float) - - LOAD_ATTRIBUTE(statsNode, stats, armor, int) - LOAD_ATTRIBUTE(statsNode, stats, magic_resist, int) - LOAD_ATTRIBUTE(statsNode, stats, dodge, int) - - LOAD_ATTRIBUTE(statsNode, stats, health_regen, float) - LOAD_ATTRIBUTE(statsNode, stats, mana_regen, float) - LOAD_ATTRIBUTE(statsNode, stats, life_steal, float) - LOAD_ATTRIBUTE(statsNode, stats, spell_vamp, float) - LOAD_ATTRIBUTE(statsNode, stats, omni_vamp, float) - LOAD_ATTRIBUTE(statsNode, stats, leech, float) - LOAD_ATTRIBUTE(statsNode, stats, vision_range, float) - - temp.component_templates.push_back(stats); - } - - LOG_INFO("Successfully loaded template entity \"%s\" from %s with %d components", temp.id.c_str(), file_name.c_str(), temp.component_templates.size()); - return temp; -} - -std::vector DataLoader::list_files_from_directory(std::string path, std::string file_ending) { - std::vector file_names; - // Verify path exists - if(!std::filesystem::exists(path)) { - LOG_ERROR("DataLoader::list_files_from_directory: Path %s does not exist!", path.c_str()); - return file_names; - } - - for(const std::filesystem::directory_entry& entry : std::filesystem::directory_iterator(path)) { - // Recursively list files in subdirectories - if(entry.exists() && entry.is_directory()) { - std::vector subdirectory_files = list_files_from_directory(entry.path().string(), file_ending); - file_names.insert(file_names.end(), subdirectory_files.begin(), subdirectory_files.end()); - } - - if(entry.exists() && entry.is_regular_file() && !entry.path().extension().compare(file_ending)) { - file_names.push_back(entry.path().string()); - } - } - - return file_names; -} \ No newline at end of file diff --git a/src/systems/data_loader.hpp b/src/systems/data_loader.hpp deleted file mode 100644 index c517c62..0000000 --- a/src/systems/data_loader.hpp +++ /dev/null @@ -1,40 +0,0 @@ -#pragma once - -#include -#include -#include - -#include -#include -#include -#include - -/** - * Template for creating new entities. - */ -class EntityTemplate { -public: - std::string id; - std::vector> component_templates; -}; - -/** - * Loads data (e.g. entities) from xml files - */ -class DataLoader { -public: - /** - * Create a new entity tempalte based on data from a file. - * @param file_name The name of the file from which to load the entity template - * @return The newly created entity template - */ - static EntityTemplate load_entity_template(std::string file_name); - - /** - * List all files with a matching file ending in a directory. - * @param path The directory to search - * @param file_ending The file ending to match for, e.g. ".xml" - * @return A vector of file names found in the directory, e.g. "path/file-name.xml" - */ - static std::vector list_files_from_directory(std::string path, std::string file_ending); -}; \ No newline at end of file diff --git a/src/systems/entity_manager.hpp b/src/systems/entity_manager.hpp index 5c6f5f3..72d57f4 100644 --- a/src/systems/entity_manager.hpp +++ b/src/systems/entity_manager.hpp @@ -4,9 +4,12 @@ #include #include #include +// Components +#include +#include +#include -#include "components/component.hpp" -#include +#include /** * Represents a unique entity ID in the ECS system. @@ -121,8 +124,7 @@ class EntityManager { public: EntityManager() : next_entity_id_(1) { // TODO probably use system independent path separator - ploinky 14/11/2025 - LOG_INFO("Loading templates from ./data"); - for(std::string file_name : DataLoader::list_files_from_directory("./data", ".xml")) { + for(std::string file_name : DataLoader::list_files_from_directory("./data/entities", ".xml")) { EntityTemplate entity_template = DataLoader::load_entity_template(file_name); if(entity_template_cache.find(entity_template.id) != entity_template_cache.end()) { LOG_WARN("OVERWRITING EXISTING TEMPLATE: %s", entity_template.id.c_str()); @@ -160,6 +162,9 @@ class EntityManager { std::unique_ptr comp_copy = comp->clone(); new_entity.add_component(std::move(comp_copy)); } + + new_entity.add_component(std::make_unique(entity_type_id)); + // Register in cache entity_pools_[entity_type_id].push_back(new_entity.get_id()); dirty_entities.erase(std::remove(dirty_entities.begin(), dirty_entities.end(), new_entity.get_id()), dirty_entities.end()); // remove from dirty entities -- cmkrist 15/11/2025 @@ -254,6 +259,44 @@ class EntityManager { std::unordered_map& get_all_entities() { return entities_; } + + /** + * Get entity template by ID without creating an entity. + * Useful for querying template data (e.g., collision radius) without consuming an entity ID. + * @param template_id The ID of the template to retrieve + * @return Const pointer to the template, or nullptr if not found + */ + const EntityTemplate* get_template(const std::string& template_id) const { + auto it = entity_template_cache.find(template_id); + if (it == entity_template_cache.end()) { + return nullptr; + } + return &it->second; + } + + /** + * Get collision radius for a template without creating an entity. + * Used for pathfinding and collision queries during spawn phase. + * @param template_id The ID of the template + * @return Collision radius (default 0.5f if not found) + */ + float get_template_collision_radius(const std::string& template_id) const { + const EntityTemplate* template_data = get_template(template_id); + if (!template_data) { + return 0.5f; // Default + } + + // Search for Movement component in template (TYPE_ID = 2001) + for (const auto& comp : template_data->component_templates) { + if (comp->get_type_id() == 2001) { // Movement component type ID + const Movement* move_template = static_cast(comp.get()); + if (move_template) { + return move_template->collision_radius; + } + } + } + return 0.5f; // Default + } private: std::unordered_map entities_; diff --git a/src/systems/gameplay_coordinator.cpp b/src/systems/gameplay_coordinator.cpp new file mode 100644 index 0000000..55355c3 --- /dev/null +++ b/src/systems/gameplay_coordinator.cpp @@ -0,0 +1,86 @@ +#include +#include +#include + +GameplayCoordinator::GameplayCoordinator() + : wave_system_(nullptr) { + // Systems initialized with default constructors + // WaveSystem will be initialized via initialize_wave_system() before use +} + +void GameplayCoordinator::initialize_wave_system(EntityManager* entity_manager, NetworkService* network_service, + NavigationService* navigation_service, const Map* map) { + wave_system_ = std::make_unique(); + wave_system_->initialize(entity_manager, network_service, navigation_service, map); +} + +void GameplayCoordinator::update(const SystemContext& ctx) { + // Execute systems in dependency order + + // INPUT & AI SYSTEMS + input_system_.update(ctx); + npc_system_.update(ctx); + brain_system_.update(ctx); // Process NPC intents + + // ENGINE SYSTEMS + wave_system_->update(ctx); + + // MOVEMENT & PHYSICS SYSTEMS + movement_system_.update(ctx); + collision_system_.update(ctx); + + // COMBAT SYSTEMS (unified) + combat_system_.update(ctx); + + // Synchronize to clients + network_sync_system_.update(ctx); + + // Cleanup dead entities (after network sync so death state reaches clients first) + cleanup_dead_entities(ctx.entity_manager); +} + +void GameplayCoordinator::mark_for_cleanup(EntityID entity_id) { + // Check if already marked + auto it = std::find(entities_marked_for_cleanup_.begin(), + entities_marked_for_cleanup_.end(), + entity_id); + if (it == entities_marked_for_cleanup_.end()) { + entities_marked_for_cleanup_.push_back(entity_id); + LOG_DEBUG("Entity %u marked for cleanup", entity_id); + } +} + +void GameplayCoordinator::cleanup_dead_entities(EntityManager& entity_manager) { + // Actually remove entities that were marked last frame + // Allows Client Sync before removal + std::vector to_remove = entities_marked_for_cleanup_; + entities_marked_for_cleanup_.clear(); + + for (EntityID entity_id : to_remove) { + if (entity_manager.destroy_entity(entity_id)) { + LOG_INFO("Cleaned up dead entity %u", entity_id); + } else { + LOG_WARN("Failed to cleanup entity %u (already removed?)", entity_id); + } + } + + // Check for newly dead entities and mark them for cleanup next frame + auto entities_with_state = entity_manager.get_entities_with_component(); + for (auto* entity : entities_with_state) { + if (!entity) continue; + + auto* state = entity->get_component(); + if (!state || state->current_state != EntityState::DEAD) { + continue; + } + + // Cleanup entity next frame + EntityID entity_id = entity->get_id(); + auto it = std::find(entities_marked_for_cleanup_.begin(), + entities_marked_for_cleanup_.end(), + entity_id); + if (it == entities_marked_for_cleanup_.end()) { + mark_for_cleanup(entity_id); + } + } +} diff --git a/src/systems/gameplay_coordinator.hpp b/src/systems/gameplay_coordinator.hpp new file mode 100644 index 0000000..d4e68fc --- /dev/null +++ b/src/systems/gameplay_coordinator.hpp @@ -0,0 +1,161 @@ +#pragma once + +#include "system_context.hpp" + +/* === Core Systems === */ +// Input & AI +#include +#include +#include + +// Spawning +#include + +// Physics & Movement +#include +#include + +// Combat +#include + +// Networking +#include + + +/** + * GameplayCoordinator - Orchestrates all game systems + * + * RESPONSIBILITIES: + * - Update all systems in correct order + * - Maintain game logic loop + * - Ensure systems execute in dependency order + * + * DOES NOT: + * - Manage players (use PlayerManager) + * - Handle networking directly (use NetworkService) + * - Manage game state transitions (use GameServer) + * + * SYSTEM UPDATE ORDER (important for correctness): + * 1. WaveSystem - Creates entities in ECS + * 2. BrainSystem - NPC decision-making and intent management + * 3. MovementSystem - Updates entity positions based on paths + * 4. CollisionSystem - Resolves overlaps and pushes entities apart + * 5. CombatSystem - Handles all combat (auto-attacks, cooldowns, target search, attack execution) + * 6. NetworkSyncSystem - BROADCASTS ALL STATE CHANGES TO CLIENTS (spawns, updates, deaths) + * + * USAGE: + * GameplayCoordinator gameplay; + * SystemContext ctx{...}; + * gameplay.update(ctx); + */ +class GameplayCoordinator { +public: + GameplayCoordinator(); + ~GameplayCoordinator() = default; + + // Prevent copying + GameplayCoordinator(const GameplayCoordinator&) = delete; + GameplayCoordinator& operator=(const GameplayCoordinator&) = delete; + + // Allow moving + GameplayCoordinator(GameplayCoordinator&&) = default; + GameplayCoordinator& operator=(GameplayCoordinator&&) = default; + + /** + * Initialize the WaveSystem with required services. + * Must be called before update() to properly set up entity spawning. + * @param entity_manager Pointer to entity manager + * @param network_service Pointer to network service (for broadcasting) + * @param navigation_service Pointer to navigation service (for pathfinding) + * @param map Pointer to map data + */ + void initialize_wave_system(EntityManager* entity_manager, NetworkService* network_service, + NavigationService* navigation_service, const Map* map); + + /** + * Update all game systems for a single frame. + * Systems are executed in dependency order to ensure correct behavior. + * + * Order: + * 1. WaveSystem::update() - Spawn new minions + * 2. BrainSystem::update() - Process NPC intents + * 3. MovementSystem::update() - Move entities + * 4. CollisionSystem::update() - Resolve collisions + * 5. CombatSystem::update() - Manage all combat + * 6. NetworkSyncSystem::update() - Broadcast state + * 7. cleanup_dead_entities() - Remove entities marked for deletion + * + * @param ctx System context containing entity manager and services + */ + void update(const SystemContext& ctx); + + /** + * Clean up dead entities that have been synced to clients. + * Removes entities that have been in DEAD state for one frame, + * allowing clients to receive and process the death state before removal. + * @param entity_manager Reference to entity manager + */ + void cleanup_dead_entities(EntityManager& entity_manager); + + /** + * Mark an entity for cleanup (removal after next network sync). + * Called when entity reaches DEAD state. + * @param entity_id ID of entity to mark for cleanup + */ + void mark_for_cleanup(EntityID entity_id); + + /** + * Get reference to brain system. + * @return Reference to BrainSystem + */ + BrainSystem& get_brain_system() { return brain_system_; } + + /** + * Get reference to input system. + * @return Reference to InputSystem + */ + InputSystem& get_input_system() { return input_system_; } + + /** + * Get reference to wave system (for direct initialization if needed). + * @return Reference to WaveSystem + */ + WaveSystem& get_wave_system() { return *wave_system_; } + + /** + * Get reference to collision system. + * @return Reference to CollisionSystem + */ + CollisionSystem& get_collision_system() { return collision_system_; } + + /** + * Get reference to movement system. + * @return Reference to MovementSystem + */ + MovementSystem& get_movement_system() { return movement_system_; } + + /** + * Get reference to network sync system. + * @return Reference to NetworkSyncSystem + */ + NetworkSyncSystem& get_network_sync_system() { return network_sync_system_; } + + /** + * Get reference to combat system. + * @return Reference to CombatSystem + */ + CombatSystem& get_combat_system() { return combat_system_; } + +private: + InputSystem input_system_; + NPCSystem npc_system_; + std::unique_ptr wave_system_; + BrainSystem brain_system_; + CollisionSystem collision_system_; + MovementSystem movement_system_; + NetworkSyncSystem network_sync_system_; + CombatSystem combat_system_; + + // Entity cleanup tracking + std::vector entities_marked_for_cleanup_; // Entities to remove next frame +}; diff --git a/src/systems/gameserver.cpp b/src/systems/gameserver.cpp index 96504bd..07c746b 100644 --- a/src/systems/gameserver.cpp +++ b/src/systems/gameserver.cpp @@ -1,7 +1,7 @@ -#include "gameserver.hpp" -#include "packet_validator.hpp" +#include +#include #include -#include +#include #include #include #include @@ -9,8 +9,12 @@ #include #include +#include +#include -#include +#include +#include +#include GameServer::GameServer(int port, int max_clients, const std::string& map_path) : max_clients_(max_clients) @@ -19,9 +23,10 @@ GameServer::GameServer(int port, int max_clients, const std::string& map_path) , map_pointer_(nullptr) , navigation_service_(nullptr) , current_state_(GAME_STATE::PREGAME) - , last_minion_broadcast_(std::chrono::high_resolution_clock::now()) , network_service_(NetworkService(port, max_clients)) - , wave_system_(nullptr) { + , player_manager_() + , packet_handler_(&player_manager_, &entity_manager_, &network_service_, &gameplay_.get_input_system()) + , gameplay_() { } @@ -33,17 +38,52 @@ GameServer::~GameServer() { } ERROR_CODE GameServer::initialize() { - // Initialize Map - std::optional map_opt = MapSystem::load_map(map_path_); + // Load map using DataLoader + std::optional map_opt = DataLoader::load_map(map_path_); if (!map_opt) { LOG_ERROR("Failed to load map"); return ERROR_CODE::ERROR_ENET_CREATION_FAILED; } - // Initialize Navigation FIRST before moving map + // Send structures to EntityManager + for (const auto& structure_data : map_opt->structures) { + Entity& structure_entity = entity_manager_.create_entity_from_template(structure_data.type); + EntityID entity_id = structure_entity.get_id(); + if (entity_id == INVALID_ENTITY_ID) { + LOG_ERROR("Failed to create structure entity from template"); + continue; + } + // Set position component + auto* move = structure_entity.get_component(); + if (move) { + move->position = Vec2(structure_data.position.x, structure_data.position.z); + } + + // Set team in Stats component + auto* stats = structure_entity.get_component(); + if (stats) { + stats->team_id = structure_data.team; + } + + // Set structure type in Structure Component + auto* structure_comp = structure_entity.get_component(); + if (structure_comp) { + structure_comp->type = [&structure_data]() { + if (structure_data.type == "core") return StructureType::NEXUS; + if (structure_data.type == "tower") return StructureType::TOWER; + if (structure_data.type == "inhibitor") return StructureType::INHIBITOR; + if (structure_data.type == "ward") return StructureType::WARD; + return StructureType::CUSTOM; + }(); + } + + + } + + // Initialize Navigation navigation_service_ = std::make_unique(map_opt.value()); - // Now create the pointer copy for map_pointer + // Create pointer copy map_pointer_ = std::make_unique(std::move(map_opt.value())); // Initialize Network @@ -56,8 +96,19 @@ ERROR_CODE GameServer::initialize() { return net_result; } - // Initialize Wave System with navigation and map - wave_system_ = std::make_unique(&entity_manager_, &network_service_, navigation_service_.get(), map_pointer_.get()); + // Initialize GameplayCoordinator Systems + initialize_coordinator(); + + // Autostart logic (Unlimited players) -- cmkrist 4/12/ + if (max_clients_ == 0) { + LOG_INFO("max_players set to 0, transitioning straight to ONGOING state"); + try_transition_state(GAME_STATE::ONGOING); + } + // Default lobby ready check + else if (is_lobby_full() && is_lobby_ready()) { + LOG_INFO("Lobby full and ready on startup, transitioning to ONGOING state"); + try_transition_state(GAME_STATE::ONGOING); + } return ERROR_CODE::ERROR_NONE; } @@ -75,26 +126,24 @@ void GameServer::run() { continue; } GameServer::frame_tick(); - - } - LOG_INFO("Server main loop ended"); } void GameServer::frame_tick() { float delta_time_ms = frame_timer_.frame_duration_in_ms(); - float delta_time_s = delta_time_ms / 1000.0f; - // Update waves - wave_system_->tick(delta_time_ms); + // Create system context with all services + SystemContext ctx(entity_manager_); + ctx.input_system = &gameplay_.get_input_system(); + ctx.navigation_service = navigation_service_.get(); + ctx.network_service = &network_service_; + ctx.map = map_pointer_.get(); + ctx.delta_time_ms = delta_time_ms; - // Update entity movement - movement_system_.update(entity_manager_, delta_time_s, navigation_service_.get(), map_pointer_.get()); - - // Synchronize entity state to clients + // Update all game systems through coordinator if (current_state_ == GAME_STATE::ONGOING) { - network_sync_system_.update(entity_manager_, &network_service_); + gameplay_.update(ctx); } } @@ -107,18 +156,8 @@ bool GameServer::is_shutdown_requested() const { return shutdown_requested_; } -bool GameServer::is_lobby_ready() const { - if (players_.empty()) { - return false; - } - - for (const auto& pair : players_) { - if (!pair.second.is_ready) { - return false; - } - } - - return true; +bool GameServer::is_lobby_ready() { + return player_manager_.are_all_players_ready(entity_manager_); } GAME_STATE GameServer::get_current_state() const { @@ -143,7 +182,7 @@ bool GameServer::try_transition_state(GAME_STATE new_state) { } size_t GameServer::get_player_count() const { - return players_.size(); + return player_manager_.get_player_count(); } int GameServer::get_max_clients() const { @@ -151,115 +190,77 @@ int GameServer::get_max_clients() const { } bool GameServer::is_lobby_full() const { - return (int)players_.size() >= max_clients_; + return player_manager_.is_full(max_clients_); } void GameServer::on_client_connect(std::string client_id) { - // Create player - Player new_player(client_id); - new_player.player_id = (uint32_t)players_.size(); - - // Store in map - auto result = players_.emplace(client_id, new_player); - - // Tell the player which map to load + // Lobby check & Map Send + if (max_clients_ > 0 && player_manager_.is_full(max_clients_)) { + LOG_WARN("Lobby full! Rejecting new connection from %s", client_id.c_str()); + network_service_.disconnect_client(client_id); + network_service_.send_packet(PACKET_TYPE::LOBBY_FULL, client_id); + return; + } + EntityID player_entity_id = player_manager_.on_client_connect(client_id, entity_manager_); + if (map_pointer_) { network_service_.send_packet(PACKET_TYPE::MAP_LOAD, map_pointer_->name, client_id); } - LOG_INFO("Player %s added. Total players: %zu/%d", - client_id.c_str(), players_.size(), max_clients_); + LOG_INFO("Player %s added. Entity ID: %u", + client_id.c_str(), player_entity_id); - if (is_lobby_full()) { - LOG_INFO("Lobby full! Waiting for all players to be ready (%zu/%d)", - players_.size(), max_clients_); + if (max_clients_ == 0) { + LOG_INFO("max_players set to 0 (unlimited), player connected"); + } else if (player_manager_.is_full(max_clients_)) { + LOG_INFO("Lobby full! Waiting for all players to be ready"); } else { - LOG_INFO("Waiting for more players... (%zu/%d)", - players_.size(), max_clients_); + LOG_INFO("Waiting for more players..."); } - - broadcast_player_list(); } void GameServer::on_packet_received(std::string client_id, const uint8_t* data, size_t length) { - // Validate and process packet - if (!PacketValidator::validate_packet(data, length)) { - LOG_WARN("Invalid packet received from %s", client_id.c_str()); - return; - } - PACKET_TYPE packet_type = (PACKET_TYPE)(data[0]); + // Delegate to PacketHandler for processing + packet_handler_.handle_packet(client_id, data, length); - switch (packet_type) { - case PACKET_TYPE::PLAYER_READY: - handle_player_ready_packet(client_id, data, length); - break; - - default: - LOG_WARN("Unknown packet type: %d", (int)(packet_type)); - break; - } - - // Update last activity for this player - auto it = players_.find(client_id); - if (it != players_.end()) { - it->second.update_activity(); - } -} - -bool GameServer::handle_player_ready_packet(std::string client_id, const uint8_t* packet_data, size_t packet_length) { - bool is_ready = false; - if (!PacketValidator::extract_ready_status(packet_data, packet_length, is_ready)) { - LOG_WARN("Failed to extract ready status from packet"); - return false; - } - - // Update player ready status - auto it = players_.find(client_id); - if (it == players_.end()) { - LOG_WARN("Received ready packet from unknown player: %s", client_id.c_str()); - return false; + // Default lobby ready check with autostart -- cmkrist 4/12/2025 + bool should_start_game = false; + if (current_state_ == GAME_STATE::PREGAME) { + if (max_clients_ == 0) { + should_start_game = true; + } else if (player_manager_.is_full(max_clients_) && + player_manager_.are_all_players_ready(entity_manager_)) { + should_start_game = true; + } } - it->second.is_ready = is_ready; - LOG_INFO("Player %s is now %s", client_id.c_str(), is_ready ? "ready" : "not ready"); - - // Check if we should transition to ONGOING - if (current_state_ == GAME_STATE::PREGAME && - is_lobby_full() && - is_lobby_ready()) { - + if (should_start_game) { LOG_INFO("All players ready! Transitioning to ONGOING state"); - if(try_transition_state(GAME_STATE::ONGOING)) { + if (try_transition_state(GAME_STATE::ONGOING)) { // Notify all players that the game is starting network_service_.broadcast_packet(PACKET_TYPE::GAME_START); - GameServer::run(); } } - - broadcast_player_list(); - return true; } void GameServer::on_client_disconnect(std::string client_id) { - // Remove player from map - auto it = players_.find(client_id); - if (it != players_.end()) { - players_.erase(it); - LOG_INFO("Player removed. Remaining players: %zu", players_.size()); + // Remove player entity via PlayerManager + bool was_removed = player_manager_.on_client_disconnect(client_id, entity_manager_); + + if (was_removed) { + LOG_INFO("Player %s disconnected", client_id.c_str()); } // Handle state changes if game was ongoing - if (current_state_ == GAME_STATE::ONGOING && players_.empty()) { + if (current_state_ == GAME_STATE::ONGOING && player_manager_.get_player_count() == 0) { LOG_WARN("All players disconnected. Returning to PREGAME"); try_transition_state(GAME_STATE::PREGAME); } - - broadcast_player_list(); } -void GameServer::broadcast_player_list() { - // TODO: Implement broadcasting player list to all connected clients - // For now, this is a placeholder for future networking implementation +void GameServer::initialize_coordinator() { + // Set up WaveSystem with all required services + gameplay_.initialize_wave_system(&entity_manager_, &network_service_, navigation_service_.get(), map_pointer_.get()); } bool GameServer::is_valid_state_transition(GAME_STATE from, GAME_STATE to) const { @@ -284,3 +285,18 @@ bool GameServer::is_valid_state_transition(GAME_STATE from, GAME_STATE to) const return false; } } + +void GameServer::start_visualizer(uint16_t port) { + if (!map_pointer_) { + LOG_ERROR("Cannot start visualizer: map not initialized"); + return; + } + + debug_visualizer_ = std::make_unique(port); + debug_visualizer_->initialize(&entity_manager_, map_pointer_.get()); + if (debug_visualizer_->start()) { + LOG_INFO("Debug visualizer started on port %u", port); + } else { + LOG_ERROR("Failed to start debug visualizer"); + } +} diff --git a/src/systems/gameserver.hpp b/src/systems/gameserver.hpp index 510a251..40ff518 100644 --- a/src/systems/gameserver.hpp +++ b/src/systems/gameserver.hpp @@ -6,24 +6,25 @@ #include #include #include -#include "components/errors.hpp" -#include "components/game_state.hpp" -#include "components/map.hpp" -#include "libs/frame_timer.h" -// Entities -#include "entities/player.hpp" +// Components +#include +#include +#include + +// Libraries +#include + +// Systems +#include +#include +#include // Systems -#include "combat_system.hpp" -#include "entity_manager.hpp" -#include "map_system.hpp" -#include "movement_system.hpp" -#include "network_sync_system.hpp" -#include "serialization_system.hpp" -#include "wave_system.hpp" +#include // Services -#include "services/navigation_service.hpp" -#include "services/network_service.hpp" +#include +#include +#include /** * Central GameServer class encapsulating all server state and logic. * Replaces global state management with proper OOP encapsulation. @@ -75,7 +76,7 @@ class GameServer { * Check if all players in lobby are ready. * @return true if all connected players have ready=true */ - bool is_lobby_ready() const; + bool is_lobby_ready(); /** * Get current game state. @@ -108,19 +109,20 @@ class GameServer { */ bool is_lobby_full() const; + /** + * Start the debug visualizer web server. + * @param port Port to listen on (default 8080) + */ + void start_visualizer(uint16_t port = 8080); + private: // Network configuration int max_clients_; std::string map_path_; std::unique_ptr map_pointer_; NetworkService network_service_; - // Systems - std::unique_ptr wave_system_; - MovementSystem movement_system_; - NetworkSyncSystem network_sync_system_; - + // Server configuration - // Server tick rate in milliseconds // TODO this should be configurable, probably - ploinky 14/11/2025 FrameTimer frame_timer_ = FrameTimer(30); @@ -129,15 +131,20 @@ class GameServer { std::atomic shutdown_requested_; GAME_STATE current_state_; - // Minion broadcast tracking (for rate limiting) - std::chrono::high_resolution_clock::time_point last_minion_broadcast_; - static constexpr float MINION_BROADCAST_INTERVAL = 0.05f; // 50ms = ~20 updates/sec + // Managers + PlayerManager player_manager_; + PacketHandler packet_handler_; + GameplayCoordinator gameplay_; - // Player management (using hash map for O(1) lookup) - std::unordered_map players_; + /** + * Initialize GameplayCoordinator with required services. + * Must be called after map and navigation service are initialized. + */ + void initialize_coordinator(); // Services (Separate thread) std::unique_ptr navigation_service_; + std::unique_ptr debug_visualizer_; // ECS systems EntityManager entity_manager_; @@ -155,15 +162,6 @@ class GameServer { * @param packet The packet the is incoming */ void on_packet_received(std::string client_id, const uint8_t* data, size_t length); - - /** - * Handle player ready status packet. - * @param client_id ID of the client that has submitted the status - * @param packet_data Pointer to packet data - * @param packet_length Length of packet data - * @return true if handled successfully - */ - bool handle_player_ready_packet(std::string client_id, const uint8_t* packet_data, size_t packet_length); /** * Handle client disconnect. @@ -175,16 +173,6 @@ class GameServer { * Perform a single frame tick: process game logic, update states, and broadcast as needed. */ void frame_tick(); - /** - * Broadcast player list to all connected clients. - */ - void broadcast_player_list(); - - /** - * Broadcast all active minion states to all connected clients. - * Rate-limited to avoid excessive network traffic. - */ - void broadcast_minion_states(); /** * Validate state transition rules. diff --git a/src/systems/map_system.cpp b/src/systems/map_system.cpp deleted file mode 100644 index 76121ae..0000000 --- a/src/systems/map_system.cpp +++ /dev/null @@ -1,454 +0,0 @@ -#include "map_system.hpp" -#include "components/map.hpp" -#include "components/navmesh.hpp" -#include -#include -#include -#include -#include -#include -#include -#include - - -std::optional MapSystem::load_map(const std::optional& file_path) { - std::string default_map_dir = "./data/maps/"; // STATIC -- cmkrist 15/11/2025 - // Check for default map directory, if missing error out and die - if (!std::filesystem::exists(default_map_dir)) { - LOG_ERROR("Default map directory does not exist: %s", default_map_dir.c_str()); - return std::nullopt; - } - // Create initial return variables - std::optional loaded_map; - std::string actual_file_path; - // Check for existing file path - if (file_path && !file_path->empty()) { - // Dedicated Path - if (file_path->find(".tscn") != std::string::npos) { - LOG_INFO("Loading map from specified file path: %s", file_path->c_str()); - actual_file_path = *file_path; - if (!std::filesystem::exists(actual_file_path)) { - LOG_ERROR("Specified map file does not exist: %s", actual_file_path.c_str()); - actual_file_path.clear(); - } - // Map Name - } else { - actual_file_path = default_map_dir + *file_path + ".tscn"; - if (std::filesystem::exists(actual_file_path)) { - LOG_INFO("Loading map from specified map name: %s", file_path->c_str()); - } else { - LOG_ERROR("Map file for specified map name does not exist: %s", actual_file_path.c_str()); - actual_file_path.clear(); - } - } - } - // ScanDir if no map set - if (actual_file_path.empty()) { - // Check for valid file paths - std::vector tscn_files; - for (const auto& entry : std::filesystem::directory_iterator(default_map_dir)) { - if (entry.is_regular_file() && entry.path().extension() == ".tscn") { - tscn_files.push_back(entry.path().filename().string()); - } - } - - if (tscn_files.empty()) { - LOG_ERROR("No .tscn files found in current directory. Cannot load map."); - return std::nullopt; - } - - actual_file_path = default_map_dir + tscn_files[0]; - LOG_INFO("Found .tscn file: %s", actual_file_path.c_str()); - } - // Read file - std::ifstream file(actual_file_path); - if (!file.is_open()) { - LOG_ERROR("Failed to open map file: %s", actual_file_path.c_str()); - return std::nullopt; - } - - std::stringstream buffer; - buffer << file.rdbuf(); - std::string file_content = buffer.str(); - file.close(); - - // Parse navmesh - NavMeshData navmesh_data = parse_navmesh_from_tscn(file_content); - if (navmesh_data.vertices.empty() || navmesh_data.polygons.empty()) { - LOG_ERROR("Failed to parse navmesh data from map file: %s", actual_file_path.c_str()); - return std::nullopt; - } - - // Parse spawnpoints - std::vector spawnpoints = parse_spawnpoints_from_tscn(file_content); - LOG_INFO("Parsed %zu spawnpoints from map file", spawnpoints.size()); - - // Add Map component - Map map; - size_t final_slash_index = actual_file_path.find_last_of("/\\"); - // Get name from file - map.name = actual_file_path.substr(final_slash_index + 1, actual_file_path.find_last_of('.') - final_slash_index - 1); - map.vertices = navmesh_data.vertices; - map.polygons = navmesh_data.polygons; - // Store spawnpoints directly (already Vec2 from parsing) - map.spawnpoints.reserve(spawnpoints.size()); - for (const auto& sp : spawnpoints) { - map.spawnpoints.push_back(sp.position); - LOG_INFO(" Spawnpoint: (%.1f, %.1f)", sp.position.x, sp.position.y); - } - // Get 2d size and offset - map.size = MapSystem::calculate_size_from_vertices(navmesh_data.vertices); - map.offset = map.size / 2.0f; - - LOG_INFO("Loaded map '%s' with %zu vertices, %zu polygons, and %zu spawnpoints", - map.name.c_str(), navmesh_data.vertices.size(), navmesh_data.polygons.size(), spawnpoints.size()); - - return std::optional(map); -} - -MapSystem::NavMeshData MapSystem::parse_navmesh_from_tscn(const std::string& file_content) { - NavMeshData data; - - // Find the NavigationMesh subsection - size_t nav_mesh_pos = file_content.find("[sub_resource type=\"NavigationMesh\""); - if (nav_mesh_pos == std::string::npos) { - LOG_WARN("NavigationMesh subsection not found in tscn file"); - return data; - } - - // Find vertices line - std::string vertices_string = "vertices = PackedVector3Array("; - size_t vertices_pos = file_content.find(vertices_string, nav_mesh_pos); - if (vertices_pos == std::string::npos) { - LOG_WARN("vertices line not found in NavigationMesh"); - return data; - } - vertices_pos += vertices_string.length(); // Skip "vertices = PackedVector3Array(" - size_t vertices_end = file_content.find(")", vertices_pos); - if (vertices_end == std::string::npos) { - LOG_WARN("vertices end not found"); - return data; - } - - std::string vertices_str = file_content.substr(vertices_pos, vertices_end - vertices_pos); - data.vertices = parse_vertices_to_2D(vertices_str); - - // Find polygons line - std::string polygons_string = "polygons = ["; - size_t polygons_pos = file_content.find(polygons_string, nav_mesh_pos); - if (polygons_pos == std::string::npos) { - LOG_WARN("polygons line not found in NavigationMesh"); - return data; - } - - polygons_pos += polygons_string.length(); // Skip "polygons = [" - size_t polygons_end = file_content.find("]", polygons_pos); - if (polygons_end == std::string::npos) { - LOG_WARN("polygons end not found"); - return data; - } - - std::string polygons_data_raw = file_content.substr(polygons_pos, polygons_end - polygons_pos); - data.polygons = parse_polygons(polygons_data_raw); - - return data; -} - -std::vector MapSystem::parse_spawnpoints_from_tscn(const std::string& file_content) { - std::vector spawnpoints; - - // Find all SpawnPoint nodes - size_t pos = 0; - while (true) { - - size_t spawn_pos = file_content.find("[node name=\"", pos); - if (spawn_pos == std::string::npos) break; - - // Check type - size_t type_pos = file_content.find("type=\"Marker3D\"", spawn_pos); - if (type_pos == std::string::npos || type_pos > file_content.find("\n", spawn_pos)) { - pos = spawn_pos + 1; - continue; // Not a Marker3D node - } - - // Check groups for team and spawn type - size_t groups_pos = file_content.find("groups=[", spawn_pos); - if (groups_pos == std::string::npos || groups_pos > file_content.find("\n", spawn_pos)) { - pos = spawn_pos + 1; - continue; // No groups found - } - size_t groups_end = file_content.find("]", groups_pos); - - // Create spawnpoint before parsing details - SpawnPoint sp; - - std::string groups_str = file_content.substr(groups_pos, groups_end - groups_pos); - bool is_spawnpoint = false; - - // Check for spawn type - for (const auto& [spawn_type_str, spawn_type_enum] : spawn_groups) { - if (groups_str.find(spawn_type_str) != std::string::npos) { - is_spawnpoint = true; - sp.type = spawn_type_enum; - LOG_INFO("Marker3D node at pos %zu is a spawnpoint of type %d", spawn_pos, spawn_type_enum); - break; - } - } - - // Check for team ID (legacy: team_id appears as a group string) - // TODO: Verify team ID parsing logic - currently only checks spawn_groups for team assignment - for (const auto& [group_name, team_id] : spawn_teams) { - if (groups_str.find(group_name) != std::string::npos) { - sp.team_id = team_id; - break; - } - } - - if (!is_spawnpoint) { - pos = spawn_pos + 1; - continue; // Not a spawnpoint - } - - - // Parse position - std::string position_string = "Transform3D("; - size_t position_pos = file_content.find(position_string, spawn_pos); - if (position_pos == std::string::npos) { - LOG_WARN("SpawnPoint missing position data"); - pos = spawn_pos + 1; - continue; - } - position_pos += position_string.length(); // Skip "Transform3D(" - size_t position_end = file_content.find(")", position_pos); - if (position_end == std::string::npos) { - LOG_WARN("SpawnPoint missing position data"); - pos = spawn_pos + 1; - continue; - } - - std::string position_data = file_content.substr(position_pos, position_end - position_pos); - // Parse all values from Transform3D(m00, m01, ..., m33) - // Godot Transform3D has 12 values: 3x3 matrix (9) + position (3) - // Position is in the last 3 values (indices 9, 10, 11 which are x, y, z) - std::vector matrix_vals; - size_t tpos = 0; - - // Parse all floats - while (tpos < position_data.length()) { - // Skip whitespace - while (tpos < position_data.length() && std::isspace(position_data[tpos])) { - tpos++; - } - - if (tpos >= position_data.length()) break; - - // Negative numbers - size_t start = tpos; - if (position_data[tpos] == '-') tpos++; - - // Find end of number - while (tpos < position_data.length() && (std::isdigit(position_data[tpos]) || position_data[tpos] == '.')) { - tpos++; - } - - // Add number to coords - std::string num_str = position_data.substr(start, tpos - start); - if (!num_str.empty()) { - try { - matrix_vals.push_back(std::stof(num_str)); - } catch (...) { - LOG_WARN("Failed to parse spawnpoint matrix value: %s", num_str.c_str()); - } - } - - // Skip comma if present - if (tpos < position_data.length() && position_data[tpos] == ',') { - tpos++; - } - } - - // Set spawnpoint position from Transform3D matrix - // Transform3D has 12 values: indices 9, 10, 11 are x, y, z translation - if (matrix_vals.size() >= 12) { - sp.position = Vec2(matrix_vals[9], matrix_vals[11]); // x and z coordinates - LOG_INFO("Parsed spawnpoint position: (%.1f, %.1f)", sp.position.x, sp.position.y); - } else { - LOG_WARN("SpawnPoint position data malformed - got %zu values instead of 12", matrix_vals.size()); - } - - spawnpoints.push_back(sp); - pos = spawn_pos + 1; - } - - return spawnpoints; -} - -std::vector MapSystem::parse_vertices_to_2D(const std::string& vertices_data_raw) { - std::vector vertices; - std::vector coords; - size_t pos = 0; - // Add all vertices to coords - while (pos < vertices_data_raw.length()) { - // Skip whitespace - while (pos < vertices_data_raw.length() && std::isspace(vertices_data_raw[pos])) { - pos++; - } - - if (pos >= vertices_data_raw.length()) break; - - // Negative numbers - size_t start = pos; - if (vertices_data_raw[pos] == '-') pos++; - - // Find end of number - while (pos < vertices_data_raw.length() && (std::isdigit(vertices_data_raw[pos]) || vertices_data_raw[pos] == '.')) { - pos++; - } - - // Add number to coords - std::string num_str = vertices_data_raw.substr(start, pos - start); - if (!num_str.empty()) { - try { - coords.push_back(std::stof(num_str)); - } catch (...) { - LOG_WARN("Failed to parse vertex coordinate: %s", num_str.c_str()); - } - } - - // Skip comma if present - if (pos < vertices_data_raw.length() && vertices_data_raw[pos] == ',') { - pos++; - } - } - - // Parse Vec2 from Vec3 data (expects X, Y, Z coordinates, we use X and Z) - for (size_t i = 0; i + 2 < coords.size(); i += 3) { - vertices.push_back(Vec2(coords[i], coords[i + 2])); - } - - return vertices; -} - -std::vector> MapSystem::parse_polygons(const std::string& polygons_data_raw) { - std::vector> polygons; - - // Parse PackedInt32Array(...) entries - constexpr size_t PACKED_ARRAY_PREFIX_LEN = 17; // Length of "PackedInt32Array(" - size_t pos = 0; - while (pos < polygons_data_raw.length()) { - // Find start of PackedInt32Array - size_t array_start = polygons_data_raw.find("PackedInt32Array(", pos); - if (array_start == std::string::npos) break; - - array_start += PACKED_ARRAY_PREFIX_LEN; - size_t array_end = polygons_data_raw.find(")", array_start); - if (array_end == std::string::npos) break; - - std::string array_str = polygons_data_raw.substr(array_start, array_end - array_start); - std::vector polygon; - - // Parse indices - size_t idx_pos = 0; - while (idx_pos < array_str.length()) { - // Skip whitespace and commas - while (idx_pos < array_str.length() && (std::isspace(array_str[idx_pos]) || array_str[idx_pos] == ',')) { - idx_pos++; - } - - if (idx_pos >= array_str.length()) break; - - // Find end of number - size_t start = idx_pos; - while (idx_pos < array_str.length() && std::isdigit(array_str[idx_pos])) { - idx_pos++; - } - - std::string num_str = array_str.substr(start, idx_pos - start); - if (!num_str.empty()) { - try { - polygon.push_back(std::stoul(num_str)); - } catch (...) { - LOG_WARN("Failed to parse polygon index: %s", num_str.c_str()); - } - } - } - - if (!polygon.empty()) { - polygons.push_back(polygon); - } - - pos = array_end + 1; - } - - LOG_DEBUG("Parsed %zu polygons", polygons.size()); - return polygons; -} - -std::vector MapSystem::serialize_map(Entity* map_entity) { - if (!map_entity) { - LOG_ERROR("Cannot serialize null map entity"); - return {}; - } - - Map* map = map_entity->get_component(); - if (!map) { - LOG_ERROR("Map entity has no Map component"); - return {}; - } - - // Calculate packet size - // 1 byte (type) + 4 bytes (name length) + name + 4 bytes (vertex count) + 4 bytes (polygon count) - // Vertex and Polygon data for verification of local asset (client-side) - uint32_t name_len = (uint32_t)(map->name.length()); - uint32_t vertex_count = (uint32_t)(map->vertices.size()); - uint32_t polygon_count = (uint32_t)(map->polygons.size()); - - size_t packet_size = 1 + 4 + name_len + 4 + 4; - - std::vector data(packet_size); - size_t offset = 0; - - // Write packet type - data[offset++] = (uint8_t)(PACKET_TYPE::MAP_LOAD); - - // Write map name length - std::memcpy(data.data() + offset, &name_len, sizeof(uint32_t)); - offset += 4; - - // Write map name - std::memcpy(data.data() + offset, map->name.c_str(), name_len); - offset += name_len; - - // Write vertex count - std::memcpy(data.data() + offset, &vertex_count, sizeof(uint32_t)); - offset += 4; - - // Write polygon count - std::memcpy(data.data() + offset, &polygon_count, sizeof(uint32_t)); - offset += 4; - - LOG_INFO("Serialized map '%s' with %zu vertices and %zu polygons into packet (size: %zu bytes)", - map->name.c_str(), map->vertices.size(), map->polygons.size(), packet_size); - - return data; -} - -Vec2 MapSystem::calculate_size_from_vertices(const std::vector& vertices) { - if (vertices.empty()) { - return Vec2(0.0f, 0.0f); - } - - float min_x = vertices[0].x; - float max_x = vertices[0].x; - float min_y = vertices[0].y; - float max_y = vertices[0].y; - - for (const auto& v : vertices) { - min_x = std::min(min_x, v.x); - max_x = std::max(max_x, v.x); - min_y = std::min(min_y, v.y); - max_y = std::max(max_y, v.y); - } - - return Vec2(max_x - min_x, max_y - min_y); -} \ No newline at end of file diff --git a/src/systems/map_system.hpp b/src/systems/map_system.hpp deleted file mode 100644 index 5a1a2e5..0000000 --- a/src/systems/map_system.hpp +++ /dev/null @@ -1,91 +0,0 @@ -#pragma once - -#include "entity_manager.hpp" -#include "math.hpp" -#include "packet_validator.hpp" -#include -#include -#include -#include - -#include - -enum spawn_type : uint8_t { - PLAYER_SPAWN, - MINION_SPAWN, - MONSTER_SPAWN, - CAMP_SPAWN, - OBJECTIVE_SPAWN -}; - -const std::unordered_map spawn_groups = { - {"player_spawn", spawn_type::PLAYER_SPAWN}, - {"minion_spawn", spawn_type::MINION_SPAWN}, - {"monster_spawn", spawn_type::MONSTER_SPAWN}, - {"camp_spawn", spawn_type::CAMP_SPAWN}, - {"objective_spawn", spawn_type::OBJECTIVE_SPAWN} -}; - -const std::unordered_map spawn_teams = { - {"team1", 1}, - {"team2", 2} -}; -/** - * System to load and manage game maps from Godot tscn files. - * Parses navmesh data and provides it to clients. - */ -class MapSystem { -public: - /** - * Data structure for navmesh information. - */ - struct NavMeshData { - std::vector vertices; - std::vector> polygons; - }; - - struct SpawnPoint { - Vec2 position; - uint8_t team_id; - spawn_type type; - }; - - /** - * Load a map from a Godot tscn file. - * If no path is provided or the file doesn't exist, scans the current directory for .tscn files. - * If no map file is found using either method, returns nullptr. - * @param map_name Name of the map (e.g., "Konda"). If empty, derived from filename. - * @param file_path Path to the .tscn file. If empty, scans current directory. - * @param entity_manager Reference to the entity manager - * @return Pointer to the created map entity, or nullptr if loading failed - */ - static std::optional load_map(const std::optional& file_path); - - /** - * Serialize map data to an ENet packet. - * Packet format: - * [0] - PACKET_TYPE::GAME_STATE - * [1-4] - name length (uint32_t, little-endian) - * [5+N] - map name (string) - * [5+N+0-3] - vertex count (uint32_t) - * [5+N+4+...] - vertices (3 floats each) - * [...-3] - polygon count (uint32_t) - * [...] - polygons (variable length) - * - * @param map_entity The map entity to serialize - * @return Vector with serialized map data, or empty vector on failure - */ - static std::vector serialize_map(Entity* map_entity); -private: - /** - * Parse a Godot tscn file and extract navmesh data. - * Looks for NavigationMesh subsection with vertices and polygons. - * @param file_content The contents of the tscn file - * @return NavMeshData with vertices and polygons, empty if parsing failed - */ - static NavMeshData parse_navmesh_from_tscn(const std::string& file_content); - static std::vector parse_spawnpoints_from_tscn(const std::string& file_content); - static std::vector parse_vertices_to_2D(const std::string& vertices_str); - static std::vector> parse_polygons(const std::string& polygons_str); - static Vec2 calculate_size_from_vertices(const std::vector& vertices); -}; diff --git a/src/systems/movement_system.cpp b/src/systems/movement_system.cpp deleted file mode 100644 index 1c88743..0000000 --- a/src/systems/movement_system.cpp +++ /dev/null @@ -1,186 +0,0 @@ -#include "movement_system.hpp" -#include "entity_manager.hpp" -#include "services/navigation_service.hpp" -#include -#include -#include -#include - -void MovementSystem::update(EntityManager& entity_manager, float delta_time, NavigationService* navigation_service, const Map* map) { - // Get all entities with movement components - auto moving_entities = entity_manager.get_entities_with_component(); - - for (auto* entity : moving_entities) { - Movement* movement = entity->get_component(); - PathfindingComponent* pathfinding = entity->get_component(); - Stats* stats = entity->get_component(); - - if (!movement || !stats) { - continue; - } - - // Update stuck detection for entities with pathfinding - if (pathfinding) { - float delta_time_ms = delta_time * 1000.0f; - - // Check if entity has moved since last frame - float movement_distance = distance(movement->position, pathfinding->last_position); - - if (movement_distance < 0.01f) { - // Entity hasn't moved - accumulate stuck time - pathfinding->stuck_time_ms += delta_time_ms; - - // If stuck too long and not already waiting for path, request new one - if (pathfinding->stuck_time_ms >= PathfindingComponent::STUCK_THRESHOLD_MS && - !pathfinding->is_waiting_for_path && - !pathfinding->waypoints.empty()) { - LOG_DEBUG("Minion %u is stuck, requesting new path", entity->get_id()); - if (navigation_service && map && pathfinding->target_spawnpoint_id < map->spawnpoints.size()) { - request_new_path(*entity, pathfinding->target_spawnpoint_id, navigation_service, map); - } - pathfinding->waypoints.clear(); - pathfinding->current_waypoint_index = 0; - } - } else { - // Entity moved - reset stuck timer - pathfinding->stuck_time_ms = 0.0f; - } - - // Update last position for next frame - pathfinding->last_position = movement->position; - } - - // If entity has pathfinding, move along the path (but not while waiting for new path) - if (pathfinding && !pathfinding->is_waiting_for_path && !pathfinding->waypoints.empty()) { - // Get current waypoint - if (pathfinding->current_waypoint_index >= pathfinding->waypoints.size()) { - // Reached end of path - request path to next spawnpoint if possible - if (map && pathfinding->target_spawnpoint_id < map->spawnpoints.size()) { - uint32_t next_spawnpoint = (pathfinding->target_spawnpoint_id + 1) % map->spawnpoints.size(); - if (navigation_service) { - request_new_path(*entity, next_spawnpoint, navigation_service, map); - } - } - pathfinding->waypoints.clear(); - pathfinding->current_waypoint_index = 0; - continue; - } - - Vec3 current_waypoint = pathfinding->waypoints[pathfinding->current_waypoint_index]; - Vec2 waypoint_2d = Vec2(current_waypoint.x, current_waypoint.z); - Vec2 current_pos = movement->position; - - // Calculate direction to waypoint - Vec2 direction = waypoint_2d - current_pos; - float distance = direction.length(); - - // Waypoint threshold for progress - constexpr float WAYPOINT_THRESHOLD = 0.5f; - - if (distance < WAYPOINT_THRESHOLD) { - // Reached waypoint, move to next one - pathfinding->current_waypoint_index++; - continue; - } - - // Normalize direction and apply speed - float move_speed = stats->move_speed; - float distance_to_move = move_speed * delta_time; - - Vec2 new_position; - if (distance_to_move >= distance) { - // Move directly to waypoint - new_position = waypoint_2d; - } else { - // Move towards waypoint - Vec2 normalized = direction.normalized(); - new_position = current_pos + (normalized * distance_to_move); - } - - // Check for collision with other entities - if (!has_collision(*entity, new_position, entity_manager)) { - movement->position = new_position; - } else { - // If direct path is blocked, try to move sideways to avoid collision - Vec2 perpendicular = Vec2(-direction.y, direction.x).normalized(); - Vec2 sideways_left = current_pos + (perpendicular * distance_to_move); - Vec2 sideways_right = current_pos + (perpendicular * -distance_to_move); - - if (!has_collision(*entity, sideways_left, entity_manager)) { - movement->position = sideways_left; - } else if (!has_collision(*entity, sideways_right, entity_manager)) { - movement->position = sideways_right; - } - // If both sideways moves are blocked, just don't move (will accumulate stuck time) - } - } - } -} - -bool MovementSystem::has_collision(const Entity& entity, const Vec2& proposed_position, EntityManager& entity_manager) { - const Movement* entity_move = entity.get_component(); - if (!entity_move) { - return false; - } - - float entity_radius = entity_move->collision_radius; - - // Check collision with all other moving entities - auto moving_entities = entity_manager.get_entities_with_component(); - for (auto* other_entity : moving_entities) { - if (other_entity->get_id() == entity.get_id()) { - continue; // Skip self - } - - const Movement* other_move = other_entity->get_component(); - if (!other_move) { - continue; - } - - float other_radius = other_move->collision_radius; - float min_distance = entity_radius + other_radius; - - float actual_distance = distance(proposed_position, other_move->position); - - if (actual_distance < min_distance) { - return true; // Collision detected - } - } - - return false; // No collision -} - -float MovementSystem::distance(const Vec2& a, const Vec2& b) { - float dx = a.x - b.x; - float dy = a.y - b.y; - return std::sqrt(dx * dx + dy * dy); -} - -void MovementSystem::request_new_path(Entity& entity, uint32_t target_spawnpoint_id, NavigationService* navigation_service, const Map* map) { - if (!navigation_service || !map || target_spawnpoint_id >= map->spawnpoints.size()) { - return; - } - - Movement* movement = entity.get_component(); - PathfindingComponent* pathfinding = entity.get_component(); - - if (!movement || !pathfinding) { - return; - } - - Vec3 start = Vec3(movement->position.x, 0.0f, movement->position.y); - Vec2 goal_2d = map->spawnpoints[target_spawnpoint_id]; - Vec3 goal = Vec3(goal_2d.x, 0.0f, goal_2d.y); - - PathRequest request; - request.entity_id = entity.get_id(); - request.current_position = start; - request.destination = goal; - request.entity_pathing_radius = 0.5f; - - if (navigation_service->MakeRequest(request)) { - pathfinding->is_waiting_for_path = true; - pathfinding->target_spawnpoint_id = target_spawnpoint_id; - LOG_DEBUG("Minion %u requested path to spawnpoint %u", entity.get_id(), target_spawnpoint_id); - } -} diff --git a/src/systems/movement_system.hpp b/src/systems/movement_system.hpp deleted file mode 100644 index 62cd95c..0000000 --- a/src/systems/movement_system.hpp +++ /dev/null @@ -1,72 +0,0 @@ -#pragma once - -#include "entity_manager.hpp" -#include "components/stats.hpp" -#include "components/movement.hpp" -#include "components/map.hpp" -#include "math.hpp" -#include - -// Forward declaration to avoid circular dependencies -class NavigationService; - -/** - * System to handle entity movement along pathfinding waypoints. - * Updates positions based on movement speed and follows path waypoints. - * Automatically requests new paths when reaching target spawnpoints. - */ -class MovementSystem { -public: - /** - * Update all moving entities. - * Moves entities toward their current waypoints and handles path progression. - * @param entity_manager Reference to the entity manager - * @param delta_time Time elapsed since last update in seconds - * @param navigation_service Optional pointer to navigation service for requesting new paths - * @param map Optional pointer to map for spawnpoint information - */ - void update(EntityManager& entity_manager, float delta_time, NavigationService* navigation_service = nullptr, const Map* map = nullptr); - -private: - /** - * Calculate distance between two 3D points. - * @param a First point - * @param b Second point - * @return Euclidean distance - */ - static float distance(const Vec3& a, const Vec3& b); - - /** - * Normalize a vector (make it unit length). - * @param v Vector to normalize - * @return Normalized vector - */ - static Vec3 normalize(const Vec3& v); - - /** - * Request a new path for an entity to a target spawnpoint. - * @param entity The entity requesting a path - * @param target_spawnpoint_id Target spawnpoint index - * @param navigation_service Navigation service to queue the request - * @param map Map containing spawnpoint information - */ - static void request_new_path(Entity& entity, uint32_t target_spawnpoint_id, NavigationService* navigation_service, const Map* map); - - /** - * Check for collision with other entities. - * @param entity The entity to check collisions for - * @param proposed_position The position the entity wants to move to - * @param entity_manager Reference to entity manager for checking other entities - * @return true if collision detected, false if path is clear - */ - static bool has_collision(const Entity& entity, const Vec2& proposed_position, EntityManager& entity_manager); - - /** - * Calculate distance between two points. - * @param a First point - * @param b Second point - * @return Distance - */ - static float distance(const Vec2& a, const Vec2& b); -}; - diff --git a/src/systems/navmesh_system.cpp b/src/systems/navmesh_system.cpp deleted file mode 100644 index c58d060..0000000 --- a/src/systems/navmesh_system.cpp +++ /dev/null @@ -1,38 +0,0 @@ -#include "navmesh_system.hpp" -#include "components/navmesh.hpp" -#include "components/component.hpp" -#include - -EntityID NavMeshSystem::map_entity_id_ = INVALID_ENTITY_ID; - -Entity& NavMeshSystem::initialize_map(EntityManager& entity_manager) { - LOG_INFO("Initializing NavMesh system"); - - // Create map entity - Entity& map_entity = entity_manager.create_entity(); - map_entity_id_ = map_entity.get_id(); - - // Create and attach navmesh component - auto navmesh = std::make_unique(); - navmesh->width = 100.0f; - navmesh->height = 20.0f; - navmesh->y_level = 0.0f; - - // Set up two spawnpoints at opposite ends of the navmesh - // Spawnpoint 0: at x=0 (left side) - navmesh->spawnpoints.push_back(Vec3(0.0f, 0.0f, 10.0f)); - - // Spawnpoint 1: at x=100 (right side) - navmesh->spawnpoints.push_back(Vec3(100.0f, 0.0f, 10.0f)); - - map_entity.add_component(std::move(navmesh)); - - LOG_INFO("Map entity created with ID %u", map_entity_id_); - LOG_INFO("NavMesh: 100x20, Spawnpoint 0: (0, 0, 10), Spawnpoint 1: (100, 0, 10)"); - - return map_entity; -} - -EntityID NavMeshSystem::get_map_entity_id() { - return map_entity_id_; -} diff --git a/src/systems/navmesh_system.hpp b/src/systems/navmesh_system.hpp deleted file mode 100644 index bcda608..0000000 --- a/src/systems/navmesh_system.hpp +++ /dev/null @@ -1,26 +0,0 @@ -#pragma once - -#include "entity_manager.hpp" - -/** - * System to initialize and manage the map and its navmesh. - * Creates a map entity with a NavMesh component and sets up spawnpoints. - */ -class NavMeshSystem { -public: - /** - * Initialize the navmesh system and create the map entity. - * @param entity_manager Reference to the entity manager - * @return Reference to the created map entity - */ - static Entity& initialize_map(EntityManager& entity_manager); - - /** - * Get the map entity ID if it exists. - * @return Map entity ID, or INVALID_ENTITY_ID if not initialized - */ - static EntityID get_map_entity_id(); - -private: - static EntityID map_entity_id_; -}; diff --git a/src/systems/network_sync_system.cpp b/src/systems/network_sync_system.cpp deleted file mode 100644 index f54f28e..0000000 --- a/src/systems/network_sync_system.cpp +++ /dev/null @@ -1,24 +0,0 @@ -#include "network_sync_system.hpp" -#include "serialization_system.hpp" -#include "components/movement.hpp" -#include - -void NetworkSyncSystem::update(EntityManager& entity_manager, NetworkService* network_service) { - if (!network_service) { - return; - } - - // Get all entities with movement components and broadcast their positions - auto moving_entities = entity_manager.get_entities_with_component(); - - for (auto* entity : moving_entities) { - Movement* move_comp = entity->get_component(); - if (move_comp) { - std::vector packet = SerializationSystem::serialize_entity_position( - entity->get_id(), - move_comp->position - ); - network_service->broadcast_packet(packet); - } - } -} diff --git a/src/systems/network_sync_system.hpp b/src/systems/network_sync_system.hpp deleted file mode 100644 index 95cfe87..0000000 --- a/src/systems/network_sync_system.hpp +++ /dev/null @@ -1,19 +0,0 @@ -#pragma once - -#include "entity_manager.hpp" -#include "services/network_service.hpp" - -/** - * System responsible for synchronizing entity state to connected clients. - * Handles broadcasting entity positions and state changes to maintain - * consistent game state across all clients. - */ -class NetworkSyncSystem { -public: - /** - * Update and broadcast entity state to all clients. - * @param entity_manager Reference to entity manager for querying entities - * @param network_service Pointer to network service for broadcasting - */ - void update(EntityManager& entity_manager, NetworkService* network_service); -}; diff --git a/src/systems/serialization_system.cpp b/src/systems/serialization_system.cpp deleted file mode 100644 index 50f9867..0000000 --- a/src/systems/serialization_system.cpp +++ /dev/null @@ -1,87 +0,0 @@ -#include "serialization_system.hpp" - -std::vector SerializationSystem::serialize_packet(PACKET_TYPE packet_type) { - std::vector packet_data; - packet_data.push_back(static_cast(packet_type)); - return packet_data; -} - -std::vector SerializationSystem::serialize_packet(PACKET_TYPE packet_type, const std::string& data) { - // Verify data size fits in uint16_t - if (data.size() > uint16_t(-1)) { - return std::vector(); // Return empty vector on error - } - - std::vector packet_data; - packet_data.push_back(static_cast(packet_type)); - - // 2 bytes for length (Big-endian) - uint16_t data_length = static_cast(data.size()); - serialize_uint16_be(data_length, packet_data); - - // Add data - packet_data.insert(packet_data.end(), data.begin(), data.end()); - - return packet_data; -} - -std::vector SerializationSystem::serialize_entity_position(uint32_t entity_id, const Vec2& position) { - std::vector packet_data; - packet_data.push_back(static_cast(PACKET_TYPE::ENTITY_POSITION)); - - // Entity ID (4 bytes, little-endian) - serialize_uint32(entity_id, packet_data); - - // Position X (4 bytes float, little-endian) - serialize_float(position.x, packet_data); - - // Position Y (4 bytes float, little-endian) - serialize_float(position.y, packet_data); - - return packet_data; -} - -std::vector SerializationSystem::serialize_entity_spawn(uint32_t entity_id, const Vec2& position, uint8_t team_id, const std::string& entity_type) { - std::vector packet_data; - packet_data.push_back(static_cast(PACKET_TYPE::ENTITY_SPAWN)); - - // Entity ID (4 bytes, little-endian) - serialize_uint32(entity_id, packet_data); - - // Position X (4 bytes float, little-endian) - serialize_float(position.x, packet_data); - - // Position Y (4 bytes float, little-endian) - serialize_float(position.y, packet_data); - - // Team ID (1 byte) - packet_data.push_back(team_id); - - // Entity type string length (4 bytes, little-endian) - uint32_t type_length = static_cast(entity_type.length()); - serialize_uint32(type_length, packet_data); - - // Entity type string data - for (char c : entity_type) { - packet_data.push_back(static_cast(c)); - } - - return packet_data; -} - -void SerializationSystem::serialize_uint32(uint32_t value, std::vector& output) { - output.push_back((value & 0xFF)); - output.push_back((value >> 8) & 0xFF); - output.push_back((value >> 16) & 0xFF); - output.push_back((value >> 24) & 0xFF); -} - -void SerializationSystem::serialize_uint16_be(uint16_t value, std::vector& output) { - output.push_back((value >> 8) & 0xFF); - output.push_back(value & 0xFF); -} - -void SerializationSystem::serialize_float(float value, std::vector& output) { - uint32_t int_value = reinterpret_cast(value); - serialize_uint32(int_value, output); -} diff --git a/src/systems/system_context.hpp b/src/systems/system_context.hpp new file mode 100644 index 0000000..bdce8c8 --- /dev/null +++ b/src/systems/system_context.hpp @@ -0,0 +1,79 @@ +#pragma once + +#include +#include "entity_manager.hpp" + +// Forward declarations to avoid circular dependencies +class NavigationService; +class NetworkService; +class InputSystem; +struct Map; + +/** + * SystemContext - Unified context for all game systems + * + * Contains references to the entity manager and all required services. + * Passed to all system update methods instead of varying parameters. + * + * BENEFITS: + * - Single, extensible context structure + * - No need to modify system signatures when adding new services + * - Clear documentation of system dependencies + * - Easier to mock for testing + * + * USAGE: + * SystemContext ctx{ + * .entity_manager = entity_manager_, + * .navigation_service = navigation_service_.get(), + * .network_service = &network_service_, + * .map = map_pointer_.get(), + * .delta_time_ms = delta_time_ms + * }; + * + * movement_system_.update(ctx); + * wave_system_.update(ctx); + * network_sync_system_.update(ctx); + */ +struct SystemContext { + // === ECS Core === + EntityManager& entity_manager; // Required: access to all entities + + // === Optional Services === + InputSystem* input_system = nullptr; // For processing player input + NavigationService* navigation_service = nullptr; // For pathfinding requests + NetworkService* network_service = nullptr; // For network broadcasting + const Map* map = nullptr; // For map data queries + + // === Timing === + float delta_time_ms = 0.0f; // Frame delta time in milliseconds + + // === Constructor === + SystemContext(EntityManager& em) + : entity_manager(em) {} + + // === Convenience Methods === + + /** + * Check if navigation service is available. + * @return true if navigation_service is not nullptr + */ + bool has_navigation_service() const { + return navigation_service != nullptr; + } + + /** + * Check if network service is available. + * @return true if network_service is not nullptr + */ + bool has_network_service() const { + return network_service != nullptr; + } + + /** + * Check if map data is available. + * @return true if map is not nullptr + */ + bool has_map() const { + return map != nullptr; + } +}; diff --git a/src/systems/combat_system.cpp b/src/systems/util/combat_calculator.cpp similarity index 82% rename from src/systems/combat_system.cpp rename to src/systems/util/combat_calculator.cpp index 67efd53..ec6f923 100644 --- a/src/systems/combat_system.cpp +++ b/src/systems/util/combat_calculator.cpp @@ -1,9 +1,12 @@ -#include "combat_system.hpp" -#include +#include +#include +#include +#include #include #include +#include -std::vector CombatSystem::apply_damage( +std::vector CombatCalculator::apply_damage( EntityManager& entity_manager, EntityID attacker_id, EntityID target_id, @@ -21,6 +24,12 @@ std::vector CombatSystem::apply_damage( Stats* target_stats = target_entity->get_component(); + // Prevent overkill damage on already-dead entities + if (target_stats->health <= 0.0f) { + LOG_DEBUG("Combat: Attempting to damage already-dead entity %u, ignoring", target_id); + return events; + } + // Get attacker entity and stats (for lifesteal/spell vamp) Entity* attacker_entity = entity_manager.get_entity(attacker_id); Stats* attacker_stats = nullptr; @@ -64,6 +73,22 @@ std::vector CombatSystem::apply_damage( float actual_damage = reduced_damage; target_stats->health -= actual_damage; + // Clamp health to never go below 0 + if (target_stats->health < 0.0f) { + target_stats->health = 0.0f; + } + + // Update entity state if dead + if (target_stats->health <= 0.0f) { + if (auto* entity_state = target_entity->get_component()) { + entity_state->current_state = EntityState::DEAD; + } + // Mark network entity as changed so death state syncs to clients + if (auto* network_entity = target_entity->get_component()) { + network_entity->force_full_sync_next_frame = true; + } + } + // Calculate lifesteal/spell vamp if (attacker_stats) { float lifesteal_amount = 0.0f; @@ -111,7 +136,7 @@ std::vector CombatSystem::apply_damage( return events; } -bool CombatSystem::apply_heal( +bool CombatCalculator::apply_heal( EntityManager& entity_manager, EntityID target_id, float heal_amount) @@ -132,7 +157,7 @@ bool CombatSystem::apply_heal( return true; } -bool CombatSystem::restore_mana( +bool CombatCalculator::restore_mana( EntityManager& entity_manager, EntityID target_id, float mana_amount) @@ -153,20 +178,7 @@ bool CombatSystem::restore_mana( return true; } -bool CombatSystem::is_dead( - EntityManager& entity_manager, - EntityID entity_id) const -{ - Entity* entity = entity_manager.get_entity(entity_id); - if (!entity || !entity->has_component()) { - return true; - } - - const Stats* stats = entity->get_component(); - return stats->health <= 0.0f; -} - -float CombatSystem::calculate_damage_reduction(float base_damage, int resistance) const +float CombatCalculator::calculate_damage_reduction(float base_damage, int resistance) const { // Damage reduction formula: damage * (100 / (100 + resistance)) // This ensures that 100 resistance reduces damage by 50%, 200 resistance by 66%, etc. @@ -177,7 +189,7 @@ float CombatSystem::calculate_damage_reduction(float base_damage, int resistance return base_damage * (100.0f / (100.0f + resistance)); } -float CombatSystem::calculate_critical_hit(const Stats& attacker) +float CombatCalculator::calculate_critical_hit(const Stats& attacker) { // Random number between 0 and 99 int roll = std::rand() % 100; diff --git a/src/systems/combat_system.hpp b/src/systems/util/combat_calculator.hpp similarity index 69% rename from src/systems/combat_system.hpp rename to src/systems/util/combat_calculator.hpp index fcfdd68..50d95dc 100644 --- a/src/systems/combat_system.hpp +++ b/src/systems/util/combat_calculator.hpp @@ -1,15 +1,45 @@ #pragma once #include "entity_manager.hpp" +#include "system_context.hpp" #include "components/stats.hpp" #include /** - * Generic combat system for all entities with Stats components. - * Handles damage calculation, critical hits, and various damage types. + * CombatCalculator - Handles damage calculation and application + * + * RESPONSIBILITIES: + * - Damage calculation and application + * - Critical hit calculation + * - Armor/resist/mitigation calculations + * - Lifesteal and spell vamp application + * - Entity death state management + * + * DATA-DRIVEN DESIGN: + * This system is purely data-driven. It only applies damage, healing, and + * mana restoration based on external input (from skills, auto-attacks, etc). + * Auto-attack behavior, cooldown management, and target selection are handled + * by the CombatSystem. + * + * COMPONENTS USED: + * - Stats (base damage, armor, resist, health) + * - EntityStateComponent (entity state tracking) + * - NetworkEntityComponent (for syncing death state) + * + * SYSTEM INTERACTION: + * - Works with NetworkSyncSystem to broadcast combat effects + * - CombatSystem calls apply_damage to execute attacks + * - Triggered by other systems for damage/healing events + * - Coordinates with GameplayCoordinator for execution order */ -class CombatSystem { +class CombatCalculator { public: + /** + * Update all combat for this frame. + * The combat calculator is data-driven - actual combat is triggered externally. + * @param ctx System context with entity manager, services, and delta time + */ + /** * Structure representing damage information for combat events. */ @@ -67,17 +97,6 @@ class CombatSystem { float mana_amount ); - /** - * Check if an entity is dead (health <= 0). - * @param entity_manager Reference to entity manager - * @param entity_id Entity ID to check - * @return true if entity is dead or doesn't exist, false otherwise - */ - bool is_dead( - EntityManager& entity_manager, - EntityID entity_id - ) const; - private: /** * Calculate damage reduction based on armor or magic resist. diff --git a/src/systems/util/component_utility.hpp b/src/systems/util/component_utility.hpp new file mode 100644 index 0000000..1e5d4da --- /dev/null +++ b/src/systems/util/component_utility.hpp @@ -0,0 +1,192 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/** + * ComponentUtility - Pure utility functions for component logic. + * + * ECS PRINCIPLE: Components are pure data containers. + * Systems query and modify components, but the logic belongs in systems/utilities. + * + * This utility provides helper functions to replace methods that were + * previously on components, following ECS principles strictly. + */ +class ComponentUtility { +public: + // ======================================================================== + // NetworkMetadataComponent Utilities + // ======================================================================== + + /** + * Check if player connection is stale (no activity for timeout_ms). + * @param metadata NetworkMetadataComponent to check + * @param timeout_ms Timeout in milliseconds (default: 30 seconds) + * @return true if last activity exceeds timeout + */ + static bool is_metadata_stale(const NetworkMetadataComponent& metadata, + unsigned int timeout_ms = 30000) { + auto now = std::chrono::steady_clock::now(); + auto elapsed = std::chrono::duration_cast( + now - metadata.last_activity); + return elapsed.count() > (long long)timeout_ms; + } + + /** + * Update network metadata last activity timestamp to current time. + * Call this whenever the player sends a packet. + * @param metadata NetworkMetadataComponent to update + */ + static void update_metadata_activity(NetworkMetadataComponent& metadata) { + metadata.last_activity = std::chrono::steady_clock::now(); + } + + // ======================================================================== + // NetworkEntityComponent Utilities + // ======================================================================== + + /** + * Check if entity position has changed significantly since last sync. + * @param net_comp NetworkEntityComponent to check + * @param current_position Current entity position + * @return true if position change is significant + */ + static bool has_network_position_changed(const NetworkEntityComponent& net_comp, + const Vec2& current_position) { + float dx = current_position.x - net_comp.last_synced_position.x; + float dy = current_position.y - net_comp.last_synced_position.y; + float dist_sq = dx * dx + dy * dy; + return dist_sq >= (NetworkEntityComponent::MIN_POSITION_CHANGE * + NetworkEntityComponent::MIN_POSITION_CHANGE); + } + + /** + * Check if entity state has changed since last sync. + * @param net_comp NetworkEntityComponent to check + * @param current_state Current entity state + * @return true if state has changed + */ + static bool has_network_state_changed(const NetworkEntityComponent& net_comp, + EntityState current_state) { + return current_state != net_comp.last_synced_state; + } + + /** + * Check if any stats have changed since last sync. + * Directly compares current stats against cached version. + * @param net_comp NetworkEntityComponent to check + * @param current_stats Current stats component + * @return true if stats have changed + */ + static bool has_network_stats_changed(const NetworkEntityComponent& net_comp, + const Stats& current_stats) { + if (!net_comp.last_synced_stats) { + return true; // No cached stats, so it's a change + } + // Simple field-by-field comparison of critical fields + return current_stats.health != net_comp.last_synced_stats->health || + current_stats.max_health != net_comp.last_synced_stats->max_health || + current_stats.mana != net_comp.last_synced_stats->mana || + current_stats.max_mana != net_comp.last_synced_stats->max_mana || + current_stats.level != net_comp.last_synced_stats->level; + } + + /** + * Mark entity as synced with the given state (updates component data). + * @param net_comp NetworkEntityComponent to update + * @param position Current position + * @param state Current state + * @param stats Current stats (for change detection) + * @param current_frame Frame number + */ + static void mark_network_entity_synced(NetworkEntityComponent& net_comp, + const Vec2& position, + EntityState state, + const Stats* stats, + uint32_t current_frame) { + net_comp.last_synced_position = position; + net_comp.last_synced_state = state; + if (stats) { + net_comp.last_synced_stats = *stats; + } + net_comp.last_sync_frame = current_frame; + net_comp.force_full_sync_next_frame = false; + } + + // ======================================================================== + // TargetComponent Utilities + // ======================================================================== + + /** + * Set a specific target on the target component. + * @param target_comp TargetComponent to update + * @param target_id Entity ID to target + */ + static void set_target(TargetComponent& target_comp, EntityID target_id) { + target_comp.current_target = target_id; + target_comp.has_target = (target_id != INVALID_ENTITY_ID); + target_comp.last_target_search_ms = 0.0f; + } + + /** + * Clear current target from the target component. + * @param target_comp TargetComponent to update + */ + static void clear_target(TargetComponent& target_comp) { + target_comp.current_target = INVALID_ENTITY_ID; + target_comp.has_target = false; + target_comp.target_in_range = false; + target_comp.distance_to_target = 0.0f; + } + + /** + * Check if should search for new target. + * @param target_comp TargetComponent to check + * @param current_time_ms Current game time + * @return true if enough time has passed since last search + */ + static bool should_search_for_target(const TargetComponent& target_comp, + float current_time_ms) { + return (current_time_ms - target_comp.last_target_search_ms) >= + target_comp.target_search_interval_ms; + } + + /** + * Mark that target search was performed. + * @param target_comp TargetComponent to update + * @param current_time_ms Current game time + */ + static void mark_target_searched(TargetComponent& target_comp, float current_time_ms) { + target_comp.last_target_search_ms = current_time_ms; + } + + /** + * Check if current target is still valid. + * @param target_comp TargetComponent to check + * @return true if target exists and is in range + */ + static bool is_target_valid(const TargetComponent& target_comp) { + return target_comp.has_target && target_comp.current_target != INVALID_ENTITY_ID && + target_comp.distance_to_target <= target_comp.target_loss_range; + } + + /** + * Update distance to target (called by systems when calculating distance). + * @param target_comp TargetComponent to update + * @param distance Current distance in game units + * @param attack_range Attack range threshold + */ + static void update_target_distance(TargetComponent& target_comp, + float distance, + float attack_range) { + target_comp.distance_to_target = distance; + target_comp.target_in_range = (distance <= attack_range); + } +}; diff --git a/src/systems/util/data_loader.cpp b/src/systems/util/data_loader.cpp new file mode 100644 index 0000000..bb4a93b --- /dev/null +++ b/src/systems/util/data_loader.cpp @@ -0,0 +1,632 @@ +#include + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/** + * Convert string to DamageType enum. + * @param str The string to convert (case-insensitive) + * @return DamageType enum value, defaults to PHYSICAL if unrecognized + */ +static DamageType string_to_damage_type(const std::string& str) { + if (str == "magical" || str == "magic") { + return DamageType::MAGICAL; + } else if (str == "true_damage" || str == "true") { + return DamageType::TRUE_DAMAGE; + } + // Default to PHYSICAL for unrecognized or empty strings + return DamageType::PHYSICAL; +} + +/** + * Convert string to NPCType enum. + * @param str The string to convert (case-insensitive) + * @return NPCType enum value, defaults to MINION if unrecognized + */ +static NPCType string_to_npc_type(const std::string& str) { + if (str == "minion") { + return NPCType::MINION; + } + // Default to MINION for unrecognized or empty strings + return NPCType::MINION; +} + +/** + * Convert string to IntentType enum. + * @param str The string to convert (case-insensitive) + * @return IntentType enum value, defaults to NONE if unrecognized + */ +static IntentType string_to_intent_type(const std::string& str) { + if (str == "move_to_position") { + return IntentType::MOVE_TO_POSITION; + } else if (str == "attack_target") { + return IntentType::ATTACK_TARGET; + } + // Default to NONE for unrecognized or empty strings + return IntentType::NONE; +} + +/** + * Convenience macro. Loads the attribute from the node if the node has it, and sets it in the component. + * @param node A pugi::xml_node which (maybe) has the attribute + * @param comp The Component in which to set the value of the attribute + * @param attr The attribute to load. Must match exactly both the attribute name in the xml and the property in the Component. + * @param as pugixml can load attributes as a number of different types, for example string, float or int + */ +#define LOAD_ATTRIBUTE(node, comp, attr, as) \ + { \ + pugi::xml_attribute loaded = node.attribute(#attr); \ + if(!loaded.empty()) { \ + comp->attr = loaded.as_##as(); \ + } \ + } + +/** + * Convenience macro for loading enum attributes from XML. + * @param node A pugi::xml_node which (maybe) has the attribute + * @param comp The Component in which to set the value of the attribute + * @param attr The attribute to load. Must match exactly both the attribute name in the xml and the property in the Component. + * @param converter A function that converts string to the enum type + */ +#define LOAD_ENUM_ATTRIBUTE(node, comp, attr, converter) \ + { \ + pugi::xml_attribute loaded = node.attribute(#attr); \ + if(!loaded.empty()) { \ + comp->attr = converter(loaded.as_string()); \ + } \ + } + + +EntityTemplate DataLoader::load_entity_template(std::string file_name) { + EntityTemplate temp; + + // NOTE: Component type IDs should match those defined in src/component_registry.hpp + // See ComponentTypes namespace for authoritative type ID assignments. + + // TODO should probably not let pugi load the file for us; thinking .pak files etc... - ploinky 14/11/2025 + pugi::xml_document doc; + pugi::xml_parse_status status = doc.load_file(file_name.c_str()).status; + if(status != pugi::xml_parse_status::status_ok) { + LOG_ERROR("Failed to load entity from data file: %s, pugixml status is %d", file_name.c_str(), status); + return temp; + } + + pugi::xml_node rootNode = doc.child("entity"); + if(rootNode == NULL) { + LOG_ERROR("Failed to load entity from data file: %s, rootNode 'entity' is missing", file_name.c_str()); + return temp; + } + + std::string id = rootNode.attribute("id").as_string(); + if(id.empty()) { + LOG_ERROR("Failed to load entity from data file: %s, rootNode 'entity' is missing id attribute", file_name.c_str()); + return temp; + } + temp.id = id; + + pugi::xml_node movementNode = rootNode.child("movement"); + if(!movementNode.empty()) { + std::shared_ptr movement = std::make_shared(); + temp.component_templates.push_back(movement); + } + + pugi::xml_node pathfindingNode = rootNode.child("pathfinding"); + if(!pathfindingNode.empty()) { + std::shared_ptr pathfinding = std::make_shared(); + temp.component_templates.push_back(pathfinding); + } + + pugi::xml_node networkNode = rootNode.child("network"); + if(!networkNode.empty()) { + std::shared_ptr network = std::make_shared(); + network->force_full_sync_next_frame = true; + temp.component_templates.push_back(network); + } + + pugi::xml_node stateNode = rootNode.child("state"); + if(!stateNode.empty()) { + std::shared_ptr state = std::make_shared(); + temp.component_templates.push_back(state); + } + + pugi::xml_node npc_node = rootNode.child("npc"); + if(!npc_node.empty()) { + std::shared_ptr npc = std::make_shared(); + LOAD_ENUM_ATTRIBUTE(npc_node, npc, npc_type, string_to_npc_type) + LOAD_ATTRIBUTE(npc_node, npc, aggression_range, float) + LOAD_ATTRIBUTE(npc_node, npc, chase_distance, float) + temp.component_templates.push_back(npc); + } + + pugi::xml_node meta_node = rootNode.child("meta"); + if(!meta_node.empty()) { + std::shared_ptr meta = std::make_shared(); + LOAD_ATTRIBUTE(meta_node, meta, name, string) + LOAD_ATTRIBUTE(meta_node, meta, description, string) + LOAD_ATTRIBUTE(meta_node, meta, icon, string) + LOAD_ATTRIBUTE(meta_node, meta, model, string) + temp.component_templates.push_back(meta); + } + + pugi::xml_node target_node = rootNode.child("target"); + if(!target_node.empty()) { + std::shared_ptr target = std::make_shared(); + LOAD_ATTRIBUTE(target_node, target, target_search_interval_ms, float) + temp.component_templates.push_back(target); + } + + pugi::xml_node attack_node = rootNode.child("attack"); + if(!attack_node.empty()) { + std::shared_ptr attack = std::make_shared(); + temp.component_templates.push_back(attack); + } + + pugi::xml_node statsNode = rootNode.child("stats"); + if(!statsNode.empty()) { + std::shared_ptr stats = std::make_shared(); + + LOAD_ATTRIBUTE(statsNode, stats, max_health, float) + LOAD_ATTRIBUTE(statsNode, stats, health, float) + LOAD_ATTRIBUTE(statsNode, stats, max_mana, float) + LOAD_ATTRIBUTE(statsNode, stats, mana, float) + LOAD_ATTRIBUTE(statsNode, stats, move_speed, float) + LOAD_ATTRIBUTE(statsNode, stats, level, int) + + LOAD_ATTRIBUTE(statsNode, stats, attack_range, float) + LOAD_ATTRIBUTE(statsNode, stats, attack_speed, float) + // TODO what to do about enums (like DamageType auto_damage_type)? - ploinky 14/11/2025 + // | + // This v -- cmkrist 16/11/2025 + LOAD_ENUM_ATTRIBUTE(statsNode, stats, auto_damage_type, string_to_damage_type) + LOAD_ATTRIBUTE(statsNode, stats, crit_chance, float) + LOAD_ATTRIBUTE(statsNode, stats, crit_bonus, int) + LOAD_ATTRIBUTE(statsNode, stats, true_bonus, int) + LOAD_ATTRIBUTE(statsNode, stats, magic_power, float) + LOAD_ATTRIBUTE(statsNode, stats, physical_power, float) + LOAD_ATTRIBUTE(statsNode, stats, projectile_speed, float) + + LOAD_ATTRIBUTE(statsNode, stats, armor, int) + LOAD_ATTRIBUTE(statsNode, stats, magic_resist, int) + LOAD_ATTRIBUTE(statsNode, stats, dodge, int) + + LOAD_ATTRIBUTE(statsNode, stats, health_regen, float) + LOAD_ATTRIBUTE(statsNode, stats, mana_regen, float) + LOAD_ATTRIBUTE(statsNode, stats, life_steal, float) + LOAD_ATTRIBUTE(statsNode, stats, spell_vamp, float) + LOAD_ATTRIBUTE(statsNode, stats, omni_vamp, float) + LOAD_ATTRIBUTE(statsNode, stats, leech, float) + LOAD_ATTRIBUTE(statsNode, stats, vision_range, float) + + LOG_DEBUG("Loaded Stats component: health=%f, max_health=%f, move_speed=%f", + stats->health, stats->max_health, stats->move_speed); + temp.component_templates.push_back(stats); + } + + pugi::xml_node intent_node = rootNode.child("intent"); + if(!intent_node.empty()) { + std::shared_ptr intent = std::make_shared(); + // Parse the initial intent if one is defined + std::string intent_type_str = intent_node.attribute("type").as_string(""); + if (!intent_type_str.empty()) { + intent->type = string_to_intent_type(intent_type_str); + + // Load optional intent parameters + LOAD_ENUM_ATTRIBUTE(intent_node, intent, type, string_to_intent_type) + + LOG_DEBUG("Loaded Intent component: type=%d", static_cast(intent->type)); + } + temp.component_templates.push_back(intent); + } + + LOG_INFO("Entity Template Loaded: \"%s\" || %d components", temp.id.c_str(), temp.component_templates.size()); + return temp; +} + +std::pair, std::vector>> DataLoader::load_navmesh_obj(const std::string& nav_file_path) { + std::vector vertices; + std::vector> polygons; + + std::ifstream file(nav_file_path); + if (!file.is_open()) { + LOG_ERROR("Failed to open navmesh OBJ file: %s", nav_file_path.c_str()); + return { vertices, polygons }; + } + + std::string line; + uint32_t line_number = 0; + + while (std::getline(file, line)) { + line_number++; + + // Skip empty lines and comments + if (line.empty() || line[0] == '#') { + continue; + } + + std::istringstream iss(line); + std::string prefix; + iss >> prefix; + + // Parse vertex (v x y z) + if (prefix == "v") { + float x, y, z; + if (!(iss >> x >> y >> z)) { + LOG_ERROR("Failed to parse vertex at line %u in: %s", line_number, nav_file_path.c_str()); + continue; + } + // Store as Vec2, using X and Z coordinates (Y is elevation) + vertices.push_back(Vec2(x, z)); + } + // Parse face (f v1 v2 v3 ...) + else if (prefix == "f") { + std::vector polygon; + std::string vertex_str; + + while (iss >> vertex_str) { + // OBJ indices are 1-based, convert to 0-based + uint32_t vertex_index = 0; + + // Handle formats: v, v/vt, v/vt/vn, v//vn + size_t first_slash = vertex_str.find('/'); + if (first_slash != std::string::npos) { + vertex_index = std::stoul(vertex_str.substr(0, first_slash)) - 1; + } else { + vertex_index = std::stoul(vertex_str) - 1; + } + + // Validate vertex index + if (vertex_index >= vertices.size()) { + LOG_ERROR("Invalid vertex index %u at line %u in: %s (only %zu vertices defined)", + vertex_index + 1, line_number, nav_file_path.c_str(), vertices.size()); + continue; + } + + polygon.push_back(vertex_index); + } + + if (polygon.size() >= 3) { + polygons.push_back(polygon); + } else if (!polygon.empty()) { + LOG_ERROR("Face with fewer than 3 vertices at line %u in: %s", line_number, nav_file_path.c_str()); + } + } + } + + file.close(); + + LOG_INFO("Loaded navmesh from %s: %zu polygons, %zu vertices", + nav_file_path.c_str(), polygons.size(), vertices.size()); + + return { vertices, polygons }; +} + +std::optional DataLoader::load_map(const std::string& map_name, const std::string& map_dir) { + Map map; + + // Verify map directory exists + if (!std::filesystem::exists(map_dir)) { + LOG_ERROR("Map directory does not exist: %s", map_dir.c_str()); + return std::nullopt; + } + + // Construct file paths + std::string xml_path = map_dir + map_name + ".xml"; + std::string nav_path = map_dir + map_name + ".nav.obj"; + + // Check both files exist + if (!std::filesystem::exists(xml_path)) { + LOG_ERROR("Map XML file not found: %s", xml_path.c_str()); + return std::nullopt; + } + + if (!std::filesystem::exists(nav_path)) { + LOG_ERROR("Map navmesh file not found: %s", nav_path.c_str()); + return std::nullopt; + } + + // Load XML metadata + pugi::xml_document doc; + pugi::xml_parse_status status = doc.load_file(xml_path.c_str()).status; + if (status != pugi::xml_parse_status::status_ok) { + LOG_ERROR("Failed to parse map XML file: %s, pugixml status is %d", xml_path.c_str(), status); + return std::nullopt; + } + + pugi::xml_node map_node = doc.child("map"); + if (map_node == NULL) { + LOG_ERROR("Map XML file missing 'map' root element: %s", xml_path.c_str()); + return std::nullopt; + } + + // Extract map name + std::string map_id = map_node.attribute("id").as_string(""); + if (map_id.empty()) { + map_id = map_name; // Fallback to provided map name + } + map.name = map_id; + + // Load navmesh OBJ data + auto [vertices, polygons] = load_navmesh_obj(nav_path); + if (vertices.empty() || polygons.empty()) { + LOG_ERROR("Failed to load navmesh data from: %s", nav_path.c_str()); + return std::nullopt; + } + + map.vertices = vertices; + map.polygons = polygons; + + // Parse spawn points from XML (if they exist) + pugi::xml_node spawn_points_node = map_node.child("spawn_points"); + if (!spawn_points_node.empty()) { + for (pugi::xml_node spawn = spawn_points_node.child("spawn"); spawn; spawn = spawn.next_sibling("spawn")) { + pugi::xml_node pos_node = spawn.child("position"); + if (!pos_node.empty()) { + float x = pos_node.attribute("x").as_float(0.0f); + float z = pos_node.attribute("z").as_float(0.0f); + map.spawnpoints.push_back(Vec2(x, z)); + LOG_DEBUG("Loaded spawn point: (%.1f, %.1f)", x, z); + } + } + } + + // Parse structures from XML (if they exist) + pugi::xml_node structures_node = map_node.child("structures"); + if (!structures_node.empty()) { + for (pugi::xml_node structure = structures_node.child("structure"); structure; structure = structure.next_sibling("structure")) { + MapStructure struct_data; + + // Load attributes + struct_data.id = structure.attribute("id").as_string(""); + struct_data.type = structure.attribute("type").as_string(""); + struct_data.team = structure.attribute("team").as_uint(0); + + // Load position + pugi::xml_node pos_node = structure.child("position"); + if (!pos_node.empty()) { + struct_data.position.x = pos_node.attribute("x").as_float(0.0f); + struct_data.position.y = pos_node.attribute("y").as_float(0.0f); + struct_data.position.z = pos_node.attribute("z").as_float(0.0f); + } + + // Load rotation + pugi::xml_node rot_node = structure.child("rotation"); + if (!rot_node.empty()) { + struct_data.rotation.x = rot_node.attribute("x").as_float(0.0f); + struct_data.rotation.y = rot_node.attribute("y").as_float(0.0f); + struct_data.rotation.z = rot_node.attribute("z").as_float(0.0f); + } + + // Load scale + pugi::xml_node scale_node = structure.child("scale"); + if (!scale_node.empty()) { + struct_data.scale.x = scale_node.attribute("x").as_float(1.0f); + struct_data.scale.y = scale_node.attribute("y").as_float(1.0f); + struct_data.scale.z = scale_node.attribute("z").as_float(1.0f); + } else { + struct_data.scale = Vec3(1.0f, 1.0f, 1.0f); // Default scale to 1.0 + } + + map.structures.push_back(struct_data); + LOG_DEBUG("Loaded structure '%s' of type '%s' (team %u) at (%.1f, %.1f, %.1f)", + struct_data.id.c_str(), struct_data.type.c_str(), struct_data.team, + struct_data.position.x, struct_data.position.y, struct_data.position.z); + } + } + + // Calculate map bounds and derived size/offset from vertices + if (!vertices.empty()) { + float min_x = vertices[0].x, max_x = vertices[0].x; + float min_y = vertices[0].y, max_y = vertices[0].y; + + for (const auto& v : vertices) { + min_x = std::min(min_x, v.x); + max_x = std::max(max_x, v.x); + min_y = std::min(min_y, v.y); + max_y = std::max(max_y, v.y); + } + + map.size = Vec2(max_x - min_x, max_y - min_y); + map.offset = Vec2((min_x + max_x) / 2.0f, (min_y + max_y) / 2.0f); + } + + // Build the polygon grid for spatial acceleration + build_polygon_grid(map); + + LOG_INFO("Loaded map '%s' from XML: %zu vertices, %zu polygons, %zu spawnpoints, %zu structures. Grid: %dx%d cells", + map.name.c_str(), map.vertices.size(), map.polygons.size(), map.spawnpoints.size(), + map.structures.size(), map.grid_width, map.grid_height); + + return std::optional(map); +} + +std::vector DataLoader::list_files_from_directory(std::string path, std::string file_ending) { + std::vector file_names; + // Verify path exists + if(!std::filesystem::exists(path)) { + LOG_ERROR("DataLoader::list_files_from_directory: Path %s does not exist!", path.c_str()); + return file_names; + } + + for(const std::filesystem::directory_entry& entry : std::filesystem::directory_iterator(path)) { + // Recursively list files in subdirectories + if(entry.exists() && entry.is_directory()) { + std::vector subdirectory_files = list_files_from_directory(entry.path().string(), file_ending); + file_names.insert(file_names.end(), subdirectory_files.begin(), subdirectory_files.end()); + } + + if(entry.exists() && entry.is_regular_file() && !entry.path().extension().compare(file_ending)) { + file_names.push_back(entry.path().string()); + } + } + + return file_names; +} + +void DataLoader::build_polygon_grid(Map& map) { + map.grid_cell_size = 1.0f; // Default cell size + // Require at least some vertices and polygons + if (map.vertices.empty() || map.polygons.empty()) { + LOG_WARN("build_polygon_grid: Invalid input (vertices: %zu, polygons: %zu, cell_size: %.1f)", + map.vertices.size(), map.polygons.size(), map.grid_cell_size); + return; + } + + // Find bounds of all vertices + Vec2 min_bounds = map.vertices[0]; + Vec2 max_bounds = map.vertices[0]; + + for (const auto& vertex : map.vertices) { + min_bounds.x = std::min(min_bounds.x, vertex.x); + min_bounds.y = std::min(min_bounds.y, vertex.y); + max_bounds.x = std::max(max_bounds.x, vertex.x); + max_bounds.y = std::max(max_bounds.y, vertex.y); + } + + // Add padding to bounds to handle edge cases + const float PADDING = map.grid_cell_size * 0.5f; + min_bounds.x -= PADDING; + min_bounds.y -= PADDING; + max_bounds.x += PADDING; + max_bounds.y += PADDING; + + map.grid_origin = min_bounds; + + // Calculate grid dimensions + Vec2 bounds_size = max_bounds - min_bounds; + map.grid_width = std::max(1, static_cast(std::ceil(bounds_size.x / map.grid_cell_size))); + map.grid_height = std::max(1, static_cast(std::ceil(bounds_size.y / map.grid_cell_size))); + + // Initialize grid: grid_cells[y * grid_width + x] = {polygon_ids} + map.grid_cells.resize(map.grid_width * map.grid_height); + + // Helper lambda to get polygon bounds + auto GetPolygonBounds = [&](const std::vector& polygon, Vec2& out_min, Vec2& out_max) { + if (polygon.empty()) { + out_min = Vec2(0.0f, 0.0f); + out_max = Vec2(0.0f, 0.0f); + return; + } + + uint32_t first_idx = polygon[0]; + if (first_idx >= map.vertices.size()) { + out_min = Vec2(0.0f, 0.0f); + out_max = Vec2(0.0f, 0.0f); + return; + } + + out_min = map.vertices[first_idx]; + out_max = map.vertices[first_idx]; + + for (uint32_t vertex_idx : polygon) { + if (vertex_idx >= map.vertices.size()) { + continue; + } + + const Vec2& vertex = map.vertices[vertex_idx]; + out_min.x = std::min(out_min.x, vertex.x); + out_min.y = std::min(out_min.y, vertex.y); + out_max.x = std::max(out_max.x, vertex.x); + out_max.y = std::max(out_max.y, vertex.y); + } + }; + + // Helper lambda to convert world position to grid coordinates + auto WorldToGridCoords = [&](const Vec2& world_pos, int& out_x, int& out_y) { + Vec2 relative_pos = world_pos - map.grid_origin; + + out_x = static_cast(std::floor(relative_pos.x / map.grid_cell_size)); + out_y = static_cast(std::floor(relative_pos.y / map.grid_cell_size)); + + // Clamp to valid grid range + out_x = std::max(0, std::min(out_x, map.grid_width - 1)); + out_y = std::max(0, std::min(out_y, map.grid_height - 1)); + }; + + // Helper lambda: point-in-polygon test using ray casting + auto PointInPolygon = [&](const Vec2& point, const std::vector& polygon) -> bool { + if (polygon.size() < 3) return false; + + int intersections = 0; + size_t n = polygon.size(); + + for (size_t i = 0; i < n; ++i) { + uint32_t idx1 = polygon[i]; + uint32_t idx2 = polygon[(i + 1) % n]; + + if (idx1 >= map.vertices.size() || idx2 >= map.vertices.size()) { + continue; + } + + const Vec2& v1 = map.vertices[idx1]; + const Vec2& v2 = map.vertices[idx2]; + + // Check if horizontal ray from point intersects edge + if ((v1.y <= point.y && point.y < v2.y) || (v2.y <= point.y && point.y < v1.y)) { + // Compute x-intersection of ray with edge + float x_intersect = v1.x + (point.y - v1.y) * (v2.x - v1.x) / (v2.y - v1.y); + if (point.x < x_intersect) { + intersections++; + } + } + } + + return intersections % 2 == 1; + }; + + // Add each polygon to grid cells it overlaps + for (uint32_t poly_id = 0; poly_id < map.polygons.size(); ++poly_id) { + Vec2 poly_min, poly_max; + GetPolygonBounds(map.polygons[poly_id], poly_min, poly_max); + + // Convert polygon bounds to grid coordinates + int min_x, min_y, max_x, max_y; + WorldToGridCoords(poly_min, min_x, min_y); + WorldToGridCoords(poly_max, max_x, max_y); + + // Add polygon ID to grid cells only if the center of the cell is inside or touches the polygon + for (int y = min_y; y <= max_y; ++y) { + for (int x = min_x; x <= max_x; ++x) { + // Calculate cell center in world coordinates + Vec2 cell_center = map.grid_origin + Vec2( + (x + 0.5f) * map.grid_cell_size, + (y + 0.5f) * map.grid_cell_size + ); + + // Only add polygon to this cell if its center is inside the polygon + if (PointInPolygon(cell_center, map.polygons[poly_id])) { + int index = y * map.grid_width + x; + map.grid_cells[index].push_back(poly_id); + } + } + } + } + + // Log statistics + size_t total_entries = 0; + int non_empty_cells = 0; + for (const auto& cell : map.grid_cells) { + if (!cell.empty()) { + non_empty_cells++; + total_entries += cell.size(); + } + } + + LOG_INFO("Polygon grid built: %d non-empty cells, %.1f polygons per cell average", + non_empty_cells, non_empty_cells > 0 ? static_cast(total_entries) / non_empty_cells : 0.0f); +} + diff --git a/src/systems/util/data_loader.hpp b/src/systems/util/data_loader.hpp new file mode 100644 index 0000000..f8fa7ca --- /dev/null +++ b/src/systems/util/data_loader.hpp @@ -0,0 +1,68 @@ +#pragma once + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +/** + * Template for creating new entities. + */ +class EntityTemplate { +public: + std::string id; + std::vector> component_templates; +}; + +/** + * Loads data (e.g. entities, maps) from xml and binary files + */ +class DataLoader { +public: + /** + * Create a new entity tempalte based on data from a file. + * @param file_name The name of the file from which to load the entity template + * @return The newly created entity template + */ + static EntityTemplate load_entity_template(std::string file_name); + + /** + * Load a map from XML and .nav files. + * @param map_name The name of the map (e.g., "konda") + * @param map_dir The directory containing map files (default: "./data/maps/") + * @return Optional Map component, or empty if loading failed + */ + static std::optional load_map(const std::string& map_name, const std::string& map_dir = "./data/maps/"); + + /** + * List all files with a matching file ending in a directory. + * @param path The directory to search + * @param file_ending The file ending to match for, e.g. ".xml" + * @return A vector of file names found in the directory, e.g. "path/file-name.xml" + */ + static std::vector list_files_from_directory(std::string path, std::string file_ending); + +private: + /** + * Load navmesh data from a .nav.obj OBJ file. + * Parses standard OBJ vertex (v) and face (f) definitions. + * @param nav_file_path Path to the .nav.obj file + * @return A pair of (vertices, polygons), empty if loading failed + */ + static std::pair, std::vector>> load_navmesh_obj(const std::string& nav_file_path); + + /** + * Build a spatial acceleration grid for polygon queries. + * Creates a 2D grid where each cell contains polygon IDs that intersect it. + * @param map The map to populate grid data into + */ + static void build_polygon_grid(Map& map); +}; \ No newline at end of file diff --git a/src/systems/util/packet_handler.cpp b/src/systems/util/packet_handler.cpp new file mode 100644 index 0000000..a03f178 --- /dev/null +++ b/src/systems/util/packet_handler.cpp @@ -0,0 +1,111 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +PacketHandler::PacketHandler(PlayerManager* player_manager, + EntityManager* entity_manager, + NetworkService* network_service, + InputSystem* input_system) + : player_manager_(player_manager) + , entity_manager_(entity_manager) + , network_service_(network_service) + , input_system_(input_system) { +} + +void PacketHandler::handle_packet(const std::string& client_id, const uint8_t* data, size_t length) { + // Validate packet structure + if (!PacketValidator::validate_packet(data, length)) { + LOG_WARN("Invalid packet received from %s", client_id.c_str()); + return; + } + + // Extract packet type + PACKET_TYPE packet_type = (PACKET_TYPE)(data[0]); + + // Update player activity + EntityID player_entity_id = player_manager_->get_player_entity_id(client_id); + if (player_entity_id != INVALID_ENTITY_ID) { + Entity* player_entity = entity_manager_->get_entity(player_entity_id); + if (player_entity) { + auto* metadata = player_entity->get_component(); + if (metadata) { + ComponentUtility::update_metadata_activity(*metadata); + } + } + } + + // Dispatch to appropriate handler + switch (packet_type) { + case PACKET_TYPE::PLAYER_READY: + handle_player_ready_packet(client_id, data, length); + break; + case PACKET_TYPE::PLAYER_MOVE: + handle_player_move_packet(client_id, data, length); + break; + default: + handle_other_packets(client_id, (uint8_t)packet_type, data, length); + break; + } +} + +bool PacketHandler::handle_player_ready_packet(const std::string& client_id, const uint8_t* data, size_t length) { + bool is_ready = false; + if (!PacketValidator::extract_ready_status(data, length, is_ready)) { + LOG_WARN("Failed to extract ready status from packet"); + return false; + } + + // Update player readiness via PlayerManager + if (!player_manager_->set_player_ready(client_id, is_ready, *entity_manager_)) { + LOG_WARN("Failed to set player ready status for client: %s", client_id.c_str()); + return false; + } + + LOG_INFO("Player %s is now %s", client_id.c_str(), is_ready ? "ready" : "not ready"); + + // Note: Game start transition logic should be handled by GameServer/GameplayCoordinator + // This handler just processes the packet and updates state + + return true; +} + +bool PacketHandler::handle_player_move_packet(const std::string& client_id, const uint8_t* data, size_t length) { + std::optional target_position = PacketValidator::extract_move_target_position(data, length); + + if(!target_position.has_value()) { + LOG_WARN("Received invalid PLAYER_MOVE packet"); + return false; + } + + EntityID ent_id = player_manager_->get_champion_entity_id(player_manager_->get_player_entity_id(client_id), *entity_manager_); + Entity* entity = entity_manager_->get_entity(ent_id); + + if(!entity) { + LOG_WARN("Received valid PLAYER_MOVE packet but client %s does not have a valid entity", client_id); + return false; + } + + // Queue the movement input through InputSystem + if (input_system_) { + input_system_->queue_movement_input(ent_id, target_position.value()); + LOG_DEBUG("Queued movement input for entity %u from client %s", ent_id, client_id.c_str()); + return true; + } else { + LOG_ERROR("InputSystem not initialized"); + return false; + } +} + +bool PacketHandler::handle_other_packets(const std::string& client_id, uint8_t packet_type, + const uint8_t* data, size_t length) { + LOG_WARN("Unhandled packet type: %d from client: %s", (int)packet_type, client_id.c_str()); + return false; +} diff --git a/src/systems/util/packet_handler.hpp b/src/systems/util/packet_handler.hpp new file mode 100644 index 0000000..7b9dede --- /dev/null +++ b/src/systems/util/packet_handler.hpp @@ -0,0 +1,87 @@ +#pragma once + +#include +#include + +class EntityManager; +class PlayerManager; +class NetworkService; +class InputSystem; + +/** + * PacketHandler - Centralized packet processing + * + * RESPONSIBILITIES: + * - Validate incoming packets + * - Dispatch to appropriate handlers + * - Update player state based on packets + * - Coordinate with managers + * + * DOES NOT: + * - Manage player state directly (use PlayerManager) + * - Manage entities directly (use EntityManager) + * - Handle networking (use NetworkService) + * + * USAGE: + * PacketHandler handler(&player_mgr, &entity_mgr, &net_service); + * handler.handle_packet(client_id, packet_data, packet_length); + */ +class PacketHandler { +public: + /** + * Constructor + * @param player_manager Pointer to PlayerManager for player state updates + * @param entity_manager Pointer to EntityManager for entity access + * @param network_service Pointer to NetworkService for packet broadcasting + * @param input_system Pointer to InputSystem for queueing player input + */ + PacketHandler(PlayerManager* player_manager, + EntityManager* entity_manager, + NetworkService* network_service, + InputSystem* input_system); + + /** + * Process an incoming packet from a client. + * Validates, dispatches, and handles the packet appropriately. + * @param client_id Source client identifier + * @param data Packet bytes + * @param length Packet length in bytes + */ + void handle_packet(const std::string& client_id, const uint8_t* data, size_t length); + +private: + PlayerManager* player_manager_; + EntityManager* entity_manager_; + NetworkService* network_service_; + InputSystem* input_system_; + + /** + * Handle PLAYER_READY packet. + * Updates player readiness state and checks for game start condition. + * @param client_id Source client identifier + * @param data Packet bytes + * @param length Packet length in bytes + * @return true if handled successfully + */ + bool handle_player_ready_packet(const std::string& client_id, const uint8_t* data, size_t length); + + /** + * Handle PLAYER_MOVE packet. + * Finds the Players entity and moves it towards the position supplied by the player. + * @param client_id Source client identifier + * @param data Packet bytes + * @param length Packet length in bytes + * @return true if handled successfully + */ + bool handle_player_move_packet(const std::string& client_id, const uint8_t* data, size_t length); + /** + * Handle other packet types (extensible for future packets). + * @param client_id Source client identifier + * @param packet_type Type of packet received + * @param data Packet bytes + * @param length Packet length in bytes + * @return true if handled successfully + */ + bool handle_other_packets(const std::string& client_id, uint8_t packet_type, + const uint8_t* data, size_t length); +}; diff --git a/src/systems/packet_validator.hpp b/src/systems/util/packet_validator.hpp similarity index 64% rename from src/systems/packet_validator.hpp rename to src/systems/util/packet_validator.hpp index 190a66d..bebd525 100644 --- a/src/systems/packet_validator.hpp +++ b/src/systems/util/packet_validator.hpp @@ -2,15 +2,21 @@ #include #include +#include +#include +#include /** * Packet type enumeration for network communication. */ +// TODO: Move packet types to xml definition for synchronization with client +// For now, update the NetworkManager in the client project as well. enum class PACKET_TYPE : uint8_t { // Engine reserved packet types GAME_START, GAME_STATE, GAME_TIME, + LOBBY_FULL, MAP_SPAWN, MAP_LOAD, // Spawn packets @@ -18,9 +24,14 @@ enum class PACKET_TYPE : uint8_t { // Update packets ENTITY_POSITION, ENTITY_STATS, + ENTITY_STATE, + // Combat packets + COMBAT_EVENT, // Player related packets PLAYER_READY, PLAYER_DISCONNECT, + // Player actions + PLAYER_MOVE }; /** @@ -38,10 +49,18 @@ class PacketValidator { switch (type) { case PACKET_TYPE::PLAYER_READY: return 2; // type (1) + ready_status (1) + case PACKET_TYPE::PLAYER_MOVE: + return 5; // type (1) + position_x (4) + position_y (4) case PACKET_TYPE::ENTITY_POSITION: return 13; // type (1) + entity_id (4) + position_x (4) + position_y (4) case PACKET_TYPE::ENTITY_SPAWN: return 19; // type (1) + entity_id (4) + position_x (4) + position_y (4) + team_id (1) + type_string_length (4) + type_string_data (variable) + case PACKET_TYPE::ENTITY_STATS: + return 25; // type (1) + entity_id (4) + health (4) + max_health (4) + mana (4) + max_mana (4) + level (4) + case PACKET_TYPE::ENTITY_STATE: + return 6; // type (1) + entity_id (4) + state_value (1) + case PACKET_TYPE::COMBAT_EVENT: + return 18; // type (1) + attacker_id (4) + target_id (4) + damage (4) + damage_type (1) + was_critical (1) + padding (2) case PACKET_TYPE::GAME_START: case PACKET_TYPE::GAME_STATE: case PACKET_TYPE::PLAYER_DISCONNECT: @@ -98,4 +117,26 @@ class PacketValidator { out_ready = (packet_data[1] != 0); return true; } + + /** + * Extract and validate player move target from packet. + * @param packet_data Pointer to packet data + * @param packet_length Length of packet data + * @return Vec2 if the target position was extracted, nullopt otherwise + */ + static std::optional extract_move_target_position(const uint8_t* packet_data, size_t packet_length) { + if(!validate_packet(packet_data, packet_length)) { + return std::nullopt; + } + + if((PACKET_TYPE)(packet_data[0]) != PACKET_TYPE::PLAYER_MOVE) { + return std::nullopt; + } + + Vec2 vec{}; + memcpy(&vec.x, packet_data + 1, 4); // offset 1 (after packet type) + memcpy(&vec.y, packet_data + 5, 4); + + return std::make_optional(vec); + } }; diff --git a/src/systems/util/player_manager.cpp b/src/systems/util/player_manager.cpp new file mode 100644 index 0000000..cb02441 --- /dev/null +++ b/src/systems/util/player_manager.cpp @@ -0,0 +1,226 @@ +#include +#include +#include +#include +#include +#include +#include + +EntityID PlayerManager::on_client_connect(const std::string& client_id, EntityManager& entity_manager) { + // Check if already connected + if (client_to_entity_.find(client_id) != client_to_entity_.end()) { + LOG_WARN("Client %s already connected", client_id.c_str()); + return INVALID_ENTITY_ID; + } + + // Create player entity + EntityID player_entity_id = create_player_entity(client_id, entity_manager); + + if (player_entity_id == INVALID_ENTITY_ID) { + LOG_ERROR("Failed to create player entity for client %s", client_id.c_str()); + return INVALID_ENTITY_ID; + } + + // Map client to entity + client_to_entity_[client_id] = player_entity_id; + + LOG_INFO("Player connected: client_id=%s, entity_id=%u, total_players=%zu", + client_id.c_str(), player_entity_id, client_to_entity_.size()); + + return player_entity_id; +} + +bool PlayerManager::on_client_disconnect(const std::string& client_id, EntityManager& entity_manager) { + auto it = client_to_entity_.find(client_id); + if (it == client_to_entity_.end()) { + LOG_WARN("Disconnect request for unknown client: %s", client_id.c_str()); + return false; + } + + EntityID player_entity_id = it->second; + + // Find and destroy the champion owned by this player + EntityID champion_id = get_champion_entity_id(player_entity_id, entity_manager); + if (champion_id != INVALID_ENTITY_ID) { + if (!entity_manager.destroy_entity(champion_id)) { + LOG_WARN("Failed to destroy champion entity %u", champion_id); + } + } + + // Destroy player entity + if (!entity_manager.destroy_entity(player_entity_id)) { + LOG_ERROR("Failed to destroy player entity %u", player_entity_id); + } + + // Remove mapping + client_to_entity_.erase(it); + + LOG_INFO("Player disconnected: client_id=%s, player=%u, champion=%u, remaining=%zu", + client_id.c_str(), player_entity_id, champion_id, client_to_entity_.size()); + + return true; +} + +bool PlayerManager::set_player_ready(const std::string& client_id, bool is_ready, EntityManager& entity_manager) { + auto it = client_to_entity_.find(client_id); + if (it == client_to_entity_.end()) { + LOG_WARN("Set ready for unknown client: %s", client_id.c_str()); + return false; + } + + EntityID player_entity_id = it->second; + Entity* player_entity = entity_manager.get_entity(player_entity_id); + if (!player_entity) { + LOG_ERROR("Player entity not found: %u", player_entity_id); + return false; + } + + ReadinessComponent* readiness = player_entity->get_component(); + if (!readiness) { + LOG_ERROR("Player entity missing ReadinessComponent: %u", player_entity_id); + return false; + } + + readiness->is_ready = is_ready; + + LOG_INFO("Player %s is now %s", client_id.c_str(), is_ready ? "ready" : "not ready"); + + return true; +} + +bool PlayerManager::are_all_players_ready(EntityManager& entity_manager) const { + if (client_to_entity_.empty()) { + return false; + } + + // Query all entities with ReadinessComponent + auto ready_entities = entity_manager.get_entities_with_component(); + + if (ready_entities.size() != client_to_entity_.size()) { + LOG_WARN("Readiness check: not all players have ReadinessComponent (%zu entities vs %zu players)", + ready_entities.size(), client_to_entity_.size()); + return false; + } + + // Check if all are marked ready + for (const auto* entity : ready_entities) { + const auto* readiness = entity->get_component(); + if (!readiness || !readiness->is_ready) { + return false; + } + } + + return true; +} + +bool PlayerManager::is_full(int max_clients) const { + return (int)client_to_entity_.size() >= max_clients; +} + +size_t PlayerManager::get_player_count() const { + return client_to_entity_.size(); +} + +EntityID PlayerManager::get_player_entity_id(const std::string& client_id) const { + auto it = client_to_entity_.find(client_id); + if (it == client_to_entity_.end()) { + return INVALID_ENTITY_ID; + } + return it->second; +} + +EntityID PlayerManager::get_champion_entity_id(EntityID player_entity_id, EntityManager& entity_manager) const { + // Find champion owned by this player + auto champions = entity_manager.get_entities_with_component(); + for (auto* champion : champions) { + auto* player_owned = champion->get_component(); + if (player_owned && player_owned->owning_player_id == player_entity_id) { + return champion->get_id(); + } + } + return INVALID_ENTITY_ID; +} + +std::vector PlayerManager::get_all_player_entities() const { + std::vector result; + for (const auto& [client_id, entity_id] : client_to_entity_) { + result.push_back(entity_id); + } + return result; +} + +void PlayerManager::update_player_latency(const std::string& client_id, EntityManager& entity_manager, unsigned int latency_ms) { + auto it = client_to_entity_.find(client_id); + if (it == client_to_entity_.end()) { + return; + } + + Entity* player_entity = entity_manager.get_entity(it->second); + if (!player_entity) { + return; + } + + auto* metadata = player_entity->get_component(); + if (metadata) { + metadata->latency_ms = latency_ms; + ComponentUtility::update_metadata_activity(*metadata); + } +} + +void PlayerManager::reset_all_players(EntityManager& entity_manager) { + for (auto* entity : entity_manager.get_entities_with_component()) { + auto* readiness = entity->get_component(); + if (readiness) { + readiness->is_ready = false; + } + } +} + +void PlayerManager::clear() { + client_to_entity_.clear(); + next_player_id_ = 0; +} + +EntityID PlayerManager::create_player_entity(const std::string& client_id, EntityManager& entity_manager) { + // Create player entity from template + Entity& player_entity = entity_manager.create_entity_from_template("player"); + EntityID player_id = player_entity.get_id(); + + // Set client info + auto client_comp = std::make_unique(); + client_comp->client_id = client_id; + client_comp->player_id = next_player_id_++; + player_entity.add_component(std::move(client_comp)); + + // Ensure readiness component exists + if (!player_entity.has_component()) { + auto readiness_comp = std::make_unique(); + player_entity.add_component(std::move(readiness_comp)); + } + + // Ensure network metadata exists + if (!player_entity.has_component()) { + auto metadata_comp = std::make_unique(); + player_entity.add_component(std::move(metadata_comp)); + } + + // Create champion entity from template + Entity& champion_entity = entity_manager.create_entity_from_template("champion"); + EntityID champion_id = champion_entity.get_id(); + + // Link champion to player + auto player_owned_comp = std::make_unique(); + player_owned_comp->owning_player_id = player_id; + champion_entity.add_component(std::move(player_owned_comp)); + + // Set champion team ID so minions don't target it (use 255 = neutral observer) + auto* champion_stats = champion_entity.get_component(); + if (champion_stats) { + champion_stats->team_id = 255; // Neutral observer - not a valid combat target + } + + LOG_INFO("Created player (ID %u) with champion (ID %u) for client %s", + player_id, champion_id, client_id.c_str()); + + return player_id; +} diff --git a/src/systems/util/player_manager.hpp b/src/systems/util/player_manager.hpp new file mode 100644 index 0000000..5b30431 --- /dev/null +++ b/src/systems/util/player_manager.hpp @@ -0,0 +1,151 @@ +#pragma once + +#include +#include +#include +#include +#include + +/** + * PlayerManager - Manages all player entities and their lifecycle + * + * RESPONSIBILITIES: + * - Map client_id to player entity IDs + * - Handle player connection/disconnection + * - Query player readiness + * - Track active players + * + * DOES NOT: + * - Directly manage entity components (use EntityManager) + * - Handle network communication (use NetworkService) + * - Manage game state (use GameplayCoordinator) + * + * USAGE: + * PlayerManager player_mgr; + * EntityID player_id = player_mgr.on_client_connect("client123", entity_manager); + * player_mgr.set_player_ready("client123", true, entity_manager); + * if (player_mgr.are_all_players_ready(entity_manager)) { + * // Start game + * } + */ +class PlayerManager { +public: + PlayerManager() = default; + ~PlayerManager() = default; + + // Prevent copying + PlayerManager(const PlayerManager&) = delete; + PlayerManager& operator=(const PlayerManager&) = delete; + + // Allow moving + PlayerManager(PlayerManager&&) = default; + PlayerManager& operator=(PlayerManager&&) = default; + + /** + * Register a new player entity on client connection. + * Creates a player entity from the "player" template and applies + * ClientInfoComponent, ReadinessComponent, and NetworkMetadataComponent. + * @param client_id Network client identifier + * @param entity_manager The entity manager (passed for component creation) + * @return Entity ID of the created player entity, or INVALID_ENTITY_ID on failure + */ + EntityID on_client_connect(const std::string& client_id, EntityManager& entity_manager); + + /** + * Unregister a player entity on client disconnection. + * Destroys the player entity and removes all player tracking. + * @param client_id Network client identifier + * @param entity_manager The entity manager (passed for entity destruction) + * @return true if player was found and removed, false if unknown client + */ + bool on_client_disconnect(const std::string& client_id, EntityManager& entity_manager); + + /** + * Mark a player as ready or not ready. + * Updates the ReadinessComponent for the player. + * @param client_id Network client identifier + * @param is_ready New readiness state + * @param entity_manager Entity manager for updating component + * @return true if player was found and updated, false if unknown client + */ + bool set_player_ready(const std::string& client_id, bool is_ready, EntityManager& entity_manager); + + /** + * Check if all connected players are ready. + * Queries all entities with ReadinessComponent and verifies all are marked ready. + * @param entity_manager Entity manager for querying components + * @return true if all players have is_ready = true, false if any are not ready or no players connected + */ + bool are_all_players_ready(EntityManager& entity_manager) const; + + /** + * Check if lobby is at capacity. + * @param max_clients Maximum allowed players + * @return true if player count >= max_clients + */ + bool is_full(int max_clients) const; + + /** + * Get number of connected players. + * @return Count of players currently connected + */ + size_t get_player_count() const; + + /** + * Get player entity ID by client ID. + * @param client_id Network client identifier + * @return Entity ID, or INVALID_ENTITY_ID if not found + */ + EntityID get_player_entity_id(const std::string& client_id) const; + + /** + * Get champion entity ID by player entity ID. + * @param player_entity_id Player entity ID + * @return Entity ID, or INVALID_ENTITY_ID if not found + */ + EntityID get_champion_entity_id(EntityID player_entity_id, EntityManager& entity_manager) const; + + /** + * Get all connected player entity IDs. + * @return Vector of entity IDs for all connected players + */ + std::vector get_all_player_entities() const; + + /** + * Update player connection metadata (latency, last activity). + * Updates the NetworkMetadataComponent for the player. + * @param client_id Network client identifier + * @param entity_manager Entity manager for updating component + * @param latency_ms Measured latency in milliseconds + */ + void update_player_latency(const std::string& client_id, EntityManager& entity_manager, unsigned int latency_ms); + + /** + * Reset all players to not-ready state. + * Used for transitioning between game states (e.g., end of game). + * @param entity_manager Entity manager for resetting components + */ + void reset_all_players(EntityManager& entity_manager); + + /** + * Clear all player tracking (typically on shutdown). + * Removes all client_id to entity mappings but does NOT destroy entities. + * Entities will be destroyed separately via EntityManager. + */ + void clear(); + +private: + // Map from client_id to player entity ID + std::map client_to_entity_; + + // Track next player ID for assignment + uint32_t next_player_id_ = 0; + + /** + * Helper: Create player entity from template and apply all player components. + * @param client_id Network client identifier + * @param entity_manager Entity manager for entity creation + * @return Entity ID of created player, or INVALID_ENTITY_ID on failure + */ + EntityID create_player_entity(const std::string& client_id, EntityManager& entity_manager); +}; diff --git a/src/systems/util/serialization_system.cpp b/src/systems/util/serialization_system.cpp new file mode 100644 index 0000000..a4bfa31 --- /dev/null +++ b/src/systems/util/serialization_system.cpp @@ -0,0 +1,178 @@ +#include +#include + +std::vector SerializationSystem::serialize_packet(PACKET_TYPE packet_type) { + std::vector packet_data; + packet_data.push_back(static_cast(packet_type)); + return packet_data; +} + +std::vector SerializationSystem::serialize_packet(PACKET_TYPE packet_type, const std::string& data) { + // Verify data size fits in uint16_t + if (data.size() > uint16_t(-1)) { + return std::vector(); // Return empty vector on error + } + + std::vector packet_data; + packet_data.push_back(static_cast(packet_type)); + + // 2 bytes for length (Big-endian) + uint16_t data_length = static_cast(data.size()); + serialize_uint16_be(data_length, packet_data); + + // Add data + packet_data.insert(packet_data.end(), data.begin(), data.end()); + + return packet_data; +} + +std::vector SerializationSystem::serialize_entity_position(uint32_t entity_id, const Vec2& position) { + std::vector packet_data; + packet_data.push_back(static_cast(PACKET_TYPE::ENTITY_POSITION)); + + // Entity ID (4 bytes, little-endian) + serialize_uint32(entity_id, packet_data); + + // Position X (4 bytes float, little-endian) + serialize_float(position.x, packet_data); + + // Position Y (4 bytes float, little-endian) + serialize_float(position.y, packet_data); + + return packet_data; +} + +std::vector SerializationSystem::serialize_entity_spawn(uint32_t entity_id, const Vec2& position, uint8_t team_id, const std::string& entity_type) { + std::vector packet_data; + packet_data.push_back(static_cast(PACKET_TYPE::ENTITY_SPAWN)); + + // Entity ID (4 bytes, little-endian) + serialize_uint32(entity_id, packet_data); + + // Position X (4 bytes float, little-endian) + serialize_float(position.x, packet_data); + + // Position Y (4 bytes float, little-endian) + serialize_float(position.y, packet_data); + + // Team ID (1 byte) + packet_data.push_back(team_id); + + // Entity type string length (4 bytes, little-endian) + uint32_t type_length = static_cast(entity_type.length()); + serialize_uint32(type_length, packet_data); + + // Entity type string data + for (char c : entity_type) { + packet_data.push_back(static_cast(c)); + } + + return packet_data; +} + +void SerializationSystem::serialize_uint32(uint32_t value, std::vector& output) { + output.push_back((value & 0xFF)); + output.push_back((value >> 8) & 0xFF); + output.push_back((value >> 16) & 0xFF); + output.push_back((value >> 24) & 0xFF); +} + +void SerializationSystem::serialize_uint16_be(uint16_t value, std::vector& output) { + output.push_back((value >> 8) & 0xFF); + output.push_back(value & 0xFF); +} + +void SerializationSystem::serialize_float(float value, std::vector& output) { + uint32_t int_value = reinterpret_cast(value); + serialize_uint32(int_value, output); +} + +std::vector SerializationSystem::serialize_entity_stats(uint32_t entity_id, const Stats& stats) { + std::vector packet_data; + packet_data.push_back(static_cast(PACKET_TYPE::ENTITY_STATS)); + + // Entity ID (4 bytes, little-endian) + serialize_uint32(entity_id, packet_data); + + // Health (4 bytes float, little-endian) + serialize_float(stats.health, packet_data); + + // Max Health (4 bytes float, little-endian) + serialize_float(stats.max_health, packet_data); + + // Mana (4 bytes float, little-endian) + serialize_float(stats.mana, packet_data); + + // Max Mana (4 bytes float, little-endian) + serialize_float(stats.max_mana, packet_data); + + // Level (4 bytes int, little-endian as uint32_t) + serialize_uint32(static_cast(stats.level), packet_data); + + return packet_data; +} + +std::vector SerializationSystem::serialize_entity_stat_change(uint32_t entity_id, const std::string& stat_name, const std::string& stat_value) { + std::vector packet_data; + packet_data.push_back(static_cast(PACKET_TYPE::ENTITY_STATS)); + + // Entity ID (4 bytes, little-endian) + serialize_uint32(entity_id, packet_data); + + // Stat name length (4 bytes, little-endian) + uint32_t name_length = static_cast(stat_name.length()); + serialize_uint32(name_length, packet_data); + + // Stat name string + for (char c : stat_name) { + packet_data.push_back(static_cast(c)); + } + + // Stat value length (4 bytes, little-endian) + uint32_t value_length = static_cast(stat_value.length()); + serialize_uint32(value_length, packet_data); + + // Stat value string + for (char c : stat_value) { + packet_data.push_back(static_cast(c)); + } + + return packet_data; +} + +std::vector SerializationSystem::serialize_entity_state(uint32_t entity_id, uint8_t state_value) { + std::vector packet_data; + packet_data.push_back(static_cast(PACKET_TYPE::ENTITY_STATE)); + + // Entity ID (4 bytes, little-endian) + serialize_uint32(entity_id, packet_data); + + // State value (1 byte) + packet_data.push_back(state_value); + + return packet_data; +} + +std::vector SerializationSystem::serialize_combat_event(uint32_t attacker_id, uint32_t target_id, + float damage_dealt, uint8_t damage_type, bool was_critical) { + std::vector packet_data; + packet_data.push_back(static_cast(PACKET_TYPE::COMBAT_EVENT)); + + // Attacker ID (4 bytes, little-endian) + serialize_uint32(attacker_id, packet_data); + + // Target ID (4 bytes, little-endian) + serialize_uint32(target_id, packet_data); + + // Damage (4 bytes float, little-endian) + serialize_float(damage_dealt, packet_data); + + // Damage Type (1 byte) + packet_data.push_back(damage_type); + + // Was Critical (1 byte: 0 or 1) + packet_data.push_back(was_critical ? 1 : 0); + + return packet_data; +} + diff --git a/src/systems/serialization_system.hpp b/src/systems/util/serialization_system.hpp similarity index 52% rename from src/systems/serialization_system.hpp rename to src/systems/util/serialization_system.hpp index c586617..7237f1b 100644 --- a/src/systems/serialization_system.hpp +++ b/src/systems/util/serialization_system.hpp @@ -4,8 +4,11 @@ #include #include -#include -#include +#include +#include + +// Forward declarations +struct Stats; /** * Handles serialization of game packets into byte arrays. @@ -47,6 +50,48 @@ class SerializationSystem { */ static std::vector serialize_entity_spawn(uint32_t entity_id, const Vec2& position, uint8_t team_id, const std::string& entity_type); + /** + * Serialize an entity stats update packet. + * @param entity_id The ID of the entity + * @param stats The Stats component containing health, mana, and level + * @return Packet data as a vector of bytes + */ + static std::vector serialize_entity_stats(uint32_t entity_id, const struct Stats& stats); + + /** + * Serialize a single stat change packet. + * Efficient for syncing individual stat changes without full stats sync. + * Format: type(1) + entity_id(4) + stat_name_length(4) + stat_name + stat_value(4/8) + * @param entity_id The ID of the entity + * @param stat_name The name of the stat being changed (e.g., "health", "mana", "level") + * @param stat_value The new value as a string representation + * @return Packet data as a vector of bytes + */ + static std::vector serialize_entity_stat_change(uint32_t entity_id, const std::string& stat_name, const std::string& stat_value); + + /** + * Serialize an entity state change packet. + * Used to sync entity state changes (e.g., SPAWNED, DEAD, MOVING). + * @param entity_id The ID of the entity + * @param state_value The new entity state as a uint8_t (see EntityState enum) + * @return Packet data as a vector of bytes + */ + static std::vector serialize_entity_state(uint32_t entity_id, uint8_t state_value); + + /** + * Serialize a combat event packet. + * Broadcasts damage, critical hits, and other combat interactions. + * Format: type(1) + attacker_id(4) + target_id(4) + damage(4) + damage_type(1) + was_critical(1) + reserved(2) + * @param attacker_id The ID of the attacking entity + * @param target_id The ID of the target entity + * @param damage_dealt The amount of damage dealt (as float) + * @param damage_type The type of damage (0=PHYSICAL, 1=MAGICAL, 2=TRUE) + * @param was_critical Whether the attack was a critical hit + * @return Packet data as a vector of bytes + */ + static std::vector serialize_combat_event(uint32_t attacker_id, uint32_t target_id, + float damage_dealt, uint8_t damage_type, bool was_critical); + private: /** * Helper function to serialize a 32-bit unsigned integer in little-endian format diff --git a/src/systems/util/targeting_utility.cpp b/src/systems/util/targeting_utility.cpp new file mode 100644 index 0000000..6e51003 --- /dev/null +++ b/src/systems/util/targeting_utility.cpp @@ -0,0 +1,306 @@ +#include +#include +#include +#include +#include +#include +#include + +std::vector TargetingUtility::get_targets_in_range( + EntityManager& entity_manager, + EntityID entity_id, + bool include_allies) +{ + std::vector targets; + + Entity* observer = entity_manager.get_entity(entity_id); + if (!observer || !observer->has_component() || !observer->has_component()) { + return targets; + } + + Movement* observer_movement = observer->get_component(); + Stats* observer_stats = observer->get_component(); + + const auto& all_entities = entity_manager.get_all_entities(); + + for (const auto& entity_pair : all_entities) { + Entity* potential_target = const_cast(&entity_pair.second); + + // Skip self + if (potential_target->get_id() == entity_id) { + continue; + } + + // Skip entities without movement or stats + if (!potential_target->has_component() || !potential_target->has_component()) { + continue; + } + + // Skip if not in vision range + Movement* target_movement = potential_target->get_component(); + if (!is_in_vision_range(observer_movement->position, target_movement->position, observer_stats->vision_range)) { + continue; + } + + // Skip if not eligible for combat + if (!can_engage_in_combat(entity_manager, entity_id, potential_target->get_id())) { + continue; + } + + targets.push_back(potential_target->get_id()); + } + + return targets; +} + +bool TargetingUtility::is_in_attack_range( + const Vec2& attacker_position, + const Vec2& target_position, + float attack_range) +{ + float distance = calculate_distance(attacker_position, target_position); + return distance <= attack_range; +} + +bool TargetingUtility::is_in_vision_range( + const Vec2& observer_position, + const Vec2& target_position, + float vision_range) +{ + float distance = calculate_distance(observer_position, target_position); + return distance <= vision_range; +} + +bool TargetingUtility::can_engage_in_combat( + EntityManager& entity_manager, + EntityID entity_id, + EntityID target_id) +{ + Entity* entity = entity_manager.get_entity(entity_id); + Entity* target = entity_manager.get_entity(target_id); + + if (!entity || !target) { + return false; + } + + // Both must have Stats and Movement for combat + if (!entity->has_component() || !target->has_component()) { + return false; + } + + if (!entity->has_component() || !target->has_component()) { + return false; + } + + // Check if target is alive + Stats* target_stats = target->get_component(); + if (target_stats->health <= 0.0f) { + return false; + } + + // Check if attacker is alive + Stats* entity_stats = entity->get_component(); + if (entity_stats->health <= 0.0f) { + return false; + } + + // TODO: Add team/faction checks here when team system is implemented + // For now, all minions can attack each other (different team_id) + if (entity_stats->team_id == target_stats->team_id) { + return false; // Can't attack same team + } + + return true; +} + +bool TargetingUtility::is_target_valid( + EntityManager& entity_manager, + EntityID entity_id, + EntityID target_id) +{ + // Validate entity still exists + Entity* entity = entity_manager.get_entity(entity_id); + if (!entity) { + return false; + } + + // Check if we can still engage in combat + if (!can_engage_in_combat(entity_manager, entity_id, target_id)) { + return false; + } + + // Check if target is still in vision range + Entity* target = entity_manager.get_entity(target_id); + if (!target || !target->has_component()) { + return false; + } + + Movement* entity_movement = entity->get_component(); + Movement* target_movement = target->get_component(); + Stats* entity_stats = entity->get_component(); + + if (!is_in_vision_range(entity_movement->position, target_movement->position, entity_stats->vision_range)) { + return false; + } + + return true; +} + +EntityID TargetingUtility::get_closest_target( + EntityManager& entity_manager, + EntityID entity_id) +{ + std::vector targets = get_targets_in_range(entity_manager, entity_id); + + if (targets.empty()) { + return INVALID_ENTITY_ID; + } + + Entity* entity = entity_manager.get_entity(entity_id); + if (!entity || !entity->has_component()) { + return INVALID_ENTITY_ID; + } + + Movement* entity_movement = entity->get_component(); + + EntityID closest_id = targets[0]; + float closest_distance = std::numeric_limits::max(); + + for (EntityID target_id : targets) { + Entity* target = entity_manager.get_entity(target_id); + if (!target || !target->has_component()) { + continue; + } + + Movement* target_movement = target->get_component(); + float distance = calculate_distance(entity_movement->position, target_movement->position); + + if (distance < closest_distance) { + closest_distance = distance; + closest_id = target_id; + } + } + + return closest_id; +} + +float TargetingUtility::calculate_distance(const Vec2& pos1, const Vec2& pos2) +{ + float dx = pos1.x - pos2.x; + float dy = pos1.y - pos2.y; + return std::sqrt(dx * dx + dy * dy); +} + +float TargetingUtility::calculate_distance(const Vec2& pos1, const EntityID& entity_id, EntityManager& entity_manager) +{ + Entity* entity = entity_manager.get_entity(entity_id); + if (!entity || !entity->has_component()) { + return std::numeric_limits::max(); + } + + Movement* entity_movement = entity->get_component(); + return calculate_distance(pos1, entity_movement->position); +} + +EntityID TargetingUtility::find_best_target( + EntityManager& entity_manager, + EntityID attacker_id, + float aggression_range, + TargetComponent::TargetPriority targeting_mode, + bool prioritize_closest) +{ + Entity* attacker = entity_manager.get_entity(attacker_id); + if (!attacker || !attacker->has_component() || !attacker->has_component()) { + return INVALID_ENTITY_ID; + } + + const auto* attacker_move = attacker->get_component(); + const auto* attacker_stats = attacker->get_component(); + + EntityID best_target = INVALID_ENTITY_ID; + float best_score = -1e9f; + + // Get all entities with Movement (potential targets) + auto all_entities = entity_manager.get_entities_with_component(); + + for (auto* potential_target : all_entities) { + if (!potential_target || potential_target->get_id() == attacker_id) { + continue; // Skip self + } + + // Check if valid target (alive, correct team, etc) + auto* target_stats = potential_target->get_component(); + if (!target_stats || target_stats->health <= 0.0f) { + continue; // Target must be alive + } + + // Can't target neutral observers (team_id == 255) + if (target_stats->team_id == 255) { + continue; + } + + // Team-based targeting validation + // Can't target same team + if (attacker_stats->team_id != 0 && target_stats->team_id == attacker_stats->team_id) { + continue; + } + + // Can't target neutrals as neutral + if (attacker_stats->team_id == 0 && target_stats->team_id == 0) { + continue; + } + + // Calculate distance + auto* target_move = potential_target->get_component(); + if (!target_move) continue; + + float distance = calculate_distance(attacker_move->position, target_move->position); + + // Skip if out of aggro range + if (distance > aggression_range) { + continue; + } + + // Calculate target score based on targeting mode + float score = 0.0f; + + switch (targeting_mode) { + case TargetComponent::TargetPriority::NEAREST: + score = -distance; // Negative distance = higher score for closer + break; + + case TargetComponent::TargetPriority::LOWEST_HEALTH: + score = -(target_stats->health); // Negative health = target low HP + break; + + case TargetComponent::TargetPriority::HIGHEST_THREAT: + score = target_stats->physical_power + target_stats->magic_power; + break; + + case TargetComponent::TargetPriority::HIGHEST_DAMAGE: + score = target_stats->physical_power; + break; + + case TargetComponent::TargetPriority::PRIORITY_TARGET: + // PRIORITY_TARGET handled elsewhere, default to nearest + score = -distance; + break; + + default: + score = -distance; + break; + } + + // Prefer closer targets as tiebreaker + if (prioritize_closest) { + score -= (distance * 0.1f); + } + + if (score > best_score) { + best_score = score; + best_target = potential_target->get_id(); + } + } + + return best_target; +} diff --git a/src/systems/util/targeting_utility.hpp b/src/systems/util/targeting_utility.hpp new file mode 100644 index 0000000..5c047ad --- /dev/null +++ b/src/systems/util/targeting_utility.hpp @@ -0,0 +1,159 @@ +#pragma once + +#include +#include +#include +#include +#include + +/** + * TargetingUtility - Data-driven targeting and vision utility functions + * + * Pure utility functions for targeting calculations and target validation. + * Used by CombatSystem and other systems that need targeting logic. + * + * RESPONSIBILITIES: + * - Range and distance calculations (vision, attack range) + * - Target eligibility validation (alive, can engage, in range) + * - Target discovery (find targets in range, get closest, etc.) + * - Team/faction checks for combat eligibility + * + * DATA-DRIVEN DESIGN: + * All behavior is configured through component properties: + * - AutoAttackComponent: personality, aggression_range, prioritization + * - Stats: vision_range, attack_range, health + * - TargetComponent: targeting_mode, current_target + * + * USAGE: + * // Find best target within vision range + * EntityID target = TargetingUtility::get_closest_target(entity_manager, entity_id); + * + * // Validate if target is still valid + * if (TargetingUtility::is_target_valid(entity_manager, attacker_id, target_id)) { + * // Can still attack target + * } + * + * NOTE: + * These are pure utility functions - they do NOT modify entity state. + * Only CombatSystem should call these functions in the main update loop. + */ +class TargetingUtility { +public: + /** + * Get all targets within an entity's vision range that can be engaged. + * @param entity_manager Reference to the entity manager + * @param entity_id ID of the entity searching for targets + * @param include_allies Whether to include allies in results (default: false) + * @return Vector of entity IDs within vision range and eligible for combat + */ + static std::vector get_targets_in_range( + EntityManager& entity_manager, + EntityID entity_id, + bool include_allies = false + ); + + /** + * Check if a target is within attack range of an attacker (2D). + * @param attacker_position Position of the attacking entity + * @param target_position Position of the target entity + * @param attack_range Attack range in world units + * @return true if target is within attack range + */ + static bool is_in_attack_range( + const Vec2& attacker_position, + const Vec2& target_position, + float attack_range + ); + + /** + * Check if a target is within vision range of an observer (2D). + * @param observer_position Position of the observing entity + * @param target_position Position of the target entity + * @param vision_range Vision range in world units + * @return true if target is within vision range + */ + static bool is_in_vision_range( + const Vec2& observer_position, + const Vec2& target_position, + float vision_range + ); + + /** + * Check if two entities can engage in combat. + * Validates that both entities are alive and have required components. + * @param entity_manager Reference to the entity manager + * @param entity_id Entity that would be attacking + * @param target_id Entity that would be attacked + * @return true if entities can engage in combat + */ + static bool can_engage_in_combat( + EntityManager& entity_manager, + EntityID entity_id, + EntityID target_id + ); + + /** + * Validate if a target is still valid for an attacker. + * Checks: alive, in vision range, can engage in combat. + * @param entity_manager Reference to the entity manager + * @param entity_id Entity that has the target + * @param target_id Current target ID to validate + * @return true if target is still valid and can be attacked + */ + static bool is_target_valid( + EntityManager& entity_manager, + EntityID entity_id, + EntityID target_id + ); + + /** + * Get the closest enemy target within vision range. + * @param entity_manager Reference to the entity manager + * @param entity_id ID of the entity searching for targets + * @return ID of closest valid target, or INVALID_ENTITY_ID if none found + */ + static EntityID get_closest_target( + EntityManager& entity_manager, + EntityID entity_id + ); + + /** + * Calculate distance between two positions (2D). + * @param pos1 First position + * @param pos2 Second position + * @return Distance between positions (Euclidean) + */ + static float calculate_distance(const Vec2& pos1, const Vec2& pos2); + + /** + * Calculate distance between two positions (2D). + * @param pos1 First position + * @param pos2 Second position + * @return Distance between positions (Euclidean) + */ + static float calculate_distance(const Vec2& pos1, const EntityID& entity_id, EntityManager& entity_manager); + + /** + * Find the best target for an entity based on targeting priority and auto-attack settings. + * Considers distance, health, threat level, and damage based on targeting mode. + * + * @param entity_manager Reference to the entity manager + * @param attacker_id ID of the entity looking for a target + * @param aggression_range Maximum distance to search for targets + * @param targeting_mode TargetComponent::TargetPriority mode for prioritization + * @param prioritize_closest If true, prefer closer targets as tiebreaker + * @return ID of best target, or INVALID_ENTITY_ID if none found + */ + static EntityID find_best_target( + EntityManager& entity_manager, + EntityID attacker_id, + float aggression_range, + TargetComponent::TargetPriority targeting_mode, + bool prioritize_closest = true + ); + +private: + // Private constructor - this is a utility class with only static methods + TargetingUtility() = delete; + ~TargetingUtility() = delete; +}; diff --git a/src/systems/wave_system.cpp b/src/systems/wave_system.cpp deleted file mode 100644 index 8784726..0000000 --- a/src/systems/wave_system.cpp +++ /dev/null @@ -1,260 +0,0 @@ -#include -#include "wave_system.hpp" -#include "entity_manager.hpp" -#include "serialization_system.hpp" -#include "services/network_service.hpp" -#include "services/navigation_service.hpp" -#include -#include -#include -#include -#include - -WaveSystem::WaveSystem(EntityManager* entity_manager, NetworkService* network_service, NavigationService* navigation_service, const Map* map) { - entity_manager_ = entity_manager; - network_service_ = network_service; - navigation_service_ = navigation_service; - map_ = map; - wave_interval_ms = 30000.0f; - wave_delay_ms = 1000.0f; - elapsed_time_ms = 0.0f; - last_spawn_timestamp = 0.0f; - minion_index = 0; - wave_index = 0; - special_wave_offset = 5; - - // TODO: Load from config file mode -- cmkrist 11/16/2025 - default_minion_wave = { - "melee_minion", - "melee_minion", - "melee_minion", - "ranged_minion", - "magic_minion" - }; - special_minion_wave = { - "melee_minion", - "melee_minion", - "melee_minion", - "cannon_minion", - "ranged_minion", - "magic_minion", - "ranged_minion" - }; -} - -void WaveSystem::tick(float delta_time_ms) { - elapsed_time_ms += delta_time_ms; - // Process pathfinding results for waiting minions - if (navigation_service_) { - std::optional result = navigation_service_->GetResult(); - while (result) { - Entity* entity = entity_manager_->get_entity(result->entity_id); - if (entity && entity->has_component()) { - PathfindingComponent* pathfinding = entity->get_component(); - pathfinding->waypoints = result->path; - pathfinding->current_waypoint_index = 0; - pathfinding->is_waiting_for_path = false; - - if (result->path.empty()) { - LOG_WARN("Minion %u received EMPTY path!", entity->get_id()); - } else { - LOG_DEBUG("Minion %u received path with %zu waypoints", - entity->get_id(), result->path.size()); - } - } - result = navigation_service_->GetResult(); - } - } - - // Retry pending pathfinding requests (entities waiting for paths) - if (navigation_service_) { - auto entities = entity_manager_->get_entities_with_component(); - for (auto* entity : entities) { - PathfindingComponent* pathfinding = entity->get_component(); - if (pathfinding && pathfinding->is_waiting_for_path && pathfinding->waypoints.empty()) { - request_minion_path(*entity, pathfinding->target_spawnpoint_id); - } - } - } - - // Minion Spawning - if(minion_index > 0) { - if (elapsed_time_ms - last_spawn_timestamp >= wave_delay_ms) { - LOG_INFO("Spawning new minion from wave %d, index %d", wave_index, minion_index); - for(uint8_t team_id = 1; team_id < 3; team_id++) { - create_minion( - (wave_index % special_wave_offset == 0 ? special_minion_wave : default_minion_wave)[minion_index - 1], - team_id, - team_id - 1 // Spawn from team-specific spawnpoint - ); - } - last_spawn_timestamp = elapsed_time_ms; - // Overflow check - if (minion_index >= (wave_index % special_wave_offset == 0 ? special_minion_wave.size() : default_minion_wave.size())) { - minion_index = 0; - } else { - minion_index++; - } - } - } - // Wave Spawning - if (elapsed_time_ms >= wave_interval_ms) { - LOG_INFO("Spawning new wave"); - minion_index = 1; - wave_index++; - elapsed_time_ms = 0; - last_spawn_timestamp = 0; - } -} - -bool WaveSystem::create_minion(const std::string& minion_template, uint8_t team_id, uint32_t spawn_point_id) { - Entity& minion = entity_manager_->create_entity_from_template(minion_template); - if (minion.get_id() == 0) { - LOG_ERROR("Failed to spawn minion of type %s", minion_template.c_str()); - return false; - } - - // Set team ID - Stats* stats = minion.get_component(); - if (stats) { - stats->team_id = team_id; - } - if (map_->spawnpoints.size() == 0) { - LOG_WARN("Map has no spawnpoints defined, minion spawn position may be invalid"); - } - // Set Spawn - Movement* move_comp = minion.get_component(); - if (move_comp && map_ && spawn_point_id < map_->spawnpoints.size()) { - move_comp->position = find_free_spawn_position(spawn_point_id, move_comp->collision_radius); - } - - // Add pathfinding component - auto pathfinding = std::make_unique(); - minion.add_component(std::move(pathfinding)); - - // Request initial path to next spawnpoint (enemy spawn) - // TODO: Make this better -- cmkrist 16/11/2025 - uint32_t target_spawnpoint = (spawn_point_id + 1) % (map_ ? map_->spawnpoints.size() : 2); - request_minion_path(minion, target_spawnpoint); - - // Notify clients of the new entity - // README: Not handled by NetworkSyncSystem (Manages ongoing entities only) -- cmkrist 16/11/2025 - if (network_service_ && move_comp) { - std::vector packet = SerializationSystem::serialize_entity_spawn(minion.get_id(), move_comp->position, team_id, minion_template); - network_service_->broadcast_packet(packet); - } - - LOG_INFO("Spawned minion with ID %u for team %d from spawnpoint %u -> %u", minion.get_id(), team_id, spawn_point_id, target_spawnpoint); - return true; -} - -void WaveSystem::request_minion_path(Entity& entity, uint32_t target_spawnpoint_id) { - if (!navigation_service_ || !map_ || target_spawnpoint_id >= map_->spawnpoints.size()) { - LOG_WARN("Cannot request path: navigation_service=%p, map=%p, spawnpoint_id=%u", navigation_service_, map_, target_spawnpoint_id); - return; - } - - Movement* move = entity.get_component(); - PathfindingComponent* pathfinding = entity.get_component(); - - if (!move || !pathfinding) { - LOG_WARN("Minion %u missing Movement or PathfindingComponent", entity.get_id()); - return; - } - - Vec3 start = Vec3(move->position.x, 0.0f, move->position.y); - Vec2 goal_2d = map_->spawnpoints[target_spawnpoint_id]; - Vec3 goal = Vec3(goal_2d.x, 0.0f, goal_2d.y); - - LOG_DEBUG("Minion %u requesting path from (%.1f, %.1f) to spawnpoint %u (%.1f, %.1f)", - entity.get_id(), start.x, start.z, target_spawnpoint_id, goal.x, goal.z); - - PathRequest request; - request.entity_id = entity.get_id(); - request.current_position = start; - request.destination = goal; - request.entity_pathing_radius = 0.5f; - - if (navigation_service_->MakeRequest(request)) { - pathfinding->is_waiting_for_path = true; - pathfinding->target_spawnpoint_id = target_spawnpoint_id; - LOG_DEBUG("Path request queued for minion %u", entity.get_id()); - } else { - // Mark as waiting for path so we retry next tick - pathfinding->is_waiting_for_path = true; - pathfinding->target_spawnpoint_id = target_spawnpoint_id; - LOG_DEBUG("Pathfinding queue full, will retry for minion %u", entity.get_id()); - } -} - -Vec2 WaveSystem::find_free_spawn_position(uint32_t spawn_point_id, float collision_radius) { - if (!map_ || spawn_point_id >= map_->spawnpoints.size()) { - // Fallback to default spawnpoint if invalid - return Vec2(0.0f, 0.0f); - } - - Vec2 base_position = map_->spawnpoints[spawn_point_id]; - - // Get all entities with movement components to check for collisions - auto moving_entities = entity_manager_->get_entities_with_component(); - - // Check if base position is free - bool position_free = true; - for (auto* entity : moving_entities) { - const Movement* other_move = entity->get_component(); - if (other_move) { - float dx = base_position.x - other_move->position.x; - float dy = base_position.y - other_move->position.y; - float distance = std::sqrt(dx * dx + dy * dy); - float min_distance = collision_radius + other_move->collision_radius; - - if (distance < min_distance) { - position_free = false; - break; - } - } - } - - if (position_free) { - return base_position; - } - - // If base position is occupied, try to find a free spot nearby - // Use expanding circles to search for free space - constexpr float SPAWN_SEARCH_RADIUS = 5.0f; - constexpr int SEARCH_SAMPLES = 16; // Number of angles to check - - for (float search_distance = collision_radius * 2.0f; search_distance <= SPAWN_SEARCH_RADIUS; search_distance += 0.5f) { - for (int i = 0; i < SEARCH_SAMPLES; ++i) { - float angle = (2.0f * 3.14159265f * i) / SEARCH_SAMPLES; - Vec2 candidate = base_position + Vec2(std::cos(angle) * search_distance, std::sin(angle) * search_distance); - - // Check if this candidate position is free - bool candidate_free = true; - for (auto* entity : moving_entities) { - const Movement* other_move = entity->get_component(); - if (other_move) { - float dx = candidate.x - other_move->position.x; - float dy = candidate.y - other_move->position.y; - float distance = std::sqrt(dx * dx + dy * dy); - float min_distance = collision_radius + other_move->collision_radius; - - if (distance < min_distance) { - candidate_free = false; - break; - } - } - } - - if (candidate_free) { - return candidate; - } - } - } - - // If no free space found after search, return base position anyway - // (let collision detection handle it later? push everyone back?(not-implemented(tm))) -- cmkrist 16/11/2025 - LOG_WARN("Could not find free spawn position for spawnpoint %u, using base position", spawn_point_id); - return base_position; -} - diff --git a/src/systems/wave_system.hpp b/src/systems/wave_system.hpp deleted file mode 100644 index 61867e3..0000000 --- a/src/systems/wave_system.hpp +++ /dev/null @@ -1,62 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include - -/** - * System to manage wave spawning and progression. - * Handles the timing and logic for enemy waves in the game. - * Minions spawn and automatically request paths to their target spawnpoints. - */ -class WaveSystem { -public: - WaveSystem(EntityManager* entity_manager, NetworkService* network_service = nullptr, NavigationService* navigation_service = nullptr, const Map* map = nullptr); - /** - * Tick the wave system to update wave state and process pathfinding results. - * @param delta_time_ms Time elapsed since last tick in milliseconds - */ - void tick(float delta_time_ms); - -private: - EntityManager* entity_manager_; - NetworkService* network_service_; - NavigationService* navigation_service_; - const Map* map_; - float wave_interval_ms; // 30 seconds between waves - float wave_delay_ms; // 1 second delay between minions in a wave - float elapsed_time_ms; - float last_spawn_timestamp; - int minion_index; - int wave_index; - int special_wave_offset; - std::vector default_minion_wave; - std::vector special_minion_wave; - - /** - * Try to spawn a minion of the given type. - * @param minion_template The template name of the minion to spawn - * @param team_id The team this minion belongs to - * @param spawn_point_id Starting spawnpoint ID (0-indexed) - * @return true if minion spawned successfully - */ - bool create_minion(const std::string& minion_template, uint8_t team_id, uint32_t spawn_point_id); - - /** - * Request a path for a minion to its target spawnpoint. - * @param entity Entity with Movement and PathfindingComponent - * @param target_spawnpoint_id Target spawnpoint ID - */ - void request_minion_path(Entity& entity, uint32_t target_spawnpoint_id); - - /** - * Find a free spawn position near the given spawnpoint. - * Checks if there are other entities at the spawnpoint and offsets position if needed. - * @param spawn_point_id The spawnpoint index - * @param collision_radius The radius to check for collisions - * @return A free position near the spawnpoint - */ - Vec2 find_free_spawn_position(uint32_t spawn_point_id, float collision_radius); -}; diff --git a/tests/tests.cpp b/tests/tests.cpp index d1363a7..100b67f 100644 --- a/tests/tests.cpp +++ b/tests/tests.cpp @@ -8,11 +8,11 @@ #include #include -#include "../src/systems/entity_manager.hpp" -#include "../src/components/movement.hpp" -#include "../src/components/stats.hpp" -#include "../src/systems/packet_validator.hpp" -#include "../src/entities/player.hpp" +#include +#include +#include +#include +#include // Test utilities int tests_passed = 0; diff --git a/web_ui/FiraMonoNerdFontMono-Regular.otf b/web_ui/FiraMonoNerdFontMono-Regular.otf new file mode 100644 index 0000000..9abf745 Binary files /dev/null and b/web_ui/FiraMonoNerdFontMono-Regular.otf differ diff --git a/web_ui/index.html b/web_ui/index.html new file mode 100644 index 0000000..f598aa1 --- /dev/null +++ b/web_ui/index.html @@ -0,0 +1,42 @@ + + + + + OpenChamp Debugger + + + + + +
+
+
+ +
+
+
+
+
Legend
+
+
+
+
+ +
+
+
+
Entities
+
+
+
+
+
+

Game State

+
+
+
+
+ + + + \ No newline at end of file diff --git a/web_ui/script.js b/web_ui/script.js new file mode 100644 index 0000000..077c121 --- /dev/null +++ b/web_ui/script.js @@ -0,0 +1,478 @@ +const canvas = document.getElementById('mapCanvas'); +const ctx = canvas.getContext('2d'); + +// Visualization layer visibility state +const layerVisibility = { + mapBounds: true, + polygonGrid: true, + navmesh: true, + spawnpoints: true, + structures: true, + entities: true, + targetingLines: true +}; + +const legend_array = [ + { name: 'Map Bounds', layer: 'mapBounds', color: '#664040' }, + { name: 'Polygon Grid', layer: 'polygonGrid', color: '#6666ff' }, + { name: 'Navmesh', layer: 'navmesh', color: '#80ff80' }, + { name: 'Spawnpoints', layer: 'spawnpoints', color: '#ffff00' }, + { name: 'Structures', layer: 'structures', color: '#ffa500' }, + { name: 'Entities', layer: 'entities', color: '#4040ff' }, + { name: 'Targeting Lines', layer: 'targetingLines', color: '#4da6ff' } +] + +// Initialize legend toggle event listeners +function initializeLegendToggles() { + const legendDiv = document.getElementById('legendContainer'); + legend_array.forEach(item => { + // Node Generation + const label = document.createElement('label'); + label.style.display = 'block'; + label.style.marginBottom = '4px'; + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.className = 'legend-toggle'; + checkbox.dataset.layer = item.layer; + checkbox.checked = layerVisibility[item.layer]; + const colorBox = document.createElement('span'); + colorBox.style.display = 'inline-block'; + colorBox.style.width = '12px'; + colorBox.style.height = '12px'; + colorBox.style.backgroundColor = item.color; + colorBox.style.marginRight = '6px'; + // Listeners + checkbox.addEventListener('click', (e) => { + e.stopPropagation(); + const layer = e.target.dataset.layer; + layerVisibility[layer] = e.target.checked; + }); + // Assembly + label.appendChild(checkbox); + label.appendChild(colorBox); + label.appendChild(document.createTextNode(item.name)); + legendDiv.appendChild(label); + }); +} + +async function updateView() { + try { + const response = await fetch('/api/gamestate'); + const data = await response.json(); + + // Clear canvas + ctx.fillStyle = '#000'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + // Draw grid first (background) + if (layerVisibility.polygonGrid) drawPolygonGrid(data.map); + + // Draw map bounds + if (layerVisibility.mapBounds) drawMapBounds(data.map); + + // Draw navmesh + if (layerVisibility.navmesh) drawNavmesh(data.map); + + // Draw spawnpoints + if (layerVisibility.spawnpoints) drawSpawnpoints(data.map); + + // Draw structures + if (layerVisibility.structures) drawStructures(data.map); + + // Draw entities + if (layerVisibility.entities) drawEntities(data.entities); + + // Update state info + updateStateInfo(data.state); + + // Update entities list + updateEntitiesList(data.entities); + } catch (e) { + console.error('Failed to fetch game state:', e); + } +} + +function screenToWorld(screenX, screenY) { + return { + x: (screenX - 400) / 10, + y: (screenY - 300) / 10 + }; +} + +function worldToScreen(worldX, worldY) { + return { + x: worldX * 10 + 400, + y: worldY * 10 + 300 + }; +} + +function drawPolygonGrid(mapData) { + if (!mapData || !mapData.grid) return; + + const grid = mapData.grid; + if (grid.width === 0 || grid.height === 0 || !grid.cells || grid.cells.length === 0) return; + + const cellSize = grid.cell_size; + const originX = grid.origin.x; + const originY = grid.origin.y; + + // Grid visualization: very subtle outline to show spatial acceleration cells + ctx.strokeStyle = '#3333ff'; + ctx.lineWidth = 0.5; + ctx.fillStyle = 'rgba(51, 51, 255, 0.05)'; // Very transparent + + let drawnCount = 0; + for (let cellData of grid.cells) { + const x = cellData.x; + const y = cellData.y; + + // Calculate cell bounds in world coordinates + const minX = originX + (x * cellSize); + const maxX = minX + cellSize; + const minY = originY + (y * cellSize); + const maxY = minY + cellSize; + + // Convert to screen coordinates + const topLeft = worldToScreen(minX, minY); + const topRight = worldToScreen(maxX, minY); + const bottomLeft = worldToScreen(minX, maxY); + const bottomRight = worldToScreen(maxX, maxY); + + // Skip if cell is completely outside canvas + if ((topLeft.x > canvas.width && topRight.x > canvas.width && bottomRight.x > canvas.width) || + (topLeft.x < 0 && topRight.x < 0 && bottomLeft.x < 0) || + (topLeft.y > canvas.height && bottomLeft.y > canvas.height && bottomRight.y > canvas.height) || + (topLeft.y < 0 && topRight.y < 0 && topRight.y < 0)) { + continue; + } + + drawnCount++; + + // Draw cell rectangle (very subtle) + ctx.beginPath(); + ctx.moveTo(topLeft.x, topLeft.y); + ctx.lineTo(topRight.x, topRight.y); + ctx.lineTo(bottomRight.x, bottomRight.y); + ctx.lineTo(bottomLeft.x, bottomLeft.y); + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + + // Only draw labels if cell is reasonably sized on screen + const cellWidth = Math.abs(topRight.x - topLeft.x); + const cellHeight = Math.abs(bottomLeft.y - topLeft.y); + + if (cellWidth > 50 && cellHeight > 50) { + const centerX = (topLeft.x + bottomRight.x) / 2; + const centerY = (topLeft.y + bottomRight.y) / 2; + ctx.fillStyle = '#3333ff'; + ctx.font = '8px monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(cellData.polygon_count + 'p', centerX, centerY); + } + } + + if (drawnCount > 0) { + console.log(`Grid: Drew ${drawnCount} / ${grid.cells.length} cells`); + } +} + +function drawMapBounds(mapData) { + if (!mapData) return; + + // Draw map boundary as a dashed line + ctx.strokeStyle = '#664040'; + ctx.lineWidth = 2; + ctx.setLineDash([10, 5]); + + const halfWidth = mapData.size.x / 2; + const halfHeight = mapData.size.y / 2; + + const minX = mapData.offset.x - halfWidth; + const maxX = mapData.offset.x + halfWidth; + const minY = mapData.offset.y - halfHeight; + const maxY = mapData.offset.y + halfHeight; + + const topLeft = worldToScreen(minX, minY); + const topRight = worldToScreen(maxX, minY); + const bottomLeft = worldToScreen(minX, maxY); + const bottomRight = worldToScreen(maxX, maxY); + + ctx.beginPath(); + ctx.moveTo(topLeft.x, topLeft.y); + ctx.lineTo(topRight.x, topRight.y); + ctx.lineTo(bottomRight.x, bottomRight.y); + ctx.lineTo(bottomLeft.x, bottomLeft.y); + ctx.closePath(); + ctx.stroke(); + + ctx.setLineDash([]); +} + +function drawNavmesh(mapData) { + if (!mapData || !mapData.vertices) return; + + const vertices = mapData.vertices; + const polygons = mapData.polygons || []; + + // Draw polygon faces + ctx.strokeStyle = '#408040'; + ctx.fillStyle = '#204020'; + ctx.lineWidth = 2; + + for (let poly of polygons) { + if (poly.length < 3) continue; + + ctx.beginPath(); + const v = vertices[poly[0]]; + const screen = worldToScreen(v.x, v.z); + ctx.moveTo(screen.x, screen.y); + + for (let i = 1; i < poly.length; i++) { + const v = vertices[poly[i]]; + const screen = worldToScreen(v.x, v.z); + ctx.lineTo(screen.x, screen.y); + } + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + } + + // Draw vertices with labels + ctx.fillStyle = '#80ff80'; + ctx.strokeStyle = '#ffffff'; + ctx.lineWidth = 1; + ctx.font = 'bold 9px monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + + for (let i = 0; i < vertices.length; i++) { + const v = vertices[i]; + const screen = worldToScreen(v.x, v.z); + + // Draw vertex circle + ctx.beginPath(); + ctx.arc(screen.x, screen.y, 3, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + + // Draw vertex index + ctx.fillStyle = '#000000'; + ctx.fillText(i.toString(), screen.x, screen.y); + ctx.fillStyle = '#80ff80'; + } + + // Draw polygon centroids + ctx.fillStyle = '#ff6b6b'; + for (let i = 0; i < polygons.length; i++) { + const poly = polygons[i]; + if (poly.length < 3) continue; + + // Calculate centroid + let cx = 0, cy = 0; + for (let idx of poly) { + cx += vertices[idx].x; + cy += vertices[idx].z; + } + cx /= poly.length; + cy /= poly.length; + + const screen = worldToScreen(cx, cy); + ctx.beginPath(); + ctx.arc(screen.x, screen.y, 2, 0, Math.PI * 2); + ctx.fill(); + } +} + +function drawSpawnpoints(mapData) { + if (!mapData || !mapData.spawnpoints) return; + + ctx.fillStyle = '#ffff00'; + ctx.strokeStyle = '#ffff00'; + ctx.lineWidth = 2; + ctx.font = '12px Arial'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'bottom'; + + for (let i = 0; i < mapData.spawnpoints.length; i++) { + const sp = mapData.spawnpoints[i]; + const screen = worldToScreen(sp.x, sp.z); + + // Draw spawnpoint star + ctx.beginPath(); + for (let j = 0; j < 5; j++) { + const angle = (j * 4 * Math.PI) / 5 - Math.PI / 2; + const radius = j % 2 === 0 ? 8 : 4; + const x = screen.x + Math.cos(angle) * radius; + const y = screen.y + Math.sin(angle) * radius; + if (j === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + + // Draw label + ctx.fillStyle = '#ffff00'; + ctx.fillText('S' + i, screen.x, screen.y - 12); + } +} + +function drawStructures(mapData) { + if (!mapData || !mapData.structures) return; + ctx.fillStyle = '#ffa500'; + ctx.strokeStyle = '#ffa500'; + ctx.lineWidth = 2; + + for (let struct of mapData.structures) { + const screen = worldToScreen(struct.position.x, struct.position.z); + // Draw structure based on type + switch (struct.type) { + case "core": + // Core is a crystal shape + ctx.beginPath(); + ctx.moveTo(screen.x, screen.y - 10); + ctx.lineTo(screen.x + 7, screen.y); + ctx.lineTo(screen.x, screen.y + 10); + ctx.lineTo(screen.x - 7, screen.y); + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + break; + case "tower": + // Tower is a crown shape with a T inside + ctx.beginPath(); + ctx.moveTo(screen.x - 8, screen.y + 10); + ctx.lineTo(screen.x - 4, screen.y - 10); + ctx.lineTo(screen.x, screen.y + 5); + ctx.lineTo(screen.x + 4, screen.y - 10); + ctx.lineTo(screen.x + 8, screen.y + 10); + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + + break; + default: + // Add more cases for other structure types as needed + } + } +} + +function drawEntities(entities) { + if (!entities) return; + + // First pass: draw targeting lines + if (layerVisibility.targetingLines) { + for (let entity of entities) { + if (entity.target_id !== undefined) { + const attacker_screen = worldToScreen(entity.position.x, entity.position.z); + + // Find target entity + const target = entities.find(e => e.id === entity.target_id); + if (target) { + const target_screen = worldToScreen(target.position.x, target.position.z); + + // Draw targeting line + ctx.strokeStyle = entity.team_id === 1 ? '#4080ff' : '#ff4080'; + ctx.lineWidth = 2; + ctx.setLineDash([5, 5]); + ctx.beginPath(); + ctx.moveTo(attacker_screen.x, attacker_screen.y); + ctx.lineTo(target_screen.x, target_screen.y); + ctx.stroke(); + ctx.setLineDash([]); + + // Draw arrowhead at target + const angle = Math.atan2(target_screen.y - attacker_screen.y, target_screen.x - attacker_screen.x); + const arrowSize = 8; + + ctx.fillStyle = entity.team_id === 1 ? '#4080ff' : '#ff4080'; + ctx.beginPath(); + ctx.moveTo(target_screen.x, target_screen.y); + ctx.lineTo(target_screen.x - arrowSize * Math.cos(angle - Math.PI / 6), target_screen.y - arrowSize * Math.sin(angle - Math.PI / 6)); + ctx.lineTo(target_screen.x - arrowSize * Math.cos(angle + Math.PI / 6), target_screen.y - arrowSize * Math.sin(angle + Math.PI / 6)); + ctx.closePath(); + ctx.fill(); + } + } + } + } + + // Second pass: draw entities + for (let entity of entities) { + const screen = worldToScreen(entity.position.x, entity.position.z); + + // Color by team + ctx.fillStyle = entity.team_id === 1 ? '#4040ff' : '#ff4040'; + ctx.beginPath(); + ctx.arc(screen.x, screen.y, 4, 0, Math.PI * 2); + ctx.fill(); + + // Draw ID + ctx.fillStyle = '#ffffff'; + ctx.font = 'bold 10px Arial'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(entity.id.toString(), screen.x, screen.y); + + // Highlight if attacking + if (entity.state === 'ATTACKING') { + ctx.strokeStyle = '#ffff00'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.arc(screen.x, screen.y, 6, 0, Math.PI * 2); + ctx.stroke(); + } + + // Draw state indicator + let stateColor; + switch (entity.state) { + case 'ATTACKING': stateColor = '#ffff00'; break; + case 'MOVING': stateColor = '#4da6ff'; break; + case 'PATHFINDING_WAITING': stateColor = '#ffa500'; break; + case 'DEAD': stateColor = '#ff0000'; break; + default: stateColor = '#ffb347'; break; + } + ctx.strokeStyle = stateColor; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.arc(screen.x, screen.y, 8, 0, Math.PI * 2); + ctx.stroke(); + } +} + +function updateStateInfo(state) { + document.getElementById('state').textContent = JSON.stringify(state, null, 2); +} + +function updateEntitiesList(entities) { + const div = document.getElementById('entities'); + div.innerHTML = ''; + + if (!entities) return; + + for (let entity of entities) { + const el = document.createElement('div'); + el.className = 'entity t'+entity.team_id; + + let targetStr = ''; + if (entity.target_id !== undefined) { + const target = entities.find(e => e.id === entity.target_id); + const targetName = target ? `E${entity.target_id}` : `E${entity.target_id} (DEAD)`; + targetStr = ` → Targeting ${targetName}`; + } + + const stateColor = entity.state === 'ATTACKING' ? '#ffff00' : + entity.state === 'MOVING' ? '#4da6ff' : + entity.state === 'DEAD' ? '#ff0000' : '#ffb347'; + + el.innerHTML = `E${entity.id} [Team ${entity.team_id}] | Intent: ${entity.intent} | State: ${entity.state}${targetStr} | (${entity.position.x.toFixed(1)}, ${entity.position.z.toFixed(1)})`; + div.appendChild(el); + } +} + + +initializeLegendToggles(); +setInterval(updateView, 50); +updateView(); \ No newline at end of file diff --git a/web_ui/styles.css b/web_ui/styles.css new file mode 100644 index 0000000..7d7f9c1 --- /dev/null +++ b/web_ui/styles.css @@ -0,0 +1,104 @@ +@import "https://www.nerdfonts.com/assets/css/webfont.css"; + +@font-face { + font-family: "fira-mono"; + src: url("./FiraMonoNerdFontMono-Regular.otf"); +} +* { + font-size: 14px; + box-sizing: border-box; + font-family: "fira-mono", monospace; +} + +body { + font-family: Arial, sans-serif; + margin: 20px; + background: #1e1e1e; + color: #d4d4d4; +} + +i { + font-style: normal; +} + + +/* Grid Layout */ +.grid_container { +display: grid; +position: absolute; +top: 0; +left: 0; +width: 100%; +height: 100%; +grid-template-columns: 1fr 4fr 1fr; +grid-template-rows: 1fr 5fr 1fr; +grid-column-gap: 0px; +grid-row-gap: 0px; +} + +.grid_container > div { + padding: 10px; + overflow: auto; +} + +.top_container { grid-area: 1 / 1 / 2 / 4; } +.left_container { grid-area: 2 / 1 / 3 / 2; } +.right_container { grid-area: 2 / 3 / 3 / 4; } +.map_container { grid-area: 2 / 2 / 3 / 3; } +.bottom_container { grid-area: 3 / 1 / 4 / 4; } + +/* Map */ +.grid_container > .map_container { + overflow: hidden; +} + +canvas { + border: 1px solid #404040; + background: #000; + display: block; + width: 100%; + height: auto; + margin-top:-10%; +} + +.info { + font-family: monospace; + white-space: pre; + background: #252526; + padding: 10px; +} + +/* Entities */ +.entity { + margin: 5px 0; + border-width: 2px; + border-radius: 3px; + border-style: dashed; +} + +.entity.t1 { + border-color: #000077; + background-color: #0000FF; +} + +.entity.t2 { + border-color: #770000; + background-color: #FF0000; +} + + +h6 { + border-bottom: 1px solid #404040; + padding-bottom: 10px; +} + +.legend-toggle { + margin-right: 5px; + cursor: pointer; +} + +#legendContainer { + background: #252526; + padding: 10px; + border-radius: 4px; +} \ No newline at end of file