Tagebuch: Industry City

So. Hab alle 3D-Modelle fertig :slight_smile::

Ich hätte schon viel früher Low-Poly machen sollen. Der Turm vom Tower Defender war z.B. schon zu aufwendig. Aber die Modelle hier sind einfach Top und sollten für jedes Endgerät taugen.

Übrigens hat es einen (imho guten) Grund, warum ich die Dummy-3D-Modelle basierend auf Unity3D-Klötzen in Blender nochmal als richtige Modelle nachgebaut hab - abgesehen davon, dass ich Spaß dran hab. Nämlich weil ich früher oder später Rendergrafiken davon brauche (z.B. für ein Baumenü). Und diese sind einfach viel leicht zu erstellen, wenn ich das in Blender machen kann. Da bereite ich einmal eine Szene vor und mach damit dann ein Fotoshooting mit allen Modellen die ich brauche. Hier mal ein paar Beispiele:

Btw hatte ich im TowerDefender-Game auch Rendergrafiken. Nur das ich diese mit Unity3D erstellt hab. Das war aber deutlich mehr Aufwand. Denn ich brauchte dafür eine extra Scene, eine speziell eingestellte Camera die nur bestimmte Bereiche auf einer RenderTexture abbildet und Code (!!!) welcher mir die RenderTexture irgendwo hin speichert. Und als ich dass das letzte mal machen wollte, hat das überhaupt nicht mehr funktioniert. Warum auch immer war der Tower blass.

So. Hab meine Modelle vom „Fotoshooting“ jetzt auf ein Podest gestellt und als Buttons eingebaut :slight_smile:. Und mir gefällt das Ergebnis richtig RICHTIG RICHTIG gut!

Hier in Action:

In meinen vorherigen Spielen hab ich das UI immer vernachlässigt. Kann man später machen. Ist zwar prinzipiell richtig - aber das hässliche UI vom Tower Defender hat mich immer gestört und immer auch etwas demotiviert. Denn ich sah darin noch nicht erledigte Arbeit die ich auch so schnell nicht angehen werde.

Diesmal werde ich dem nicht folgen und versuchen direkt ein tolles GUI zu gestalten. Das Spiel muss schon beim Anblick Spaß machen :slight_smile: !

1 „Gefällt mir“

Und weil ichs gerade so schön finde, hier der komplette Animationsablauf:

Hatte zwischendurch kurz Sorge, weil die Buttons manchmal mit Verzögerung verschwunden sind. Dachte ich hätte einen Lag drin. War aber zum Glück nicht so. Bei den Animationsübergängen kann man einstellen ob die eine ExitTime haben (standardmäßig aktiv). Das hat für eine Verzögerung gesorgt, wenn man nach erscheinen aller Buttons direkt den nächsten anklickt.
Nachdem ich das Flag entfernt hab funktioniert alles wie gewollt :slight_smile:.

So. War wieder fleißig. Hab gestern den Shader gebaut, den ich für Gebäude verwende, welche platziert werden sollen (man muss diese ja von bestehenden Gebäuden unterscheiden können).

Und heute hab ich dafür gesorgt, dass es mit dem Baumenü gekoppelt ist (ist aber noch quick’n’dirty, da ich nicht wirklich viel Zeit hatte). So schaut es bisher aus:

So. Hab effektiv kaum einen Fortschritt gemacht. Denn ich hab meine quick’n’dirty variante jetzt in sauber implementiert. Und dabei versuche ich so gut es geht dem zu folgen was ich hier gesehen habe:

Von dem her werde ich später auch wirklich viele Interfaces haben. Auch wenn ich mit denen sonst immer eher sparsam umgehe - hier macht es irgendwie mehr Sinn.

Und zwar aus zwei Gründen:

  1. In C# kann man Klassen nicht so einfach mocken
  2. Ich weiß wirklich sehr wenig manchmal darüber, wer die Implementierung bereit stellen wird (dementsprechend wäre eine konkrete Klasse schlecht, wenn ich ein Monobehavior brauch).

Auf jeden Fall bekomme ich jetzt meine Funktionalitäten viel besser gekapselt. Hier mal ein Beispiel, wie mein BluePrintProvider ausschaut:

namespace Game.Modes {
    
    public class BluePrintProvider : MonoBehaviour {
        private SignalBus _signalBus;
        private IMapService _mapService;
        private IBuildConfigLookup _lookup;

        private GameObject _bluePrint;
        
        [Inject]
        public void Init(SignalBus signalBus, IMapService mapService, IBuildConfigLookup lookup) {
            _signalBus = signalBus;
            _mapService = mapService;
            _lookup = lookup;
            
            _signalBus.Subscribe<EnterBuildModeSignal>(OnEnterBuildMode);
            _signalBus.Subscribe<ExitBuildModeSignal>(OnExitBuildMode);
        }

        private void OnEnterBuildMode(EnterBuildModeSignal signal) {
            if (_lookup.TryLookup(signal.BuildingType, out var config)) {
                _bluePrint = _mapService.PlaceObject(config.Selected);
            }
        }

        private void OnExitBuildMode() {
            Destroy(_bluePrint);
        }
        
    }
    
}

In meinen anderen Projekten wären die Interfaces wohl alle nicht vorhanden und die komplette Logik wäre erstmal im BluePrintProvider selber gelandet. Was er macht? Erkläre ich kurz.

SignalBuss
Der SignalBus ist eigentlich ein EventBus. Ich kann mich für bestimmte Events (hier: Signale) registrieren und bekomme dann mit, wenn was passiert.
Der SignalBus wird von Zenject bereit gestellt.

MapService
Der MapService erstellt mir Instanzen von den übergebenen Objekten und platziert diese in der Szene. Wer Unity kennt, für den klingt das erstmal nach einem Aufruf von Object.Instantiate. Da dieser Aufruf das testen schwer machen kann, wäre es deswegen schon wert das auszulagern, aber nein - es passiert etwas mehr.

Der MapService wird von einem MonoBehavior bereit gestellt, denn ich brauche Informationen aus der Szene:

  1. Kameraposition
  2. Das GameObject welches als Parent dienen soll

Die Kameraposition brauche ich, damit ich das Objekt immer im Sichtfeld vom Benutzer platzieren kann. Nachdem das geschehen ist, passt der Service auch gleichzeitig noch die Position am Raster an.

LookupService
Das ist ein richtig tolles Ding. Denn das schaut nach ob es zu einem GebäudeTyp im Resourcenordner eine BuildConfig gibt. Falls ja liefert es true zurück und gibt die Config zurück (der zweite Parameter ist ein out-param). In der BuildConfig sind momentan 3 Infos enthalten:

  • GebäudeTyp (Factory, House oder Straße)
  • Prefab des Gebäude
  • Prefab des Gebäude als BluePrint

Die Implementierung von diesem Service ist in diesem Fall kein MonoBehavior. Denn ich brauche keine Informationen aus der Szene. Kann es aber wunderbar mittels Zenject verteilen.


Was mir hierbei extrem gut gefällt ist, dass ich wirklich kleine Klassen hab die sehr stark dem SRP folgen. Dementsprechend waren meine Tests bisher auch alle sehr einfach.

Dadurch ließ sich auch der BluePrintProvider (welcher noch nicht fertig ist) bisher sehr einfach bauen. Seltsamerweise war mir noch nicht komplett klar, wie oder was der BluePrintProvider tun soll. Als ich die Klasse vor mir hatte, drehten sich meine Gedanken mehr um die Szene und wo ich den Provider platziere. Als ich mich dann entschloss mit dem Test anzufangen war es ganz einfach. Ich wusste was er tun soll und schrieb das runter. Der Test hat also wirklich geholfen Fokus zu bekommen.

Zudem konnte ich (dank der Interfaces) den Provider schreiben obwohl mir noch eine Implementierung für den IMapService gefehlt hatte.


Also alles in allem entdecke ich hier gerade DI wieder neu. Ich hätte nicht erwartet, dass es solch einen merklichen Unterschied schon jetzt macht. Begeistert bin ich aber auf jeden Fall :slight_smile:.

So. Kurzes Update von heute: ich hab jetzt drin, dass man Gebäude platzieren kann :slight_smile:. Der UI-Button zum platzieren wird noch ausgetauscht. Ich brauchte halt was, was mir ein Signal sendet ^^:

Schritt für Schritt geht es nun langsam vorwärts. Hab eben fertig gestellt, dass die Autos Ihre Wegfindung updaten sobald ein Gebäude platziert wird.

Um das Baumenü abzuschließen fehlt dann nicht mehr viel. Im wesentlichen nur noch folgende Tasks:

  • Bestätigen Button
  • Abbrechen Button
  • Verhindern das ein Gebäude über ein anderes platziert wird

Heute hielt sich mein Fortschritt in Grenzen. Hab hauptsächlich die Buttons vorbereitet.

Aber dafür hab ich einige Unity Add-ons gekauft. Denn Humble Bundle hat gerade ein Unity Event laufen und da gibt es aktuell den PlayMaker. Wollte ich schon immer Mal ausprobieren. Bin mir nur nicht sicher ob ich visual Scripting tatsächlich einsetzen möchte (da ich es nicht kompatibel mit TDD halte). Aber Mal schauen.

Abgesehen davon waren glaub auch nette 3d Modelle und UI Tools dabei.

Ich hab mir heute mal die Bundles angeschaut, die ich gekauft hab. Hauptsächlich 2 Stück.

  • Doozy-UI
  • PlayMaker

DoozyUi gefällt mir … nicht wirklich. Die Idee dahinter ist zwar schon echt cool und man scheint schon coole UIs machen zu können. Allerdings wirkt es sehr aufgeblasen und wirklich stabil scheint es auch nicht zu sein. Des weiteren wirkt es sehr überladen.

PlayMaker hingegen scheint echt ganz nett zu sein. Eine visuelle StateMachine zu haben wäre schon nicht verkehrt. Zumal man auch sehr einfach selber Aktionen dafür schreiben kann.

Ich muss nur mal schauen, wie gut sich das verbinden lässt. Und vor allem, wie gut es mit meinem DI-FW zusammenarbeitet.

Ok, das arbeitet tatsächlich sehr gut zusammen. In meiner Szene habe ich ein SceneContext-Objekt welches meinen IoC-Container konfiguriert (bei Guice würde man von einem Modul reden). Dieses kann ich mir einfach in die Action von Playmaker laden. Dafür hab ich eine eigene abstrakte Klasse geschrieben:

using HutongGames.PlayMaker;
using UnityEngine;
using Zenject;

namespace Game.FSM {
    
    public abstract class DiFsmStateAction : FsmStateAction {
        
        public override void Awake() {
            base.Awake();
            
            Object.FindObjectOfType<SceneContext>().Container.Inject(this);
        }
    }        
}

D.h. alle Aktionen die DI brauchen, müssen lediglich davon erben. Da meine Signale alle DI brauchen und ich meine Listener nach dem selben Konzept registriere, hab ich dafür auch eine Abstrakte Klasse gemacht:

using Game.Signals;
using HutongGames.PlayMaker;
using Zenject;

namespace Game.FSM {
    
    public abstract class SignalAction<T> : DiFsmStateAction {
        [Inject] private SignalBus _bus;

        public FsmEvent targetEvent;

        public override void OnEnter() {
            _bus.Subscribe<T>(OnSignal);
            
            Finish();
        }

        public override void OnExit() {
            _bus.Unsubscribe<T>(OnSignal);
        }

        public virtual void OnSignal(T obj) {
            Fsm.Event(targetEvent);
        }
    }
    
}

Und zum Schluss noch meine konkreten Implementierungen:

[ActionCategory("Industry City")]
public class EnterBuildModeAction : SignalAction<EnterBuildModeSignal> {}

[ActionCategory("Industry City")]
public class ExitBuildModeAction : SignalAction<ExitBuildModeSignal> {}

Und das funktioniert wunderbar. Bei Spielstart schaut meine StateMachine so aus:

und wenn ich einen Button anklicke so:

Ich bin mal gespannt, was sich daraus bauen lässt :slight_smile:

Ok das ist cool. Ich hab mit dem PlayMaker gerade eine komplette Klasse unnötig gemacht. Nämlich die, die meine Menu-Buttons animiert hat :slight_smile:.

Also was ich mir auf jeden Fall anschauen muss, ist wie gut sich Playmaker mit tests koppeln lässt. Den theoretisch müsste ich den BluePrintProvider komplett in Playmaker realisieren können.

Fraglich ist bei sowas halt nur: wird es halt nur, ob es sich genauso gut mit integrationstests abdecken lässt.

Denn mit der Änderung die ich gestern gemacht hab, hab ich schon ein kleines Loch in meine Testabdeckung gerissen. Es geht hier zwar lediglich darum, Animationen zur richtigen Zeit zu setzen - aber das war vorher halt auch abgedeckt.

Ich hoffe inständig, dass sich dafür tests einfacher schreiben lassen, als ich es gerade befürchte ^^

So. Ich habe Fortschritte gemacht was die Einbindung von Playmaker in mein Projekt angeht. Denn wie gesagt: ich wollte es unbedingt testbar haben.

Und mein Ziel war dieser Setup:

Um es kurz zu erklären. START ist der Entry-Point von jeder StateMachine. Und der erste State der erreicht wird ist Listener. Dieser hat eine Aktion, welcher auf ein Signal hört - das von Zenject gesendet wird. Nachdem dieses Signal gefeuert wurde soll Playmaker das also mitbekommen und in den State Change Position übergehen. Hier mal der entsprechende Test:

[UnityTest]
public IEnumerator ChangePosition() {
    yield return null; // Warte auf ein Update, damit die aktion registriert wurde
    
    signalBus.Fire(new EnterBuildModeSignal(BuildingType.Factory));
    
    Assert.AreEqual(
        new Vector3(4, 3, 4),
        fsm.transform.position
    );
}

Anfangs hatte ich das Problem, dass ich mich zu blöd angestellt habe um ein GameObject mit StateMachine in meinen Test zu laden. Gefolgt von dem Problem, dass ich meinen DiContainer nicht bekommen hab. Was in der Scene funktioniert - funktioniert nicht im Test. Darum bin ich jetzt zu etwas übergegangen was naheliegend und einfach war: Ich pack den DiContainer in eine statische variable und greife so drauf zu.

Eigentlich bin ich überhaupt kein Freund von sowas - aber derzeit fällt mir kein anderer Weg ein. Da ich keinen Einfluss auf die Erstellung der Aktionen hab, kann ich da auch nicht früher ansetzen. Von daher muss ich nachträglich meine Abhängigkeiten injecten lassen.

Sollte mir mal eine bessere Variante einfallen, dann kann ich das aber (hoffentlich) einfach ändern. Denn jede Aktion die DI verwendet muss von einer speziellen Klasse erben. Somit hab ich diese Lösung an einer zentrallen Stelle und kann das jederzeit anpassen.

So. Bin noch immer dran an den „aufräumarbeiten“. Denn durch Playmaker konnte ich auch ein weiteres Script durch eine Aktion ausmerzen (den sogenannten BuildButton). Dieser hatte ursprünglich 2 Aufgaben:

  • Sende das richtige Signal
  • De-/aktiviere dich basierend auf dem Signal.

Die zweite Aufgabe wurde ja generell etwas vereinfacht. Da ich nun ein komplett neues Menü im Build-Mode anzeige, müssen alle Buttons verschwinden. Von daher hab ich die Animation eine Ebene höher auf das Panel gelegt (was über PlayMaker gesteuert wird + getestet ist).

Somit blieb nur noch das Signal übrig. Das kann aber auch PlayMaker übernehmen. Und letztendlich tut es das auch. Da ich jetzt für jedes Signal eine Aktion hab, hab ich funktional die Buttons alle fertig - sodass auch der hässliche Button am oberen Ende verschwinden konnte. Das Ergebnis schaut dann bisher so aus:

Damit hätte ich diesen (etwas sehr viel größeren als gewohnt Task) auch fast fertig. Zumindest von den Anforderungen her. Was mir da fehlt ist letztendlich nur, dass ich verbiete das Gebäude übereinander platziert werden können.

Technisch gesehen hab ich noch etwas mehr zu tun. Da ich mit PlayMaker noch keine Erfahrungen hab, musste ich viel ausprobieren. Deswegen hab ich in dem Bereich natürlich kein TDD betrieben. Somit fehlen mir noch tests für:

  • Alle meine Aktionen
  • Die StateMachine die im Video zu sehen ist

Seit meinem letzten post vor 2h hab ich gefrühstückt und mir mal angeschaut, wie ich meine Actions testen kann. Was zuerst ausgesehen hat, als ob es total einfach wäre - stellte sich als komplexe Aufgabe heraus.

Denn die „netten“ Leute von PlayMaker haben wohl so überhaupt keinen Bock darauf, dass man deren Zeug weg mocken könnte und haben alles dicht gemacht. Also hab ich einen weiteren Layer einbauen müssen, welche meine Listener-actions nun nutzen. Bevor jetzt ein Event-Call an PlayMaker geht, geht dieser call erstmal durch meinen neuen Layer - welchen ich mocken kann. Denn mir war es einfach nicht möglich im Test abzufangen, ob meine Action jetzt einen neuen State triggern wird oder nicht -.-.

Als nächstes durfte ich mit dem Feld rumkämpfen, welches den Layer hält. Ist es public, dann wird es im Editor angezeigt - aber der Editor hat keinen support für den Typ also fliegt es mir um die Ohren. Wer das jetzt nicht nachvollziehen kann - nicht wichtig. Es war einfach nur nervig wie Sau, dass ich per Try-Error herausfinden musste, wie ich jetzt meinen Layer halten kann.

Nachdem ich das hatte, konnte ich ENDLICH einen Test grün bekommen.

Zufrieden damit war ich aber noch immer nicht. Denn ich wollte nicht für jeden Signal-Listener einen eigenen Test aufsetzen. Da sich diese kaum unterscheiden würden hab ich mich für eine parameterisierte Variante entschieden.

Mein Test schaut dann so aus:

using FakeItEasy;
using Game.DI;
using Game.FSM;
using Game.FSM.SignalListener;
using HutongGames.PlayMaker;
using NUnit.Framework;
using Zenject;

namespace Tests.UnitTests.Game.FSM.SignalListener {
    public class SignalListenerTest : ZenjectUnitTestFixture {

        private IFsmUtil util;
        private FsmEvent fsmEvent;

        public override void Setup() {
            base.Setup();

            SignalInstaller.Install(Container);
            
            util = A.Fake<IFsmUtil>();
            fsmEvent = new FsmEvent("Generic FSM Event");
        }

        [TestCaseSource(typeof(SignalListenerTestCases), nameof(SignalListenerTestCases.Signals))]
        public void CheckSignals(SignalAction action, object signal) {
            SetupAction(action);

            action.OnEnter();
            
            FireSignal(signal);

            A.CallTo(
                () => util.Event(A<FsmEvent>.That.Matches(
                    receivedEvent => receivedEvent == fsmEvent
                ))
            ).MustHaveHappenedOnceExactly();
        }

        private void FireSignal(object signal) {
            Container.Resolve<SignalBus>().Fire(signal);
        }

        private void SetupAction(SignalAction action) {
            action.targetEvent = fsmEvent;
            action.SetFsmUtil(util);
            action.Init(Container);
            action.Init(new FsmState(new Fsm()));
        }
    }
}

Und meine Datenquelle für den Test so:

using System.Collections;
using Core.Buildings;
using Game.FSM.SignalListener;
using Game.Signals;
using NUnit.Framework;

namespace Tests.UnitTests.Game.FSM.SignalListener {
    public static class SignalListenerTestCases {

        public static IEnumerable Signals() {
            yield return new TestCaseData(new ExitBuildModeAction(), new ExitBuildModeSignal());
            yield return new TestCaseData(new EnterBuildModeAction(), new EnterBuildModeSignal(BuildingType.Factory));
        }
        
    }
}

Sollte ich einen neuen Listener haben, dann brauch ich also in Zukunft nur eine Zeile Code hinzuzufügen um diesen zu testen - anstatt einen kompletten test aufzusetzen :slight_smile:.

So. Habe eben meine Tests für die Sender von Signalen fertig geschrieben. Die funktionieren ähnlich wie die Listener. Nur konnte ich meine Tests etwas verbessern.

Wie Ihr in meinem Codebeispiel vom letzten mal sehen könnt, hatte ich meine Generics im Test aufgelöst und mit object gearbeitet. Per Zufall hab ich gerade rausgefunden, dass ich auch Generics für den Test an sich nutzen kann. Somit schaut mein Test jetzt so aus:

using FakeItEasy;
using Game.DI;
using Game.FSM;
using Game.FSM.SignalListener;
using HutongGames.PlayMaker;
using NUnit.Framework;
using Zenject;

namespace Tests.UnitTests.Game.FSM.SignalListener {
    public class SignalListenerTest : ZenjectUnitTestFixture {

        private IFsmUtil util;
        private FsmEvent fsmEvent;

        public override void Setup() {
            base.Setup();

            SignalInstaller.Install(Container);
            
            util = A.Fake<IFsmUtil>();
            fsmEvent = new FsmEvent("Generic FSM Event");
        }

        [TestCaseSource(typeof(SignalListenerTestCases), nameof(SignalListenerTestCases.Signals))]
        public void CheckSignals<T>(SignalAction<T> action, T signal) {
            SetupAction(action);

            action.OnEnter();
            
            FireSignal(signal);

            A.CallTo(
                () => util.Event(A<FsmEvent>.That.Matches(
                    receivedEvent => receivedEvent == fsmEvent
                ))
            ).MustHaveHappenedOnceExactly();
        }

        private void FireSignal(object signal) {
            Container.Resolve<SignalBus>().Fire(signal);
        }

        private void SetupAction<T>(SignalAction<T> action) {
            action.targetEvent = fsmEvent;
            action.InitForTest(Container, util);
        }
    }
}

So langsam fühl ich mich wohl mit PlayMaker und testen. Dürfte auch jetzt wieder alles voll abgedeckt haben :slight_smile:.

Was gut ist, denn damit hab ich meine technische Schuld abgearbeitet, die ich mit PlayMaker eingeführt hab.

Das bedeutet auch, dass ich als nächstes den letzten Punkt für die Story angehen kann. Nämlich verhindern das Gebäude übereinander platziert werden können.

So. Hab die Story praktisch fertig. Man kann Gebäude nicht über andere bauen und somit ist das Baumenü eigentlich fertig. Eigentlich.

Letztendlich ist es natürlich doch relativ komplex. Deswegen läuft gerade die erste refactoring Runde. Und eine zweite - größere - ist auch schon geplant.

Der Punkt ist der. Würde ich jetzt mit dem Projekt starten, dann würde ich mehr auf PlayMaker setzen. Dementsprechend möchte ich sehen, wieviel Aufwand es ist die bisherige Logik in PlayMaker-Actions umzuschreiben und diese zu nutzen. Dann wäre das System auch einheitlich.

Zum Schluss natürlich noch ein Video vom aktuellen Stand der Dinge :slight_smile::

Shit. Meine Umsetzung passt doch noch nicht. Ich nutze Raycast zum prüfen ob ein Gebäude an einer Stelle ist. Dummerweise hab ich false-positives weiter hinten.

Muss also eine andere Lösung finden. Vielleicht arbeite ich mit einer Registry oder einfachen Map. Immerhin sind die Koordinaten alle absolute Werte und wenn ich ein Level speichern möchte, brauche ich die Informationen eh.

Von daher gesehen ist es eigentlich gar nicht so schlecht, dass der Bug aufgetaucht ist.