Kein Pong - Erstaunliches mit ItemsControl.Itemspanel

    • WPF

    Es gibt 2 Antworten in diesem Thema. Der letzte Beitrag () ist von ErfinderDesRades.

      Kein Pong - Erstaunliches mit ItemsControl.Itemspanel

      In diesem Wpf-Tutorial hab ich ja eine CollectionView vorgestellt, die die User-Selection hereinholt ins Viewmodel.
      Hier jetzt gehe ich noch einen Schritt weiter, und verstoße richtig gegen den MVVM-Pattern: Ich hole mir nämlich ein Control selbst ins Viewmodel 8| . Ich hab da irgendwie keine Moral: wenn ich die Tastendrücke, die an ein Control gesendet werden, verarbeiten möchte, dann brauche ich den Eventhandler im Viewmodel.
      Man kann da nun iwas umständliches reißen, mit einem Attached Behavior, wo das Event empfangen wird, und was dann iwas ans Viewmodel sendet - aber imo ist das nur eine verkomplizierende Verschleierung desselben: Nämlich, dass im Viewmodel Control-Events verarbeitet werden.
      Also ich habe für solche Fälle ein Universal-AttachedBehavior - nenne ich VisualSubscribe, weils dazu da ist, dass im Viewmodel Visual-Events abonniert werden (subscribe).
      Die Anwendung sieht ganz harmlos aus:

      XML-Quellcode

      1. <ItemsControl ItemsSource="{Binding Path=Moveables}" hlp:VisualSubscribe.Subscribe="{Binding Path=GetItemsControl}">
      Im Viewmodel lungert dann eine Action(Of Object)-Property rum, an die das VisualSubscribe gebunden wird, und die genau dann genau einmal aufgerufen wird, wenn die Bindung erfolgt:

      VB.NET-Quellcode

      1. Public Property GetItemsControl As Action(Of Object) = Sub(obj) _ItemsControl.Be(obj)
      2. Private WithEvents _ItemsControl As ItemsControl
      Also im Bindungs-Moment wird die Property aufgerufen, und kann dann das _ItemsControl setzen (meine .Be()-Extension übernimmt den notwendigen Cast). Und weils WithEvents ist, kann ich alle dessen Events verarbeiten :D .
      Aber vlt. sollte ich erstmal erzählen, wasses werden soll: Ich will eine Spielfläche haben, und darauf sollen Objekte herumfahren, deren Bewegungen vom User gesteuert werden.
      Das Viewmodel der Objekte ist super-banal - einfach ein Dingens, was seine X/Y-Position angeben kann:
      Class Moveable

      VB.NET-Quellcode

      1. Imports System.ComponentModel
      2. Public Class Moveable : Inherits NotifyPropertyChanged
      3. Private _X As Double = 0
      4. Public Property X() As Double
      5. Get
      6. Return _X
      7. End Get
      8. Set(value As Double)
      9. ChangePropIfDifferent(value, _X, "X")
      10. End Set
      11. End Property
      12. Protected _Y As Double = 0
      13. Public Property Y() As Double
      14. Get
      15. Return _Y
      16. End Get
      17. Set(value As Double)
      18. ChangePropIfDifferent(value, _Y, "Y")
      19. End Set
      20. End Property
      21. End Class

      Das Mainmodel nun enthält viele solcher Dingense in einer ObservableCollection:

      VB.NET-Quellcode

      1. Imports System.ComponentModel
      2. Imports System.Collections.ObjectModel
      3. Public Class MainModel : Inherits MainModelBase(Of MainModel)
      4. Public Property StepSize As Double = 2
      5. Public Property Moveables As New ObservableCollection(Of Moveable)
      6. Public Property GetItemsControl As Action(Of Object) = Sub(obj) _ItemsControl.Be(obj)
      7. Private WithEvents _ItemsControl As ItemsControl
      8. Public Sub New()
      9. If IsProvisional Then
      10. _Moveables.Add({New Moveable, New Moveable With {.X = 33, .Y = 22}})
      11. Return
      12. End If
      13. Call 5.Times(Sub(i) Moveables.Add(New Moveable With {.X = i * 30, .Y = i * 15}))
      14. End Sub
      15. Private Sub _ItemsControl_PreviewKeyDown(sender As Object, e As KeyEventArgs) Handles _ItemsControl.PreviewKeyDown
      16. Dim focused = _Moveables.Last
      17. Select Case e.Key
      18. Case Key.Up : focused.Y -= StepSize
      19. Case Key.Right : focused.X += StepSize
      20. Case Key.Down : focused.Y += StepSize
      21. Case Key.Left : focused.X -= StepSize
      22. Case Else : Return
      23. End Select
      24. e.Handled = True
      25. End Sub
      26. End Class
      Und man sieht auch, wieso ich die Tastendrücke haben will: nämlich um die Position des fokussierten Moveables (um StepSize) zu verändern (zeilen#23-#26).

      Und wie ist im Xaml daran gebunden?

      XML-Quellcode

      1. <ItemsControl ItemsSource="{Binding Path=Moveables}" hlp:VisualSubscribe.Subscribe="{Binding Path=GetItemsControl}" >
      2. <ItemsControl.ItemsPanel>
      3. <ItemsPanelTemplate>
      4. <Canvas/>
      5. </ItemsPanelTemplate>
      6. </ItemsControl.ItemsPanel>
      7. <ItemsControl.ItemContainerStyle>
      8. <Style TargetType="{x:Type ContentPresenter}">
      9. <Setter Property="Canvas.Left" Value="{Binding Path=X}"/>
      10. <Setter Property="Canvas.Top" Value="{Binding Path=Y}"/>
      11. </Style>
      12. </ItemsControl.ItemContainerStyle>
      13. <ItemsControl.ItemTemplate>
      14. <DataTemplate>
      15. <Button Content="ClickMe" />
      16. </DataTemplate>
      17. </ItemsControl.ItemTemplate>
      18. </ItemsControl>
      Tss! - einfach ein ItemsControl (also eine abgespeckte Listbox)!
      Und im .ItemTemplate (#13-#17) ist an garnix gebunden - da wird einfach nur ein Standard-Button generiert, für jedes Moveable einer.
      Aber das listige ist das .ItemsPanel (#2-#6): Das ist nämlich ein Canvas - also ein Panel, was die enthaltenen Controls nicht von oben nach unten aufreiht (wies bei einer Listbox normal wäre), sondern im Canvas wird jedem Control einzeln eine X/Y - Position zugewiesen, also Canvas.Left/.Top, um genau zu sein.
      Dummerweise nützt es nix, die Buttons im DataTemplate zu positionieren, denn die sitzen gar nicht direkt auf dem Canvas auf, sondern die Buttons liegen jeweils in einem ItemContainer, und der liegt im Canvas.
      Also muss der ItemContainer positioniert werden - nicht der Button im DataTemplate. Die ItemContainer (es sind ja viele - für jeden Button einer) sind beim ItemsControl vom Typ ContentPresenter, und beeinflussen kann man die nur über einen Style (#7-#12), den das ItemsControl dann auf jeden ItemContainer anwendet, wenn die Daten-Präsentation generiert wird.

      Uff!!
      Dann aber ist lustig, weil: (fast) fertig.
      Also das ItemsControl ist funktional immer noch eigentlich eine Listbox, nur es sieht überhaupt nicht mehr wie eine Listbox aus, weil die Items darin ganz beliebig angeordnet sein können, und sich sogar bewegen!

      Aber wir haben noch ein Problem zu lösen, nämlich die Z-Reihenfolge.
      Denn natürlich soll ein Item nach vorne kommen, wenn mans fokussiert - wie geht das? Naja, im ItemsControl werden die Daten-Präsentationen genau in der Reihenfolge generiert, die die Auflistung im Viewmodel vorgibt - das letzte Item wird also zuoberst dargestellt. Also muss man im Viewmodel einfach das angeklickte Item nehmen und an die letzte Stelle verschieben - dann kommt im View seine Präsentation (hier: der Button) nach vorne - Code:

      VB.NET-Quellcode

      1. Private Sub _ItemsControl_GotFocus(sender As Object, e As RoutedEventArgs) Handles _ItemsControl.GotFocus
      2. Dim focused = e.OriginContextAs(Of Moveable)()
      3. With _Moveables
      4. Dim i = .IndexOf(focused)
      5. Dim ubound = .Count - 1
      6. If i < 0 OrElse i = ubound Then Return
      7. .Move(i, ubound) 'move focused at last position
      8. End With
      9. End Sub
      Das .GotFocus ist ein RoutedEvent, und zwar mit RoutingStrategy.Bubble. Das heißt: wenn man auf einen der Item-Buttons klickst, empfängt zuerst der dieses Event. Aber er leitet es weiter, an seinen Parent, den ContentPresenter. Und der leitet es noch weiter ans Items-Control, und so kommts am Ende hier heraus. Und mit meiner e.OriginContextAs()-Extension kann ich den DataContext von e.OriginalSource abrufen - den DataContext des geklicksten Buttons - mit anderen Worten: dasjenige Moveable, auf dessen Daten-Präsentation geklickst wurde :D .
      Ja, und der weitere Code moved das Moveable an die letzte Position in der ObeservableCollection - ist nett, oder? - die hat extra eine Methode zum Verschieben von Elementen, und via Databinding benachrichtigt sie auch das ItemsControl.
      Dateien
      • Pong.zip

        (93,65 kB, 216 mal heruntergeladen, zuletzt: )

      Dieser Beitrag wurde bereits 5 mal editiert, zuletzt von „ErfinderDesRades“ ()

      Es ist übrigens noch ein zweites Projekt dabei, bei dem ich die Moveable-Class um ein Speed-Feature (zeile#26) erweitert hab:

      VB.NET-Quellcode

      1. Imports System.ComponentModel
      2. Public Class Moveable : Inherits NotifyPropertyChanged
      3. Private _X As Double = 0
      4. Public Property X() As Double
      5. Get
      6. Return _X
      7. End Get
      8. Set(value As Double)
      9. ChangePropIfDifferent(value, _X, "X")
      10. End Set
      11. End Property
      12. Protected _Y As Double = 0
      13. Public Property Y() As Double
      14. Get
      15. Return _Y
      16. End Get
      17. Set(value As Double)
      18. ChangePropIfDifferent(value, _Y, "Y")
      19. End Set
      20. End Property
      21. ''' <summary> speed in Pix/second </summary>
      22. Public Property Speed As Vector
      23. Public Sub Move(seconds As Double, clip As Size)
      24. Dim pos = New Vector(_X, _Y) + Speed * seconds
      25. If pos.X > clip.Width - 10 Then
      26. pos.X = -10
      27. ElseIf pos.X < -10 Then
      28. pos.X = clip.Width - 10
      29. End If
      30. If pos.Y > clip.Height - 10 Then
      31. pos.Y = -10
      32. ElseIf pos.Y < -10 Then
      33. pos.Y = clip.Height - 10
      34. End If
      35. X = pos.X
      36. Y = pos.Y
      37. End Sub
      38. End Class
      Speed ist ein Vector, und im Mainmodel die Tastendrücke ändern nun nicht mehr direkt die Position des Moveables, sondern sie ändern seinen Speed, und zwar in X- oder Y-Richtung.
      Weiters gibts dort einen Timer, der alle 30ms die Move()-Sub aufruft, und dadurch wird die Position geändert.
      Mainmodel

      VB.NET-Quellcode

      1. Private Sub _ItemsControl_PreviewKeyDown(sender As Object, e As KeyEventArgs) Handles _ItemsControl.PreviewKeyDown
      2. With _Moveables.Last
      3. Select Case e.Key
      4. Case Key.Up : .Speed = New Vector(.Speed.X, .Speed.Y - StepSize)
      5. Case Key.Down : .Speed = New Vector(.Speed.X, .Speed.Y + StepSize)
      6. Case Key.Left : .Speed = New Vector(.Speed.X - StepSize, .Speed.Y)
      7. Case Key.Right : .Speed = New Vector(.Speed.X + StepSize, .Speed.Y)
      8. Case Else : Return
      9. End Select
      10. End With
      11. e.Handled = True
      12. End Sub
      13. Private WithEvents _Timer As New DispatcherTimer With {.Interval = TimeSpan.FromMilliseconds(30)}
      14. Private prevTime As Date = Date.Now
      15. Private Sub _Timer_Tick(sender As Object, e As System.EventArgs) Handles _Timer.Tick
      16. Dim currTime = Date.Now
      17. Dim timespan = (currTime - prevTime).TotalSeconds
      18. Dim clip = New Size(_ItemsControl.ActualWidth, _ItemsControl.ActualHeight)
      19. For Each mvbl In Moveables
      20. mvbl.Move(timespan, clip)
      21. Next
      22. prevTime = currTime
      23. End Sub

      Also Pong2 ist viel lustiger, da kann man wirklich alle Buttons und in jede Richtung herumsausen lassen.
      (aber nicht zu schnell machen, sonst kannstese kaum noch anklicksen, zum abbremsen!)

      Kein Pong :(
      Übrigens - mancher hats wohl schon gemerkt: Die Anwendung ist kein Pong.
      Pong-Grundlagen sind vlt. gelegt, aber ich scheitere an den physikalischen Berechnungen, wenn ein bewegtes, rundes Objekt auf die Ecke eines bewegten, rechteckigen Schlägers auftrifft.

      Edit: Bitte glaubt mir - die Physik dazu ist ziemlich kompliziert - also Vorschläge dazu sind mir sehr willkommen, möchte ich aber nicht hier im Tutorial diskutieren.
      Am liebsten wäre mir, wenn jmd, der das gebacken kriegt, die Source dann im SourceCodeAustausch veröffentlicht, oder auch gleich ein Tutorial.

      Dieser Beitrag wurde bereits 1 mal editiert, zuletzt von „ErfinderDesRades“ ()

      Kollisionen

      Ich habe jetzt - in einem 3. Projekt innerhalb derselben Solution - ein klein wenig Kollisions-Behandlung eingebaut.
      Nämlich jetzt gibt es Bälle und einen Schläger, der kontinuirlich hin und her-wandert. Basisklasse beider ist Moveable.
      Der Schläger interagiert aber noch nicht mit den Bällen, sondern bislang ist nur die Kollision der Moveables gegen die Wandungen implementiert:

      VB.NET-Quellcode

      1. Public Sub BorderCollision(ByVal borders As Size)
      2. 'Eine Kollision wird immer einen Tick zu spät wahrgenommen - nämlich wenn bereits eine ungültige Körper-Überlappung
      3. 'stattfindet. Daher ist nicht nur der Geschw-Vektor zu ändern, sondern auch die Location zu korrigieren
      4. 'Bei Kollision mit Wandung sind 4 Fälle zu unterscheiden
      5. With Speed
      6. Dim diff = borders.Width - (_Location.X + _HalfSize.X)
      7. If diff < 0 Then
      8. Speed = New Vector(-.X, .Y)
      9. _Location = New Vector(borders.Width - (_HalfSize.X - diff), _Location.Y)
      10. Else
      11. diff = _Location.X - _HalfSize.X
      12. If diff < 0 Then
      13. Speed = New Vector(-.X, .Y)
      14. _Location = New Vector(_HalfSize.X - diff, _Location.Y)
      15. End If
      16. End If
      17. End With
      18. With Speed
      19. Dim diff = borders.Height - (_Location.Y + _HalfSize.Y)
      20. If diff < 0 Then
      21. Speed = New Vector(.X, -.Y)
      22. _Location = New Vector(_Location.X, borders.Height - (_HalfSize.Y - diff))
      23. Else
      24. diff = _Location.Y - _HalfSize.Y
      25. If diff < 0 Then
      26. Speed = New Vector(.X, -.Y)
      27. _Location = New Vector(_Location.X, _HalfSize.Y - diff)
      28. End If
      29. End If
      30. End With
      31. End Sub
      (Übrigens, _Location bezeichnet den Körper-Mittelpunkt. Deshalb taucht an versch. Stellen _HalfSize auf, um die Körper-Grenze zu bestimmen)

      Im Mainmodell sind Bewegen, Kollidieren und GuiUpdate nun getrennte Vorgänge:

      VB.NET-Quellcode

      1. For Each mvbl In Moveables
      2. mvbl.Move(timespan)
      3. mvbl.BorderCollision(borders)
      4. With TryCast(mvbl, Ball)
      5. If .NotNull Then .RacketCollision(_Racket)
      6. End With
      7. mvbl.UpdateGui()
      8. Next
      Das eröffnet die Möglichkeit, in einem "Frame" für jedes Moveable alle Kollisionen abzuarbeiten, welche auftreten können.
      Wie man sieht, sind bislang Kollisionen zw. Moveable und Wandung (betrifft also den Schläger auch), und zusätzlich für Bälle Kollision zw. Ball und Schläger vorgesehen.
      Wie gesagt: Bei der Schläger-Kollision passiert noch nichts - ich stelle hier nur die ich denke optimale Vorlage bereit, und hoffe, dass jemand, der mit Kollisions-Mathematik besser zurande kommt als ich, da "eben mal" die fehlenden Berechnungen einfügt.
      Dazu sind keinerlei Wpf-Kenntnisse nötig, die vorgelegte Methode kann sich ganz und einzig darauf konzentrieren, beim Überlappungs-Zustand der beiden Körper des Balles Location und Speed zu ändern, sodass
      1. der ungültige Überlappungszustand behoben ist
      2. der Geschindigkeits-Vektor auf Physikalisch-ähnlich geändert ist
      Ich sag "Physikalisch-ähnlich", weil das Modell (bislang) sehr grob vereinfacht:
      • Massenverhältnisse zu berücksichtigen ist bislang nicht vorgesehen: Die Masse eines Balles wird als klein, die der Wandungen und des Schlägers als nahe unendlich angenommen.
      • keine Reibung: weder bei Bewegung im Raum, noch bei den Kollisionen.
      • Demzufolge auch keinen "Spin" der Bälle.


      2-dimensionaler Stoss
      Uih - die letzten beiden Tage hab ich mich doch nochmal in die Physik verbissen, und glaub immerhin das Problem des 2-dimensionalen elastischen Stosses prinzipiell gelöst.
      Damit ist der Fall gemeint, dass ein Ball un-mittig getroffen wird, bzw. dass Bälle aus verschiedenen Richtungen aufeinander prallen, und so nicht nur ihre Geschwindigkeit ändern, sondern auch die Richtung. Und verschiedene Massen und verschiedene Radien.
      Also da brauchts eine kleine "Physik-Engine", weil ich muss graden Stoss können, versetzten Stoss, und sogar den Schnittpunkt einer Kreis-Sekante.
      Letzteres ist nötig für das allergemeinste Problem, nämlich die Korrektur des Kollisions-Punktes.
      Weil eine Kollision detected man anhand der Überlappung der Körper, und somit immer einen Tick zu spät. Daher muss man aufwändig vom vorgefundenen Punkt mit Überlappung zurückrechnen auf den Punkt, an dem die Kreise sich grad berührteten
      "PhysicEngine"

      VB.NET-Quellcode

      1. Public Class PhysicEngine
      2. ''' <summary> positionDelta bezeichnet den Lage-Unterschied der Masse-Schwerpunkte zum Kollisions-Zeitpunkt</summary>
      3. Public Shared Sub Collision_2D(positionDelta As Vector, m1 As Double, ByRef v1 As Vector, m2 As Double, ByRef v2 As Vector)
      4. positionDelta.Normalize()
      5. 'Vector-Zerlegungen in Kollisionsrichtung und Orthogonal dazu
      6. 'beachte Operator-Überladungen: mal bedeutet '*' SkalarProdukt, mal SkalarMultiplikation - und das ist wichtiger Unterschied (Wiki lesen)!!!
      7. Dim vColl1 = v1 * positionDelta * positionDelta
      8. Dim vOrtho1 = v1 - vColl1
      9. Dim vColl2 = v2 * positionDelta * positionDelta
      10. Dim vOrtho2 = v2 - vColl2
      11. 'Kollisions-Anteile eindimensional kollidieren, dann mit Orthogonal-Teil wiedervereinen
      12. Collision_1D(m1, vColl1, m2, vColl2)
      13. v1 = vOrtho1 + vColl1
      14. v2 = vOrtho2 + vColl2
      15. End Sub
      16. ''' <summary> eindimensionaler elastischer Stoss - v1 und v2 müssen genau gegenläufig sein </summary>
      17. Public Shared Sub Collision_1D(m1 As Double, ByRef v1 As Vector, m2 As Double, ByRef v2 As Vector)
      18. 'Formel aus Umstellung von https://de.wikipedia.org/wiki/Sto%C3%9F_(Physik)#Elastischer_Sto%C3%9F
      19. ' Es wird eine "impuls-gewichtete Durchschnittsgeschwindigkeit" berechnet, und die Abweichung davon ist die neue Geschwindigkeit der Gegenseite
      20. Dim weightedAverageSpeed = 2 * (m1 * v1 + m2 * v2) / (m1 + m2)
      21. v1 = weightedAverageSpeed - v1 ' der Ausdruck berechnet die v2-Abweichung vom Durchschnitt - und weist sie an v1 zu
      22. v2 = weightedAverageSpeed - v2
      23. #If False Then 'In dieser Form erkennt man Impuls-Satz und Mittelwertbildung wieder:
      24. Dim imPulsSum = m1 * v1 + m2 * v2
      25. Dim averageWeight = (m1 + m2) / 2
      26. weightedAverageSpeed = imPulsSum / averageWeight
      27. v1 = weightedAverageSpeed - v1 ' der Ausdruck berechnet die v2-Abweichung vom Durchschnitt - und weist sie an die Gegenseite: v1 zu
      28. #End If
      29. End Sub
      30. ''' <summary> Kreis-Eintrittspunkt des Vectors "direction", dessen Gerade durch ptSupport verläuft - also ein Schnittpunkt der Sekante </summary>
      31. Public Shared Function VectorCircleEnter(radius As Double, direction As Vector, ptSupport As Vector) As Vector
      32. #If False Then
      33. geradengleichung in Normalform: ax + by = c, wobei (a,b) ein NormalenVektor der Geraden ist
      34. (Beachte: "NormalenVektor" ist was anneres als ein "normalisierter Vektor" (Wiki lesen)
      35. s. https://de.wikipedia.org/wiki/Schnittpunkt#Schnittpunkte_einer_Gerade_mit_einem_Kreis
      36. #End If
      37. Dim directionNormale = New Vector(direction.Y, -direction.X)
      38. Dim a = directionNormale.X, b = directionNormale.Y, c = ptSupport * directionNormale
      39. Dim ab2 = a * a + b * b
      40. Dim wurzel = Math.Sqrt(radius * radius * ab2 - c * c)
      41. Dim x = ((a * c) + b * wurzel) / ab2
      42. Dim y = ((b * c) - a * wurzel) / ab2
      43. #If False Then 'der zweite Schnittpunkt der Sekante wäre:
      44. Dim x = ((a * c) - b * wurzel) / ab2
      45. Dim y = ((b * c) + a * wurzel) / ab2
      46. #End If
      47. Return New Vector(x, y)
      48. End Function
      49. Public Shared Sub BallCollision(b1 As Ball, b2 As Ball)
      50. Dim relPos = b2.Location - b1.Location
      51. Dim rSum = b2.Radius + b1.Radius
      52. If rSum * rSum < relPos.LengthSquared Then Return ' keine Überlappung
      53. 'Eine Kollision wird immer verspätet wahrgenommen - wenn bereits Überlappung besteht.
      54. Dim relSpeed = b2.Speed - b1.Speed
      55. Dim pCollision = PhysicEngine.VectorCircleEnter(rSum, relSpeed, relPos) 'Eintrittspunkt der Relativ-Bewegung in den Kreis mit Radius rSum
      56. Dim moveBack = (pCollision - relPos).Length ' dieser Weg ist also "zurückzurudern"
      57. Dim tDelay = moveBack / relSpeed.Length ' Zeit, die dafür gebraucht wird
      58. ' Eliminierung der Verspätung: beide Bälle um tDelay zurück-moven, kollidieren, dann mit neuer Richtung vor-moven
      59. Dim balls = {b1, b2}
      60. balls.ForEach(Sub(b) b.Move(-tDelay)) 'zurückrudern
      61. PhysicEngine.Collision_2D(pCollision, b1.Weight, b1.Speed, b2.Weight, b2.Speed) ' kollidieren
      62. balls.ForEach(Sub(b) b.Move(tDelay)) 'in neuer Richtung fortsetzen
      63. End Sub
      64. End Class
      Jetzt ballern die Bälle gegeneinander - aber der Schläger interagiert immer noch nicht.
      Wie gesagt: Wer Lust hat...

      (Update in Post#1)

      Dieser Beitrag wurde bereits 4 mal editiert, zuletzt von „ErfinderDesRades“ ()