99using MegaCrit . Sts2 . Core . Multiplayer . Game ;
1010using MegaCrit . Sts2 . Core . Multiplayer . Serialization ;
1111using MegaCrit . Sts2 . Core . Runs ;
12+ using STS2RitsuLib . Content ;
1213using STS2RitsuLib . Models . Identity ;
1314using STS2RitsuLib . Networking . Sidecar ;
1415
@@ -25,14 +26,21 @@ public static class ModRightClickRegistry
2526 private const string SidecarNonCombatRequestKey = "model_right_click_noncombat_request" ;
2627 private const string SidecarNonCombatApplyKey = "model_right_click_noncombat_apply" ;
2728 private const int InitialOffset = 0 ;
29+ private const int InterfaceBindingPriority = int . MinValue ;
2830
2931 private static readonly Lock Gate = new ( ) ;
32+ private static long _nextBindingSequence ;
3033
3134 private static readonly List < IModRightClickHandler > Handlers =
3235 [
33- new InterfaceModelRightClickHandler ( ) ,
36+ new BuiltInModelRightClickHandler ( ) ,
3437 ] ;
3538
39+ private static readonly List < RegisteredRightClickBinding > Bindings = [ ] ;
40+
41+ private static readonly ModRightClickBindingId InterfaceBindingId =
42+ new ( ModContentRegistry . GetQualifiedRightClickId ( Const . ModId , "model_interface" ) ) ;
43+
3644 private static readonly RitsuLibSidecarSyncActionDescriptor < ModRightClickSyncPayload > SyncActionDescriptor =
3745 new (
3846 SidecarModuleId ,
@@ -79,6 +87,48 @@ public static void Register(IModRightClickHandler handler)
7987 }
8088 }
8189
90+ /// <summary>
91+ /// Registers a synced right-click binding for models of type <typeparamref name="TModel" />.
92+ /// 为 <typeparamref name="TModel" /> 类型的模型注册同步右键绑定。
93+ /// </summary>
94+ /// <returns>
95+ /// A disposable registration handle.
96+ /// 可释放的注册句柄。
97+ /// </returns>
98+ public static IDisposable Register < TModel > (
99+ string modId ,
100+ string localStem ,
101+ Func < ModRightClickContext , bool > canHandle ,
102+ Func < ModRightClickExecutionContext , Task > execute ,
103+ int priority = 0 )
104+ where TModel : AbstractModel
105+ {
106+ ArgumentException . ThrowIfNullOrWhiteSpace ( modId ) ;
107+ ArgumentException . ThrowIfNullOrWhiteSpace ( localStem ) ;
108+ ArgumentNullException . ThrowIfNull ( canHandle ) ;
109+ ArgumentNullException . ThrowIfNull ( execute ) ;
110+
111+ var id = new ModRightClickBindingId ( ModContentRegistry . GetQualifiedRightClickId ( modId , localStem ) ) ;
112+ var binding = new RegisteredRightClickBinding (
113+ id ,
114+ typeof ( TModel ) ,
115+ canHandle ,
116+ execute ,
117+ priority ,
118+ Interlocked . Increment ( ref _nextBindingSequence ) ) ;
119+
120+ lock ( Gate )
121+ {
122+ if ( Bindings . Any ( existing => existing . Id == id ) )
123+ throw new InvalidOperationException ( $ "Right-click binding is already registered: { id } ") ;
124+
125+ Bindings . Add ( binding ) ;
126+ SortBindings ( ) ;
127+ }
128+
129+ return binding ;
130+ }
131+
82132 /// <summary>
83133 /// Attempts to dispatch a local right-click request.
84134 /// 尝试分发一个本地右键请求。
@@ -101,10 +151,14 @@ internal static void RegisterBuiltInSyncDescriptors()
101151 RitsuLibSidecarSyncMessages . Register ( NonCombatApplyDescriptor ) ;
102152 }
103153
104- private static bool TryRequestSyncedModelAction ( ModRightClickContext context )
154+ private static bool TryRequestSyncedModelAction (
155+ ModRightClickContext context ,
156+ IReadOnlyList < ModRightClickBindingId > bindingIds )
105157 {
106158 if ( ! TryCreatePayload ( context , out var payload ) )
107159 return false ;
160+
161+ payload = payload with { BindingIds = [ .. bindingIds ] } ;
108162 if ( ! CombatManager . Instance . IsInProgress )
109163 return TryRequestNonCombatAction ( payload ) ;
110164
@@ -127,7 +181,8 @@ private static bool TryCreatePayload(ModRightClickContext context, out ModRightC
127181 context . Player . NetId ,
128182 kind ,
129183 token ,
130- context . Trigger ) ;
184+ context . Trigger ,
185+ [ ] ) ;
131186 return true ;
132187 }
133188
@@ -200,6 +255,7 @@ private static byte[] SerializePayload(ModRightClickSyncPayload payload)
200255 if ( payload . Trigger . Metadata != null )
201256 writer . WriteString ( payload . Trigger . Metadata ) ;
202257
258+ SerializeBindingIds ( writer , payload . BindingIds ) ;
203259 writer . ZeroByteRemainder ( ) ;
204260 return writer . Buffer . AsSpan ( InitialOffset , writer . BytePosition ) . ToArray ( ) ;
205261 }
@@ -215,11 +271,13 @@ private static ModRightClickSyncPayload DeserializePayload(ReadOnlySpan<byte> by
215271
216272 var isController = reader . ReadBool ( ) ;
217273 var metadata = reader . ReadBool ( ) ? reader . ReadString ( ) : null ;
274+ var bindingIds = DeserializeBindingIds ( reader ) ;
218275 return new (
219276 ownerNetId ,
220277 kind ,
221278 new ( identity , modelId ) ,
222- new ( isController , metadata ) ) ;
279+ new ( isController , metadata ) ,
280+ bindingIds ) ;
223281 }
224282
225283 private static async Task ExecuteSynced (
@@ -238,8 +296,8 @@ private static Task HandleNonCombatRequest(
238296 RunManager . Instance ? . NetService is not NetHostGameService ||
239297 context . Message . OwnerNetId != context . SenderNetId ||
240298 ! TryGetPlayer ( context . Message . OwnerNetId , out var player ) ||
241- ! TryResolveModel ( player , context . Message , out var model ) ||
242- model is not IModRightClickableModel )
299+ ! TryResolveModel ( player , context . Message , out _ ) ||
300+ context . Message . BindingIds . Count == 0 )
243301 return Task . CompletedTask ;
244302
245303 _ = RitsuLibSidecarSyncMessages . Broadcast (
@@ -264,16 +322,27 @@ private static async Task ExecutePayload(
264322 return ;
265323 if ( ! TryResolveModel ( player , payload , out var model ) )
266324 return ;
267- if ( model is not IModRightClickableModel rightClickable )
268- return ;
269325
270- await rightClickable . OnRightClick ( new (
326+ var executionContext = new ModRightClickExecutionContext (
271327 player ,
272328 model ,
273329 payload . Trigger ,
274330 playerChoiceContext ,
275- action ) ) ;
276- model . InvokeExecutionFinished ( ) ;
331+ action ) ;
332+ var executed = false ;
333+ foreach ( var bindingId in payload . BindingIds )
334+ try
335+ {
336+ if ( await TryExecuteBinding ( bindingId , model , executionContext ) )
337+ executed = true ;
338+ }
339+ catch ( Exception ex )
340+ {
341+ RitsuLibFramework . Logger . Warn ( $ "[RightClick] Binding '{ bindingId } ' failed: { ex . Message } ") ;
342+ }
343+
344+ if ( executed )
345+ model . InvokeExecutionFinished ( ) ;
277346 }
278347
279348 private static bool TryGetPlayer ( ulong ownerNetId , out Player player )
@@ -329,20 +398,161 @@ private static bool TryResolveModel(
329398 }
330399 }
331400
332- private sealed class InterfaceModelRightClickHandler : IModRightClickHandler
401+ private static async Task < bool > TryExecuteBinding (
402+ ModRightClickBindingId bindingId ,
403+ AbstractModel model ,
404+ ModRightClickExecutionContext context )
405+ {
406+ if ( bindingId == InterfaceBindingId )
407+ {
408+ if ( model is not IModRightClickableModel rightClickable )
409+ return false ;
410+
411+ await rightClickable . OnRightClick ( context ) ;
412+ return true ;
413+ }
414+
415+ var binding = TryGetBinding ( bindingId ) ;
416+ if ( binding == null || ! binding . ModelType . IsInstanceOfType ( model ) )
417+ return false ;
418+
419+ await binding . Execute ( context ) ;
420+ return true ;
421+ }
422+
423+ private static RegisteredRightClickBinding ? TryGetBinding ( ModRightClickBindingId bindingId )
424+ {
425+ lock ( Gate )
426+ {
427+ return Bindings . FirstOrDefault ( binding => binding . Id == bindingId ) ;
428+ }
429+ }
430+
431+ private static RegisteredRightClickBinding [ ] GetBindingsSnapshot ( )
432+ {
433+ lock ( Gate )
434+ {
435+ return [ .. Bindings ] ;
436+ }
437+ }
438+
439+ private static void SortBindings ( )
440+ {
441+ Bindings . Sort ( ( a , b ) =>
442+ {
443+ var priority = b . Priority . CompareTo ( a . Priority ) ;
444+ return priority != 0 ? priority : a . Sequence . CompareTo ( b . Sequence ) ;
445+ } ) ;
446+ }
447+
448+ private static void SerializeBindingIds (
449+ PacketWriter writer ,
450+ IReadOnlyList < ModRightClickBindingId > bindingIds )
451+ {
452+ writer . WriteInt ( bindingIds . Count ) ;
453+ foreach ( var bindingId in bindingIds )
454+ writer . WriteString ( bindingId . Id ) ;
455+ }
456+
457+ private static IReadOnlyList < ModRightClickBindingId > DeserializeBindingIds ( PacketReader reader )
458+ {
459+ var remainingBits = reader . Buffer . Length * 8 - reader . BitPosition ;
460+ if ( remainingBits < 32 )
461+ return [ ] ;
462+
463+ var count = reader . ReadInt ( ) ;
464+ if ( count <= 0 )
465+ return [ ] ;
466+
467+ var ids = new List < ModRightClickBindingId > ( count ) ;
468+ for ( var i = 0 ; i < count ; i ++ )
469+ {
470+ var id = reader . ReadString ( ) ;
471+ if ( ! string . IsNullOrWhiteSpace ( id ) )
472+ ids . Add ( new ( id . Trim ( ) ) ) ;
473+ }
474+
475+ return ids ;
476+ }
477+
478+ private sealed class BuiltInModelRightClickHandler : IModRightClickHandler
333479 {
334480 public bool TryHandle ( ModRightClickContext context )
335481 {
336- if ( context . Model is not IModRightClickableModel rightClickable )
482+ var bindingIds = CollectBindingIds ( context ) ;
483+ return bindingIds . Count > 0 && TryRequestSyncedModelAction ( context , bindingIds ) ;
484+ }
485+
486+ private static List < ModRightClickBindingId > CollectBindingIds ( ModRightClickContext context )
487+ {
488+ var bindings = GetBindingsSnapshot ( ) ;
489+ var ids = ( from binding in bindings
490+ where binding . ModelType . IsInstanceOfType ( context . Model )
491+ where TryCanHandle ( binding , context )
492+ select binding . Id ) . ToList ( ) ;
493+
494+ if ( context . Model is not IModRightClickableModel rightClickable ||
495+ ! rightClickable . CanHandleRightClickLocal ( context ) )
496+ return ids ;
497+
498+ var insertIndex = 0 ;
499+ while ( insertIndex < bindings . Length && bindings [ insertIndex ] . Priority > InterfaceBindingPriority )
500+ insertIndex ++ ;
501+
502+ ids . Insert ( Math . Min ( insertIndex , ids . Count ) , InterfaceBindingId ) ;
503+ return ids ;
504+ }
505+
506+ private static bool TryCanHandle ( RegisteredRightClickBinding binding , ModRightClickContext context )
507+ {
508+ try
509+ {
510+ return binding . CanHandle ( context ) ;
511+ }
512+ catch ( Exception ex )
513+ {
514+ RitsuLibFramework . Logger . Warn (
515+ $ "[RightClick] Binding '{ binding . Id } ' preflight failed: { ex . Message } ") ;
337516 return false ;
338- return rightClickable . CanHandleRightClickLocal ( context ) && TryRequestSyncedModelAction ( context ) ;
517+ }
518+ }
519+ }
520+
521+ private sealed class RegisteredRightClickBinding (
522+ ModRightClickBindingId id ,
523+ Type modelType ,
524+ Func < ModRightClickContext , bool > canHandle ,
525+ Func < ModRightClickExecutionContext , Task > execute ,
526+ int priority ,
527+ long sequence ) : IDisposable
528+ {
529+ private bool _disposed ;
530+
531+ public ModRightClickBindingId Id { get ; } = id ;
532+ public Type ModelType { get ; } = modelType ;
533+ public Func < ModRightClickContext , bool > CanHandle { get ; } = canHandle ;
534+ public Func < ModRightClickExecutionContext , Task > Execute { get ; } = execute ;
535+ public int Priority { get ; } = priority ;
536+ public long Sequence { get ; } = sequence ;
537+
538+ public void Dispose ( )
539+ {
540+ if ( _disposed )
541+ return ;
542+
543+ _disposed = true ;
544+ lock ( Gate )
545+ {
546+ Bindings . Remove ( this ) ;
547+ }
339548 }
340549 }
341550
342551 private readonly record struct ModRightClickSyncPayload (
343552 ulong OwnerNetId ,
344553 ModRightClickModelKind Kind ,
345554 ModModelIdentityToken Token ,
346- ModRightClickTrigger Trigger ) ;
555+ ModRightClickTrigger Trigger ,
556+ IReadOnlyList < ModRightClickBindingId > BindingIds ) ;
347557 }
348558}
0 commit comments