C# OOP (sca)
Quellen:
1. Vektoren
Auftrag in Kürze: Erstelle eine Vector Klasse, mit der du Vektorrechnungen durchführen kannst.
Ziel: Lerne anhand dieses Beispiels die Grundlagen von OOP in C#.
- Erstelle ein neues C#-Konsolen-Programm. Nenne es z.B. MathVector
- Erstelle darin eine neue Klasse Vector in einem neuen File (pro Klasse ein .cs File). Klicke dazu im Projektmappen-Explorer mit rechts auf den Namen der Solution → Hinzufügen → Klasse
- Erstelle einen Konstruktor in der Vector-Klasse. Shortcut: ctor + TAB.
- Ein Vector soll dann in Program.cs mithilfe eines Double-Arrays definiert werden können:
Vector v = new Vector(new double[] { 1, 2, 3 });
- Ein Vektor hat Eigenschaften wie seine Dimension (Anzahl Zahlen, aus dem er besteht) oder seine Länge/Magnitude. Ist
v
ein Vektor, so soll z.B. überv.Magnitude
auf diese Eigenschaft zugegriffen werden. Informiere dich darüber, was Eigenschaften und Felder, sowie getter und setter in C# sind. Schaue dir dazu z.B. folgende Quellen an: - Programmiere nun den Konstruktor aus: Speichere die dem Konstruktor übergebenen Werte in einem privaten Feld
components
, ein Double-Array, welches die Komponenten des Vektors beinhaltet. Regle über eine Eigenschaft (get/set)Components
den Zugriff aufcomponents
. Sinn könnte machen:- Die Komponenten sind unveränderbar.
- Die Komponenten sind nur veränderbar, wenn sie die gleiche Dimension haben (benötigt
Dim
von unten).
- Füge nun deiner Vector-Klasse die Eigenschaften
Dim
(für Dimension) undMagnitude
. Diese sollen über eine Eigenschaft (get) abgerufen werden. Natürlich sollen sie nicht von aussen über einen setter verändert werden können!
- Füge deiner Klasse folgende (nicht-statische) public Methoden hinzu:
IsZeroVector()
undIsUnitVector()
. Diese geben den Bool true oder false zurück, je nachdem ob es sich beim Vektor um einen Nullvektor oder einen Einheitsvektor (unit vector) handelt.
Achtung: Da wir mit doubles Arbeiten, können schnell Rundungsfehler entstehen. Es kann deshalb sein, dass die Länge des Vektors eigentlich $0$ wäre, der Code aber eine ganz kleine Zahl berechnet. Deshalb sollte man der Klasse eine Eigenschaftaccuracy
geben: Ist der Betrag des Werts kleiner dieser Genauigkeit, so wird der Wert als $0$betrachtet.
- Nun wollen wir mit den Vektoren Rechnen können. Dazu fügen wir drei statische Methoden mit den Namen Add, Sub und ScalarProd für die Vektoraddition, Vektorsubtraktion und das Skalarprodukt hinzu. Über
Vector.Add(v1,v2);
soll man dann die Vektorsumme zweier Vektoren berechnen können.
- Die beiden Rechenmethoden vom letzten Punkt sollen können nur Funktionieren, wenn beide Vektoren, die als Argument übergeben werden, die gleiche Dimension haben. Überprüfe dies und gib gegebenenfalls einen Fehler aus:
throw new System.Exception("...");
- Nun wollen wir aber nicht immer
Vector.Sub(v1,v2);
schreiben müssen, um zwei Vektoren zu Subtrahieren. Stattdessen soll das mit dem Operator-
, alsov1-v2
, gehen. Dies geht ganz einfach mit einem Operator Overloading, siehe z.B. hier: C# - Operator Overloading. Füge nun jeweils ein Operator Overloading für die Operatoren + (Vektoraddition), - (Vektorsubtraktion) und * (Skalarprodukt) hinzu.
- Aktuell gibt der Befehl
System.Console.WriteLine(v);
für einen Vektorv
nur „Vector“ aus. Schön wäre aber, wenn man direkt die Komponenten erhalten würde: Jede Klasse in C# erbt automatisch von der Basisklasseobject
, undobject
definiert die MethodeToString()
. Wenn man alsoConsole.WriteLine(anyObject)
aufrufst, wird internanyObject.ToString()
verwendet, ganz egal wasanyObject
für ein Objekt ist. Mitpublic override string ToString()
können wir nun diese standardmässige Methode überschreiben.
- Über eine Eigenschaft soll von einem Vektor der zugehörige Einheitsvektor ausgegeben werden:
v.UnitVector
. Dieser Einheitsvektor soll selbst wieder vom Typ Vector sein! Um von einem Vektor den zugehörigen Einheitsvektor zu erhalten, dividiert man jede Komponente des Vektors durch die Länge des gesamten Vektors. Tipp: Überlege dir gut, wo genau der Einheitsvektor berechnet werden soll!
- Es ist etwas umständlich, für neue Vektoren immer zuerst ein double-Array erstellen zu müssen. Stattdessen wollen wir die gängigsten Vektoren (sagen wir 1-4D) auch durch
Vector v = new Vector(3.2,0,4)
erzeugen. Dafür müssen wir den Konstruktor überladen, in dem wir 'weitere Konstruktoren' wiepublic Vector(double x, double y, double z)
hinzufügen.
- Erweitere nun deine Klasse beliebig. Hier einige Vorschläge:
- Weiter Operationen:
DotProduct
ScalarMultiplication
(überladen, damitScalarMultiplication(3,v)
undScalarMultiplication(v,3)
funktionieren.VectorProduct
(nur für 3D Vektoren)
- Winkel zwischen zwei Vektoren:
AngleInRad
,AngleInRad
- Zwei Vektoren vergleichen:
ArePerpendicular
,AreParallel
,AreAntiParallel
,HaveSameMagnitude
- Operatoren überladen: Mit
v1 + v2
soll man zwei Vektoren addieren können. Der Operator+
muss dazu überladen werden: Wird+
auf zwei Vektoren angewendet, soll dieAdd
-Methode aufgerufen werden. Gleiches fürv1 * v2
(dot product) unds * v
resp.v * s
(scalar multiplication).
- Optional: Falls du sehr motiviert bist, könntest du eine Klasse Matrix definieren, mit der man Matrizen-Rechnungen durchführen kann. Matrizen können als Verallgemeinerungen von Vektoren betrachtet werden.
2. MonoGame
Setup
MonoGame Installieren:
dotnet new install MonoGame.Templates.CSharp
Neues Projekt erstellen:
dotnet new mgdesktopgl -o FirstMonoGame
Und ausführen:
cd FirstMonoGame dotnet run
Man sollte nun einen einfarbigen Hintergrund sehen.
Die Game1-Klasse
Das Herzstück des Games ist die Klasse (im gleichnamigen File) Game1
. Schauen wir uns die wichtigsten Aspekte diese Klasse an:
Die Klasse erbt von Microsoft.Xna.Framework.Game, das den Spiel-Loop und die Lebenszyklus-Methoden bereitstellt.
Wichtigste Felder
GraphicsDeviceManager _graphics;
: Verwaltet Grafikeinstellungen (z. B. Auflösung, Vollbild).SpriteBatch _spriteBatch;
: Wird verwendet, um 2D-Texturen (Sprites) auf Bildschirm zu zeichnen
Lebenszyklus-Methoden
Diese werden automatisch vom MonoGame-Framework aufgerufen:
Initialize()
:- Wird zu Beginn 1x aufgerufen.
- Hier wird Logik eingerichtet, die nichts mit Grafik zu tun hat, z.B. Spielzustände, Eingabesysteme
LoadContent()
:- Wird nach Initialize 1x aufgerufen.
- Hier werden Texturen, Schriftarten, Sounds usw. geladen
_spriteBatch
wird initialisiert
Update(GameTime gameTime)
:- Wird in jedem Frame aufgerufen um Gamezustand updaten
- Verarbeitung von Eingabe, Spiellogik, Animation, Physik usw. aktualisieren
gameTime
enthält Zeitinformationen (z. B. vergangene Zeit seit dem letzten Frame).
Draw()
:- Wird nach
Update()
in jedem Frame abgerufen. - Hier alle Spielobjekte auf Bildschirm zeichnen
Zeichnen einer Textur
- snippet.csharp
spriteBatch.Begin(); spriteBatch.Draw(texture, position, Color.White); spriteBatch.End();
Begin()
undEnd()
umschliessen deine Zeichenbefehle.Draw()
rendert eine Textur an einer bestimmten Position mit einer Farbtönung.
Bild laden
- Bild in
Content
oderAssets
Ordner ablegen. - Sicherstellen, dass Datei beim Kompilieren mitkopiert wird. Der .csproj-Datei anfügen:
<ItemGroup> <Content Include="Content/grumpy_cat.png"> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> </Content> </ItemGroup>
- In LoadContent laden:
Texture2D myTexture; protected override void LoadContent() { myTexture = Texture2D.FromFile(GraphicsDevice, "Content/grumpy_cat.png"); }
- Bild zeichnen in
Draw()
zwischenBegin()
undEnd()
- An bestimmte Position:
_spriteBatch.Draw(myTexture, new Vector2(100, 100), Color.White);
- Skalieren, in Rechteck zeichnen:
_spriteBatch.Draw(myTexture, new Rectangle(100, 100, 200, 200), Color.White);
- Bild drehen und skalieren:
_spriteBatch.Draw( texture: myTexture, position: new Vector2(200, 200), // Mittelpunkt der Rotation sourceRectangle: null, // Ganzes Bild color: Color.White, // Keine Farbtönung rotation: MathF.PI / 4, // 45 Grad im Bogenmass origin: new Vector2(myTexture.Width / 2f, myTexture.Height / 2f), // Rotationszentrum (Mitte des Bildes) scale: 1.0f, // Keine Skalierung effects: SpriteEffects.None, layerDepth: 0f );
Eigene Texturen erstellen
Um eigene Texturen um z.B. geometrische Figuren wie Rechtecke oder Kreise zu zeichnen, fügt man der Klasse eigene Methoden hinzufügen. Hier das Beispiel für ein Rechteck:
public Texture2D CreateRectangleTexture(GraphicsDevice graphicsDevice, int width, int height, Color color) { Texture2D texture = new Texture2D(graphicsDevice, width, height); Color[] colorData = new Color[width * height]; int i = 0; for (int y = 0; y < height; y++) { for (int x = 0; x < width; x++) { colorData[i] = color; i++; } } texture.SetData(colorData); return texture; }
Zunächst wird mit new Texture2D(graphicsDevice, width, height)
eine neue leere Textur mit den angegebenen Abmessungen erzeugt. Danach wird ein Farbarray (Color[] colorData) erstellt, das genau so viele Elemente enthält wie die Anzahl der Pixel in der Textur, also width * height
. Dieses Array wird anschliessend in einer verschachtelten Schleife gefüllt: Für jede Zeile (y) und jede Spalte (x) wird der aktuelle Index i im Array mit der gewünschten Farbe belegt. Dadurch wird jeder Pixel der Textur auf denselben Farbwert gesetzt. Um ein Rechteck zu zeichnen, würde eine einfache Schleife reichen:
for(int i=0;i<colorData.Length;i++){ colorData[i] = color; }
Möchte man aber komplexere Figuren wie Kreise zeichnen, ist man oft froh, wenn man auf die x- und y-Werte einzeln Zugriff hat.
Nachdem das Array vollständig gefüllt ist, wird es mit texture.SetData(colorData) auf die Textur übertragen. Schliesslich gibt die Methode die fertige Textur zurück, die nun einfarbig ist und direkt im Spiel verwendet oder gezeichnet werden kann.
Textur bewegen und rotieren
- Private Felder festlegen für Position und Winkel
private Vector2 texturePos = new Vector2(0, 200); private float textureAngle = 0f; // in rad
- Felder in Draw() verwenden, um Texture zu zeichnen:
_spriteBatch.Draw( texture: myTexture, position: texturePos, rotation: textureAngle, ... );
- In Update() Position und Winkel verändern.
texturePos.X += 3f; textureAngle += (float)gameTime.ElapsedGameTime.TotalSeconds;
Auflösung ändern
protected override void Initialize() { _graphics.PreferredBackBufferWidth = 1200; _graphics.PreferredBackBufferHeight = 800; _graphics.ApplyChanges(); base.Initialize(); }
3. Auftrag: MonoGame kennenlernen
Ziel: Mache dich mit MonoGame vertraut.
Auftrag:
Studiere das Tutorial oben und löse folgenden Auftrag:
- Erstelle ein neues MonoGame-Projekt.
- Lege die Auflösung fest.
- Lade ein Bild hinein.
- Zeige ein farbiges Quadrat ein.
- Schreibe selbst eine Methode
CreateCircleTexture()
, mit der man kreisförmige Texturen erstellen kann. VerwendeCreateRectangleTexture()
von oben als Startpunkt. - Die Figuren sollen sich durch das Bild bewegen.
- Eine der Figuren soll rotieren.
4. Auftrag: Simulation des N-Body-Problems mit MonoGame
Ziel: Schreibe mit C# und MonoGames eine Simulation der Orbits von $N$ Himmelskörpern, die sich gegenseitig mittels des Newton'schen Gravitationsgesetztes anziehen. Programmiere deinen Code schön und strikt objektorientiert. Natürlich soll dabei die eigene Vektor-Klasse so viel wie möglich verwendet werden.
Tipps:
- Starte mit dem 2-Körperproblem und erweitere es dann zu beliebig vielen Körpern.
- Machst du eine strikte Trennung von Model und View, kannst du deinen Code später besser wiederverwenden.
Physik
Beim N-Body-Problem betrachtet man $N$ Himmelskörper $i=1,\ldots,N$, jeweils mit Masse $m_i$, an Position $\vec{r}_i$ und aktueller Geschwindigkeit $\vec{v}_i$. Die Gravitationskraft, die auf einen Himmelskörper $i$ aufgrund der Präsenz eines einzelnen anderen Himmelskörpers $k$ wirkt ist: $$\vec{F}_{ik} = G m_i m_k \frac{\vec{r}_k-\vec{r}_i}{|\vec{r}_k-\vec{r}_i|^3}$$ Da aber alle anderen $N-1$ Himmelskörper eine solche Gravitationskraft auswirken, müssen sämtliche einzelnen Kraftvektoren aufsummiert werden (Superpositionsprinzip), die resultierende Kraft ist dann also: $$\vec{F}_{i} = G m_i \sum_{k=1,k\neq i}^N m_k \frac{\vec{r}_k-\vec{r}_i}{|\vec{r}_k-\vec{r}_i|^3}$$
Ein Problem, das oft auftaucht, ist das folgende: Kommen sich zwei Massen sehr nah, so wird $|\vec{r}_k-\vec{r}_i|^3$ extrem klein. Dadurch wird die Kraft sehr gross und eine Masse kann dann regelrecht aus dem Bildschirm geschleudert werden. Dies soll verhindert werden. Dazu addiert man dem Nenner einen kleinen konstanten Softening-Term $s$: $$\vec{F}_{i} = G m_i \sum_{k=1,k\neq i}^N m_k \frac{\vec{r}_k-\vec{r}_i}{(|\vec{r}_k-\vec{r}_i| + s)^3}$$
Idee Code
Um die Bewegung der Himmelskörper zu simulieren, legt man die Anfangsbedingungen (Initial Conditions) fest: Anfängliche Position $\vec{r}\_i$ und Geschwindigkeit $\vec{v}\_i$ (und natürlich auch Masse) von jedem Himmelkörper $i$.
Im Code geht man dann in einer Schleife alle $N$ Himmelskörper durch. In jedem Durchgang wird dann die Position und Geschwindigkeit von jedem Himmelskörper $i$ updated:
- Berechne mithilfe der Formel oben die resultierende Kraft, die auf den Himmelskörper $i$ wirkt.
- Berechne daraus die tatsächliche Beschleunigung des Himmelskörpers: $$\vec{a}_i = \frac{\vec{F}_i}{m_i}$$
- Nutze diese, um den Geschwindigkeitsvektor des Himmelskörpers zu updaten: $$\vec{v_i} \rightarrow \vec{v_i} + \Delta t \cdot \vec{a_i}$$ Dabei ist $\Delta t$ ein kleiner konstanter Zeitschritt: Je kleiner, desto genauer aber auch ineffizienter wird die Simulation. $\Delta t = 1 [\text{s}]$ ist ein guter Startwert.
- Mit dem neuen Geschwindigkeitsvektor wird nun der Positionsvektor updated: $$\vec{x_i} \rightarrow \vec{x_i} + \Delta t \cdot \vec{v_i}$$
Kontrolle: Um zu kontrollieren, ob der Code sich richtig verhält, kann man zu jedem Zeitpunkt den Schwerpunkt des Gesamtsystems (alle Himmelskörper zusammen) betrachten. Der Schwerpunkt muss unbeschleunigt sein, er muss also entweder in Ruhe sein oder sich mit konstanter Geschwindigkeit entlang einer Geraden bewegen. Wird der Schwerpunkt schneller oder langsam oder bewegt er sich auf einer gekrümmten Bahn, so muss ein Fehler vorliegen. Wie der Schwerpunkt berechnet wird, kann im PHAM Dossier zum Schwerpunkt (Siehe Formel unter 'Diskrete Massenverteilung') nachgeschlagen werden.