@@ -19,6 +19,10 @@ public class AttachingBallChainHandler
1919 private readonly ILevelService _levelService ;
2020 private readonly ILevelLocalProgressService _levelLocalProgressService ;
2121
22+ // Counter instead of bool: handles user-shot combos overlapping with chain reactions
23+ private int _activeComboCount ;
24+ public bool IsProcessingCombo => _activeComboCount > 0 ;
25+
2226 public AttachingBallChainHandler (
2327 PathCreator pathCreator ,
2428 BallChainDTO ballChainDto ,
@@ -45,7 +49,7 @@ public void TryAttachBall(Ball newBall)
4549 InsertBallToSegment ( newBall , segment , index ) ;
4650 }
4751
48- // Called by BallChainController after two segments merge
52+ // Called by BallChainController.CheckMerges only when no combo chain is active
4953 public void CheckMatchAtJunction ( ChainSegment segment , int junctionIdx )
5054 {
5155 if ( segment . Count == 0 ) return ;
@@ -55,16 +59,15 @@ public void CheckMatchAtJunction(ChainSegment segment, int junctionIdx)
5559 Ball anchor = balls [ junctionIdx ] ;
5660 var matches = FindMatchesAroundBall ( anchor , balls ) ;
5761
58- if ( matches . Count >= _ballChainDto . MatchingCount )
59- {
60- int score = matches . Count *
61- _levelService . GetCurrentLevelStaticData ( ) . LevelConfig . ScoreConfig . ScorePerItem ;
62- _levelLocalProgressService . AddScore ( score ) ;
63- _widgetBallChainProvider . SetUpWidget ( matches , anchor , score ) ;
64- _winBallChainHandler . TryWin ( _segments . Sum ( s => s . Count ) - matches . Count ) ;
62+ if ( matches . Count < _ballChainDto . MatchingCount ) return ;
6563
66- DestroyAndSplit ( matches , segment ) . Forget ( ) ;
67- }
64+ int score = matches . Count *
65+ _levelService . GetCurrentLevelStaticData ( ) . LevelConfig . ScoreConfig . ScorePerItem ;
66+ _levelLocalProgressService . AddScore ( score ) ;
67+ _widgetBallChainProvider . SetUpWidget ( matches , anchor , score ) ;
68+ _winBallChainHandler . TryWin ( _segments . Sum ( s => s . Count ) - matches . Count ) ;
69+
70+ RunComboChain ( matches , segment ) . Forget ( ) ;
6871 }
6972
7073 private ( ChainSegment segment , int index ) FindClosestCollision ( Ball newBall )
@@ -139,55 +142,113 @@ private async UniTask CheckAndDestroyMatches(Ball pivotBall)
139142 _widgetBallChainProvider . SetUpWidget ( matches , pivotBall , score ) ;
140143 _winBallChainHandler . TryWin ( _segments . Sum ( s => s . Count ) - matches . Count ) ;
141144
142- await DestroyAndSplit ( matches , segment ) ;
145+ await RunComboChain ( matches , segment ) ;
143146 }
144147 else
145148 {
146149 pivotBall . SetInteractive ( false ) ;
147150 }
148151 }
149152
150- private async UniTask DestroyAndSplit ( List < Ball > matchBalls , ChainSegment segment )
153+ // Entry point for all combo chains — tracks nesting so IsProcessingCombo stays true
154+ // for the full duration of a chain reaction, even across multiple sequential steps
155+ private async UniTask RunComboChain ( List < Ball > matchBalls , ChainSegment segment )
151156 {
157+ _activeComboCount ++ ;
158+ try
159+ {
160+ await ComboStep ( matchBalls , segment ) ;
161+ }
162+ finally
163+ {
164+ _activeComboCount -- ;
165+ }
166+ }
167+
168+ // One step of a combo: destroy → split → wait for physical merge → check next match
169+ private async UniTask ComboStep ( List < Ball > matchBalls , ChainSegment segment )
170+ {
171+ // 1. Wait for all destroy animations to finish
152172 await UniTask . WhenAll ( matchBalls . Select ( WaitForDestroyAnimation ) ) ;
153173
154174 int lowestIndex = matchBalls . Min ( b => b . Index ) ;
155175
156- // Remove from high to low to preserve indices during removal
176+ // 2. Remove matched balls from high to low to keep indices valid
157177 foreach ( var ball in matchBalls . OrderByDescending ( b => b . Index ) )
158178 {
159179 segment . RemoveBallAt ( ball . Index ) ;
160180 ball . Deactivate ( ) ;
161181 }
162182
163- // Split remaining balls after match into back segment
164- if ( lowestIndex < segment . Count )
183+ if ( lowestIndex >= segment . Count )
165184 {
166- ChainSegment back = segment . SplitAt ( lowestIndex ) ;
185+ // Nothing behind the gap — just reindex and clean up
167186 segment . ReIndexBalls ( ) ;
168- back . ReIndexBalls ( ) ;
187+ TryCleanupEmptySegment ( segment ) ;
188+ return ;
189+ }
169190
170- int segIdx = _segments . IndexOf ( segment ) ;
191+ // 3. Split: balls [lowestIndex..] become the new back segment
192+ ChainSegment back = segment . SplitAt ( lowestIndex ) ;
193+ segment . ReIndexBalls ( ) ;
194+ back . ReIndexBalls ( ) ;
171195
172- if ( segment . Count > 0 )
173- {
174- // Front has balls — back catches up
175- back . IsCatchingUp = true ;
176- back . CurrentSpeed = back . BaseSpeed * _ballChainDto . CatchupSpeedMultiplier ;
177- _segments . Insert ( segIdx + 1 , back ) ;
178- }
179- else
180- {
181- // Front is empty — back becomes the lead, no one to catch
182- _segments [ segIdx ] = back ;
183- }
196+ int segIdx = _segments . IndexOf ( segment ) ;
197+
198+ if ( segment . Count == 0 )
199+ {
200+ // Front wiped — back takes over, no gap to close
201+ _segments [ segIdx ] = back ;
202+ TryCleanupEmptySegment ( segment ) ;
203+ return ;
184204 }
185- else
205+
206+ // Index of front's last ball — this becomes the junction after merge
207+ int junctionIdx = segment . Count - 1 ;
208+
209+ // 4. Launch back segment catch-up; CheckMerges will do the physical merge
210+ back . IsCatchingUp = true ;
211+ back . CurrentSpeed = back . BaseSpeed * _ballChainDto . CatchupSpeedMultiplier ;
212+ _segments . Insert ( segIdx + 1 , back ) ;
213+
214+ // 5. Wait until CheckMerges has logically merged back into front
215+ await UniTask . WaitUntil ( ( ) => ! _segments . Contains ( back ) ) ;
216+
217+ // 6. Wait for the visual gap at the junction to actually close.
218+ // Logical merge fires early (distance math), but SmoothDamp needs more time.
219+ // junctionIdx = last ball of original front
220+ // junctionIdx + 1 = first ball of original back (now part of segment after merge)
221+ int nextIdx = junctionIdx + 1 ;
222+ if ( nextIdx < segment . Count )
186223 {
187- segment . ReIndexBalls ( ) ;
224+ await UniTask . WaitUntil ( ( ) =>
225+ nextIdx < segment . Count &&
226+ segment . GetVisualDistance ( junctionIdx ) - segment . GetVisualDistance ( nextIdx )
227+ <= _ballChainDto . SpacingBalls * 1.5f
228+ ) ;
188229 }
189230
190- // Clean up empty front segment
231+ // 7. Chain is visually closed — check for a new match at the junction
232+ if ( junctionIdx < 0 || junctionIdx >= segment . Count ) return ;
233+
234+ var mergedBalls = segment . Balls . ToList ( ) ;
235+ Ball anchor = mergedBalls [ junctionIdx ] ;
236+ var nextMatches = FindMatchesAroundBall ( anchor , mergedBalls ) ;
237+
238+ if ( nextMatches . Count < _ballChainDto . MatchingCount ) return ;
239+
240+ int nextScore = nextMatches . Count *
241+ _levelService . GetCurrentLevelStaticData ( ) . LevelConfig . ScoreConfig . ScorePerItem ;
242+ _levelLocalProgressService . AddScore ( nextScore ) ;
243+ _widgetBallChainProvider . SetUpWidget ( nextMatches , anchor , nextScore ) ;
244+ _winBallChainHandler . TryWin ( _segments . Sum ( s => s . Count ) - nextMatches . Count ) ;
245+
246+ // 8. Continue combo chain sequentially — same _activeComboCount scope
247+ await ComboStep ( nextMatches , segment ) ;
248+ }
249+
250+ private void TryCleanupEmptySegment ( ChainSegment segment )
251+ {
191252 if ( segment . Count == 0 && _segments . Contains ( segment ) )
192253 _segments . Remove ( segment ) ;
193254 }
0 commit comments