Tagebuch: Industry City

Ja - so kommt es von einem zum anderen ^^. Aber genau die Iteration (was ja dann im Prototyp endet) scheint extrem wichtig zu sein.

  1. Das war echt simpel. Ich hab einen Sphere-Kollider (also ne Kugel) an die Kanone des Turms gepackt. Der hat dann halt einen relativ großen Radius (in dem ein oder anderen geposteten Video sieht man den auch). Läuft nun ein Minion da rein, dann löst er einen Trigger aus. Die Kanone erkennt also den Minion und nimmt diesen aufs Korn.
  2. Jupp, die Kugel bekommt Ihr Ziel vom Turm mit. Dadurch kann sie diesem folgen und macht Schaden sobald diese den Gegner trifft. Solange es damit nicht zu Problemen mit der Performance kommt würde ich das auch so lassen.
  3. Meinst du mit Impact den Schaden oder die Berührung an sich? Ersteres ist einfach eine Subtraktion von 1 (Minion startet mit 10 Leben initial). Die Berührung an sich ist dabei denkbar einfach: es geschieht mithilfe von Collidern (oder anders ausgedrückt: Unity3D nimmt mir die Arbeit ab^^).

Ich muss sagen, das Spiel hier hat mich nie los gelassen. Und ich entwickle gerade ein neues Konzept wie ich das Spiel vielleicht doch realisiert bekommen kann.

Und ich hab es soweit, dass ich das grobe Konzept euch nun anhand des bisherigen Prototypen vorstellen möchte:

Grafikstil
Ist praktisch schon final. Ich möchte es in Low-Poly-Grafik halten und der Prototyp gibt gut wieder - was zu erwarten ist. Alles was man so sieht ist auch komplett in Unity3D umgesetzt. Auch das Auto:

Map aufbau
Die Map möchte ich so aufbauen, dass es Ressourcenfelder gibt (z.B. Stein, holz). Diese sollen auf der Map zufällig verteilt werden.
Noch unklar ist, ob ich alle Ressourcenfelder zu beginn platziere oder diese nacheinander auftauchen lasse. Z.b. nach dem Erfüllen von Aufgaben.

Um an die Ressourcen zu kommen muss der Spieler eine Straße dahin bauen, damit ein Transporter diese erreichen kann.

Gebäude
Derzeit sind zwei Sorten von Gebäuden vorgesehen. Fabriken und Häuser. Mit Häusern kann man Einwohner holen und somit Kaufkraft generieren. Fabriken stellen kaufbare Waren her.

In Überlegung ist gerade der Shop. Shops werden von Fabriken beliefert - kaufen aber nur soviel Ware, wie sie selber absetzen können. Ich denke die Regel hier wäre sehr einfach. Sagen wir ein Anwohner benötigt pro Zeiteinheit 10 Steine - also kauft der Shop pro Einwohner auch nur 10 Steine. Eine erweiterte Variante könnte auch so ausschauen: Ein Einwohner möchte 10 auf Vorrat haben und braucht pro Zeiteinhheit nur 1 Stein. Dementsprechend wäre die Kaufkraft zu beginn hoch - aber die Einwohner kaufen dann nur noch Steine nach um Ihren Vorrat aufzufüllen.

Möglicherweise könnte man da dann auch noch einen weitern Faktor „Nachfrage“ einbauen. Je mehr Leute kaufen desto mehr kann der Shop verlangen.

Gebäude ausbauen
Wie auf dem Screenshot zu sehen ist die Fabrik höher als das Haus. Die Idee dahinter ist, dass eine Fabrik auf Lvl 1 nur eine Ressource herstellen kann. Auf Level 2 kann diese dann 2 verschiedene Ressourcen herstellen.

Beim Haus wäre es dann so - je größer das Gebäude, desto mehr Einwohner (Effekt sollte klar sein).

Versionen
Weiterführende Versionen wären dann sowas wie Techtree, Lagerhallen, Lieferketten, etc.

Hoffnungen und Träume
Ich hoffe, dass das Projekt mir nicht übern Kopf wächst - so wie im ersten Konzept. Aber das, was ich derzeit überschauen kann scheint aus momentaner Sicht auf die Dinge machbar.

Prototyp in "Action"

Wollte jetzt mal den „Wusselfaktor“ etwas ausprobieren, wenn mehrere Fahrzeuge unterwegs sind. Und hielt es für eine gute Idee, die Fahrzeuge zu verkleinern. So scheinen die auch nicht aneinander hängen zu bleiben:

Und ich werde diesmal ein weiteres Framework einsetzen: Zenject.

Ich habe gemerkt, dass ich mit ScriptableObjects zwar sehr weit komme - aber diese können kein DI-FW ersetzen. Z.B. ist es ziemlich blöde, wenn man ein ScriptableObject erstellt um Service-Implementierungen verteilen zu können.

Darum guck ich mir gerade das Tutorial von Zenject an. Ich möchte z.B. im groben wissen, was das DI-FW kann.

Das mit dem Zinject gefällt mir glaub schon sehr gut. Denn ich hab beim ersten Versuch mehr Abhängigkeiten weg bekommen als erwartet.

Ich kann nämlich SubContainer an meine GameObjects ranhängen (was ich bei den Autos gemacht hab). Denn ich hab einen Service, der mir den NavAgent (für das Pathfinding) abstrahieren soll. Der Service selber schaut dann so aus:

using UnityEngine;
using UnityEngine.AI;

namespace Game.Services {
    public class NavAgentService : INavAgentService {

        private NavMeshAgent _navMeshAgent;

        public NavAgentService(NavMeshAgent navMeshAgent) {
            _navMeshAgent = navMeshAgent;
        }

        public bool TargetReached => _navMeshAgent.stoppingDistance > _navMeshAgent.remainingDistance;
        
        public void Move(Vector3 target) {
            _navMeshAgent.destination = target;
        }

        public void Move(Transform target) {
            _navMeshAgent.destination = target.position;
        }
    }
}

Und das ganze wird hier verwendet:

using Game.Services;
using UnityEngine;
using Zenject;

namespace Game.Vehicles {
    
    public class WayPointMover : MonoBehaviour {
        [SerializeField] private Transform source;
        [SerializeField] private Transform target;

        public Transform Source {
            get => source;
            set => source = value;
        }

        public Transform Target {
            get => target;
            set => target = value;
        }
        
        [Inject] public INavAgentService NavAgentService { get; set; }
        
        private bool _backToTarget;

        private void Start() {
            NavAgentService.Move(target);
        }

        private void Update() {
            if (!NavAgentService.TargetReached) return;

            if (_backToTarget) {
                _backToTarget = false;
                NavAgentService.Move(target);
            } else {
                _backToTarget = true;
                NavAgentService.Move(source);
            }
        }
    }
    
}

Früher hätte WayPointMover den NavMeshAgent kennen müssen. Und nun hab ich ein Interface was ich super einfach mocken kann. Die einzigen Parameter sind somit Daten - nämlich source und target.

Und zur Vollständigkeit hier der IntegrationsTest:

using System.Collections;
using FakeItEasy;
using Game.Services;
using Game.Vehicles;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.TestTools;

namespace Tests.IntegrationTests.Game.Vehicles {
    
    public class WayPointMoverTest {

        private WayPointMover _wayPointMover;
        private Transform _source, _target;

        private INavAgentService _navAgentService;
        
        [UnitySetUp]
        public IEnumerator SetUp() {
            _navAgentService = A.Fake<INavAgentService>();
            
            _wayPointMover = new GameObject("WayPointMover").AddComponent<WayPointMover>();
            _source = new GameObject("WayPointMover - source").transform;
            _target = new GameObject("WayPointMover - target").transform;

            _wayPointMover.NavAgentService = _navAgentService;
            _wayPointMover.Source = _source;
            _wayPointMover.Target = _target;
            
            yield return null;
        }

        [TearDown]
        public void CleanUp() {
            Object.Destroy(_wayPointMover.gameObject);
            Object.Destroy(_source.gameObject);
            Object.Destroy(_target.gameObject);
        }

        [UnityTest]
        public IEnumerator ChangeTargets() {
            // Move to target OnStart
            A.CallTo(() => _navAgentService.Move(_target)).MustHaveHappenedOnceExactly();

            // Check if target was reached (= false) and do not move to source
            Fake.ClearRecordedCalls(_navAgentService);
            yield return null;
            A.CallTo(() => _navAgentService.TargetReached).MustHaveHappenedOnceExactly();
            A.CallTo(() => _navAgentService.Move(_source)).MustNotHaveHappened();

            // Target was reached and should go to source
            Fake.ClearRecordedCalls(_navAgentService);
            A.CallTo(() => _navAgentService.TargetReached).Returns(true);
            yield return null;
            A.CallTo(() => _navAgentService.Move(_source)).MustHaveHappenedOnceExactly();
        }
    }
    
}

Ich werde mir dann auch mal Gedanken machen müssen, wie ich den NavAgentService an sich teste. Denn NavMeshAgent kann ich nicht mocken und zum testen bräuche ich für den Fall eine komplette Szene.
Möglicherweise werde ich das einfach ungetestet lassen.

So. Da ich alles ja wie gesagt im Low-Poly-Style halten möchte und Low-Poly-Elemente schnell umzusetzen sind, hab ich das heute gemacht. So ziemlich alles hat jetzt ein (vllt sogar finales) 3D-Modell:

Mir fehlt lediglich noch eines fürs Wasser.

Besonders Stolz bin ich irgendwie auf meinen Transporter:

Ich hab es auch direkt so gemacht, dass die Fracht des Vehicles ausgeblendet werden kann. Damit kann ich dann auch im Spiel anzeigen - ob das Gefährt was geladen hat oder nicht :slight_smile:

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.