From a7330cf46984c6da1f0cf792c3385bacea57c3f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lancelot=20de=20Ferri=C3=A8re?= Date: Sat, 26 Apr 2025 17:48:41 +0200 Subject: [PATCH] Improve group movement by distributing units around the target. This improves pathfinding feel as units no longer try to clump on a particular point. However, can lead to oddities if there's a lot of impassable terrain around. Closes #7791 --- .../public/simulation/helpers/Commands.js | 45 ++++++++-- .../simulation2/components/CCmpPathfinder.cpp | 85 ++++++++++++++++++- .../components/CCmpPathfinder_Common.h | 4 +- .../simulation2/components/ICmpPathfinder.cpp | 3 +- .../simulation2/components/ICmpPathfinder.h | 7 +- .../components/tests/test_TerritoryManager.h | 3 +- 6 files changed, 133 insertions(+), 14 deletions(-) diff --git a/binaries/data/mods/public/simulation/helpers/Commands.js b/binaries/data/mods/public/simulation/helpers/Commands.js index 470337ac6f..1f73fef809 100644 --- a/binaries/data/mods/public/simulation/helpers/Commands.js +++ b/binaries/data/mods/public/simulation/helpers/Commands.js @@ -104,9 +104,18 @@ var g_Commands = { "walk": function(player, cmd, data) { - GetFormationUnitAIs(data.entities, player, cmd, data.formation).forEach(cmpUnitAI => { - cmpUnitAI.Walk(cmd.x, cmd.z, cmd.queued, cmd.pushFront); - }); + const ents = data.entities.length; + const uais = GetFormationUnitAIs(data.entities, player, cmd, data.formation); + if (uais.length === 1 || uais.length !== ents) + uais.forEach(cmpUnitAI => { + cmpUnitAI.Walk(cmd.x, cmd.z, cmd.queued, cmd.pushFront); + }); + else { + const positions = Engine.QueryInterface(SYSTEM_ENTITY, IID_Pathfinder).DistributeAround(data.entities, cmd.x, cmd.z); + uais.forEach((cmpUnitAI, index) => { + cmpUnitAI.Walk(positions[index].x, positions[index].y, cmd.queued, cmd.pushFront); + }); + } }, "walk-custom": function(player, cmd, data) @@ -130,9 +139,18 @@ var g_Commands = { "attack-walk": function(player, cmd, data) { - GetFormationUnitAIs(data.entities, player, cmd, data.formation).forEach(cmpUnitAI => { - cmpUnitAI.WalkAndFight(cmd.x, cmd.z, cmd.targetClasses, cmd.allowCapture, cmd.queued, cmd.pushFront); - }); + const ents = data.entities.length; + const uais = GetFormationUnitAIs(data.entities, player, cmd, data.formation); + if (uais.length === 1 || uais.length !== ents) + uais.forEach(cmpUnitAI => { + cmpUnitAI.WalkAndFight(cmd.x, cmd.z, cmd.targetClasses, cmd.allowCapture, cmd.queued, cmd.pushFront); + }); + else { + const positions = Engine.QueryInterface(SYSTEM_ENTITY, IID_Pathfinder).DistributeAround(data.entities, cmd.x, cmd.z); + uais.forEach((cmpUnitAI, index) => { + cmpUnitAI.WalkAndFight(positions[index].x, positions[index].y, cmd.targetClasses, cmd.allowCapture, cmd.queued, cmd.pushFront); + }); + } }, "attack-walk-custom": function(player, cmd, data) @@ -152,9 +170,18 @@ var g_Commands = { "patrol": function(player, cmd, data) { - GetFormationUnitAIs(data.entities, player, cmd, data.formation).forEach(cmpUnitAI => - cmpUnitAI.Patrol(cmd.x, cmd.z, cmd.targetClasses, cmd.allowCapture, cmd.queued) - ); + const ents = data.entities.length; + const uais = GetFormationUnitAIs(data.entities, player, cmd, data.formation); + if (uais.length === 1 || uais.length !== ents) + uais.forEach(cmpUnitAI => { + cmpUnitAI.Patrol(cmd.x, cmd.z, cmd.targetClasses, cmd.allowCapture, cmd.queued) + }); + else { + const positions = Engine.QueryInterface(SYSTEM_ENTITY, IID_Pathfinder).DistributeAround(data.entities, cmd.x, cmd.z); + uais.forEach((cmpUnitAI, index) => { + cmpUnitAI.Patrol(positions[index].x, positions[index].y, cmd.targetClasses, cmd.allowCapture, cmd.queued) + }); + } }, "heal": function(player, cmd, data) diff --git a/source/simulation2/components/CCmpPathfinder.cpp b/source/simulation2/components/CCmpPathfinder.cpp index 0096e5cb93..2f6603c4aa 100644 --- a/source/simulation2/components/CCmpPathfinder.cpp +++ b/source/simulation2/components/CCmpPathfinder.cpp @@ -1,4 +1,4 @@ -/* Copyright (C) 2024 Wildfire Games. +/* Copyright (C) 2025 Wildfire Games. * This file is part of 0 A.D. * * 0 A.D. is free software: you can redistribute it and/or modify @@ -27,6 +27,7 @@ #include "simulation2/MessageTypes.h" #include "simulation2/components/ICmpObstruction.h" #include "simulation2/components/ICmpObstructionManager.h" +#include "simulation2/components/ICmpPosition.h" #include "simulation2/components/ICmpTerrain.h" #include "simulation2/components/ICmpWaterManager.h" #include "simulation2/helpers/HierarchicalPathfinder.h" @@ -857,6 +858,88 @@ bool CCmpPathfinder::IsGoalReachable(entity_pos_t x0, entity_pos_t z0, const Pat return m_PathfinderHier->IsGoalReachable(i, j, goal, passClass); } +std::vector CCmpPathfinder::DistributeAround(std::vector units, entity_pos_t x, entity_pos_t z) const +{ + PROFILE2("DistributeAround"); + + std::vector positions; + if (units.empty()) + return positions; + + positions.reserve(units.size()); + + // Initialize spiral parameters + fixed angle = fixed::Zero(); + fixed radius = fixed::FromInt(1); + const fixed increment = fixed::FromInt(7) / 4; + + // Calculate the angle step so that at this radius we don't crowd the units + fixed angleStep = fixed::FromInt(9).MulDiv(fixed::FromInt(1), fixed::Pi().Multiply(radius)); + + for (size_t i = 0; i < units.size(); ++i) + { + CFixedVector2D offset(radius, fixed::Zero()); + offset = offset.Rotate(angle); + + positions.emplace_back(x + offset.X, z + offset.Y); + + angle += angleStep; + // If we complete a circle, increase radius and recalculate angle step + if (angle >= fixed::Pi().Multiply(fixed::FromInt(2))) + { + angle = fixed::Zero(); + radius += increment; + angleStep = fixed::FromInt(9).MulDiv(fixed::FromInt(1), fixed::Pi().Multiply(radius)); + } + } + + // Get current unit positions to calculate travel distances + std::vector unitPositions; + unitPositions.reserve(units.size()); + + CmpPtr cmpPosition(GetSystemEntity()); + for (entity_id_t unit : units) + { + CmpPtr unitPos(GetSimContext(), unit); + if (!unitPos || !unitPos->IsInWorld()) + return positions; + + CFixedVector2D pos(unitPos->GetPosition2D()); + unitPositions.push_back(pos); + } + + // Optimize positions by swapping them if it reduces total travel distance. + bool improved; + do + { + improved = false; + for (size_t i = 0; i < positions.size(); ++i) + { + // Helper to compute squared distance between two points as integers + auto distSq = [](const CFixedVector2D& p1, const CFixedVector2D& p2) -> u32 { + i32 dx = (p1.X - p2.X).ToInt_RoundToInfinity(); + i32 dy = (p1.Y - p2.Y).ToInt_RoundToInfinity(); + return dx*dx + dy*dy; + }; + + for (size_t j = i + 1; j < positions.size(); ++j) + { + u32 currentDistSq = distSq(positions[i], unitPositions[i]) + distSq(positions[j], unitPositions[j]); + u32 swappedDistSq = distSq(positions[j], unitPositions[i]) + distSq(positions[i], unitPositions[j]); + + // Swap if it reduces total squared distance + if (swappedDistSq < currentDistSq) + { + std::swap(positions[i], positions[j]); + improved = true; + } + } + } + } while (improved); + + return positions; +} + bool CCmpPathfinder::CheckMovement(const IObstructionTestFilter& filter, entity_pos_t x0, entity_pos_t z0, entity_pos_t x1, entity_pos_t z1, entity_pos_t r, pass_class_t passClass) const diff --git a/source/simulation2/components/CCmpPathfinder_Common.h b/source/simulation2/components/CCmpPathfinder_Common.h index 970838edbf..3df751c316 100644 --- a/source/simulation2/components/CCmpPathfinder_Common.h +++ b/source/simulation2/components/CCmpPathfinder_Common.h @@ -1,4 +1,4 @@ -/* Copyright (C) 2022 Wildfire Games. +/* Copyright (C) 2025 Wildfire Games. * This file is part of 0 A.D. * * 0 A.D. is free software: you can redistribute it and/or modify @@ -225,6 +225,8 @@ public: void SetAtlasOverlay(bool enable, pass_class_t passClass = 0) override; + std::vector DistributeAround(std::vector units, entity_pos_t x, entity_pos_t z) const override; + bool CheckMovement(const IObstructionTestFilter& filter, entity_pos_t x0, entity_pos_t z0, entity_pos_t x1, entity_pos_t z1, entity_pos_t r, pass_class_t passClass) const override; ICmpObstruction::EFoundationCheck CheckUnitPlacement(const IObstructionTestFilter& filter, entity_pos_t x, entity_pos_t z, entity_pos_t r, pass_class_t passClass, bool onlyCenterPoint) const override; diff --git a/source/simulation2/components/ICmpPathfinder.cpp b/source/simulation2/components/ICmpPathfinder.cpp index d571872cbb..a838d9e680 100644 --- a/source/simulation2/components/ICmpPathfinder.cpp +++ b/source/simulation2/components/ICmpPathfinder.cpp @@ -1,4 +1,4 @@ -/* Copyright (C) 2022 Wildfire Games. +/* Copyright (C) 2025 Wildfire Games. * This file is part of 0 A.D. * * 0 A.D. is free software: you can redistribute it and/or modify @@ -27,4 +27,5 @@ DEFINE_INTERFACE_METHOD("SetHierDebugOverlay", ICmpPathfinder, SetHierDebugOverl DEFINE_INTERFACE_METHOD("GetClearance", ICmpPathfinder, GetClearance) DEFINE_INTERFACE_METHOD("GetPassabilityClass", ICmpPathfinder, GetPassabilityClass) DEFINE_INTERFACE_METHOD("UpdateGrid", ICmpPathfinder, UpdateGrid) +DEFINE_INTERFACE_METHOD("DistributeAround", ICmpPathfinder, DistributeAround) END_INTERFACE_WRAPPER(Pathfinder) diff --git a/source/simulation2/components/ICmpPathfinder.h b/source/simulation2/components/ICmpPathfinder.h index ea406f39b7..53873244e2 100644 --- a/source/simulation2/components/ICmpPathfinder.h +++ b/source/simulation2/components/ICmpPathfinder.h @@ -1,4 +1,4 @@ -/* Copyright (C) 2021 Wildfire Games. +/* Copyright (C) 2025 Wildfire Games. * This file is part of 0 A.D. * * 0 A.D. is free software: you can redistribute it and/or modify @@ -130,6 +130,11 @@ public: */ virtual void SetDebugPath(entity_pos_t x0, entity_pos_t z0, const PathGoal& goal, pass_class_t passClass) = 0; + /** + * Distribute units around a point, returning a list of positions. + */ + virtual std::vector DistributeAround(std::vector units, entity_pos_t x, entity_pos_t z) const = 0; + /** * @return true if the goal is reachable from (x0, z0) for the given passClass, false otherwise. * Warning: this is synchronous, somewhat expensive and not should not be called too liberally. diff --git a/source/simulation2/components/tests/test_TerritoryManager.h b/source/simulation2/components/tests/test_TerritoryManager.h index a99c21f116..5c85ded49b 100644 --- a/source/simulation2/components/tests/test_TerritoryManager.h +++ b/source/simulation2/components/tests/test_TerritoryManager.h @@ -1,4 +1,4 @@ -/* Copyright (C) 2024 Wildfire Games. +/* Copyright (C) 2025 Wildfire Games. * This file is part of 0 A.D. * * 0 A.D. is free software: you can redistribute it and/or modify @@ -54,6 +54,7 @@ public: virtual WaypointPath ComputeShortPathImmediate(const ShortPathRequest&) const override { return WaypointPath(); } virtual void SetDebugPath(entity_pos_t, entity_pos_t, const PathGoal&, pass_class_t) override {} virtual bool IsGoalReachable(entity_pos_t, entity_pos_t, const PathGoal&, pass_class_t) override { return false; } + virtual std::vector DistributeAround(std::vector, entity_pos_t, entity_pos_t) const override { return {}; } virtual bool CheckMovement(const IObstructionTestFilter&, entity_pos_t, entity_pos_t, entity_pos_t, entity_pos_t, entity_pos_t, pass_class_t) const override { return false; } virtual ICmpObstruction::EFoundationCheck CheckUnitPlacement(const IObstructionTestFilter&, entity_pos_t, entity_pos_t, entity_pos_t, pass_class_t, bool = false) const override { return ICmpObstruction::FOUNDATION_CHECK_SUCCESS; } virtual ICmpObstruction::EFoundationCheck CheckBuildingPlacement(const IObstructionTestFilter&, entity_pos_t, entity_pos_t, entity_pos_t, entity_pos_t, entity_pos_t, entity_id_t, pass_class_t) const override { return ICmpObstruction::FOUNDATION_CHECK_SUCCESS; }