Turmverteidigung in Einheit schaffen: Ballistik

[ Der erste , zweite und dritte Teil des Tutorials]

  • Unterstützung für verschiedene Arten von Türmen.
  • Erstellen eines Mörserturms.
  • Berechnung parabolischer Trajektorien.
  • Explodierende Granaten starten.

Dies ist der vierte Teil eines Tutorials zum Erstellen eines einfachen Tower Defense- Spiels. Darin werden wir Mörsertürme hinzufügen, die bei einer Kollision Sprengpatronen abfeuern.

Das Tutorial wurde in Unity 2018.4.4f1 erstellt.


Feinde werden bombardiert.

Arten von Türmen


Ein Laser ist nicht die einzige Art von Waffe, die auf einem Turm platziert werden kann. In diesem Tutorial werden wir die zweite Art von Türmen hinzufügen, die Granaten abfeuern, die bei Kontakt explodieren und alle Feinde in der Nähe beschädigen. Dazu benötigen wir Unterstützung für verschiedene Türmtypen.

Abstrakter Turm


Die Zielerfassung und -verfolgung ist eine Funktion, die jeder Turm verwenden kann. Daher werden wir sie in die abstrakte Basisklasse der Türme einordnen. Dazu verwenden wir einfach die Tower Klasse, duplizieren jedoch zuerst ihren Inhalt für die spätere Verwendung in einer bestimmten LaserTower Klasse. Dann entfernen wir den gesamten laserbezogenen Code aus Tower . Der Tower verfolgt möglicherweise kein bestimmtes Ziel. Löschen Sie daher das target und ändern Sie AcquireTarget und TrackTarget so, dass der Ausgabeparameter als Verknüpfungsparameter verwendet wird. Dann werden wir die OnDrawGizmosSelected aus OnDrawGizmosSelected entfernen, aber den OnDrawGizmosSelected verlassen, da er für alle Türme verwendet wird.

 using UnityEngine; public abstract class Tower : GameTileContent { const int enemyLayerMask = 1 << 9; static Collider[] targetsBuffer = new Collider[100]; [SerializeField, Range(1.5f, 10.5f)] protected float targetingRange = 1.5f; protected bool AcquireTarget (out TargetPoint target) { … } protected bool TrackTarget (ref TargetPoint target) { … } void OnDrawGizmosSelected () { Gizmos.color = Color.yellow; Vector3 position = transform.localPosition; position.y += 0.01f; Gizmos.DrawWireSphere(position, targetingRange); } } 

Lassen Sie uns die doppelte Klasse so ändern, dass sie sich in einen LaserTower , der den Tower und die Funktionalität seiner Basisklasse nutzt, um den doppelten Code zu LaserTower .

 using UnityEngine; public class LaserTower : Tower { [SerializeField, Range(1f, 100f)] float damagePerSecond = 10f; [SerializeField] Transform turret = default, laserBeam = default; TargetPoint target; Vector3 laserBeamScale; void Awake () { laserBeamScale = laserBeam.localScale; } public override void GameUpdate () { if (TrackTarget(ref target) || AcquireTarget(out target)) { Shoot(); } else { laserBeam.localScale = Vector3.zero; } } void Shoot () { … } } 

Aktualisieren Sie dann das Fertighaus des Laserturms, um die neue Komponente zu verwenden.


Bestandteil eines Laserturms.

Erstellen eines bestimmten Turmtyps


Um auswählen zu können, welche Türme auf dem Feld platziert werden sollen, fügen wir eine TowerType Aufzählung hinzu, die TowerType ähnelt. Wir werden Unterstützung für den bestehenden Laserturm und Mörtelturm schaffen, die wir später schaffen werden.

 public enum TowerType { Laser, Mortar } 

Da wir für jeden Turmtyp eine Klasse erstellen, fügen wir Tower eine abstrakte Getter-Eigenschaft hinzu, um seinen Typ anzugeben. Dies funktioniert ähnlich wie das Verhalten einer Figur in der Tutorials-Reihe " Objektverwaltung" .

  public abstract TowerType TowerType€ { get; } 

LaserTower Sie es in LaserTower damit es den richtigen Typ zurückgibt.

  public override TowerType TowerType€ => TowerType.Laser; 

Ändern Sie als Nächstes die GameTileContentFactory damit die Fabrik den Turm des gewünschten Typs herstellen kann. Wir implementieren dies mit einem Array von Türmen und fügen eine alternative öffentliche Get Methode mit dem TowerType Parameter hinzu. Um zu überprüfen, ob das Array korrekt konfiguriert ist, verwenden wir Zusicherungen. Eine andere öffentliche Get Methode gilt jetzt nur für den Inhalt von Kacheln ohne Türme.

  [SerializeField] Tower[] towerPrefabs = default; public GameTileContent Get (GameTileContentType type) { switch (type) { … } Debug.Assert(false, "Unsupported non-tower type: " + type); return null; } public GameTileContent Get (TowerType type) { Debug.Assert((int)type < towerPrefabs.Length, "Unsupported tower type!"); Tower prefab = towerPrefabs[(int)type]; Debug.Assert(type == prefab.TowerType€, "Tower prefab at wrong index!"); return Get(prefab); } 

Es wäre logisch, den spezifischsten Typ zurückzugeben. Idealerweise sollte der Rückgabetyp der neuen Get Methode Tower . Die private Instanzmethode Get , die zum Instanziieren des Fertighauses verwendet wird, gibt jedoch einen GameTileContent . Hier können Sie entweder die Konvertierung durchführen oder die private Get Methode generisch machen. Wählen wir die zweite Option.

  public Tower Get (TowerType type) { … } T Get<T> (T prefab) where T : GameTileContent { T instance = CreateGameObjectInstance(prefab); instance.OriginFactory = this; return instance; } 

Während wir nur einen Laserturm haben, werden wir ihn zum einzigen Element der Reihe von Türmen der Fabrik machen.


Eine Reihe von vorgefertigten Türmen.

Instanzen bestimmter Turmtypen erstellen


Um einen Tower eines bestimmten Typs zu erstellen, GameBoard.ToggleTower wir GameBoard.ToggleTower so, dass der Parameter TowerType erforderlich TowerType , und übergeben ihn an die Fabrik.

  public void ToggleTower (GameTile tile, TowerType towerType) { if (tile.Content.Type == GameTileContentType.Tower€) { … } else if (tile.Content.Type == GameTileContentType.Empty) { tile.Content = contentFactory.Get(towerType); … } else if (tile.Content.Type == GameTileContentType.Wall) { tile.Content = contentFactory.Get(towerType); updatingContent.Add(tile.Content); } } 

Dies schafft eine neue Möglichkeit: Der Zustand des Turms wechselt, wenn er bereits vorhanden ist, aber die Türme sind von unterschiedlicher Art. Bisher wird durch das Umschalten nur der vorhandene Turm entfernt, aber es wäre logisch, ihn durch einen neuen Typ zu ersetzen. Lassen Sie uns dies implementieren. Da die Kachel weiterhin belegt ist, müssen Sie nicht erneut nach dem Pfad suchen.

  if (tile.Content.Type == GameTileContentType.Tower€) { updatingContent.Remove(tile.Content); if (((Tower)tile.Content).TowerType€ == towerType) { tile.Content = contentFactory.Get(GameTileContentType.Empty); FindPaths(); } else { tile.Content = contentFactory.Get(towerType); updatingContent.Add(tile.Content); } } 

Game sollte nun den Typ des umschaltbaren Turms verfolgen. Wir bezeichnen einfach jeden Turmtyp mit einer Zahl. Der Laserturm ist 1, es ist der Standardturm und der Mörtelturm ist 2. Durch Drücken der Zifferntasten wählen wir den geeigneten Türmtyp aus.

  TowerType selectedTowerType; … void Update () { … if (Input.GetKeyDown(KeyCode.G)) { board.ShowGrid = !board.ShowGrid; } if (Input.GetKeyDown(KeyCode.Alpha1)) { selectedTowerType = TowerType.Laser; } else if (Input.GetKeyDown(KeyCode.Alpha2)) { selectedTowerType = TowerType.Mortar; } … } … void HandleTouch () { GameTile tile = board.GetTile(TouchRay); if (tile != null) { if (Input.GetKey(KeyCode.LeftShift)) { board.ToggleTower(tile, selectedTowerType); } else { board.ToggleWall(tile); } } } 

Mörtelturm


Es wird noch nicht möglich sein, den Mörtelturm zu platzieren, da er noch kein Fertighaus hat. Beginnen wir mit der Erstellung eines minimalen MortarTower Typs. Mörser haben eine Feuerfrequenz, um anzuzeigen, welche Sie im Konfigurationsfeld „Schüsse pro Sekunde“ verwenden können. Außerdem benötigen wir eine Verbindung zum Mörtel, damit er zielen kann.

 using UnityEngine; public class MortarTower : Tower { [SerializeField, Range(0.5f, 2f)] float shotsPerSecond = 1f; [SerializeField] Transform mortar = default; public override TowerType TowerType€ => TowerType.Mortar; } 

Erstellen Sie nun ein Fertighaus für den Mörtelturm. Dies kann durch Duplizieren des Fertighauses des Laserturms und Ersetzen seiner Turmkomponente erfolgen. Dann werden wir die Objekte des Turms und des Laserstrahls los. Benennen Sie den turret in mortar , bewegen Sie ihn nach unten, sodass er oben auf der Basis steht, geben Sie ihm eine hellgraue Farbe und befestigen Sie ihn. Wir können den Mörtelkollider in diesem Fall mit einem separaten Objekt verlassen, bei dem es sich um einen einfachen Kollider handelt, der der Standardausrichtung des Mörtels überlagert ist. Ich habe einen Mörserbereich von 3,5 und eine Frequenz von 1 Schuss pro Sekunde zugewiesen.

Szene

Hierarchie

Inspektor

Fertighaus des Mörtelturms.

Warum heißen sie Mörser?
Die ersten Sorten dieser Waffe waren im Wesentlichen Eisenschalen, ähnlich wie Mörser, bei denen die Zutaten mit einem Stößel gemahlen wurden.

Fügen Sie die vorgefertigten Mörtel zum Werksarray hinzu, damit Mörteltürme auf dem Feld platziert werden können. Sie tun jedoch noch nichts.

Inspektor

Szene

Zwei Arten von Türmen, von denen einer inaktiv ist

Flugbahnberechnung


Mortira schießt schräg auf eine Granate, so dass er über Hindernisse fliegt und das Ziel von oben trifft. In der Regel werden Granaten verwendet, die bei Kollisionen mit oder über einem Ziel explodieren. Um die Dinge nicht zu komplizieren, werden wir immer auf den Boden zielen, damit die Granaten explodieren, wenn ihre Höhe auf Null fällt.

Horizontales Zielen


Um den Mörser auszurichten, müssen wir ihn horizontal auf das Ziel richten und dann seine vertikale Position ändern, damit das Projektil im richtigen Abstand landet. Wir werden mit dem ersten Schritt beginnen. Zunächst verwenden wir feste relative Punkte, keine sich bewegenden Ziele, um sicherzustellen, dass unsere Berechnungen korrekt sind.

Fügen Sie MortarTower eine MortarTower Methode GameUpdate , die immer die Launch Methode aufruft. Anstatt ein echtes Projektil abzufeuern, werden wir vorerst mathematische Berechnungen visualisieren. Der Brennpunkt ist die Position des Mörsers in der Welt, der sich direkt über dem Boden befindet. Wir platzieren den Punkt des Ziels drei Einheiten von ihm entlang der X-Achse und setzen die Y-Komponente auf Null, weil wir immer auf den Boden zielen. Dann zeigen wir die Punkte, indem wir Debug.DrawLine gelbe Linie zwischen ihnen ziehen, indem Debug.DrawLine aufrufen. Die Linie ist im Szenenmodus für ein Bild sichtbar, dies reicht jedoch aus, da in jedem Bild eine neue Linie gezeichnet wird.

  public override void GameUpdate () { Launch(); } public void Launch () { Vector3 launchPoint = mortar.position; Vector3 targetPoint = new Vector3(launchPoint.x + 3f, 0f, launchPoint.z); Debug.DrawLine(launchPoint, targetPoint, Color.yellow); } 


Wir zielen auf einen Punkt, der relativ zum Turm festgelegt ist.

Mit dieser Linie können wir ein rechtwinkliges Dreieck definieren. Sein oberer Punkt befindet sich in der Mörserposition. In Bezug auf Mörtel ist dies  beginbmatrix00 endbmatrix . Der Punkt unten an der Basis des Turms ist  beginbmatrix0y endbmatrix und der Punkt im Ziel ist  beginbmatrixxy endbmatrix wo x gleich 3 und y Ist die negative vertikale Position des Mörtels. Wir müssen diese beiden Werte verfolgen.

  Vector3 launchPoint = mortar.position; Vector3 targetPoint = new Vector3(launchPoint.x + 3f, 0f, launchPoint.z); float x = 3f; float y = -launchPoint.y; 


Dreieck zielen.

Im Allgemeinen kann sich das Ziel an einer beliebigen Stelle in der Reichweite des Turms befinden, daher muss auch Z berücksichtigt werden. Das Zieldreieck bleibt jedoch immer noch zweidimensional, es dreht sich einfach um die Y-Achse. Um dies zu veranschaulichen, fügen wir in Launch einen Parameter des relativen Verschiebungsvektors hinzu und rufen ihn mit vier Verschiebungen in XZ auf:  beginbmatrix30 endbmatrix ,  beginbmatrix01 endbmatrix ,  beginbmatrix11 endbmatrix und  beginbmatrix31 endbmatrix . Wenn der Zielpunkt gleich dem Schusspunkt plus diesem Versatz wird und seine Y-Koordinate gleich Null wird.

  public override void GameUpdate () { Launch(new Vector3(3f, 0f, 0f)); Launch(new Vector3(0f, 0f, 1f)); Launch(new Vector3(1f, 0f, 1f)); Launch(new Vector3(3f, 0f, 1f)); } public void Launch (Vector3 offset) { Vector3 launchPoint = mortar.position; Vector3 targetPoint = launchPoint + offset; targetPoint.y = 0f; … } 

Jetzt ist x des Zieldreiecks gleich der Länge des 2D-Vektors, der von der Basis des Turms zum Zielpunkt zeigt. Durch Normalisieren dieses Vektors erhalten wir auch den XZ-Richtungsvektor, mit dem das Dreieck ausgerichtet werden kann. Sie können es anzeigen, indem Sie den unteren Rand des Dreiecks als weiße Linie aus der Richtung und x zeichnen.

  Vector2 dir; dir.x = targetPoint.x - launchPoint.x; dir.y = targetPoint.z - launchPoint.z; float x = dir.magnitude; float y = -launchPoint.y; dir /= x; Debug.DrawLine(launchPoint, targetPoint, Color.yellow); Debug.DrawLine( new Vector3(launchPoint.x, 0.01f, launchPoint.z), new Vector3( launchPoint.x + dir.x * x, 0.01f, launchPoint.z + dir.y * x ), Color.white ); 


Ausgerichtete Zieldreiecke.

Schusswinkel


Als nächstes sollten wir herausfinden, in welchem ​​Winkel das Projektil abgeschossen werden soll. Es ist notwendig, es aus der Physik der Projektilbahn abzuleiten. Wir werden nicht den Luftwiderstand, den Wind und andere Hindernisse berücksichtigen, sondern nur die Geschwindigkeit des Schusses v und Schwerkraft g=9,81 .

Offset d Das Projektil entspricht dem Zieldreieck und kann durch zwei Komponenten beschrieben werden. Mit horizontaler Verschiebung ist es einfach: es ist dx=vxt wo t - Zeit nach dem Schuss. Bei der vertikalen Komponente ist alles ähnlich, dann unterliegt sie aufgrund der Schwerkraft einer negativen Beschleunigung, daher hat sie die Form dy=vyt(gt2)/2 .

Wie wird die Offsetberechnung durchgeführt?
Geschwindigkeit v bestimmt durch die Entfernung pro Sekunde, daher Multiplizieren der Geschwindigkeit mit der Dauer t wir bekommen die Entfernung d = v t . Wenn es um Beschleunigung geht a ist die Geschwindigkeit variabel. Die Beschleunigung ist die Änderung der Geschwindigkeit pro Sekunde, dh der Abstand pro Sekunde im Quadrat. Die Geschwindigkeit ist jederzeit v = b e i . In unserem Fall gibt es eine konstante Beschleunigung a=g Wir können es also in zwei Hälften teilen, um die Durchschnittsgeschwindigkeit zu erhalten, und mit der Zeit multiplizieren, um den Versatz zu ermitteln d=(at2)/2 verursacht durch die Schwerkraft.

Wir schießen Granaten mit der gleichen Geschwindigkeit s das hängt nicht vom Winkel des Schusses ab  theta (Theta). Also vx=s cos theta und vy=s sin theta .


Berechnung der Schussgeschwindigkeit.

Wenn wir die Substitution durchführen, bekommen wir dx=st cos theta und dy=st sin theta(gt2)/2 .

Das Projektil wird so abgefeuert, dass seine Flugzeit t ist der genaue Wert, der zur Erreichung des Ziels benötigt wird. Da es einfacher ist, mit horizontaler Verschiebung zu arbeiten, können wir die Zeit als ausdrücken t=dx/vx . Am Endpunkt dx=x , also t=x/(s cos theta) . Das bedeutet das y=x tan theta(gx2)/(2s2 cos2 theta) .

Wie bekomme ich die Gleichung y?
y=dy=s(x/(s cos theta)) sin theta(g(x/(s cos theta))2)/2=x sin theta/ cos Theta(gx2)/(2s2 cos2 Theta) und  tan theta= sin theta/ cos theta .

Mit dieser Gleichung finden wir  tan theta=(s2+ sqrt(s4g(gx2+2ys2)))/(gx) .
Wie bekomme ich die Gleichung tan θ?
Zuerst werden wir die trigonometrische Identität verwenden  sec theta=1/ cos theta und 1+ tan2 theta= sec2 theta zu kommen y=x tan theta(gx2)/(2s2)(1+ tan2 theta)=(gx2)/(2s2) tan2 theta+x tan theta(gx2)/(2s2) .

Dies ist ein Ausdruck der Form au2+bu+c=0 wo u= tan theta , a=(gx2)/(2s2) , b=x und c=ay .

Wir können es mit der Wurzelformel der quadratischen Gleichung lösen u=(b+ sqrt(b24ac))/(2a) .

Nach der Ersetzung wird die Gleichung verwirrend, aber Sie können sie vereinfachen, indem Sie mit multiplizieren m=s2/x so zu bekommen  tan theta=(mb+m sqrtr)/(2ma) wo r=b24ac .

In diesem Fall erhalten wir  tan theta=(s2+ sqrt(m2r))/(gx) .

Ergebend m2r=(s4/x2)r=s4+2gs2c=s4g2x22gys2=s4g(gx2+2ys2) .

Es gibt zwei mögliche Winkel, da Sie hoch oder niedrig zielen können. Eine niedrige Flugbahn ist schneller, weil sie näher an einer geraden Linie zum Ziel liegt. Aber die hohe Flugbahn sieht interessanter aus, also werden wir sie wählen. Dies bedeutet, dass wir nur die größte Lösung verwenden müssen.  tan theta=(s2+ sqrt(s4g(gx2+2ys2)))/(gx) . Wir berechnen es und auch  cos theta mit  sin theta , weil wir sie brauchen, um den Geschwindigkeitsvektor des Schusses zu erhalten. Dazu müssen Sie konvertieren  tan theta auf den Bogenmaßwinkel mit Mathf.Atan . Verwenden wir zunächst eine konstante Schussgeschwindigkeit von 5.

  float x = dir.magnitude; float y = -launchPoint.y; dir /= x; float g = 9.81f; float s = 5f; float s2 = s * s; float r = s2 * s2 - g * (g * x * x + 2f * y * s2); float tanTheta = (s2 + Mathf.Sqrt(r)) / (g * x); float cosTheta = Mathf.Cos(Mathf.Atan(tanTheta)); float sinTheta = cosTheta * tanTheta; 

Lassen Sie uns die Flugbahn visualisieren, indem wir zehn blaue Segmente zeichnen, die die erste Sekunde des Fluges zeigen.

  float sinTheta = cosTheta * tanTheta; Vector3 prev = launchPoint, next; for (int i = 1; i <= 10; i++) { float t = i / 10f; float dx = s * cosTheta * t; float dy = s * sinTheta * t - 0.5f * g * t * t; next = launchPoint + new Vector3(dir.x * dx, dy, dir.y * dx); Debug.DrawLine(prev, next, Color.blue); prev = next; } 


Parabelflugwege von einer Sekunde Dauer.

Die beiden am weitesten entfernten Punkte können in weniger als einer Sekunde erreicht werden, sodass wir ihre gesamte Flugbahn sehen und die Segmente etwas weiter unter der Erde verlaufen. Für die beiden anderen Punkte werden größere Schusswinkel benötigt, wodurch die Flugbahnen länger werden und der Flug länger als eine Sekunde dauert.

Schussgeschwindigkeit


Wenn Sie in einer Sekunde die nächsten zwei Punkte erreichen möchten, müssen Sie die Schussgeschwindigkeit verringern. Machen wir es gleich 4.

  float s = 4f; 


Schussgeschwindigkeit auf 4 reduziert.

Ihre Flugbahnen sind jetzt vollständig, aber die anderen beiden sind weg. Dies geschah, weil die Schussgeschwindigkeit jetzt nicht mehr ausreicht, um diese Punkte zu erreichen. In solchen Fällen Lösungen zu  tan theta Nein, das heißt, wir erhalten die Quadratwurzel einer negativen Zahl, was zu NaN-Werten und dem Verschwinden von Linien führt. Wir können dies durch Überprüfen erkennen r zur Negativität.

  float r = s2 * s2 - g * (g * x * x + 2f * y * s2); Debug.Assert(r >= 0f, "Launch velocity insufficient for range!"); 

Diese Situation kann vermieden werden, indem eine ausreichend hohe Schussgeschwindigkeit eingestellt wird. Wenn es jedoch zu groß ist, sind zum Erreichen von Zielen in der Nähe des Turms sehr hohe Flugbahnen und eine lange Flugzeit erforderlich. Sie sollten daher die Geschwindigkeit so niedrig wie möglich halten. Die Schussgeschwindigkeit sollte ausreichen, um das Ziel mit maximaler Reichweite zu treffen.

Bei maximaler Reichweite r=0 d.h.  tan theta Es gibt nur eine Lösung, die einem niedrigen Pfad entspricht. Dies bedeutet, dass wir die erforderliche Geschwindigkeit des Schusses kennen. s = s q r t ( g ( y + s q r t ( x 2 + y 2 ) ) )   .

Wie leitet man diese Gleichung für s ab?
Müssen sich entscheiden s4g(gx2+2ys2)=s42gys2g2x2=0 für s .

Dies ist ein Ausdruck der Form au2+bu+c=0 wo u=s2 , a=1 , b=2gy und c=g2x2 .

Sie können es mit der vereinfachten Formel der Wurzeln der quadratischen Gleichung lösen u=(b+ sqrt(b24c))/2 .

Nach dem Wechsel bekommen wir s2=(2gy+ sqrt(4g2y2+4g2x2))/2=gy+g sqrt(x2+y2) .

Wir brauchen eine positive Lösung, also kommen wir zu s2=g(y+ sqrt(x2+y2)) .

Wir müssen die erforderliche Geschwindigkeit nur bestimmen, wenn die Mörser aufwachen (Awake) oder wenn wir ihre Reichweite im Play-Modus ändern. Daher werden wir es anhand des Feldes verfolgen und in Awake und OnValidate .

  float launchSpeed; void Awake () { OnValidate(); } void OnValidate () { float x = targetingRange; float y = -mortar.position.y; launchSpeed = Mathf.Sqrt(9.81f * (y + Mathf.Sqrt(x * x + y * y))); } 

Aufgrund von Einschränkungen in der Genauigkeit von Gleitkommaberechnungen kann es jedoch fehlerhaft sein, das Ziel sehr nahe am maximalen Bereich zu bestimmen. Daher addieren wir bei der Berechnung der erforderlichen Geschwindigkeit einen kleinen Betrag zum Bereich. Außerdem vergrößert der Radius des Kolliders des Feindes im Wesentlichen den maximalen Radius der Reichweite des Turms. Wir haben es auf 0,125 gebracht, aber mit einer Vergrößerung der feindlichen Skala kann es sich so weit wie möglich verdoppeln, sodass wir die tatsächliche Reichweite um etwa 0,25 erhöhen, beispielsweise um 0,25001.

  float x = targetingRange + 0.25001f; 

Wenden Sie als Nächstes die abgeleitete Gleichung für die Geschwindigkeit eines Schusses beim Launch .

  float s = launchSpeed; 


Wenden Sie die berechnete Geschwindigkeit auf den Zielbereich 3.5 an.

Schießen


Mit der korrekten Berechnung der Flugbahn können Sie die relativen Testziele loswerden. Jetzt müssen Sie den Startpunkt an das Ziel übergeben.

  public void Launch (TargetPoint target) { Vector3 launchPoint = mortar.position; Vector3 targetPoint = target.Position; targetPoint.y = 0f; … } 

Außerdem werden nicht in jedem Bild Schüsse abgegeben. Wir müssen den Prozess des Schusses genauso verfolgen wie den Prozess des Erstellens von Feinden und ein zufälliges Ziel erfassen, wenn die Zeit für den Schuss in GameUpdate . Zu diesem Zeitpunkt sind jedoch möglicherweise keine Ziele verfügbar. In diesem Fall setzen wir den Brennvorgang fort, jedoch ohne weitere Akkumulation. Um eine Endlosschleife zu vermeiden, müssen Sie sie auf etwas weniger als 1 einstellen.

  float launchProgress; … public override void GameUpdate () { launchProgress += shotsPerSecond * Time.deltaTime; while (launchProgress >= 1f) { if (AcquireTarget(out TargetPoint target)) { Launch(target); launchProgress -= 1f; } else { launchProgress = 0.999f; } } } 

Wir verfolgen keine Ziele zwischen den Schüssen, aber wir müssen den Mörser während der Schüsse korrekt drehen. Sie können die horizontale Richtung des Schusses verwenden, um den Mörtel mit Quaternion.LookRotation horizontal zu drehen. Wir brauchen auch mit  t a n t h e t a  Wenden Sie den Schusswinkel für die Komponente Y des Richtungsvektors an. Dies funktioniert, weil die horizontale Richtung eine Länge von 1 hat, d.h.  t a n t h e t a = s i n t h e t a    .


Zerlegung des Turn-Vektors des Looks.

  float tanTheta = (s2 + Mathf.Sqrt(r)) / (g * x); float cosTheta = Mathf.Cos(Mathf.Atan(tanTheta)); float sinTheta = cosTheta * tanTheta; mortar.localRotation = Quaternion.LookRotation(new Vector3(dir.x, tanTheta, dir.y)); 

Um die Flugbahn der Aufnahmen weiterhin zu sehen, können Sie Debug.DrawLine einen Parameter Debug.DrawLine , mit dem sie für eine lange Zeit gezeichnet werden können.

  Vector3 prev = launchPoint, next; for (int i = 1; i <= 10; i++) { … Debug.DrawLine(prev, next, Color.blue, 1f); prev = next; } Debug.DrawLine(launchPoint, targetPoint, Color.yellow, 1f); Debug.DrawLine( … Color.white, 1f ); 


Zielen.

Muscheln


Die Berechnung von Flugbahnen bedeutet, dass wir jetzt wissen, wie man Granaten schießt. Als nächstes müssen wir sie erstellen und schießen.

Kriegsfabrik


Wir brauchen eine Fabrik, um Shell-Objekte zu instanziieren. In der Luft existieren die Granaten für sich und sind nicht mehr von den Mörsern abhängig, die sie erschossen haben. Daher sollten sie nicht vom Mörtelturm verarbeitet werden, und die Flieseninhaltsfabrik ist auch dafür nicht geeignet.Lassen Sie uns für alles, was mit Waffen zu tun hat, eine neue Fabrik erstellen und sie Kriegsfabrik nennen. Erstellen Sie zunächst eine Zusammenfassung WarEntitymit einer Eigenschaft OriginFactoryund einer Methode Recycle.

 using UnityEngine; public abstract class WarEntity : MonoBehaviour { WarFactory originFactory; public WarFactory OriginFactory { get => originFactory; set { Debug.Assert(originFactory == null, "Redefined origin factory!"); originFactory = value; } } public void Recycle () { originFactory.Reclaim(this); } } 

Erstellen Sie dann eine bestimmte Entität Shellfür die Shells.

 using UnityEngine; public class Shell : WarEntity { } 

Erstellen Sie dann WarFactorydiejenige, die das Projektil mit der Eigenschaft public getter erstellt.

 using UnityEngine; [CreateAssetMenu] public class WarFactory : GameObjectFactory { [SerializeField] Shell shellPrefab = default; public Shell Shell€ => Get(shellPrefab); T Get<T> (T prefab) where T : WarEntity { T instance = CreateGameObjectInstance(prefab); instance.OriginFactory = this; return instance; } public void Reclaim (WarEntity entity) { Debug.Assert(entity.OriginFactory == this, "Wrong factory reclaimed!"); Destroy(entity.gameObject); } } 

Erstellen Sie ein Fertighaus für das Projektil. Ich habe einen einfachen Würfel mit der gleichen Skala von 0,25 und dunklem Material sowie eine Komponente verwendet Shell. Erstellen Sie dann das Werksobjekt und weisen Sie ihm das Fertighaus des Projektils zu.


Kriegsfabrik.

Spielverhalten


Um die Muscheln zu bewegen, müssen sie aktualisiert werden. Sie können denselben Ansatz verwenden, Gamemit dem der Status von Feinden aktualisiert wird. Tatsächlich können wir diesen Ansatz sogar verallgemeinern, indem wir eine abstrakte Komponente erstellen GameBehavior, MonoBehaviourdie eine virtuelle Methode erweitert und hinzufügt GameUpdate.

 using UnityEngine; public abstract class GameBehavior : MonoBehaviour { public virtual bool GameUpdate () => true; } 

Führen Sie nun das Refactoring durch EnemyCollectionund verwandeln Sie es in GameBehaviorCollection.

 public class GameBehaviorCollection { List<GameBehavior> behaviors = new List<GameBehavior>(); public void Add (GameBehavior behavior) { behaviors.Add(behavior); } public void GameUpdate () { for (int i = 0; i < behaviors.Count; i++) { if (!behaviors[i].GameUpdate()) { int lastIndex = behaviors.Count - 1; behaviors[i] = behaviors[lastIndex]; behaviors.RemoveAt(lastIndex); i -= 1; } } } } 

Lassen Sie es uns WarEntityerweitern GameBehavior, nicht MonoBehavior.

 public abstract class WarEntity : GameBehavior { … } 

Wir werden das Gleiche tun Enemy, um diesmal die Methode zu überschreiben GameUpdate.

 public class Enemy : GameBehavior { … public override bool GameUpdate () { … } … } 

Von nun an Gamemüssen zwei Sammlungen verfolgt werden, eine für Feinde und eine für Nicht-Feinde. Nicht-Feinde müssen nach allem anderen aktualisiert werden.

  GameBehaviorCollection enemies = new GameBehaviorCollection(); GameBehaviorCollection nonEnemies = new GameBehaviorCollection(); … void Update () { … enemies.GameUpdate(); Physics.SyncTransforms(); board.GameUpdate(); nonEnemies.GameUpdate(); } 

Der letzte Schritt bei der Implementierung eines Shell-Upgrades besteht darin, sie einer Sammlung von Nicht-Feinden hinzuzufügen. Lassen Sie uns dies mit einer Funktion tun Game, die eine statische Fassade für eine Kriegsfabrik darstellt, damit Projektile durch eine Herausforderung erzeugt werden können Game.SpawnShell(). Damit dies funktioniert, Gamemüssen Sie einen Link zur Kriegsfabrik haben und Ihre eigene Instanz verfolgen.

  [SerializeField] WarFactory warFactory = default; … static Game instance; public static Shell SpawnShell () { Shell shell = instance.warFactory.Shell€; instance.nonEnemies.Add(shell); return shell; } void OnEnable () { instance = this; } 


Spiel mit der Kriegsfabrik.

Ist eine statische Fassade eine gute Lösung?
, , .

Wir schießen eine Granate


Nachdem eine Instanz des Projektils erstellt wurde, sollte es auf seinem Weg fliegen, bis es das endgültige Ziel erreicht. Fügen Sie dazu die ShellMethode hinzu Initializeund geben Sie damit den Schusspunkt, den Zielpunkt und die Schussgeschwindigkeit an.

  Vector3 launchPoint, targetPoint, launchVelocity; public void Initialize ( Vector3 launchPoint, Vector3 targetPoint, Vector3 launchVelocity ) { this.launchPoint = launchPoint; this.targetPoint = targetPoint; this.launchVelocity = launchVelocity; } 

Jetzt können wir eine Shell erstellen MortarTower.Launchund auf die Straße schicken.

  mortar.localRotation = Quaternion.LookRotation(new Vector3(dir.x, tanTheta, dir.y)); Game.SpawnShell().Initialize( launchPoint, targetPoint, new Vector3(s * cosTheta * dir.x, s * sinTheta, s * cosTheta * dir.y) ); 

Projektilbewegung


Um Shelluns zu bewegen, müssen wir die Dauer seiner Existenz verfolgen, dh die Zeit, die seit dem Schuss vergangen ist. Dann können wir seine Position in berechnen GameUpdate. Wir tun dies immer in Bezug auf den Schusspunkt, damit das Projektil unabhängig von der Bildwiederholfrequenz perfekt dem Pfad folgt.

  float age; … public override bool GameUpdate () { age += Time.deltaTime; Vector3 p = launchPoint + launchVelocity * age; py -= 0.5f * 9.81f * age * age; transform.localPosition = p; return true; } 


Beschuss.

Um die Schalen an ihren Trajektorien auszurichten, müssen sie entlang des abgeleiteten Vektors schauen, der ihre Geschwindigkeit zum entsprechenden Zeitpunkt ist.

  public override bool GameUpdate () { … Vector3 d = launchVelocity; dy -= 9.81f * age; transform.localRotation = Quaternion.LookRotation(d); return true; } 


Die Muscheln drehen sich.

Wir putzen das Spiel


Jetzt, da klar ist, dass die Muscheln genau so fliegen, wie sie sollten, können Sie die MortarTower.LaunchFlugbahnen aus der Visualisierung entfernen .

  public void Launch (TargetPoint target) { … Game.SpawnShell().Initialize( launchPoint, targetPoint, new Vector3(s * cosTheta * dir.x, s * sinTheta, s * cosTheta * dir.y) ); } 

Außerdem müssen wir sicherstellen, dass die Granaten nach dem Auftreffen auf das Ziel zerstört werden. Da wir immer auf den Boden zielen, können Sie dies überprüfen, indem Sie prüfen, Shell.GameUpdateob die vertikale Position unter Null liegt. Sie können dies sofort nach der Berechnung tun, bevor Sie die Position ändern und das Projektil drehen.

  public override bool GameUpdate () { age += Time.deltaTime; Vector3 p = launchPoint + launchVelocity * age; py -= 0.5f * 9.81f * age * age; if (py <= 0f) { OriginFactory.Reclaim(this); return false; } transform.localPosition = p; … } 

Detonation


Wir schießen Granaten, weil sie Sprengstoff enthalten. Wenn das Projektil sein Ziel erreicht, muss es detonieren und allen Gegnern im Bereich der Explosion Schaden zufügen. Der Radius der Explosion und der verursachte Schaden hängen von der Art der vom Mörser abgefeuerten Granaten ab. Daher fügen wir MortarTowerKonfigurationsoptionen für diese hinzu.

  [SerializeField, Range(0.5f, 3f)] float shellBlastRadius = 1f; [SerializeField, Range(1f, 100f)] float shellDamage = 10f; 


Explosionsradius und 1,5 Schaden von 15 Granaten.

Diese Konfiguration ist nur während der Explosion wichtig, daher muss sie hinzugefügt werden Shellund ihre Methode Initialize.

  float age, blastRadius, damage; public void Initialize ( Vector3 launchPoint, Vector3 targetPoint, Vector3 launchVelocity, float blastRadius, float damage ) { … this.blastRadius = blastRadius; this.damage = damage; } 

MortarTower sollte erst nach seiner Erstellung Daten an das Projektil übertragen.

  Game.SpawnShell().Initialize( launchPoint, targetPoint, new Vector3(s * cosTheta * dir.x, s * sinTheta, s * cosTheta * dir.y), shellBlastRadius, shellDamage ); 

Um auf Feinde in Reichweite zu schießen, muss das Projektil Ziele erfassen. Wir haben bereits Code dafür, aber er ist in Tower. Da es für alles nützlich ist, was ein Ziel benötigt, kopieren Sie seine Funktionalität in TargetPointund machen Sie es statisch verfügbar. Fügen Sie eine Methode zum Füllen des Puffers, eine Eigenschaft zum Abrufen der gepufferten Menge und eine Methode zum Abrufen des gepufferten Ziels hinzu.

  const int enemyLayerMask = 1 << 9; static Collider[] buffer = new Collider[100]; public static int BufferedCount { get; private set; } public static bool FillBuffer (Vector3 position, float range) { Vector3 top = position; top.y += 3f; BufferedCount = Physics.OverlapCapsuleNonAlloc( position, top, range, buffer, enemyLayerMask ); return BufferedCount > 0; } public static TargetPoint GetBuffered (int index) { var target = buffer[index].GetComponent<TargetPoint>(); Debug.Assert(target != null, "Targeted non-enemy!", buffer[0]); return target; } 

Jetzt können wir alle Ziele in Reichweite bis zur maximalen Puffergröße empfangen und bei der Detonation Schaden zufügen Shell.

  if (py <= 0f) { TargetPoint.FillBuffer(targetPoint, blastRadius); for (int i = 0; i < TargetPoint.BufferedCount; i++) { TargetPoint.GetBuffered(i).Enemy€.ApplyDamage(damage); } OriginFactory.Reclaim(this); return false; } 


Detonation von Granaten.

Sie können auch eine TargetPointstatische Eigenschaft hinzufügen, um ein zufälliges Ziel aus dem Puffer abzurufen.

  public static TargetPoint RandomBuffered => GetBuffered(Random.Range(0, BufferedCount)); 

Dies ermöglicht uns eine Vereinfachung Tower, da Sie jetzt nach einem zufälligen Ziel suchen können TargetPoint.

 protected bool AcquireTarget (out TargetPoint target) { if (TargetPoint.FillBuffer(transform.localPosition, targetingRange)) { target = TargetPoint.RandomBuffered; return true; } target = null; return false; } 

Explosionen


Alles funktioniert, aber es sieht immer noch nicht sehr glaubwürdig aus. Sie können das Bild verbessern, indem Sie die Explosion bei der Detonation der Granate visualisieren. Dies wird nicht nur interessanter aussehen, sondern dem Spieler auch nützliches Feedback geben. Dazu erstellen wir ein Fertighaus der Explosion wie einen Laserstrahl. Nur wird es eine transparentere Kugel von heller Farbe sein. Fügen Sie eine neue Entitätskomponente Explosionmit einer benutzerdefinierten Dauer hinzu. Eine halbe Sekunde wird ausreichen. Fügen Sie ihr eine Methode hinzu Initialize, die die Position und den Radius der Explosion festlegt. Beim Einstellen der Skalierung müssen Sie den Radius verdoppeln, da der Radius des Kugelnetzes 0,5 beträgt. Es ist auch ein guter Ort, um allen Gegnern in Reichweite Schaden zuzufügen. Daher fügen wir auch einen Schadensparameter hinzu. Außerdem benötigt er eine Methode GameUpdate, um zu überprüfen, ob die Zeit knapp wird.

 using UnityEngine; public class Explosion : WarEntity { [SerializeField, Range(0f, 1f)] float duration = 0.5f; float age; public void Initialize (Vector3 position, float blastRadius, float damage) { TargetPoint.FillBuffer(position, blastRadius); for (int i = 0; i < TargetPoint.BufferedCount; i++) { TargetPoint.GetBuffered(i).Enemy.ApplyDamage(damage); } transform.localPosition = position; transform.localScale = Vector3.one * (2f * blastRadius); } public override bool GameUpdate () { age += Time.deltaTime; if (age >= duration) { OriginFactory.Reclaim(this); return false; } return true; } } 

Fügen Sie eine Explosion hinzu WarFactory.

  [SerializeField] Explosion explosionPrefab = default; [SerializeField] Shell shellPrefab = default; public Explosion Explosion€ => Get(explosionPrefab); public Shell Shell => Get(shellPrefab); 


Kriegsfabrik mit einer Explosion.

Fügen Sie auch der GameFassadenmethode hinzu.

  public static Explosion SpawnExplosion () { Explosion explosion = instance.warFactory.Explosion€; instance.nonEnemies.Add(explosion); return explosion; } 

Jetzt Shellkann es beim Erreichen des Ziels eine Explosion erzeugen und auslösen. Die Explosion selbst wird Schaden verursachen.

  if (py <= 0f) { Game.SpawnExplosion().Initialize(targetPoint, blastRadius, damage); OriginFactory.Reclaim(this); return false; } 


Explosionen von Muscheln.

Glattere Explosionen


Unveränderliche Kugeln statt Explosionen sehen nicht sehr schön aus. Sie können sie verbessern, indem Sie Deckkraft und Skalierung animieren. Sie können hierfür eine einfache Formel verwenden, aber verwenden wir Animationskurven, die einfacher einzurichten sind. Fügen Sie für diese Explosionbeiden Konfigurationsfelder hinzu AnimationCurve. Wir werden die Kurven verwenden, um die Werte über die Lebensdauer der Explosion anzupassen, und Zeit 1 zeigt das Ende der Explosion an, unabhängig von ihrer tatsächlichen Dauer. Gleiches gilt für den Umfang und den Radius der Explosion. Dies vereinfacht ihre Konfiguration.

  [SerializeField] AnimationCurve opacityCurve = default; [SerializeField] AnimationCurve scaleCurve = default; 

Die Deckkraft beginnt und endet mit Null und wird sanft auf einen Durchschnittswert von 0,3 skaliert. Die Skala beginnt bei 0,7, steigt schnell an und nähert sich dann langsam 1.


Explosionskurven.

Um die Farbe des Materials festzulegen, verwenden wir den Materialeigenschaftsblock. Dabei ist Schwarz die Deckkraftvariable. Die Skalierung ist jetzt auf eingestellt GameUpdate, aber wir müssen mithilfe des Radiusfelds verfolgen. In können InitializeSie die Verdopplungsskala verwenden. Die Werte der Kurven werden ermittelt, indem sie Evaluatemit einem Argument aufgerufen werden, das als aktuelle Lebensdauer der Explosion berechnet wird, geteilt durch die Dauer der Explosion.

  static int colorPropertyID = Shader.PropertyToID("_Color"); static MaterialPropertyBlock propertyBlock; … float scale; MeshRenderer meshRenderer; void Awake () { meshRenderer = GetComponent<MeshRenderer>(); Debug.Assert(meshRenderer != null, "Explosion without renderer!"); } public void Initialize (Vector3 position, float blastRadius, float damage) { … transform.localPosition = position; scale = 2f * blastRadius; } public override bool GameUpdate () { … if (propertyBlock == null) { propertyBlock = new MaterialPropertyBlock(); } float t = age / duration; Color c = Color.clear; ca = opacityCurve.Evaluate(t); propertyBlock.SetColor(colorPropertyID, c); meshRenderer.SetPropertyBlock(propertyBlock); transform.localScale = Vector3.one * (scale * scaleCurve.Evaluate(t)); return true; } 


Animierte Explosionen.

Tracer Muscheln


Da die Schalen klein sind und eine ziemlich hohe Geschwindigkeit haben, können sie schwer zu bemerken sein. Und wenn Sie sich den Screenshot eines einzelnen Frames ansehen, sind die Flugbahnen völlig unverständlich. Sie können sie deutlicher machen, indem Sie Ihren Muscheln einen Spureneffekt hinzufügen. Für herkömmliche Schalen ist dies nicht sehr realistisch, aber wir können sagen, dass dies Tracer sind. Solche Munition wird speziell hergestellt, damit sie einen hellen Fleck hinterlassen und ihre Flugbahnen sichtbar machen.

Es gibt verschiedene Möglichkeiten, Traces zu erstellen, aber Sie werden eine sehr einfache verwenden. Wir machen die Explosionen neu, so dass Shellin jedem Frame eine kleine Explosion entsteht. Diese Explosionen verursachen keinen Schaden, daher ist das Erfassen von Zielen eine Verschwendung von Ressourcen. Hinzufügen zuExplosionUnterstützen Sie diese Verwendung, indem Sie den Schaden verursachen, wenn er größer als Null ist, und machen Sie dann den Schadensparameter Initializeoptional.

  public void Initialize ( Vector3 position, float blastRadius, float damage = 0f ) { if (damage > 0f) { TargetPoint.FillBuffer(position, blastRadius); for (int i = 0; i < TargetPoint.BufferedCount; i++) { TargetPoint.GetBuffered(i).Enemy.ApplyDamage(damage); } } transform.localPosition = position; radius = 2f * blastRadius; } 

Wir werden am Ende eine Explosion Shell.GameUpdatemit einem kleinen Radius erzeugen , zum Beispiel 0,1, um sie in Tracer-Muscheln zu verwandeln. Es sollte beachtet werden, dass bei diesem Ansatz Explosionen Frame für Frame erzeugt werden, dh sie hängen von der Framerate ab, aber für einen so einfachen Effekt ist dies akzeptabel.

  public override bool GameUpdate () { … Game.SpawnExplosion().Initialize(p, 0.1f); return true; } 

Bild

Projektil-Tracer.

Tutorial Repository
PDF Artikel

Source: https://habr.com/ru/post/de461605/


All Articles