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
This commit is contained in:
Atrik 2026-01-13 06:15:32 +01:00 committed by Vantha
parent 6cdbdae87c
commit 99e3799883
6 changed files with 98 additions and 18 deletions

View file

@ -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);
};

View file

@ -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);

View file

@ -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<CFixedVector2D> 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<ICmpUnitMotion> cmpControllerMotion(GetSimContext(), m_FormationController);

View file

@ -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<CFixedVector2D> GetFormationOffset() const override
{
return m_Script.Call<std::optional<CFixedVector2D>>("GetFormationOffset");
}
bool IsMoveRequested() const override
{
return m_Script.Call<bool>("IsMoveRequested");
}
bool IsMovingAsFormation() const override
{
return m_Script.Call<bool>("IsMovingAsFormation");
}
fixed GetSpeed() const override
{
return m_Script.Call<fixed>("GetSpeed");

View file

@ -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<CFixedVector2D> 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.
*/

View file

@ -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 <js/experimental/TypedData.h>
#include <string>
#include <vector>
#include <optional>
#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<CFixedVector2D>(const ScriptRequest& rq, JS::Mu
ret.setObject(*objVec);
}
template<> bool Script::FromJSVal<std::optional<CFixedVector2D>>(const ScriptRequest& rq, JS::HandleValue v, std::optional<CFixedVector2D>& 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<std::optional<CFixedVector2D>>(const ScriptRequest& rq, JS::MutableHandleValue ret, const std::optional<CFixedVector2D>& val)
{
if (!val.has_value())
ret.setNull();
else
ToJSVal(rq, ret, val.value());
}
template<> void Script::ToJSVal<Grid<u8> >(const ScriptRequest& rq, JS::MutableHandleValue ret, const Grid<u8>& val)
{
u32 length = (u32)(val.m_W * val.m_H);