Skip to content

Commit 8067028

Browse files
Copilotkellyelton
andauthored
Implement Shake Card feature with networking, animations, and scripting API (#2259)
* Initial plan * Implement shake card feature with networking, animations, and scripting API Co-authored-by: kellyelton <1356163+kellyelton@users.noreply.github.com> * Fix shake animation to work with existing card transform system Co-authored-by: kellyelton <1356163+kellyelton@users.noreply.github.com> * Fix shake network messaging - remove ShakeReq pattern and add Shake at end of protocol Co-authored-by: kellyelton <1356163+kellyelton@users.noreply.github.com> * Increase shake animation duration from 400ms to 1.5 seconds Co-authored-by: kellyelton <1356163+kellyelton@users.noreply.github.com> * fix missing shake action from csproj * generate networking files * Refactor CardControl.xaml.cs for improved readability and organization * focus player tab on shake * ability to stop switching tabs on shake in case it gets abused * allow shaking other players * better messages when shaking * recent changes --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: kellyelton <1356163+kellyelton@users.noreply.github.com> Co-authored-by: Kelly Elton <its.the.doc@gmail.com>
1 parent 4c0fa58 commit 8067028

File tree

19 files changed

+322
-1
lines changed

19 files changed

+322
-1
lines changed

octgnFX/Octgn.JodsEngine/Networking/BinaryParser.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -970,6 +970,18 @@ public void Parse(byte[] data)
970970
handler.GrantPileViewPermission(arg0, arg1, arg2, arg3, arg4, arg5, arg6);
971971
break;
972972
}
973+
case 110:
974+
{
975+
var arg0 = Player.Find(reader.ReadByte());
976+
if (arg0 == null)
977+
{ Debug.WriteLine("[Shake] Player not found."); return; }
978+
var arg1 = Card.Find(reader.ReadInt32());
979+
if (arg1 == null)
980+
{ Debug.WriteLine("[Shake] Card not found."); return; }
981+
Log.Debug($"OCTGN IN: Shake");
982+
handler.Shake(arg0, arg1);
983+
break;
984+
}
973985
default:
974986
Debug.WriteLine("[Client Parser] Unknown message (id =" + method + ")");
975987
break;

octgnFX/Octgn.JodsEngine/Networking/BinaryStubs.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1440,6 +1440,24 @@ public void GrantPileViewPermission(Player owner, Group group, Player requester,
14401440
writer.Close();
14411441
Send(stream.ToArray());
14421442
}
1443+
1444+
public void Shake(Player player, Card card)
1445+
{
1446+
Log.Debug($"OCTGN OUT: {nameof(Shake)}");
1447+
if(Program.Client == null)return;
1448+
MemoryStream stream = new MemoryStream(512);
1449+
stream.Seek(4, SeekOrigin.Begin);
1450+
BinaryWriter writer = new BinaryWriter(stream);
1451+
1452+
writer.Write(Program.Client.Muted);
1453+
writer.Write((byte)110);
1454+
writer.Write(player.Id);
1455+
writer.Write(card.Id);
1456+
writer.Flush(); writer.Seek(0, SeekOrigin.Begin);
1457+
writer.Write((int)stream.Length);
1458+
writer.Close();
1459+
Send(stream.ToArray());
1460+
}
14431461
}
14441462

14451463
public class BinarySenderStub : BaseBinaryStub

octgnFX/Octgn.JodsEngine/Networking/ClientHandler.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -786,6 +786,15 @@ public void Rotate(Player player, Card card, CardOrientation rot)
786786
new Rotate(player, card, rot).Do();
787787
}
788788

789+
public void Shake(Player player, Card card)
790+
{
791+
WriteReplayAction(player.Id);
792+
// Ignore the moves we made ourselves
793+
if (IsLocalPlayer(player))
794+
return;
795+
new Shake(player, card).Do();
796+
}
797+
789798
public void Shuffled(Player player, Group group, int[] card, short[] pos)
790799
{
791800
WriteReplayAction(player.Id);

octgnFX/Octgn.JodsEngine/Networking/IServerCalls.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,5 +83,6 @@ public interface IServerCalls
8383
void SetPlayerColor(Player player, string color);
8484
void RequestPileViewPermission(Player requester, Group group, Player targetPlayer, string viewType, int cardCount);
8585
void GrantPileViewPermission(Player owner, Group group, Player requester, bool granted, bool permanent, string viewType, int cardCount);
86+
void Shake(Player player, Card card);
8687
}
8788
}

octgnFX/Octgn.JodsEngine/Octgn.JodsEngine.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,7 @@
358358
<Compile Include="Play\Actions\Create.cs" />
359359
<Compile Include="Play\Actions\Move.cs" />
360360
<Compile Include="Play\Actions\Rotate.cs" />
361+
<Compile Include="Play\Actions\Shake.cs" />
361362
<Compile Include="Play\Actions\Target.cs" />
362363
<Compile Include="Play\Actions\Turn.cs" />
363364
<Compile Include="Play\Card.cs" />
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
using System.Diagnostics;
2+
using System.Reflection;
3+
using System.Threading.Tasks;
4+
using System.Windows.Controls;
5+
using Octgn.DataNew.Entities;
6+
7+
namespace Octgn.Play.Actions
8+
{
9+
internal sealed class Shake : ActionBase
10+
{
11+
private readonly Card _card;
12+
private readonly Player _who;
13+
14+
public Shake(Player who, Card card)
15+
{
16+
_who = who;
17+
_card = card;
18+
}
19+
20+
public override void Do()
21+
{
22+
base.Do();
23+
24+
// If card is in a pile or hand, focus the player's tab BEFORE the animation starts
25+
var did_focus = false;
26+
if (_card.Group != null && _card.Group != Program.GameEngine.Table)
27+
{
28+
did_focus = FocusPlayerTab(_card.Group.Owner);
29+
}
30+
31+
if (!did_focus) {
32+
_card.DoShake();
33+
} else {
34+
Program.Dispatcher.InvokeAsync(async () => {
35+
await Task.Delay(100);
36+
_card.DoShake();
37+
});
38+
}
39+
40+
// Generate appropriate message based on card visibility and group type
41+
var (message, args) = GetShakeMessage();
42+
Program.GameMess.PlayerEvent(_who, message, args);
43+
}
44+
45+
private bool FocusPlayerTab(Player player)
46+
{
47+
try
48+
{
49+
var windowManager = WindowManager.PlayWindow;
50+
if (windowManager == null) return false;
51+
52+
// Check if the "Switch Tabs on Shake" setting is enabled
53+
if (!windowManager.SwitchTabsOnShake) return false;
54+
55+
// Use reflection to access the private playerTabs field
56+
var playerTabsField = windowManager.GetType().GetField("playerTabs", BindingFlags.NonPublic | BindingFlags.Instance);
57+
if (playerTabsField?.GetValue(windowManager) is TabControl playerTabs)
58+
{
59+
// Find the tab for this player
60+
foreach (Player tab_player in playerTabs.Items)
61+
{
62+
if (tab_player.Id == player.Id)
63+
{
64+
if (playerTabs.SelectedItem != tab_player) {
65+
playerTabs.SelectedItem = tab_player;
66+
return true;
67+
}
68+
}
69+
}
70+
}
71+
}
72+
catch (System.Exception ex)
73+
{
74+
// Log the error but don't crash the game
75+
Debug.WriteLine($"Error focusing player tab: {ex.Message}");
76+
}
77+
78+
return false;
79+
}
80+
81+
private (string message, object[] args) GetShakeMessage()
82+
{
83+
// If card is on the table, use the standard message regardless of visibility
84+
if (_card.Group == null || _card.Group == Program.GameEngine.Table)
85+
{
86+
return ("shakes '{0}'", new object[] { _card });
87+
}
88+
89+
var groupOwnerName = _card.Group.Owner?.Name ?? "Unknown";
90+
var groupName = _card.Group.Name;
91+
92+
// Card is not on the table, always specify the group
93+
// If card is visible, show the card name in the group
94+
if (_card.Name != "Card")
95+
{
96+
return ("shakes '{0}' in {1}'s {2}", new object[] { _card.Name, groupOwnerName, groupName });
97+
}
98+
99+
// Card is not visible, determine appropriate message based on group type
100+
// Check if the group is a pile
101+
if (_card.Group is Pile pile)
102+
{
103+
// Check if it's collapsed (PileCollapsedControl)
104+
if (pile.ViewState == GroupViewState.Collapsed)
105+
{
106+
return ("shook {0}'s {1}", new object[] { groupOwnerName, groupName });
107+
}
108+
109+
// Check if it's a pile without fanning (PileControl without fanning)
110+
// For GroupViewState.Pile, we assume it's not fanned
111+
if (pile.ViewState == GroupViewState.Pile)
112+
{
113+
return ("shook {0}'s {1}", new object[] { groupOwnerName, groupName });
114+
}
115+
}
116+
117+
// For other cases (expanded piles, hands, etc.), use "in <groupname>" format
118+
return ("shook a card in {0}'s {1}", new object[] { groupOwnerName, groupName });
119+
}
120+
}
121+
}

octgnFX/Octgn.JodsEngine/Play/Card.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,6 +412,19 @@ public CardOrientation Orientation
412412
}
413413
}
414414

415+
public void Shake(bool network = true)
416+
{
417+
new Shake(Player.LocalPlayer, this).Do();
418+
if (network)
419+
Program.Client.Rpc.Shake(Player.LocalPlayer, this);
420+
}
421+
422+
internal void DoShake()
423+
{
424+
// Fire a property change event to trigger the shake animation
425+
OnPropertyChanged("IsShaking");
426+
}
427+
415428
public bool Anchored
416429
{
417430
get { return _anchored; }

octgnFX/Octgn.JodsEngine/Play/Gui/CardControl.xaml.cs

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,9 @@ private void PropertyChangeHandler(object sender, PropertyChangedEventArgs e)
377377
case "Anchored":
378378
this.IsAnchored = Card.Anchored;
379379
break;
380+
case "IsShaking":
381+
DoShakeAnimation();
382+
break;
380383
}
381384
}
382385

@@ -445,6 +448,67 @@ protected void Turned(object sender, EventArgs e)
445448
turn.BeginAnimation(ScaleTransform.ScaleXProperty, anim);
446449
}
447450

451+
public void DoShakeAnimation()
452+
{
453+
// Use the existing transform group from the XAML
454+
var contentCtrl = FindName("contentCtrl") as ContentControl;
455+
if (contentCtrl?.RenderTransform is TransformGroup transforms)
456+
{
457+
// Find or create a TranslateTransform for the shake
458+
var translateTransform = transforms.Children.OfType<TranslateTransform>().FirstOrDefault();
459+
if (translateTransform == null)
460+
{
461+
translateTransform = new TranslateTransform();
462+
transforms.Children.Add(translateTransform);
463+
}
464+
465+
// Create a vigorous shake animation for left-right movement with bounce effect
466+
var shakeAnim = new DoubleAnimationUsingKeyFrames();
467+
shakeAnim.Duration = new Duration(TimeSpan.FromMilliseconds(500)); // Faster individual cycle
468+
shakeAnim.RepeatBehavior = new RepeatBehavior(3); // Repeat 3 times for more vigorous shaking
469+
shakeAnim.KeyFrames.Add(new EasingDoubleKeyFrame(0, TimeSpan.FromMilliseconds(0)));
470+
shakeAnim.KeyFrames.Add(new EasingDoubleKeyFrame(12, TimeSpan.FromMilliseconds(75)) { EasingFunction = new BounceEase() { Bounces = 2, Bounciness = 3 } });
471+
shakeAnim.KeyFrames.Add(new EasingDoubleKeyFrame(-12, TimeSpan.FromMilliseconds(150)) { EasingFunction = new BounceEase() { Bounces = 2, Bounciness = 3 } });
472+
shakeAnim.KeyFrames.Add(new EasingDoubleKeyFrame(10, TimeSpan.FromMilliseconds(225)) { EasingFunction = new BounceEase() { Bounces = 2, Bounciness = 3 } });
473+
shakeAnim.KeyFrames.Add(new EasingDoubleKeyFrame(-10, TimeSpan.FromMilliseconds(300)) { EasingFunction = new BounceEase() { Bounces = 2, Bounciness = 3 } });
474+
shakeAnim.KeyFrames.Add(new EasingDoubleKeyFrame(8, TimeSpan.FromMilliseconds(375)) { EasingFunction = new BounceEase() { Bounces = 1, Bounciness = 2 } });
475+
shakeAnim.KeyFrames.Add(new EasingDoubleKeyFrame(-8, TimeSpan.FromMilliseconds(425)) { EasingFunction = new BounceEase() { Bounces = 1, Bounciness = 2 } });
476+
shakeAnim.KeyFrames.Add(new EasingDoubleKeyFrame(0, TimeSpan.FromMilliseconds(500)) { EasingFunction = new BounceEase() { Bounces = 3, Bounciness = 4 } });
477+
shakeAnim.FillBehavior = FillBehavior.Stop;
478+
479+
// Create scale animations using a temporary ScaleTransform
480+
var tempScale = transforms.Children.OfType<ScaleTransform>().FirstOrDefault();
481+
if (tempScale == null)
482+
{
483+
tempScale = new ScaleTransform();
484+
transforms.Children.Add(tempScale);
485+
}
486+
487+
// Set the center point for scaling to the center of the card (absolute pixel values)
488+
tempScale.CenterX = contentCtrl.ActualWidth / 2;
489+
tempScale.CenterY = contentCtrl.ActualHeight / 2;
490+
491+
// Create a combined scale animation that goes up then down
492+
var scaleAnimX = new DoubleAnimationUsingKeyFrames();
493+
scaleAnimX.Duration = new Duration(TimeSpan.FromMilliseconds(1500));
494+
scaleAnimX.KeyFrames.Add(new EasingDoubleKeyFrame(1.0, TimeSpan.FromMilliseconds(0)));
495+
scaleAnimX.KeyFrames.Add(new EasingDoubleKeyFrame(1.6, TimeSpan.FromMilliseconds(100)) { EasingFunction = new BackEase() { Amplitude = 0.3 } });
496+
scaleAnimX.KeyFrames.Add(new EasingDoubleKeyFrame(1.6, TimeSpan.FromMilliseconds(1400)));
497+
scaleAnimX.KeyFrames.Add(new EasingDoubleKeyFrame(1.0, TimeSpan.FromMilliseconds(1500)) { EasingFunction = new BackEase() { Amplitude = 0.3 } });
498+
scaleAnimX.FillBehavior = FillBehavior.Stop;
499+
500+
var scaleAnimY = scaleAnimX.Clone();
501+
502+
// Remove the temporary scale transform after animation
503+
scaleAnimX.Completed += (s, e) => transforms.Children.Remove(tempScale);
504+
505+
// Apply the animations
506+
tempScale.BeginAnimation(ScaleTransform.ScaleXProperty, scaleAnimX);
507+
tempScale.BeginAnimation(ScaleTransform.ScaleYProperty, scaleAnimY);
508+
translateTransform.BeginAnimation(TranslateTransform.XProperty, shakeAnim);
509+
}
510+
}
511+
448512
#endregion
449513

450514
#region Drag and Drop

octgnFX/Octgn.JodsEngine/Play/Gui/GroupControl.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,12 @@ protected virtual List<Control> CreateCardMenuItems(Card card, DataNew.Entities.
346346
item = new MenuItem { Header = "Take control" };
347347
item.Click += delegate { card.TakeControl(); };
348348
items.Add(item);
349+
350+
// Add Shake menu item for cards you can't manipulate
351+
var shakeMenuItem = new MenuItem { Header = "Shake" };
352+
shakeMenuItem.Click += delegate { ContextCard.Shake(); };
353+
items.Add(shakeMenuItem);
354+
349355
return items;
350356
}
351357
else
@@ -382,6 +388,12 @@ protected virtual List<Control> CreateCardMenuItems(Card card, DataNew.Entities.
382388
peekItem.Click += delegate { ContextCard.Peek(); };
383389
items.Add(peekItem);
384390
}
391+
392+
// Add Shake menu item
393+
var shakeItem = new MenuItem { Header = "Shake" };
394+
shakeItem.Click += delegate { ContextCard.Shake(); };
395+
items.Add(shakeItem);
396+
385397
return items;
386398
}
387399

octgnFX/Octgn.JodsEngine/Play/PlayWindow.xaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@
106106
</MenuItem>
107107
<MenuItem Header="Reset Screen Position" Command="gui:Commands.ResetScreen" />
108108
<MenuItem Header="Extended Tooltips" IsCheckable="True" IsChecked="{Binding ShowExtendedTooltips, BindsDirectlyToSource=True, RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type play:PlayWindow}}}" />
109+
<MenuItem Header="Switch Tabs on Shake" IsCheckable="True" IsChecked="{Binding SwitchTabsOnShake, BindsDirectlyToSource=True, RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type play:PlayWindow}}}" />
109110
<MenuItem Header="Card List" IsCheckable="True" IsEnabled="{Binding Source={x:Static octgn:Program.GameSettings}, Path=AllowCardList}" IsChecked="{Binding Source={x:Static octgn:Program.GameEngine}, Path=DeckStats.IsVisible}"></MenuItem>
110111
<MenuItem x:Name="CardPreviewToggleChecked" Header="Undock Card Preview" Click="ToggleCardPreviewWindow" IsCheckable="True"></MenuItem>
111112
<MenuItem x:Name="ChatToggleChecked" Header="Undock Chat" Click="ToggleChatDockPanel" IsCheckable="True"></MenuItem>

0 commit comments

Comments
 (0)