diff --git a/.github/workflows/docker-build-dev.yml b/.github/workflows/docker-build-dev.yml new file mode 100644 index 0000000..aa4e907 --- /dev/null +++ b/.github/workflows/docker-build-dev.yml @@ -0,0 +1,27 @@ +name: Build and Push Dev Docker Image + +on: + push: + branches: [dev] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: | + ${{ secrets.DOCKERHUB_USERNAME }}/triggeron_game_server:dev + ${{ secrets.DOCKERHUB_USERNAME }}/triggeron_game_server:dev-${{ github.sha }} diff --git a/Network/game_server.cpp b/Network/game_server.cpp index 9b1d59d..79916f5 100644 --- a/Network/game_server.cpp +++ b/Network/game_server.cpp @@ -7,8 +7,10 @@ #include "game_server.h" #include "enet_server_network.h" +#include "map_colliders.h" #include #include +#include GameServer::GameServer() : m_pNetwork(nullptr) @@ -30,6 +32,20 @@ void GameServer::Initialize(ENetServerNetwork* pNetwork) m_ServerTime = 0.0; m_CurrentTick = 0; m_Players.clear(); + + // Register colliders from shared map data (must match client) + m_Colliders.clear(); + for (int i = 0; i < MAP_COLLIDER_COUNT; i++) + { + const MapColliderDef& def = MAP_COLLIDERS[i]; + ServerCollider sc; + sc.aabb = { + { def.minX, def.minY, def.minZ }, + { def.maxX, def.maxY, def.maxZ } + }; + sc.isGround = def.isGround; + m_Colliders.push_back(sc); + } } void GameServer::Finalize() @@ -53,15 +69,27 @@ uint8_t GameServer::AssignTeam() const } //----------------------------------------------------------------------------- -// Spawn position per team + player ID +// Spawn position — random within one of 4 corner areas (4x4 each) +// +// Corner A (top-left): X: -9 to -5, Z: -9 to -5 +// Corner B (top-right): X: 5 to 9, Z: -9 to -5 +// Corner C (bottom-left): X: -9 to -5, Z: 5 to 9 +// Corner D (bottom-right): X: 5 to 9, Z: 5 to 9 //----------------------------------------------------------------------------- -Float3 GameServer::GetSpawnPosition(uint8_t playerId, uint8_t teamId) +Float3 GameServer::GetSpawnPosition(uint8_t /*playerId*/, uint8_t /*teamId*/) { - float offsets[] = { 0.0f, 5.0f, -5.0f, 10.0f }; - float x = offsets[playerId % 4]; - if (teamId == PlayerTeam::BLUE) - x = -x; // Mirror for blue team - float z = (teamId == PlayerTeam::RED) ? -20.0f : 20.0f; + struct SpawnArea { float minX, maxX, minZ, maxZ; }; + static const SpawnArea areas[4] = { + { -9.0f, -5.0f, -9.0f, -5.0f }, // top-left + { 5.0f, 9.0f, -9.0f, -5.0f }, // top-right + { -9.0f, -5.0f, 5.0f, 9.0f }, // bottom-left + { 5.0f, 9.0f, 5.0f, 9.0f }, // bottom-right + }; + + int corner = rand() % 4; + const SpawnArea& a = areas[corner]; + float x = a.minX + static_cast(rand()) / RAND_MAX * (a.maxX - a.minX); + float z = a.minZ + static_cast(rand()) / RAND_MAX * (a.maxZ - a.minZ); return { x, 0.0f, z }; } @@ -80,8 +108,15 @@ void GameServer::OnPlayerConnected(uint8_t playerId) data.state.yaw = 0.0f; data.state.pitch = 0.0f; data.state.stateFlags = NetStateFlags::IS_GROUNDED; + data.state.health = MAX_HEALTH; + data.state.hitByPlayerId = 0xFF; + data.state.fireCounter = 0; data.lastInput = {}; data.reloadTimer = 0.0; + data.health = MAX_HEALTH; + data.respawnTimer = 0.0; + data.fireTimer = 0.0; + data.fireCounter = 0; m_Players[playerId] = data; printf("[GameServer] Player %u (Team %s) spawned at (%.1f, %.1f, %.1f)\n", @@ -135,13 +170,45 @@ void GameServer::Tick() // 3. Simulate physics for all players SimulatePhysics(); - // 4. Update tick ID in all player states + // 4. Process firing and combat for all players + for (auto& [id, player] : m_Players) + { + // Clear hit marker each tick + player.state.hitByPlayerId = 0xFF; + + // Respawn timer + if (player.state.stateFlags & NetStateFlags::IS_DEAD) + { + player.respawnTimer -= TICK_DURATION; + if (player.respawnTimer <= 0.0) + { + // Respawn + player.health = MAX_HEALTH; + player.state.stateFlags &= ~NetStateFlags::IS_DEAD; + player.state.stateFlags |= NetStateFlags::IS_GROUNDED; + player.state.position = GetSpawnPosition(id, player.teamId); + player.state.velocity = { 0.0f, 0.0f, 0.0f }; + player.respawnTimer = 0.0; + printf("[GameServer] Player %u respawned\n", id); + } + } + else + { + ProcessFiring(player, id); + } + + // Sync combat data to state + player.state.health = player.health; + player.state.fireCounter = player.fireCounter; + } + + // 5. Update tick ID in all player states for (auto& [id, player] : m_Players) { player.state.tickId = m_CurrentTick; } - // 5. Broadcast per-player snapshots + // 6. Broadcast per-player snapshots BroadcastSnapshots(); } @@ -171,6 +238,14 @@ void GameServer::ProcessInputCmd(const InputCmd& cmd, uint8_t playerId) PlayerData& player = it->second; player.lastInput = cmd; + // Dead players: only update camera, skip all actions + if (player.state.stateFlags & NetStateFlags::IS_DEAD) + { + player.state.yaw = cmd.yaw; + player.state.pitch = cmd.pitch; + return; + } + player.state.yaw = cmd.yaw; player.state.pitch = cmd.pitch; @@ -217,6 +292,9 @@ void GameServer::SimulatePhysics() { for (auto& [id, player] : m_Players) { + // Skip dead players + if (player.state.stateFlags & NetStateFlags::IS_DEAD) + continue; SimulatePlayerPhysics(player); } } @@ -239,6 +317,7 @@ void GameServer::SimulatePlayerPhysics(PlayerData& player) const InputCmd& input = player.lastInput; bool isGrounded = (state.stateFlags & NetStateFlags::IS_GROUNDED) != 0; + bool wasGroundedAtStart = isGrounded; float yaw = input.yaw; float frontX = sinf(yaw); @@ -305,7 +384,7 @@ void GameServer::SimulatePlayerPhysics(PlayerData& player) } } - if (!isGrounded) + if (!wasGroundedAtStart) { state.velocity.y -= GRAVITY * dt; } @@ -314,12 +393,34 @@ void GameServer::SimulatePlayerPhysics(PlayerData& player) state.position.z += state.velocity.z * dt; state.position.y += state.velocity.y * dt; - if (state.position.y <= 0.0f) + // Collision Detection (Capsule vs World AABBs) + if (!m_Colliders.empty()) { - state.position.y = 0.0f; - state.velocity.y = 0.0f; - state.stateFlags |= NetStateFlags::IS_GROUNDED; - state.stateFlags &= ~NetStateFlags::IS_JUMPING; + auto result = ServerCollision::ResolveCapsule( + m_Colliders, state.position, + PLAYER_HEIGHT, CAPSULE_RADIUS, state.velocity); + state.position = result.position; + state.velocity = result.velocity; + if (result.isGrounded) + { + state.stateFlags |= NetStateFlags::IS_GROUNDED; + state.stateFlags &= ~NetStateFlags::IS_JUMPING; + } + else + { + state.stateFlags &= ~NetStateFlags::IS_GROUNDED; + } + } + else + { + // Fallback: simple floor at y=0 + if (state.position.y <= 0.0f) + { + state.position.y = 0.0f; + state.velocity.y = 0.0f; + state.stateFlags |= NetStateFlags::IS_GROUNDED; + state.stateFlags &= ~NetStateFlags::IS_JUMPING; + } } } @@ -360,3 +461,149 @@ void GameServer::BroadcastSnapshots() m_pNetwork->SendSnapshotToPlayer(myId, snapshot); } } + +//----------------------------------------------------------------------------- +// ProcessFiring - Handle fire rate and hitscan for a player +//----------------------------------------------------------------------------- +void GameServer::ProcessFiring(PlayerData& shooter, uint8_t shooterId) +{ + bool isFiring = (shooter.lastInput.buttons & InputButtons::FIRE) != 0; + + if (!isFiring) + { + shooter.fireTimer = 0.0; + return; + } + + // Determine RPM and damage based on team + double rpm = (shooter.teamId == PlayerTeam::RED) ? RED_RPM : BLUE_RPM; + uint8_t damage = (shooter.teamId == PlayerTeam::RED) ? RED_DAMAGE : BLUE_DAMAGE; + double fireInterval = 60.0 / rpm; + + // First shot fires immediately; subsequent shots at RPM interval + bool shouldFire = false; + if (shooter.fireTimer <= 0.0) + { + // First press — fire immediately + shouldFire = true; + shooter.fireTimer = fireInterval; + } + else + { + shooter.fireTimer -= TICK_DURATION; + if (shooter.fireTimer <= 0.0) + { + shouldFire = true; + shooter.fireTimer += fireInterval; + } + } + + if (!shouldFire) return; + + // Increment fire counter + shooter.fireCounter++; + + // Cast ray from eye position + Float3 eyePos = { + shooter.state.position.x, + shooter.state.position.y + 1.5f, // eye height (match client camera) + shooter.state.position.z + }; + Float3 rayDir = ServerRaycast::DirectionFromYawPitch( + shooter.state.yaw, shooter.state.pitch); + + // Test against all other alive players (no friendly fire) + // Also test against world geometry — player hit only counts if closer than world + uint8_t hitId = 0xFF; + float hitDist = 0.0f; + float worldDist = RaycastWorld(eyePos, rayDir); + if (RaycastPlayers(eyePos, rayDir, shooterId, shooter.teamId, hitId, hitDist) + && hitDist < worldDist) + { + // Apply damage + auto hitIt = m_Players.find(hitId); + if (hitIt != m_Players.end()) + { + PlayerData& target = hitIt->second; + if (target.health > damage) + { + target.health -= damage; + } + else + { + target.health = 0; + target.state.stateFlags |= NetStateFlags::IS_DEAD; + target.respawnTimer = RESPAWN_TIME; + target.state.velocity = { 0.0f, 0.0f, 0.0f }; + printf("[GameServer] Player %u killed Player %u\n", shooterId, hitId); + } + + // Record hit for attacker's hit marker + shooter.state.hitByPlayerId = hitId; + } + } +} + +//----------------------------------------------------------------------------- +// RaycastPlayers - Test ray against all alive enemy players +// +// Returns true if any enemy player is hit. outHitId/outDist are set to the +// closest hit. Players on excludeTeam are skipped (no friendly fire). +//----------------------------------------------------------------------------- +bool GameServer::RaycastPlayers(const Float3& origin, const Float3& dir, + uint8_t excludeId, uint8_t excludeTeam, + uint8_t& outHitId, float& outDist) +{ + bool anyHit = false; + float closestT = 9999.0f; + + for (const auto& [id, player] : m_Players) + { + // Skip self + if (id == excludeId) continue; + // Skip same team (no friendly fire) + if (player.teamId == excludeTeam) continue; + // Skip dead + if (player.state.stateFlags & NetStateFlags::IS_DEAD) continue; + + float t = 0.0f; + if (ServerRaycast::RayCapsule(origin, dir, + player.state.position, PLAYER_HEIGHT, CAPSULE_RADIUS, t)) + { + if (t < closestT) + { + closestT = t; + outHitId = id; + anyHit = true; + } + } + } + + if (anyHit) + outDist = closestT; + + return anyHit; +} + +//----------------------------------------------------------------------------- +// RaycastWorld - Test ray against all map AABB colliders +// +// Returns the closest hit distance, or a very large value if no hit. +//----------------------------------------------------------------------------- +float GameServer::RaycastWorld(const Float3& origin, const Float3& dir) +{ + float closest = 99999.0f; + + for (const auto& col : m_Colliders) + { + float t = 0.0f; + if (ServerRaycast::RayAABB(origin, dir, + col.aabb.min, col.aabb.max, t)) + { + if (t < closest) + closest = t; + } + } + + return closest; +} diff --git a/Network/game_server.h b/Network/game_server.h index 2426ca6..43aa7ff 100644 --- a/Network/game_server.h +++ b/Network/game_server.h @@ -13,6 +13,8 @@ //============================================================================= #include "net_common.h" +#include "server_collision.h" +#include "server_raycast.h" #include #include @@ -55,11 +57,21 @@ class GameServer InputCmd lastInput{}; double reloadTimer = 0.0; uint8_t teamId = PlayerTeam::RED; + // Combat + uint8_t health = 200; + double respawnTimer = 0.0; + double fireTimer = 0.0; + uint16_t fireCounter = 0; }; void Tick(); void ProcessPlayerEvents(); void ProcessInputCmd(const InputCmd& cmd, uint8_t playerId); + void ProcessFiring(PlayerData& shooter, uint8_t shooterId); + bool RaycastPlayers(const Float3& origin, const Float3& dir, + uint8_t excludeId, uint8_t excludeTeam, + uint8_t& outHitId, float& outDist); + float RaycastWorld(const Float3& origin, const Float3& dir); void SimulatePlayerPhysics(PlayerData& player); void SimulatePhysics(); void BroadcastSnapshots(); @@ -80,4 +92,19 @@ class GameServer // Per-player game state std::unordered_map m_Players; + + // Collision world for gravity + std::vector m_Colliders; + + // Player collision parameters (must match client) + static constexpr float PLAYER_HEIGHT = 1.6f; + static constexpr float CAPSULE_RADIUS = 0.3f; + + // Weapon parameters per team + static constexpr double RED_RPM = 600.0; + static constexpr uint8_t RED_DAMAGE = 34; + static constexpr double BLUE_RPM = 800.0; + static constexpr uint8_t BLUE_DAMAGE = 25; + static constexpr uint8_t MAX_HEALTH = 200; + static constexpr double RESPAWN_TIME = 2.0; }; diff --git a/Network/map_colliders.h b/Network/map_colliders.h new file mode 100644 index 0000000..b04b88d --- /dev/null +++ b/Network/map_colliders.h @@ -0,0 +1,84 @@ +#pragma once +//============================================================================= +// map_colliders.h +// +// Shared map data definitions. +// This file is used by BOTH client and server — keep in sync! +// No DirectXMath dependency — pure C structs. +// +// MAP_GRID[][] defines the obstacle layout (1=block, 0=empty). +// MAP_COLLIDERS[] defines merged AABBs for physics/hitscan. +// The client generates individual cube draw calls from the grid; +// the server only reads MAP_COLLIDERS[]. +//============================================================================= + +//----------------------------------------------------------------------------- +// Grid-based map layout +//----------------------------------------------------------------------------- +static const int MAP_GRID_ROWS = 20; +static const int MAP_GRID_COLS = 20; +static const int MAP_BLOCK_HEIGHT = 4; // cubes stacked per obstacle cell +static const float MAP_OFFSET_X = -10.0f; // world X offset (center the grid) +static const float MAP_OFFSET_Z = -10.0f; // world Z offset (center the grid) + +static const int MAP_GRID[MAP_GRID_ROWS][MAP_GRID_COLS] = +{ + {0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0}, + {0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0}, + {0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0}, + {0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0}, + {0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0}, + {0,0,0,0,0,1,1,1,0,0,0,0,1,1,1,0,0,0,0,0}, + {0,0,0,0,0,1,1,1,0,0,0,0,1,1,1,0,0,0,0,0}, + {0,0,0,0,0,1,1,1,0,0,0,0,1,1,1,0,0,0,0,0}, + {0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0}, + {0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0}, + {0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0}, + {0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0}, + {0,0,0,0,0,1,1,1,0,0,0,0,1,1,1,0,0,0,0,0}, + {0,0,0,0,0,1,1,1,0,0,0,0,1,1,1,0,0,0,0,0}, + {0,0,0,0,0,1,1,1,0,0,0,0,1,1,1,0,0,0,0,0}, + {0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0}, + {0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0}, + {0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0}, + {0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0}, + {0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0}, +}; + +//----------------------------------------------------------------------------- +// Collider definition (physics / hitscan) +//----------------------------------------------------------------------------- +struct MapColliderDef +{ + float minX, minY, minZ; + float maxX, maxY, maxZ; + bool isGround; +}; + +// ============================================================================ +// COLLIDERS — Manually merged AABBs for physics/hitscan. +// +// Grid cell (row, col) maps to world: +// X = col + MAP_OFFSET_X, Z = row + MAP_OFFSET_Z +// A group from (r1,c1)-(r2,c2) → AABB: +// min = (c1-10, 0, r1-10) max = (c2+1-10, 4, r2+1-10) +// ============================================================================ +static const MapColliderDef MAP_COLLIDERS[] = +{ + // AABB min AABB max ground + + // --- Ground plane --- + { -128.0f,-1.0f,-128.0f, 128.0f, 0.0f, 128.0f, true }, + + // --- Interior blocks (3x3 each, height 4) --- + // Block A: rows 5-7, cols 5-7 + { -5.0f, 0.0f, -5.0f, -2.0f, 4.0f, -2.0f, true }, + // Block B: rows 5-7, cols 12-14 + { 2.0f, 0.0f, -5.0f, 5.0f, 4.0f, -2.0f, true }, + // Block C: rows 12-14, cols 5-7 + { -5.0f, 0.0f, 2.0f, -2.0f, 4.0f, 5.0f, true }, + // Block D: rows 12-14, cols 12-14 + { 2.0f, 0.0f, 2.0f, 5.0f, 4.0f, 5.0f, true }, +}; + +static const int MAP_COLLIDER_COUNT = sizeof(MAP_COLLIDERS) / sizeof(MAP_COLLIDERS[0]); diff --git a/Network/net_common.h b/Network/net_common.h index 0004b1b..016aed8 100644 --- a/Network/net_common.h +++ b/Network/net_common.h @@ -64,6 +64,7 @@ constexpr uint32_t IS_FIRING = 1 << 2; constexpr uint32_t IS_ADS = 1 << 3; constexpr uint32_t IS_RELOADING = 1 << 4; constexpr uint32_t IS_RELOAD_EMPTY = 1 << 5; +constexpr uint32_t IS_DEAD = 1 << 6; } // namespace NetStateFlags //----------------------------------------------------------------------------- @@ -76,6 +77,9 @@ struct NetPlayerState { float yaw; // Camera yaw float pitch; // Camera pitch uint32_t stateFlags; // Bitfield of StateFlags + uint8_t health; // 0-200, server authoritative + uint8_t hitByPlayerId; // 0xFF = no hit, else attacker ID + uint16_t fireCounter; // Server-tracked fire count }; //----------------------------------------------------------------------------- @@ -125,7 +129,7 @@ struct Snapshot { //----------------------------------------------------------------------------- static_assert(sizeof(InputCmd) == 24, "InputCmd size changed - update network serialization"); -static_assert(sizeof(NetPlayerState) == 40, +static_assert(sizeof(NetPlayerState) == 44, "NetPlayerState size changed - update network serialization"); -static_assert(sizeof(RemotePlayerEntry) == 44, +static_assert(sizeof(RemotePlayerEntry) == 48, "RemotePlayerEntry size changed - update network serialization"); diff --git a/Network/server_collision.h b/Network/server_collision.h new file mode 100644 index 0000000..a7d41f1 --- /dev/null +++ b/Network/server_collision.h @@ -0,0 +1,185 @@ +#pragma once +//============================================================================= +// server_collision.h +// +// Pure-math collision detection for game_server. +// No DirectXMath dependency - uses Float3 from net_common.h. +// Must be kept in sync with game_client CollisionWorld logic. +//============================================================================= + +#include "net_common.h" +#include +#include +#include + +struct ServerAABB +{ + Float3 min; + Float3 max; +}; + +struct ServerCollider +{ + ServerAABB aabb; + bool isGround; +}; + +struct ServerCollisionResult +{ + Float3 position; + Float3 velocity; + bool isGrounded; +}; + +//----------------------------------------------------------------------------- +// Inline implementation (header-only for simplicity) +//----------------------------------------------------------------------------- +namespace ServerCollision +{ + +inline Float3 ClosestPointOnSegment(const Float3& segA, const Float3& segB, const Float3& point) +{ + float abx = segB.x - segA.x; + float aby = segB.y - segA.y; + float abz = segB.z - segA.z; + + float abLenSq = abx * abx + aby * aby + abz * abz; + if (abLenSq < 1e-8f) + return segA; + + float apx = point.x - segA.x; + float apy = point.y - segA.y; + float apz = point.z - segA.z; + + float t = (apx * abx + apy * aby + apz * abz) / abLenSq; + t = std::clamp(t, 0.0f, 1.0f); + + return { segA.x + abx * t, segA.y + aby * t, segA.z + abz * t }; +} + +inline Float3 ClampToAABB(const Float3& point, const ServerAABB& aabb) +{ + return { + std::clamp(point.x, aabb.min.x, aabb.max.x), + std::clamp(point.y, aabb.min.y, aabb.max.y), + std::clamp(point.z, aabb.min.z, aabb.max.z) + }; +} + +inline ServerCollisionResult ResolveCapsule( + const std::vector& colliders, + const Float3& capsuleBottom, + float capsuleHeight, + float capsuleRadius, + const Float3& velocity) +{ + ServerCollisionResult result; + result.position = capsuleBottom; + result.velocity = velocity; + result.isGrounded = false; + + float innerBottom = capsuleRadius; + float innerTop = capsuleHeight - capsuleRadius; + if (innerTop < innerBottom) innerTop = innerBottom; + + for (int iter = 0; iter < 4; ++iter) + { + bool anyCollision = false; + + for (const auto& collider : colliders) + { + const ServerAABB& aabb = collider.aabb; + + Float3 segA = { + result.position.x, + result.position.y + innerBottom, + result.position.z + }; + Float3 segB = { + result.position.x, + result.position.y + innerTop, + result.position.z + }; + + Float3 closestOnSeg = ClosestPointOnSegment(segA, segB, + { std::clamp(segA.x, aabb.min.x, aabb.max.x), + std::clamp(segA.y, aabb.min.y, aabb.max.y), + std::clamp(segA.z, aabb.min.z, aabb.max.z) }); + + Float3 closestOnAABB = ClampToAABB(closestOnSeg, aabb); + + closestOnSeg = ClosestPointOnSegment(segA, segB, closestOnAABB); + closestOnAABB = ClampToAABB(closestOnSeg, aabb); + + float dx = closestOnSeg.x - closestOnAABB.x; + float dy = closestOnSeg.y - closestOnAABB.y; + float dz = closestOnSeg.z - closestOnAABB.z; + float distSq = dx * dx + dy * dy + dz * dz; + + if (distSq > capsuleRadius * capsuleRadius) + continue; + + float dist = sqrtf(distSq); + float nx, ny, nz; + + if (dist > 1e-6f) + { + nx = dx / dist; + ny = dy / dist; + nz = dz / dist; + } + else + { + float cx = (aabb.min.x + aabb.max.x) * 0.5f; + float cy = (aabb.min.y + aabb.max.y) * 0.5f; + float cz = (aabb.min.z + aabb.max.z) * 0.5f; + float hx = (aabb.max.x - aabb.min.x) * 0.5f; + float hy = (aabb.max.y - aabb.min.y) * 0.5f; + float hz = (aabb.max.z - aabb.min.z) * 0.5f; + + float relX = closestOnSeg.x - cx; + float relY = closestOnSeg.y - cy; + float relZ = closestOnSeg.z - cz; + + float overlapX = hx + capsuleRadius - fabsf(relX); + float overlapY = hy + capsuleRadius - fabsf(relY); + float overlapZ = hz + capsuleRadius - fabsf(relZ); + + nx = ny = nz = 0.0f; + if (overlapX <= overlapY && overlapX <= overlapZ) + nx = (relX >= 0.0f) ? 1.0f : -1.0f; + else if (overlapY <= overlapX && overlapY <= overlapZ) + ny = (relY >= 0.0f) ? 1.0f : -1.0f; + else + nz = (relZ >= 0.0f) ? 1.0f : -1.0f; + } + + float penetration = capsuleRadius - dist; + + result.position.x += nx * penetration; + result.position.y += ny * penetration; + result.position.z += nz * penetration; + + float velDotN = result.velocity.x * nx + result.velocity.y * ny + result.velocity.z * nz; + if (velDotN < 0.0f) + { + result.velocity.x -= velDotN * nx; + result.velocity.y -= velDotN * ny; + result.velocity.z -= velDotN * nz; + } + + if (collider.isGround && ny > 0.7f) + { + result.isGrounded = true; + } + + anyCollision = true; + } + + if (!anyCollision) break; + } + + return result; +} + +} // namespace ServerCollision diff --git a/Network/server_raycast.h b/Network/server_raycast.h new file mode 100644 index 0000000..c523d50 --- /dev/null +++ b/Network/server_raycast.h @@ -0,0 +1,244 @@ +#pragma once +//============================================================================= +// server_raycast.h +// +// Ray-Capsule intersection test for server-side hitscan. +// Pure math, no DirectXMath dependency — uses Float3 from net_common.h. +//============================================================================= + +#include "net_common.h" +#include + +namespace ServerRaycast { + +//----------------------------------------------------------------------------- +// Dot product +//----------------------------------------------------------------------------- +inline float Dot(const Float3& a, const Float3& b) +{ + return a.x * b.x + a.y * b.y + a.z * b.z; +} + +//----------------------------------------------------------------------------- +// Subtract +//----------------------------------------------------------------------------- +inline Float3 Sub(const Float3& a, const Float3& b) +{ + return { a.x - b.x, a.y - b.y, a.z - b.z }; +} + +//----------------------------------------------------------------------------- +// Add +//----------------------------------------------------------------------------- +inline Float3 Add(const Float3& a, const Float3& b) +{ + return { a.x + b.x, a.y + b.y, a.z + b.z }; +} + +//----------------------------------------------------------------------------- +// Scale +//----------------------------------------------------------------------------- +inline Float3 Scale(const Float3& v, float s) +{ + return { v.x * s, v.y * s, v.z * s }; +} + +//----------------------------------------------------------------------------- +// RaySphere — test ray against a sphere +// +// Returns true if hit, outT = entry distance along ray. +//----------------------------------------------------------------------------- +inline bool RaySphere(const Float3& origin, const Float3& dir, + const Float3& center, float radius, float& outT) +{ + Float3 oc = Sub(origin, center); + float a = Dot(dir, dir); + float h = Dot(oc, dir); + float c = Dot(oc, oc) - radius * radius; + float disc = h * h - a * c; + if (disc < 0.0f) return false; + float sqrtDisc = sqrtf(disc); + float t = (-h - sqrtDisc) / a; + if (t < 0.0f) t = (-h + sqrtDisc) / a; + if (t < 0.0f) return false; + outT = t; + return true; +} + +//----------------------------------------------------------------------------- +// RayCapsule — test ray against a capsule (cylinder + two hemispheres) +// +// Decomposes the capsule into: +// 1. Infinite cylinder (clamped to segment extent) +// 2. Bottom hemisphere (sphere at segA) +// 3. Top hemisphere (sphere at segB) +// Returns the closest hit among all three. +// +// Parameters: +// origin — ray start position (eye position) +// dir — ray direction (normalized) +// capBottom — capsule foot position (bottom of capsule) +// capHeight — total height of capsule +// capRadius — capsule radius +// outT — [out] hit distance along ray (if hit) +// maxRange — maximum ray range (default 200m) +// +// Returns: true if hit within maxRange +//----------------------------------------------------------------------------- +inline bool RayCapsule(const Float3& origin, const Float3& dir, + const Float3& capBottom, float capHeight, float capRadius, + float& outT, float maxRange = 200.0f) +{ + // Capsule segment endpoints (sphere centers) + Float3 segA = { capBottom.x, capBottom.y + capRadius, capBottom.z }; + Float3 segB = { capBottom.x, capBottom.y + capHeight - capRadius, capBottom.z }; + Float3 segDir = Sub(segB, segA); + float segLenSq = Dot(segDir, segDir); + + float bestT = maxRange + 1.0f; + bool hasHit = false; + + // 1. Test ray against infinite cylinder, clamp to segment extent + if (segLenSq > 1e-8f) + { + float segLen = sqrtf(segLenSq); + Float3 axis = Scale(segDir, 1.0f / segLen); + Float3 oc = Sub(origin, segA); + + float dDotAxis = Dot(dir, axis); + float ocDotAxis = Dot(oc, axis); + + // Project out the capsule axis component + Float3 dPerp = Sub(dir, Scale(axis, dDotAxis)); + Float3 ocPerp = Sub(oc, Scale(axis, ocDotAxis)); + + float a = Dot(dPerp, dPerp); + float b = Dot(dPerp, ocPerp); + float c = Dot(ocPerp, ocPerp) - capRadius * capRadius; + + float disc = b * b - a * c; + if (disc >= 0.0f && a > 1e-8f) + { + float sqrtDisc = sqrtf(disc); + float t = (-b - sqrtDisc) / a; + if (t < 0.0f) t = (-b + sqrtDisc) / a; + + if (t >= 0.0f && t <= maxRange) + { + float hitOnAxis = ocDotAxis + t * dDotAxis; + if (hitOnAxis >= 0.0f && hitOnAxis <= segLen) + { + bestT = t; + hasHit = true; + } + } + } + } + + // 2. Test against bottom hemisphere (sphere at segA) + float tSphere; + if (RaySphere(origin, dir, segA, capRadius, tSphere)) + { + if (tSphere <= maxRange && tSphere < bestT) + { + bestT = tSphere; + hasHit = true; + } + } + + // 3. Test against top hemisphere (sphere at segB) + if (RaySphere(origin, dir, segB, capRadius, tSphere)) + { + if (tSphere <= maxRange && tSphere < bestT) + { + bestT = tSphere; + hasHit = true; + } + } + + if (hasHit) + { + outT = bestT; + return true; + } + return false; +} + +//----------------------------------------------------------------------------- +// DirectionFromYawPitch — compute normalized direction vector +//----------------------------------------------------------------------------- +inline Float3 DirectionFromYawPitch(float yaw, float pitch) +{ + float cosPitch = cosf(pitch); + return { + sinf(yaw) * cosPitch, + sinf(pitch), + cosf(yaw) * cosPitch + }; +} + +//----------------------------------------------------------------------------- +// RayAABB — test ray against an axis-aligned bounding box (slab method) +// +// Returns true if hit within maxRange, outT = entry distance along ray. +//----------------------------------------------------------------------------- +inline bool RayAABB(const Float3& origin, const Float3& dir, + const Float3& aabbMin, const Float3& aabbMax, + float& outT, float maxRange = 200.0f) +{ + float tMin = 0.0f; + float tMax = maxRange; + + // X axis + if (fabsf(dir.x) < 1e-8f) + { + if (origin.x < aabbMin.x || origin.x > aabbMax.x) return false; + } + else + { + float invD = 1.0f / dir.x; + float t1 = (aabbMin.x - origin.x) * invD; + float t2 = (aabbMax.x - origin.x) * invD; + if (t1 > t2) { float tmp = t1; t1 = t2; t2 = tmp; } + if (t1 > tMin) tMin = t1; + if (t2 < tMax) tMax = t2; + if (tMin > tMax) return false; + } + + // Y axis + if (fabsf(dir.y) < 1e-8f) + { + if (origin.y < aabbMin.y || origin.y > aabbMax.y) return false; + } + else + { + float invD = 1.0f / dir.y; + float t1 = (aabbMin.y - origin.y) * invD; + float t2 = (aabbMax.y - origin.y) * invD; + if (t1 > t2) { float tmp = t1; t1 = t2; t2 = tmp; } + if (t1 > tMin) tMin = t1; + if (t2 < tMax) tMax = t2; + if (tMin > tMax) return false; + } + + // Z axis + if (fabsf(dir.z) < 1e-8f) + { + if (origin.z < aabbMin.z || origin.z > aabbMax.z) return false; + } + else + { + float invD = 1.0f / dir.z; + float t1 = (aabbMin.z - origin.z) * invD; + float t2 = (aabbMax.z - origin.z) * invD; + if (t1 > t2) { float tmp = t1; t1 = t2; t2 = tmp; } + if (t1 > tMin) tMin = t1; + if (t2 < tMax) tMax = t2; + if (tMin > tMax) return false; + } + + outT = tMin; + return true; +} + +} // namespace ServerRaycast