Skip to content

Commit 53ba55f

Browse files
committed
feat(RightClick): implement right-click binding registration and ID management
- Added `RegisterRightClick` method to `RitsuLibFramework` for registering synced right-click bindings for models. - Introduced `GetQualifiedRightClickId` method in `ModContentRegistry` to build mod-scoped right-click binding IDs. - Created `ModRightClickBindingId` struct for stable identity management of registered right-click bindings. - Enhanced `ModRightClickRegistry` to support registration and execution of right-click actions, improving interaction capabilities.
1 parent 5f66fdb commit 53ba55f

4 files changed

Lines changed: 281 additions & 15 deletions

File tree

Content/ModContentRegistry.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,20 @@ public static string GetQualifiedTopBarButtonId(string modId, string localButton
291291
return GetCompoundId(modId, "TOPBARBUTTON", localButtonStem);
292292
}
293293

294+
/// <summary>
295+
/// Builds a mod-scoped right-click binding id using the ritsulib <c>MODID_CATEGORY_TYPENAME</c>
296+
/// convention with middle segment <c>RIGHTCLICK</c>.
297+
/// 使用 ritsulib <c>MODID_CATEGORY_TYPENAME</c> 约定构建 mod 作用域右键绑定 id,
298+
/// 中间段为 <c>RIGHTCLICK</c>。
299+
/// </summary>
300+
public static string GetQualifiedRightClickId(string modId, string localRightClickStem)
301+
{
302+
ArgumentException.ThrowIfNullOrWhiteSpace(modId);
303+
ArgumentException.ThrowIfNullOrWhiteSpace(localRightClickStem);
304+
305+
return GetCompoundId(modId, "RIGHTCLICK", localRightClickStem);
306+
}
307+
294308
/// <summary>
295309
/// Returns the singleton registry for <paramref name="modId" /> (created on first use).
296310
/// 返回 <paramref name="modId" /> 的单例注册表(首次使用时创建)。
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
namespace STS2RitsuLib.Interactions.RightClick
2+
{
3+
/// <summary>
4+
/// Stable identity for a registered right-click binding.
5+
/// 已注册右键绑定的稳定身份。
6+
/// </summary>
7+
public readonly record struct ModRightClickBindingId(string Id)
8+
{
9+
/// <inheritdoc />
10+
public override string ToString()
11+
{
12+
return Id;
13+
}
14+
}
15+
}

Interactions/RightClick/ModRightClickRegistry.cs

Lines changed: 225 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
using MegaCrit.Sts2.Core.Multiplayer.Game;
1010
using MegaCrit.Sts2.Core.Multiplayer.Serialization;
1111
using MegaCrit.Sts2.Core.Runs;
12+
using STS2RitsuLib.Content;
1213
using STS2RitsuLib.Models.Identity;
1314
using 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

Comments
 (0)