From fe78d1503ad09f7678504dea08d1964e09a7a6a9 Mon Sep 17 00:00:00 2001 From: triatomic <32312517+triatomic@users.noreply.github.com> Date: Wed, 26 Aug 2026 23:56:44 +0300 Subject: [PATCH 1/2] feat(client): Add SelectionCircle option drawing a ring under selected objects Options.ini: SelectionCircle = Yes draws a green ring on the ground under every selected object, so the current selection reads at a glance. The ring is a projected decal through the shadow system rather than screen space lines, so it wraps the terrain and the model genuinely occludes it. It lives in its own decal slot, so selecting a horde unit does not evict its horde ring, and it is released alongside the shadow and terrain decal when the render object is torn down - left behind it would dangle in the projected shadow manager and crash the next renderShadows. Sized from the bounding circle, scaled per kind - a building fills its circle, a soldier barely occupies the middle of one. Expects a PlainRingSelection.tga in the mod's assets; the art is tinted green at runtime, so a plain white ring works. The drawing lives in the shared W3D model draw code with a default no-op in both titles' draw module interface; the option currently drives it from Zero Hour's Drawable only. A Generals replica can follow after review. --- .../Include/Common/OptionPreferences.h | 1 + .../Source/Common/OptionPreferences.cpp | 14 +++++ .../GameClient/Module/W3DModelDraw.h | 5 ++ .../GameClient/Drawable/Draw/W3DModelDraw.cpp | 60 +++++++++++++++++++ .../GameEngine/Include/Common/DrawModule.h | 3 + .../GameEngine/Include/Common/DrawModule.h | 3 + .../GameEngine/Include/Common/GlobalData.h | 2 + .../GameEngine/Include/GameClient/Drawable.h | 3 + .../GameEngine/Source/Common/GlobalData.cpp | 2 + .../GameEngine/Source/GameClient/Drawable.cpp | 45 +++++++++++++- 10 files changed, 137 insertions(+), 1 deletion(-) diff --git a/Core/GameEngine/Include/Common/OptionPreferences.h b/Core/GameEngine/Include/Common/OptionPreferences.h index 85aba4228be..6d7f664d8ba 100644 --- a/Core/GameEngine/Include/Common/OptionPreferences.h +++ b/Core/GameEngine/Include/Common/OptionPreferences.h @@ -76,6 +76,7 @@ class OptionPreferences : public UserPreferences Real getScrollFactor(); Bool getDrawScrollAnchor(); Bool getMoveScrollAnchor(); + Bool getSelectionCircleEnabled() const; Bool getCursorCaptureEnabledInWindowedGame() const; Bool getCursorCaptureEnabledInWindowedMenu() const; Bool getCursorCaptureEnabledInFullscreenGame() const; diff --git a/Core/GameEngine/Source/Common/OptionPreferences.cpp b/Core/GameEngine/Source/Common/OptionPreferences.cpp index e681ef8b192..ec2ba7ffd20 100644 --- a/Core/GameEngine/Source/Common/OptionPreferences.cpp +++ b/Core/GameEngine/Source/Common/OptionPreferences.cpp @@ -216,6 +216,20 @@ Bool OptionPreferences::getRightMouseScrollWithAlternateMouseEnabled() const return FALSE; } +// TheSuperHackers @feature Options.ini: SelectionCircle = Yes draws a green ring on the ground +// under every selected object, so the current selection reads at a glance. +Bool OptionPreferences::getSelectionCircleEnabled() const +{ + OptionPreferences::const_iterator it = find("SelectionCircle"); + if (it == end()) + return FALSE; + + if (stricmp(it->second.str(), "yes") == 0) { + return TRUE; + } + return FALSE; +} + Bool OptionPreferences::getRetaliationModeEnabled() { OptionPreferences::const_iterator it = find("Retaliation"); diff --git a/Core/GameEngineDevice/Include/W3DDevice/GameClient/Module/W3DModelDraw.h b/Core/GameEngineDevice/Include/W3DDevice/GameClient/Module/W3DModelDraw.h index 9defb85d715..0a7a56f2d22 100644 --- a/Core/GameEngineDevice/Include/W3DDevice/GameClient/Module/W3DModelDraw.h +++ b/Core/GameEngineDevice/Include/W3DDevice/GameClient/Module/W3DModelDraw.h @@ -363,6 +363,9 @@ class W3DModelDraw : public DrawModule, public ObjectDrawInterface virtual void setFullyObscuredByShroud(Bool fullyObscured) override; virtual void setTerrainDecal(TerrainDecalType type) override; + // TheSuperHackers @feature Selection ring, kept in its own slot so it does not evict the + // horde or chem suit decal while a unit is selected. + virtual void setSelectionDecal(Bool enable, Real radius) override; virtual Bool isVisible() const override; virtual void reactToTransformChange(const Matrix3D* oldMtx, const Coord3D* oldPos, Real oldAngle) override; @@ -501,6 +504,8 @@ class W3DModelDraw : public DrawModule, public ObjectDrawInterface RenderObjClass* m_renderObject; ///< W3D Render object for this drawable Shadow* m_shadow; ///< Updates/Renders shadows of this object Shadow* m_terrainDecal; + // TheSuperHackers @feature Selection ring decal, independent of m_terrainDecal. + Shadow* m_selectionDecal; TerrainTracksRenderObjClass* m_trackRenderObject; ///< This is rendered under object ParticleSystemIDVec m_particleSystemIDs; ///< The ID numbers of the particle systems currently running. std::vector m_subObjectVec; diff --git a/Core/GameEngineDevice/Source/W3DDevice/GameClient/Drawable/Draw/W3DModelDraw.cpp b/Core/GameEngineDevice/Source/W3DDevice/GameClient/Drawable/Draw/W3DModelDraw.cpp index 6b9d2bd3095..4719bb096ef 100644 --- a/Core/GameEngineDevice/Source/W3DDevice/GameClient/Drawable/Draw/W3DModelDraw.cpp +++ b/Core/GameEngineDevice/Source/W3DDevice/GameClient/Drawable/Draw/W3DModelDraw.cpp @@ -1732,6 +1732,8 @@ W3DModelDraw::W3DModelDraw(Thing *thing, const ModuleData* moduleData) : DrawMod m_shadow = nullptr; m_shadowEnabled = TRUE; m_terrainDecal = nullptr; + // TheSuperHackers @feature no selection ring until one is asked for + m_selectionDecal = nullptr; m_trackRenderObject = nullptr; m_whichAnimInCurState = -1; m_nextState = nullptr; @@ -2719,12 +2721,62 @@ Bool W3DModelDraw::updateBonesForClientParticleSystems() +//------------------------------------------------------------------------------------------------- +// TheSuperHackers @feature Selection ring decal. +//------------------------------------------------------------------------------------------------- +/** Put a green ring on the ground under this object, or take it away. + * + * Uses the projected shadow system rather than screen space lines, so the ring is genuinely + * projected onto the terrain and the model draws over it. It lives in its own slot rather than + * sharing m_terrainDecal, so selecting a horde unit does not evict its horde ring. + * + * Expects a PlainRingSelection.tga in the mod's assets. The engine appends the extension, and + * the art is tinted green at runtime, so a plain white or greyscale ring works. */ +//------------------------------------------------------------------------------------------------- +void W3DModelDraw::setSelectionDecal(Bool enable, Real radius) +{ + if (m_selectionDecal) + { + m_selectionDecal->release(); + m_selectionDecal = nullptr; + } + + if (!enable || m_renderObject == nullptr || TheProjectedShadowManager == nullptr) + return; + + Shadow::ShadowTypeInfo decalInfo; + decalInfo.allowUpdates = FALSE; //the ring never needs regenerating + decalInfo.allowWorldAlign = TRUE; //wrap it around terrain and world objects + decalInfo.m_type = SHADOW_ALPHA_DECAL; + strlcpy(decalInfo.m_ShadowName, "PlainRingSelection", ARRAY_SIZE(decalInfo.m_ShadowName)); + decalInfo.m_sizeX = radius * 2.0f; + decalInfo.m_sizeY = radius * 2.0f; + decalInfo.m_offsetX = 0.0f; + decalInfo.m_offsetY = 0.0f; + + m_selectionDecal = TheProjectedShadowManager->addDecal(m_renderObject, &decalInfo); + if (m_selectionDecal) + { + m_selectionDecal->enableShadowInvisible(m_fullyObscuredByShroud); + m_selectionDecal->enableShadowRender(TRUE); + //the art is a plain ring, so tint it to the selection green + m_selectionDecal->setColor(GameMakeColor(0, 255, 0, 255)); + } +} + //------------------------------------------------------------------------------------------------- void W3DModelDraw::setTerrainDecal(TerrainDecalType type) { if (m_terrainDecal) m_terrainDecal->release(); + // TheSuperHackers @feature drop the selection ring too + if (m_selectionDecal) + { + m_selectionDecal->release(); + m_selectionDecal = nullptr; + } + m_terrainDecal = nullptr; if (type == TERRAIN_DECAL_NONE || type >= TERRAIN_DECAL_MAX) @@ -2790,6 +2842,14 @@ void W3DModelDraw::nukeCurrentRender(Matrix3D* xform) m_terrainDecal->release(); m_terrainDecal = nullptr; + // TheSuperHackers @fix The selection ring is bound to the render object about to be torn down + // here, exactly like the shadow and the terrain decal above, so it has to go with them. Left + // behind it stayed registered with the projected shadow manager while the render object it + // points at was freed, and the next renderShadows walked that dangling entry into the driver. + if (m_selectionDecal) + m_selectionDecal->release(); + m_selectionDecal = nullptr; + // remove existing render object from the scene if (m_renderObject) { diff --git a/Generals/Code/GameEngine/Include/Common/DrawModule.h b/Generals/Code/GameEngine/Include/Common/DrawModule.h index 1867db65564..e920b609d24 100644 --- a/Generals/Code/GameEngine/Include/Common/DrawModule.h +++ b/Generals/Code/GameEngine/Include/Common/DrawModule.h @@ -82,6 +82,9 @@ class DrawModule : public DrawableModule virtual void setTerrainDecal(TerrainDecalType type) {}; virtual void setTerrainDecalSize(Real x, Real y) {}; virtual void setTerrainDecalOpacity(Real o) {}; + // TheSuperHackers @feature Selection ring decal, in its own slot so it does not evict the + // horde or chem suit decal while a unit is selected. + virtual void setSelectionDecal(Bool enable, Real radius) {}; virtual void setFullyObscuredByShroud(Bool fullyObscured) = 0; diff --git a/GeneralsMD/Code/GameEngine/Include/Common/DrawModule.h b/GeneralsMD/Code/GameEngine/Include/Common/DrawModule.h index d2595fc4954..00ccc8a25e9 100644 --- a/GeneralsMD/Code/GameEngine/Include/Common/DrawModule.h +++ b/GeneralsMD/Code/GameEngine/Include/Common/DrawModule.h @@ -82,6 +82,9 @@ class DrawModule : public DrawableModule virtual void setTerrainDecal(TerrainDecalType type) {}; virtual void setTerrainDecalSize(Real x, Real y) {}; virtual void setTerrainDecalOpacity(Real o) {}; + // TheSuperHackers @feature Selection ring decal, in its own slot so it does not evict the + // horde or chem suit decal while a unit is selected. + virtual void setSelectionDecal(Bool enable, Real radius) {}; virtual void setFullyObscuredByShroud(Bool fullyObscured) = 0; diff --git a/GeneralsMD/Code/GameEngine/Include/Common/GlobalData.h b/GeneralsMD/Code/GameEngine/Include/Common/GlobalData.h index 89a5fa08f9d..f8ad4424d16 100644 --- a/GeneralsMD/Code/GameEngine/Include/Common/GlobalData.h +++ b/GeneralsMD/Code/GameEngine/Include/Common/GlobalData.h @@ -142,6 +142,8 @@ class GlobalData : public SubsystemInterface Bool m_useAlternateMouse; Bool m_useRightMouseScrollWithAlternateMouse; // TheSuperHackers @feature User option for RMB scroll in Alternate Mouse mode. Bool m_clientRetaliationModeEnabled; + // TheSuperHackers @feature Draw a green hexagon ring under selected objects. + Bool m_selectionCircleEnabled; Bool m_doubleClickAttackMove; Bool m_rightMouseAlwaysScrolls; Int m_jpegQuality; // TheSuperHackers @feature Quality for JPEG screenshots. diff --git a/GeneralsMD/Code/GameEngine/Include/GameClient/Drawable.h b/GeneralsMD/Code/GameEngine/Include/GameClient/Drawable.h index 7169b8cb51f..487bf1acb79 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameClient/Drawable.h +++ b/GeneralsMD/Code/GameEngine/Include/GameClient/Drawable.h @@ -396,6 +396,9 @@ class Drawable : public Thing, /// Return true if drawable has been marked as "selected" Bool isSelected() const { return m_selected; } + // TheSuperHackers @feature Green selection ring decal (Options.ini: SelectionCircle). + // Client only -- never xfer'd, never read by game logic. + void updateSelectionDecal( void ); void onSelected(); ///< Work unrelated to selection that must happen at time of selection void onUnselected(); ///< Work unrelated to selection that must happen at time of unselection diff --git a/GeneralsMD/Code/GameEngine/Source/Common/GlobalData.cpp b/GeneralsMD/Code/GameEngine/Source/Common/GlobalData.cpp index e862cd149d5..d8e9f4d7cd1 100644 --- a/GeneralsMD/Code/GameEngine/Source/Common/GlobalData.cpp +++ b/GeneralsMD/Code/GameEngine/Source/Common/GlobalData.cpp @@ -1062,6 +1062,7 @@ GlobalData::GlobalData() m_useRightMouseScrollWithAlternateMouse = TRUE; #endif m_clientRetaliationModeEnabled = TRUE; //On by default. + m_selectionCircleEnabled = FALSE; m_doubleClickAttackMove = FALSE; } @@ -1206,6 +1207,7 @@ void GlobalData::parseGameDataDefinition( INI* ini ) TheWritableGlobalData->m_useRightMouseScrollWithAlternateMouse = optionPref.getRightMouseScrollWithAlternateMouseEnabled(); TheWritableGlobalData->m_clientRetaliationModeEnabled = optionPref.getRetaliationModeEnabled(); TheWritableGlobalData->m_doubleClickAttackMove = optionPref.getDoubleClickAttackMoveEnabled(); + TheWritableGlobalData->m_selectionCircleEnabled = optionPref.getSelectionCircleEnabled(); TheWritableGlobalData->m_jpegQuality = optionPref.getJpegQuality(); TheWritableGlobalData->m_keyboardScrollFactor = optionPref.getScrollFactor(); TheWritableGlobalData->m_drawScrollAnchor = optionPref.getDrawScrollAnchor(); diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/Drawable.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/Drawable.cpp index 54dff3325f2..cf1f71df61d 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameClient/Drawable.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/Drawable.cpp @@ -979,8 +979,50 @@ void Drawable::colorTint( const RGBColor* color ) //------------------------------------------------------------------------------------------------- /** Gathering point for all things besides actual selection that must happen on selection */ //------------------------------------------------------------------------------------------------- +// TheSuperHackers @feature Selection ring decal. +//------------------------------------------------------------------------------------------------- +/** Show or hide the green selection ring, following the SelectionCircle option. + * + * Only the first draw module gets one, matching how terrain decals avoid stacking. */ +//------------------------------------------------------------------------------------------------- +void Drawable::updateSelectionDecal( void ) +{ + Bool wanted = TheGlobalData && TheGlobalData->m_selectionCircleEnabled && isSelected(); + + const Object *obj = getObject(); + if( obj == nullptr || obj->isEffectivelyDead() || obj->isKindOf( KINDOF_IGNORED_IN_GUI ) ) + wanted = FALSE; + + Real radius = 0.0f; + if( wanted ) + { + // The bounding circle encloses the whole model, so it reads as too big drawn at full + // size. How much too big depends on the shape: a building fills its circle, a soldier + // barely occupies the middle of one, so scale per kind. + Real scale; + if( obj->isKindOf( KINDOF_STRUCTURE ) ) + scale = 1.0f; + else if( obj->isKindOf( KINDOF_INFANTRY ) ) + scale = 0.7f; + else + scale = 0.85f; // vehicles, aircraft and everything else + + radius = getDrawableGeometryInfo().getBoundingCircleRadius() * scale; + if( radius < 1.0f ) + radius = 1.0f; + } + + for( DrawModule **dm = getDrawModules(); *dm; ++dm ) + { + (*dm)->setSelectionDecal( wanted, radius ); + break; // first draw module only, so rings do not stack + } +} + void Drawable::onSelected() { + // TheSuperHackers @feature put the selection ring up straight away rather than waiting a frame + updateSelectionDecal(); flashAsSelected();//much simpler @@ -1001,7 +1043,8 @@ void Drawable::onSelected() //------------------------------------------------------------------------------------------------- void Drawable::onUnselected() { - // nothing + // TheSuperHackers @feature take the selection ring down + updateSelectionDecal(); } //------------------------------------------------------------------------------------------------- From 82071368f879d48d1129d6431226c33299640f34 Mon Sep 17 00:00:00 2001 From: triatomic <32312517+triatomic@users.noreply.github.com> Date: Thu, 27 Aug 2026 00:23:01 +0300 Subject: [PATCH 2/2] fix(client): Keep the selection ring through model swaps, shroud and geometry changes Addresses the review findings: - setTerrainDecal no longer drops the ring: a horde or chem suit decal update while the unit is selected left it ringless. - The wanted state and radius are remembered in the draw module, and the ring is recreated after a render object rebuild - a damage state or upgrade swaps the model while the object stays selected, and the ring used to vanish until reselection. - Hidden and fully shrouded transitions now disable the ring's render like the shadow and terrain decal, so it cannot expose a unit the client should not display. - reactToGeometryChange resizes the ring along with the geometry its radius is derived from. --- .../GameClient/Module/W3DModelDraw.h | 6 ++++- .../GameClient/Drawable/Draw/W3DModelDraw.cpp | 25 +++++++++++++------ .../GameEngine/Source/GameClient/Drawable.cpp | 3 +++ 3 files changed, 26 insertions(+), 8 deletions(-) diff --git a/Core/GameEngineDevice/Include/W3DDevice/GameClient/Module/W3DModelDraw.h b/Core/GameEngineDevice/Include/W3DDevice/GameClient/Module/W3DModelDraw.h index 0a7a56f2d22..5e3f0241f44 100644 --- a/Core/GameEngineDevice/Include/W3DDevice/GameClient/Module/W3DModelDraw.h +++ b/Core/GameEngineDevice/Include/W3DDevice/GameClient/Module/W3DModelDraw.h @@ -504,8 +504,12 @@ class W3DModelDraw : public DrawModule, public ObjectDrawInterface RenderObjClass* m_renderObject; ///< W3D Render object for this drawable Shadow* m_shadow; ///< Updates/Renders shadows of this object Shadow* m_terrainDecal; - // TheSuperHackers @feature Selection ring decal, independent of m_terrainDecal. + // TheSuperHackers @feature Selection ring decal, independent of m_terrainDecal. The wanted + // state is remembered separately so the ring survives render object rebuilds - a damage + // state or upgrade swaps the model while the object stays selected. Shadow* m_selectionDecal; + Bool m_selectionDecalWanted; + Real m_selectionDecalRadius; TerrainTracksRenderObjClass* m_trackRenderObject; ///< This is rendered under object ParticleSystemIDVec m_particleSystemIDs; ///< The ID numbers of the particle systems currently running. std::vector m_subObjectVec; diff --git a/Core/GameEngineDevice/Source/W3DDevice/GameClient/Drawable/Draw/W3DModelDraw.cpp b/Core/GameEngineDevice/Source/W3DDevice/GameClient/Drawable/Draw/W3DModelDraw.cpp index 4719bb096ef..5c1ad4cd725 100644 --- a/Core/GameEngineDevice/Source/W3DDevice/GameClient/Drawable/Draw/W3DModelDraw.cpp +++ b/Core/GameEngineDevice/Source/W3DDevice/GameClient/Drawable/Draw/W3DModelDraw.cpp @@ -1734,6 +1734,8 @@ W3DModelDraw::W3DModelDraw(Thing *thing, const ModuleData* moduleData) : DrawMod m_terrainDecal = nullptr; // TheSuperHackers @feature no selection ring until one is asked for m_selectionDecal = nullptr; + m_selectionDecalWanted = FALSE; + m_selectionDecalRadius = 0.0f; m_trackRenderObject = nullptr; m_whichAnimInCurState = -1; m_nextState = nullptr; @@ -1838,6 +1840,10 @@ void W3DModelDraw::setHidden(Bool hidden) if (m_terrainDecal) m_terrainDecal->enableShadowRender(!hidden); + // TheSuperHackers @fix the ring must not keep rendering under a hidden drawable + if (m_selectionDecal) + m_selectionDecal->enableShadowRender(!hidden); + if (m_trackRenderObject && hidden) { const Coord3D* pos = getDrawable()->getPosition(); m_trackRenderObject->addCapEdgeToTrack(pos->x,pos->y); @@ -1951,6 +1957,9 @@ void W3DModelDraw::setFullyObscuredByShroud(Bool fullyObscured) m_shadow->enableShadowInvisible(m_fullyObscuredByShroud); if (m_terrainDecal) m_terrainDecal->enableShadowInvisible(m_fullyObscuredByShroud); + // TheSuperHackers @fix the ring must not expose a fully shrouded unit + if (m_selectionDecal) + m_selectionDecal->enableShadowInvisible(m_fullyObscuredByShroud); doStartOrStopParticleSys(); } @@ -2735,6 +2744,10 @@ Bool W3DModelDraw::updateBonesForClientParticleSystems() //------------------------------------------------------------------------------------------------- void W3DModelDraw::setSelectionDecal(Bool enable, Real radius) { + // remembered so the ring can be recreated after a model swap tears the render object down + m_selectionDecalWanted = enable; + m_selectionDecalRadius = radius; + if (m_selectionDecal) { m_selectionDecal->release(); @@ -2770,13 +2783,6 @@ void W3DModelDraw::setTerrainDecal(TerrainDecalType type) if (m_terrainDecal) m_terrainDecal->release(); - // TheSuperHackers @feature drop the selection ring too - if (m_selectionDecal) - { - m_selectionDecal->release(); - m_selectionDecal = nullptr; - } - m_terrainDecal = nullptr; if (type == TERRAIN_DECAL_NONE || type >= TERRAIN_DECAL_MAX) @@ -3138,6 +3144,11 @@ void W3DModelDraw::setModelState(const ModelConditionInfo* newState) } } + // TheSuperHackers @fix The selection ring was bound to the render object that was just + // torn down; the object is still selected, so put the ring back on the new one. + if (m_selectionDecalWanted) + setSelectionDecal(TRUE, m_selectionDecalRadius); + if( m_renderObject ) { // set collision type for render object. Used by WW3D2 collision code. diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/Drawable.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/Drawable.cpp index cf1f71df61d..a28d5bb9743 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameClient/Drawable.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/Drawable.cpp @@ -4204,6 +4204,9 @@ void Drawable::reactToTransformChange(const Matrix3D* oldMtx, const Coord3D* old //------------------------------------------------------------------------------------------------- void Drawable::reactToGeometryChange() { + // TheSuperHackers @fix resize the selection ring along with the geometry it is derived from + updateSelectionDecal(); + for (DrawModule** dm = getDrawModules(); *dm; ++dm) { (*dm)->reactToGeometryChange();