WPF自定义控件 —— 复合控件(中国象棋联机版)
对于自己做的游戏即使输了脸上一般还是洋溢着笑容的,那么如何做一个简单的游戏呢?示例下载(.netFramework 3.5 SP1)
一.再说呈现
在自绘那篇我们已经知道了如何把东西画出来,或许你已经能通过重载OnRender函数非常熟练的画出这样棋子。
那么OnRender方法中的DrawingContext参数到底来自哪呢?因为DrawingContext是抽象类,所以微软创建了一个叫做RenderDataDrawingContext的具体类以及他的子类VisualDrawingContext,我们的所用的DrawingContext实际就是VisualDrawingContext这个类,不过微软都把他们定义为了internal,我们在程序集以外无法访问,既然无法访问,那么当我们需要多个这样的对象时如何创建呢?
DrawingVisual这类为我们实现多个DrawingContext成为了可能,因为他的实例方法RenderOpen()在内部创建了VisualDrawingContext,DrawingVisual也可以说继承于Visual,当我们用DrawingContext的Drawing一些东西的时候,其实产生的是画图的数据,数据有了,可要把数据给UI 的线程才能被显示,WPF似乎是用ContextLayoutManager这个类来把UI重绘请求放到Dispatcher队列,用Visual里的DUCE发送消息和线程对话.
我们创建可视数据的代码可以写成这样:
DrawingVisual boardVisual = new DrawingVisual(); using (DrawingContext drawingContext = boardVisual.RenderOpen()) { //画棋盘 }
以上是创建了画图的数据,那么怎么用ContextLayoutManager把数据给Dispatcher队列。UIElement中的 PropagateResumeLayout方法循环递归把需要刷新的Visual对象放到队列中,经过这样的分装我们只需要知道把需要呈现的Visual仍给系统就可以,他自己会判断是否要刷新。
怎么给Visual,微软要求我们先给Visual的数量,这需要我们通过以下方式来给定
protected override int VisualChildrenCount { get { return 1; } }
然后他会用一个for循环来得到需要的Visual对象
for (int i = 0; i < internalVisualChildrenCount; i++) { Visual visualChild = v.InternalGetVisualChild(i); if (visualChild != null) { PropagateResumeLayout(v, visualChild); } }
internalVisualChildrenCount的数量就是VisualChildrenCount返回的值, InternalGetVisualChild的方法实际做的就是我们常重载的GetVisualChild方法。
protected override Visual GetVisualChild(int index) { if (index == 0) return boardVisual; else throw new ArgumentOutOfRangeException("out of range"); }就这么简单,我们是否已经看到自己画的棋盘了。
二.可视树与逻辑树
虽然我也承认这两棵树已经被人刨根问底的掘了N次,但对于自定义控件来说这树可不是说舍弃就舍弃的(要充分合理利用资源嘛)。
逻辑树(Logical Tree)当然是逻辑意义上的,如果把Visual 可以比作汽车的一部分比如车厢,轮胎,油箱,当我们坐在车厢里的时候我们实际也可以说做在车中,车是一个逻辑意义上的含义,是各个汽车零件和的总称。所以我们的控件上的Parent或者是Child一般都是逻辑的物件,那么加入他除了一个标示外,还有其他的什么意义呢?他还可以属性值继承,比如说我们在我们这个象棋控件上设置下字体的变化,希望上面的棋子车、马、帅等的字体也发生变化就可以用到他。
在我们的象棋控件中这样注册:
public static readonly DependencyProperty FontFamilyProperty = DependencyProperty.Register("FontFamily", typeof(FontFamily), typeof(ChineseChessboard), new FrameworkPropertyMetadata(SystemFonts.MessageFontFamily, FrameworkPropertyMetadataOptions.Inherits));
棋子中可以这样
public static readonly DependencyProperty FontFamilyProperty = ChineseChessboard.FontFamilyProperty.AddOwner(typeof(ChessmanControl), new FrameworkPropertyMetadata(SystemFonts.MessageFontFamily, FrameworkPropertyMetadataOptions.Inherits));
另外的还有事件路由和资源就不多说了(我也没用到^-^)。
可视树(Visual Tree)据说是逻辑树的扩展,所以把元素只加入可视树依然可以进行属性继承,当然前提是类要继承于FrameworkElement 或FrameworkContentElement,从树的字面意义来看似乎告诉我们不加入他就不能看到,实际上他和呈现没有太大关系,可问题是当你把一个visual显示出来了,也停留在只可远观而不可亵玩的地步 —— 不能引发事件,加入了VisualTree 这个问题就可以解决了,有了点击测试(HitTest),事件也有了,人也顺心了,编程也不困惑了。这里要说明下当我把整个大棋盘加入到可视树时,上面的棋子未加入到可视树的前提下,棋子上的鼠标响应依然可以获得HitTest也能捕捉的到棋子,可见WPF似乎根据一个区域的像素点来判断。至于VisualTreeHelper里的方法可以自己查询MSDN了解。
说到这两个树还有两个类值得提下:VisualCollection和UIElementCollection 前者可以用来操作可视树,后者既可以操作可视树又可以操作逻辑树,如果只想操作可视树,可以把UIElementCollection 构造函数中的logicParent 赋为null。使用了这两个类你就可以简单操作树上的元素了。
三.HitTest和TranslateTransform
当我们把棋子放到UIElementCollection 中,并输出队列
protected override int VisualChildrenCount { get { // +1 是为棋盘 return (ChessmanCollection != null ? ChessmanCollection.Count : 0) + 1; } } protected override Visual GetVisualChild(int index) { //第一个为棋盘其他为棋子 if (index == 0) return boardVisual; return ChessmanCollection[index - 1]; }并输出他们的实际大小
protected override Size MeasureOverride(Size availableSize) { for (int i = 0; ChessmanCollection != null && i < ChessmanCollection.Count; i++) { ChessmanCollection[i].Measure(availableSize); } return new Size(CellWidth * 10, CellWidth * 11); }
看到了棋子的显示,我们接下来的事便是要选中棋子
protected override void OnMouseLeftButtonDown(MouseButtonEventArgs e) { Point location = e.GetPosition(this); HitTestResult result = VisualTreeHelper.HitTest(this, location); ChessmanControl chessmanControl = result.VisualHit as ChessmanControl;
当然点击测试还可以做很多事,具体的看http://msdn.microsoft.com/zh-cn/library/ms752097.aspx
选中以后我们要移动,并需要一个移动过程的效果
TranslateTransform _moveTransform = new TranslateTransform(); _chessmanBaseDic[newChessman].RenderTransform = _moveTransform; DoubleAnimation xAnimation = new DoubleAnimation((newPoint.X - oldPoint.X) * CellWidth, _moveDuration, FillBehavior.Stop); _moveTransform.BeginAnimation(TranslateTransform.XProperty, xAnimation); DoubleAnimation yAnimation = new DoubleAnimation((newPoint.Y - oldPoint.Y) * CellWidth, _moveDuration, FillBehavior.Stop); EventHandler tempAction = default(EventHandler); tempAction = delegate { _chessmanBaseDic[newChessman].ClearValue(UIElement.RenderTransformProperty); _chessmanBaseDic[newChessman].isSelected = false; _currentChessmanControl = null; if (oldChessman != null) { ChessmanCollection.Remove(_chessmanBaseDic[oldChessman]); } //更新 this.InvalidateArrange(); //移除本身 yAnimation.Completed -= tempAction; }; yAnimation.Completed += tempAction; _moveTransform.BeginAnimation(TranslateTransform.YProperty, yAnimation);
如果你感觉写两个Animation来分别控制有点蠢的话,你也可以把TranslateTransform 放到CompositionTarget.Rendering来控制。
当然这样移动是暂时的,所以我们要引发InvalidateArrange()来实际输出控件的位置
protected override Size ArrangeOverride(Size finalSize) { for (int i = 0; ChessmanCollection != null && i < ChessmanCollection.Count; i++) { ChessmanControl item = ChessmanCollection[i] as ChessmanControl; item.Arrange(new Rect( new Point(item.Chessman.Location.X * CellWidth - item.DesiredSize.Width / 2+CellWidth, item.Chessman.Location.Y * CellWidth - item.DesiredSize.Height / 2+CellWidth), item.DesiredSize)); } return this.DesiredSize; }
四.画图问题
棋盘背景可以加张图片
BitmapImage backgroundImage = new BitmapImage(); backgroundImage.BeginInit(); backgroundImage.UriSource = new Uri(@"pack://application:,,,/ChineseChessControl;component/Images/woodDeskground.jpg", UriKind.RelativeOrAbsolute); backgroundImage.EndInit(); backgroundImage.Freeze();
其中的Uri 如果不熟悉的话可以看下面两个网址(一个为3.5的一个为3.0的):
http://msdn.microsoft.com/en-us/library/aa970069.aspx#The_Pack_URI_Scheme
http://msdn.microsoft.com/en-us/library/aa970069(VS.85).aspx
对于棋盘的立体效果,是用两条线来达到的,上面一条用了深色画笔,下面一个用了浅色画笔
drawingContext.DrawLine(darkPen, new Point(CellWidth * i, CellWidth), new Point(CellWidth * i, CellWidth * 10 / 2)); drawingContext.DrawLine(lightPen, new Point(CellWidth * i + 1.5, CellWidth), new Point(CellWidth * i + 1.5, CellWidth * 10 / 2));
棋盘的话也可以先画四分之一,然后通过反转得到
//第一象限 drawingContext.PushTransform(new ScaleTransform(-1, 1, CellWidth * 5, CellWidth * 5.5)); quarterBoard(); drawingContext.Pop(); //第二象限 quarterBoard(); //第三象限 drawingContext.PushTransform(new ScaleTransform(1, -1, CellWidth * 5, CellWidth * 5.5)); quarterBoard(); drawingContext.Pop(); //第四象限 drawingContext.PushTransform(new ScaleTransform(-1, -1, CellWidth * 5, CellWidth * 5.5)); quarterBoard(); drawingContext.Pop();
对于楚河、汉界几个字先文字竖排,文字竖排其实就是限制文字输出的宽度,让他把字给挤下去。
FormattedText textFormat = new FormattedText( text, System.Globalization.CultureInfo.CurrentCulture, FlowDirection.LeftToRight, new Typeface("STLiti"), fontSize, Brushes.Black); textFormat.MaxTextWidth = fontSize; textFormat.TextAlignment = TextAlignment.Justify;
在翻转的时候要注意的是我们的翻转的文字块往往是长方形,而不是正方形,
所以你在需要先把A点的X坐标加上长宽之差一半的距离再加全部字宽度的一半,Y坐标减去长宽之差的一半加上字体大小的一半再加全部字的高度的一半
五.动画提醒
有些时候下棋的时候会没有觉察出对方下了哪个棋,应该过几秒提醒下,顺便也督促下已经思考了一段时间了快快下吧。
我们可以使用 DispatcherTimer类来计时,让其每个几秒来做提醒。
DispatcherTimer _dispatcherTimer = new DispatcherTimer(DispatcherPriority.ApplicationIdle); _dispatcherTimer.Interval = TimeSpan.FromSeconds(10); _dispatcherTimer.Tick += delegate { _renderingListener.StartListening(); };
然后用CompositionTarget.Rendering来改变控件的透明度达到跳动的效果。
_renderingListener.Rendering += delegate { if (tempChessmanControl == null) tempChessmanControl = _lastMoveChessman; if (tempChessmanControl != null) { if (frameCount % 20 == 0) { tempChessmanControl.Opacity = tempChessmanControl.Opacity > 0 ? 0 : 1; } frameCount++; if (tempChessmanControl != _lastMoveChessman //已经换了棋子 || frameCount > 120 //提醒大于规定 || !IsRedReady //红方没有开始,或已经结束 || !IsBlueReady) //蓝方没有开始,或已经结束 { tempChessmanControl.Opacity = 1; frameCount = 0; tempChessmanControl = null; _renderingListener.StopListening(); } } };
六.联机操作
协议:UDP
- 先发送广播给局域网看是否有空闲主机。
- 空闲主机接到广播如果空闲则回应。
- 创建主机的时候再发广播告诉已经有新建主机。
- 互联后告诉其他主机已经在对战,让其从服务列表下拿掉。
- 走一步告诉对方是从哪个坐标点移动到哪个坐标点
- 没做
发送广播
public void SendBroadcast(object obj, int port) { byte[] sendbuf = UDPClass.Serialization(obj); Socket s = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); IPEndPoint ep = new IPEndPoint(IPAddress.Broadcast, port); s.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.Broadcast, 1); s.SendTo(sendbuf, ep); s.Close(); }
发送普通消息
public void Send(IPAddress ipAddress, object obj, int port) { byte[] sendbuf = UDPClass.Serialization(obj); Socket s = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); IPEndPoint ep = new IPEndPoint(ipAddress, port); s.SendTo(sendbuf, ep); s.Close(); }
监听端口
private void StartUdpListenerPort() { bool done = true; using (UdpClient listener = new UdpClient()) { listener.EnableBroadcast = true; IPEndPoint iep = new IPEndPoint(IPAddress.Any, _listenPort); //端口复用 listener.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); listener.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ExclusiveAddressUse, false); listener.Client.Bind(iep);//绑定这个实例 try { while (done) { byte[] bytes = listener.Receive(ref iep); _callback(iep,_listenPort, UDPClass.Deserialize(bytes)); } } finally { listener.Close(); } } }
因为存在多客户端所以一个端口不能重用,我是从2000可以,每次加一看看是否被使用过。这里用了比较烂的方法,不知道大家有没有比较好的办法
public static int GetIdlePort(int startPort) { while (true) { Socket s = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); IPEndPoint ipport = new IPEndPoint(IPAddress.Any, startPort); try { s.Bind(ipport); break; } catch { startPort++; } finally { s.Close(); } } return startPort; }
七.窗体关闭事件
窗体的事件是在View上的,如何让ViewModel和之上的事件相绑定呢?我们知道事件其实包含的是一个委托的集合,如果让View上的事件所用的委托同ViewModel的委托相绑定不就得的效果了。我们可以参考Prism框架做个中间件,用附加属性给窗体以作绑定。
<Window x:Class="ChineseChess.Shell" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:local="clr-namespace:ChineseChess" xmlns:views="clr-namespace:ChineseChess.Views" Width="500" Height="600"> <local:WindowRegionMetadata.WindowRegionMetadata> <local:WindowRegionMetadata BeforeClose="{Binding PersistAction}"/> </local:WindowRegionMetadata.WindowRegionMetadata> <Window.Title> <MultiBinding StringFormat="{}{0}:{1}"> <Binding Path="LocalIP"/> <Binding Path="Port"/> </MultiBinding> </Window.Title>
但是App中的启动要改成这样
protected override void OnStartup(StartupEventArgs e) { base.OnStartup(e); Shell window = WindowRegionBehavior.CreateWindow<Shell>(null, null); ShellViewModel shellViewModel = new ShellViewModel(); window.DataContext = shellViewModel; window.Show(); }
具体的代码限于篇幅请参见事例。
八.写在最后
本事例只是简单的实现了一些功能,如有需要添加的可以自己练练手,做些小游戏还是很能提高对程序的积极性的。
转载请注明