From 99e3799883693607eaffd63d9e4077e1d668b1ab Mon Sep 17 00:00:00 2001 From: Atrik Date: Tue, 13 Jan 2026 06:15:32 +0100 Subject: [PATCH] Fix formation reshuffling after entity rename When entities in formations were renamed (e.g., during promotion), the formation would immediately recalculate all member positions, and queue movement orders causing visible shuffling. Changes: 1. Transfer existing offsets movement to the renamed entity to maintain current formation structure 2. Schedule offset recalculation for the next tick to allow proper reordering after all systems have updated This preserves formation integrity during renames while allowing eventual optimal position recalculation. Fixes #8656 --- .../public/simulation/components/Formation.js | 28 +++++++++++++++---- .../public/simulation/helpers/Transform.js | 19 +++++++++---- .../simulation2/components/CCmpUnitMotion.h | 18 ++++++++---- .../simulation2/components/ICmpUnitMotion.cpp | 12 ++++++++ .../simulation2/components/ICmpUnitMotion.h | 12 ++++++++ .../scripting/EngineScriptConversions.cpp | 27 +++++++++++++++++- 6 files changed, 98 insertions(+), 18 deletions(-) diff --git a/binaries/data/mods/public/simulation/components/Formation.js b/binaries/data/mods/public/simulation/components/Formation.js index 8341ccc83f..cfdb194463 100644 --- a/binaries/data/mods/public/simulation/components/Formation.js +++ b/binaries/data/mods/public/simulation/components/Formation.js @@ -389,7 +389,9 @@ Formation.prototype.RemoveMembers = function(ents, renamed = false) if (!ents.length) return; - this.offsets = undefined; + if (!renamed) + this.offsets = undefined; + this.members = this.members.filter(ent => !ents.includes(ent)); for (const ent of ents) @@ -441,9 +443,10 @@ Formation.prototype.RemoveMembers = function(ents, renamed = false) * * @see ArrangeFormation - To update formation layout after adding members */ -Formation.prototype.AddMembers = function(ents) +Formation.prototype.AddMembers = function(ents, renamed = false) { - this.offsets = undefined; + if (!renamed) + this.offsets = undefined; for (const ent of this.formationMembersWithAura) { @@ -1048,11 +1051,24 @@ Formation.prototype.OnGlobalEntityRenamed = function(msg) // First remove the old member to be able to reuse its position. this.RemoveMembers([msg.entity], true); - this.AddMembers([msg.newentity]); + this.AddMembers([msg.newentity], true); this.memberPositions[msg.newentity] = this.memberPositions[msg.entity]; + delete this.memberPositions[msg.entity]; - // Update Formation - // to make sure added (renamed) members will move with the controller if applicable. + if (this.resetOffsetsScheduled === undefined) + { + this.resetOffsetsScheduled = true; + + // Schedule offset reset for the next tick so that it reorders if necessary. + const cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer); + cmpTimer.SetTimeout(this.entity, IID_Formation, "ResetOffsetsAndUpdate", 0, null); + } +}; + +Formation.prototype.ResetOffsetsAndUpdate = function() +{ + this.resetOffsetsScheduled = undefined; + this.offsets = undefined; this.UpdateFormation(false, false); }; diff --git a/binaries/data/mods/public/simulation/helpers/Transform.js b/binaries/data/mods/public/simulation/helpers/Transform.js index cacd668cce..19f2d8ece1 100644 --- a/binaries/data/mods/public/simulation/helpers/Transform.js +++ b/binaries/data/mods/public/simulation/helpers/Transform.js @@ -62,6 +62,7 @@ function ChangeEntityTemplate(oldEnt, newTemplate) const cmpUnitMotion = Engine.QueryInterface(oldEnt, IID_UnitMotion); const cmpNewUnitMotion = Engine.QueryInterface(newEnt, IID_UnitMotion); + const cmpOldUnitAI = Engine.QueryInterface(oldEnt, IID_UnitAI); if (cmpUnitMotion && cmpNewUnitMotion) { const currentSpeed = cmpUnitMotion.GetCurrentSpeed(); @@ -69,6 +70,13 @@ function ChangeEntityTemplate(oldEnt, newTemplate) const acceleration = cmpUnitMotion.GetAcceleration(); cmpNewUnitMotion.SetAcceleration(acceleration); + if (cmpOldUnitAI) + { + const formationControllerID = cmpOldUnitAI.GetFormationController(); + const formationMemberOffsetPosition = cmpUnitMotion.GetFormationOffset(); + if (formationControllerID && formationMemberOffsetPosition && cmpUnitMotion.IsMovingAsFormation()) + cmpNewUnitMotion.MoveToFormationOffset(formationControllerID, formationMemberOffsetPosition.x, formationMemberOffsetPosition.y); + } } // Prevent spawning subunits on occupied positions. @@ -172,16 +180,15 @@ function ChangeEntityTemplate(oldEnt, newTemplate) Engine.PostMessage(oldEnt, MT_EntityRenamed, { "entity": oldEnt, "newentity": newEnt }); // UnitAI generally needs other components to be properly initialised. - const cmpUnitAI = Engine.QueryInterface(oldEnt, IID_UnitAI); const cmpNewUnitAI = Engine.QueryInterface(newEnt, IID_UnitAI); - if (cmpUnitAI && cmpNewUnitAI) + if (cmpOldUnitAI && cmpNewUnitAI) { - const pos = cmpUnitAI.GetHeldPosition(); + const pos = cmpOldUnitAI.GetHeldPosition(); if (pos) cmpNewUnitAI.SetHeldPosition(pos.x, pos.z); - cmpNewUnitAI.SwitchToStance(cmpUnitAI.GetStanceName()); - cmpNewUnitAI.AddOrders(cmpUnitAI.GetOrders()); - const guarded = cmpUnitAI.IsGuardOf(); + cmpNewUnitAI.SwitchToStance(cmpOldUnitAI.GetStanceName()); + cmpNewUnitAI.AddOrders(cmpOldUnitAI.GetOrders()); + const guarded = cmpOldUnitAI.IsGuardOf(); if (guarded) { const cmpGuarded = Engine.QueryInterface(guarded, IID_Guard); diff --git a/source/simulation2/components/CCmpUnitMotion.h b/source/simulation2/components/CCmpUnitMotion.h index 25be7a0742..eb4767b7a8 100644 --- a/source/simulation2/components/CCmpUnitMotion.h +++ b/source/simulation2/components/CCmpUnitMotion.h @@ -456,6 +456,11 @@ public: return m_MoveRequest.m_Type != MoveRequest::NONE; } + bool IsMovingAsFormation() const override + { + return IsFormationMember() && m_MoveRequest.m_Type == MoveRequest::OFFSET; + } + fixed GetSpeedMultiplier() const override { return m_SpeedMultiplier; @@ -588,6 +593,14 @@ public: m_FormationController = controller; } + std::optional GetFormationOffset() const override + { + if (m_MoveRequest.m_Type != MoveRequest::OFFSET) + return std::nullopt; + + return m_MoveRequest.m_Position; + } + bool IsTargetRangeReachable(entity_id_t target, entity_pos_t minRange, entity_pos_t maxRange) override; void FaceTowardsPoint(entity_pos_t x, entity_pos_t z) override; @@ -627,11 +640,6 @@ private: return m_FormationController != INVALID_ENTITY; } - bool IsMovingAsFormation() const - { - return IsFormationMember() && m_MoveRequest.m_Type == MoveRequest::OFFSET; - } - bool IsFormationControllerMoving() const { CmpPtr cmpControllerMotion(GetSimContext(), m_FormationController); diff --git a/source/simulation2/components/ICmpUnitMotion.cpp b/source/simulation2/components/ICmpUnitMotion.cpp index 750c853e15..94d7955f39 100644 --- a/source/simulation2/components/ICmpUnitMotion.cpp +++ b/source/simulation2/components/ICmpUnitMotion.cpp @@ -32,8 +32,10 @@ DEFINE_INTERFACE_METHOD("PossiblyAtDestination", ICmpUnitMotion, PossiblyAtDesti DEFINE_INTERFACE_METHOD("FaceTowardsPoint", ICmpUnitMotion, FaceTowardsPoint) DEFINE_INTERFACE_METHOD("StopMoving", ICmpUnitMotion, StopMoving) DEFINE_INTERFACE_METHOD("GetCurrentSpeed", ICmpUnitMotion, GetCurrentSpeed) +DEFINE_INTERFACE_METHOD("GetFormationOffset", ICmpUnitMotion, GetFormationOffset) DEFINE_INTERFACE_METHOD("SetCurrentSpeed", ICmpUnitMotion, SetCurrentSpeed) DEFINE_INTERFACE_METHOD("IsMoveRequested", ICmpUnitMotion, IsMoveRequested) +DEFINE_INTERFACE_METHOD("IsMovingAsFormation", ICmpUnitMotion, IsMovingAsFormation) DEFINE_INTERFACE_METHOD("GetSpeed", ICmpUnitMotion, GetSpeed) DEFINE_INTERFACE_METHOD("GetWalkSpeed", ICmpUnitMotion, GetWalkSpeed) DEFINE_INTERFACE_METHOD("GetRunMultiplier", ICmpUnitMotion, GetRunMultiplier) @@ -104,11 +106,21 @@ public: m_Script.CallVoid("SetCurrentSpeed", speed); } + std::optional GetFormationOffset() const override + { + return m_Script.Call>("GetFormationOffset"); + } + bool IsMoveRequested() const override { return m_Script.Call("IsMoveRequested"); } + bool IsMovingAsFormation() const override + { + return m_Script.Call("IsMovingAsFormation"); + } + fixed GetSpeed() const override { return m_Script.Call("GetSpeed"); diff --git a/source/simulation2/components/ICmpUnitMotion.h b/source/simulation2/components/ICmpUnitMotion.h index ce0a3c12c9..d092a5f6e1 100644 --- a/source/simulation2/components/ICmpUnitMotion.h +++ b/source/simulation2/components/ICmpUnitMotion.h @@ -109,11 +109,23 @@ public: */ virtual void SetCurrentSpeed(const fixed& speed) = 0; + /** + * Get the current formation offset if this unit is moving as a formation member. + * @returns std::nullopt if the unit is not in formation movement mode, + * otherwise returns the formation offset. + */ + virtual std::optional GetFormationOffset() const = 0; + /** * @returns true if the unit has a destination. */ virtual bool IsMoveRequested() const = 0; + /** + * @returns true if the unit is moving orderly in it's Formation. + */ + virtual bool IsMovingAsFormation() const = 0; + /** * Get the unit template walk speed after modifications. */ diff --git a/source/simulation2/scripting/EngineScriptConversions.cpp b/source/simulation2/scripting/EngineScriptConversions.cpp index 183093851c..5ab171264d 100644 --- a/source/simulation2/scripting/EngineScriptConversions.cpp +++ b/source/simulation2/scripting/EngineScriptConversions.cpp @@ -1,4 +1,4 @@ -/* Copyright (C) 2025 Wildfire Games. +/* Copyright (C) 2026 Wildfire Games. * This file is part of 0 A.D. * * 0 A.D. is free software: you can redistribute it and/or modify @@ -48,6 +48,7 @@ #include #include #include +#include #define FAIL(msg) STMT(LOGERROR(msg); return false) #define FAIL_VOID(msg) STMT(ScriptException::Raise(rq, msg); return) @@ -210,6 +211,30 @@ template<> void Script::ToJSVal(const ScriptRequest& rq, JS::Mu ret.setObject(*objVec); } +template<> bool Script::FromJSVal>(const ScriptRequest& rq, JS::HandleValue v, std::optional& out) +{ + if (v.isNullOrUndefined()) + { + out = std::nullopt; + return true; + } + + CFixedVector2D vec; + if (!FromJSVal(rq, v, vec)) + return false; + + out = vec; + return true; +} + +template<> void Script::ToJSVal>(const ScriptRequest& rq, JS::MutableHandleValue ret, const std::optional& val) +{ + if (!val.has_value()) + ret.setNull(); + else + ToJSVal(rq, ret, val.value()); +} + template<> void Script::ToJSVal >(const ScriptRequest& rq, JS::MutableHandleValue ret, const Grid& val) { u32 length = (u32)(val.m_W * val.m_H);