Jump to content
Unity Insider Forum

Terminal - ähnlich Fallout


Recommended Posts

Hi,

es ist nicht direkt ein Projekt, da es aus Langeweile am We entstanden ist.

Es soll ein fallout-ähnliches Terminal darstellen.

 

Ich wollte einfach mal probieren wie man das in Unity umsetzen könnte und bin über das Ergebnis überrascht. Leider aber auch über die scheinbar unzähligen Möglichkeiten der Umsetzung :D .

 

Der erste Grundgedanke war das ich es ähnlich wie ein Programm in der Hierarchie "programmieren" kann. Das hat auch super funktioniert, aber wurde unübersichtlich.

Als Beispiel: einen public string überschreiben: "schreibe;=;30;1" -> is klar, schreibt 30 mal ein "=" in die Zeile 1.

Aber man kann es eben übertreiben :rolleyes: . Deswegen erstmal das Augenmerk auf Funktionalität der Konsole.

 

Was sollte das Terminal können?:

1. Programme zur Auswahl haben(geht)

2. Programme laden können(geht)

3. Programme nutzen(geht noch nicht)

4. Eingaben entgegen nehmen(geht zum Teil)

5. Ausgaben anzeigen(geht)

5. Effekte auf die Ausgaben anwenden

-> Strings linksbündig, von vorn nach hinten schrittweise ausgeben in einer beliebigen Zeit(geht)

-> Strings linksbündig, von hinten nach vorn schrittweise ausgeben (ähnlich Laufschrift) in einer beliebigen Zeit(geht)

 

Leider gibt es eben unzählige Varianten. Gefallen würde mir eine Modulare, so wie es jetzt ist. Bedeutet das jedes "Programm" ein eigenständiges ist. Das ist aber auch recht aufwendig.

Man kann quasi in der Hierarchie eine Liste um 1 Wert erhöhen und man hat 1 neues "Unterprogramm" und kann Eigenschaften einstellen. Ohne Code nachträglich zu ändern.

 

Einfacher ist es wenn das Terminal das einzige Programm ist. Lässt sich einfacher realisieren, da man einfach nur simple Fallunterscheidung machen muss, welches "Progamm" gerade aktiv ist. Lässt sich aber dann nicht so schön in der Hierarchie einstellen.

 

Bevor die Frage aufkommt wofür es gut sein soll, das man die Herangehensweise festlegen kann. Es ist für nichts gut. Ich wollte wie gesagt nur mal schauen wie man es realisieren könnte.

 

Mal schauen, vielleicht realisiere ich noch den "Taschenrechner". Aber sonst wars das auch schon dazu.

 

Hier noch der Anhang.

 

MfG Felix

post-2150-0-09223800-1455798992_thumb.png

post-2150-0-30696700-1455798998_thumb.png

Link zu diesem Kommentar
Auf anderen Seiten teilen

Hi und danke.

 

Ja das hätte ich noch schreiben können. Nein das soll der Spieler nicht können.

Ich wollte halt mal eine vielleicht etwas sinnlose Art der Realisierung unternehmen was eigentlich Richtung "Prefab" geht. Wie gesagt, man kann das alles fest im Code verankern, aber das wollte ich nicht.

 

Vielleicht hilft das mehr:

Ein Objekt vom Typ Programm kann verschiedene Eigenschaften usw haben(OOP).

z.B.:

- Überschrift

- Unterprogramme, Oberprogramme

- Verwaltung der Programme

- die Möglichkeit Text ein- und auszugeben, zu speichern

- zu rechnen usw...

 

Alles Sachen die sich einfach realisieren lassen.

Es geht mir eher um den Code für die Programmverwaltung bzw deren Nutzung. Ziel ist eben das ich einfach mehrere Programme in der Hierarchie hinzufügen kann, ohne den Code für die Verwaltung erweitern zu müssen oder vom Programm.

 

z.B: Ich lege in der Hierarchie(Programmliste) ein weiteres Programm an.

Das Programm soll Eingaben entgegennehmen, speichern können, rechnen können. Das alles könnte man mit true/ false oder so festsetzen. Der Verwaltungs-Code legt dann anhand der verfügbaren Zeilen fest wo was hinkommt.

 

Im Beispiel oben:

-Schreibe überschrift

-hat das Programm Unterprogramme, wenn ja-> Freizeile Programme hinschreiben, diese Listen, auswählbar und ladbar machen.

 

Wenn Rechner gewählt ist und Enter gedrückt wird:

-> lösche Bildschirm->öffne Rechner

 

Wenn das Programm(Rechner) lesen, schreiben und rechnen kann: lege die Zeilen so und so an. Mache dies und das auswählbar und änderbar usw.

Und wenn es ein Oberprogramm hat(Terminal) schreibe unten zurück zu "Terminal...".

Hier wäre dann quasi die Zeile wo man seine Formel schreiben kann auswählbar und die Zeile zurück zum Terminal.

 

Wie gesagt, man kann es eben übertreiben und sowas ist eigentlich nicht notwendig wenn man Spiel entwickelt. Ich wollte es halt nur mal so probieren und ein wenig "rumspielen" und mal was anderes machen.

Link zu diesem Kommentar
Auf anderen Seiten teilen

Hmm, ehrlich gesagt verwirt mich deine Beschreibung, vorallem da du Fallout Terminals erwähnst. Sprichst du hier von Fallout 4 (habe ich noch nicht gespielt) oder von den vorherigen teilen? Weil das was mir bei Terminal in den sinn kommt sind da zwei sachen.

 

1) Die Terminals die herumstehen und lediglich primär nur informationen und texte enthalten

2) Und manche Terminals haben erst dieses (total beckackte) minispiel zum hacken davor.

 

Allerdings habe ich bisher noch kein Terminal gesehen wo irgendwelche art von programmen drauf laufen. Sprichst du also hier von irgendwelche Fallout 4 Terminals? Weil wenn du Fallout 3/New Vegas meinst, habe ich keine ahnung inwiefern deine Terminals mit diesem dort zu tun haben.

 

 

Ansonsten wenn ich dich richtig verstehe dann versuchst du einen weg zu finden das du zwar einen Terminal einmal programmieren kannst, als ein Prefab. Danach es für dich aber relativ einfach ist das Terminal selber zu programmieren was es anzeigt, wie es etwas anzeigt und praktisch damit kleine Mini-Programme haben kannst. Also du möchtest praktisch ein Terminal prefab im spiel irgendwo hinstellen. Und danach möchtest du z.B. einfach festlegen welches Modul ein Terminal nutzen soll. Habe ich die Logik soweit richtig verstanden?

Link zu diesem Kommentar
Auf anderen Seiten teilen

Hi,

nein es ist auch kein Fallout-Terminal wie im Spiel. Ich wollte optisch nur mal eins erzeugen. Die Idee mit den Programmen kam danach.

Du hast die Logik verstanden.

Wie gesagt, ich wollte nur mal bissl rumspielen und was anderes machen. Das Teil dient zu nix, außer der Diskussion hier.

Link zu diesem Kommentar
Auf anderen Seiten teilen

Nunja so wie du es beschreibst möchtest du also im grunde deine eigene Programmiersprache/DSL etc. generieren. Du müsstest halt einen Parser für deine Sprache schreiben. In der Regel parst man dann eine Sprache zu einem AST https://en.wikipedia.org/wiki/Abstract_syntax_tree hast du den AST dann musst du einen Interpreter schreiben der die Kommandos abarbeitet.

 

Mag sich vielleicht etwas aufwenig und komplex anhören, ist es aber eigentlich gar nicht so sehr. Zum anderen kommt es halt darauf an wie umfangreich deine befehle deiner Sprache sind. In grunde genommen kannst du zwei sachen machen. low-level befehle zur verfügung stehen. Zum beispiel "get/put" um sachen zu lesen oder zu setzen. "compare" um sachen zu vergleichen etc. Sprich low-level befehle die an assembler erinnern. Hast du diese low-level befehle kannst du darauf aufbauend alle deine neuen befehle aufbauen.

 

Oder du bietest "high-level" befehle an. Ist leichter umzusetzen. Jedoch musst du dann jede Funktionalität explizit einbauen. Existiert eine funktionalität nicht ist es evtl. schwerer bis gar unmöglich diese funktionalität anders darzustellen. Allerdings kann das bereits in deinem fall ausreichen.

 

Um das Design mal grundsätzlich zu beschreiben. Dann hast du drei dinge:

 

Parser: Parst etwas und wandelt es in einem AST um

AST: Datenstruktur das dein programm als Baum darstellt

Funktion: Erwartet einen AST als parameter und mach etwas damit

 

Zuersteinmal lasse ich den Parser beiseite. Eventuell reicht nämlich auch schon ein AST und eine entsprechende Funktion die ein AST entgegen nimmt. Ansonsten zeige ich einmal Beispielcode in F#. Ganz einfach deswegen da sowas in F# super simpel ist. Ich erkläre paar grundsachen der Sprache, und denke die Implementation sollte verständlich sein. Wenn du das Konzept verstanden hast, gehe ich einmal darauf ein wie du das ganze auch in C# umsetzen könntest.

 

Ein super Crash-Kurs in F#.

 

Variablen definieren C#

 

var x = 10;
var y = "Hallo";

 

Variablen definieren F#

 

let x = 10
let y = "Hallo"

 

Zu beachten:

1) "let" anstatt "var".

2) Keine Semikolions am ende.

3) Wie in C# hat man type-inferenz das automatisch die typen ermittelt.

4) Weiterhin sind es keine variablen. Sondern sie sind "immutable". x kann nach der definition nicht mehr geändert werden. Spielt aber für alles was ich zeige ohnehin keine rolle.

5) Man könnte eine veränderbare variable erstellen indem man "mutable" als keywort hinzufügt

 

Funktionen definieren in C#

 

public static Sum(int a, int  {
   return a + b;
}

 

Funktionen definieren in F#

 

let sum a b = a + b

 

Zu beachten:

1) "let" wird auch genutzt um funktionen zu definieren

2) Parameter werden einfach mit leerzeichen getrennt geschrieben

3) Type-inferenz funktioniert auch bei funktionen in F#. Typen von "a" und "b" werden automatisch ermittelt

4) Es gibt kein "return". Das letzte statement wird automatisch zurück gegeben.

5) Hier nicht sichtbar, aber es gibt auch keine klammern, code blöcke werden durch einrückung dargestellt.

 

Umfangreicheres Beispiel:

 

public static Sum(int a, int  {
   var x = a + 10;
var y = b + 10;
var z = x + y;
return z;
}

 

let sum a b =
   let x = a + 10
   let y = b + 10
   let z = x + y
z

 

Funktionen aufrufen in C#

 

var x = Sum(5, 10);

 

Funktionen aufrufen in F#

 

let x = sum 5 10

 

Zu beachten:

1) Keine runden klammern nötig

2) kein komma zum trennen der parameter

 

 

Das was die aufgabe in F# so einfach macht sind sogenannte Discriminated Unions (DU), C# hat nichts vergleichbares, aber werde eben später darauf eingehen wie du DUs in C# halbwegs nachbauen kannst. Simples Beispiel. Wir definieren uns unseren eigenen Bool in F#.

 

type Bool =
| True
| False

 

Das was du hier siehst. Ist ein Typ namens "Bool". Der Bool kann zwei zustände haben. "True" oder "False". Natürlich macht es keinen Sinn sowas zu definieren da es den Bool typen in C# sowie F# schon gibt. Trotzdem zeigt er grob schonmal was es ein DU ist. Ein DU ist ein Datentyp der mehrere unterschiedliche Werte annehmen kann. Jedoch immer nur einen einzigen speziellen. Sprich entweder "True" ODER "False". Aber niemals True UND False zugleich. Wenn dich das an einem enum erinnert. Ja dieses Beispiel wäre ähnlich einem enum. Ansonsten kannst du beliebig viele neue sachen dran hängen.

 

Im oberen Beispiel kan man davon sprechen das es zwei cases gibt. Das was DUs aber machen können ist pro Case zusätzliche Daten enthalten. Etwas was bei enums nicht geht. Um mal dein Beispiel mit dem Terminal aufzugreifen, du brauchst ja funktionen um auf den Terminal etwas auszugeben. Jeder dieser funktionen die du dir vorstellst wird dann ein case in einem DU. Und einige Cases benötigen ja extra daten. Sagen wir also du möchtest folgende Funktionen haben "Einen Text ausgeben (Print)", "eine leere zeile ausgeben (EmptyLine)". Das ganze könnte dann z.B. so aussehen.

 

type TerminalCommand =
| EmptyLine
| Print of string

 

Wir haben also einen typ namens TerminalCommand. Dieser kann entweder ein "EmptyLine" sein, ODER aber ein "Print" bei einem Print wird aber ein "string" als extra datum mitgeführt. Das was wir nun brauchen ist eine Funktion die ein TerminalCommand entgegen nimmt. Prüft welchen der beiden fälle wir haben, und dann entsprechend je nach fall etwas anderes tut. Das Konstrukt um eine variable auf fälle zu prüfen nennt man "Pattern Matching". Ansonsten das ganze Beispiel kann dann so aussehen.

 

let eval command =
   match command with
   | EmptyLine ->
       printfn ""
   | Print text ->
       printfn "%s" text

 

Das ist eine Funktion die nun folgendes macht.

 

1) Pattern match auf die variable "command"

2) Prüfen ob es "EmptyLine" ist, wenn ja dann den code (printfn "") ausführen (was eine leere zeile erzeugt)

3) Wenn es kein EmptyLine ist, dann den nächsten fall prüfen.

4) Prüfen ob es "Print" ist. Beim prüfen wird der wert den ein "Print" mit sich führt aber der variable "text" zugewiesen. Führt dann den code (printfn "%s" text) aus.

 

printfn ist vergleichbar einem Console.WriteLine()

 

printfn ""
Console.WriteLine();

printfn "%s" text
Console.WriteLine("{0}", text);

 

Der erste parameter bei printfn ist ein Format String.

 

Das ganze könnte man bisher folgendermaßen nutzen.

 

let command1 = EmptyLine
let command2 = Print "Hallo"
eval command1
eval command2

 

1) Wir erstellen also zwei variablen "command1" und "command2"

2) Beide Variablen sind vom Typ "TerminalCommand"

3) Die variablen werden der Funktion eval übergeben. Diese prüft dann ob es EmptyLine oder Print ist, und macht dann das was in eval definiert wurde.

 

Das bisherige würde also nur eine leere zeile ausgeben, und dann einen text ausgeben. Der Vorteil des ganzen ist aber eben das die eigentliche Logik deines Programmes aber eben als Datenstruktur gespeichert ist. In deinem fall könnte es ja anders sein. Anstatt ein Konsolen Programm das etwas auf einer Konsole ausgiebt willst du ja das etwas in deinem terminal im Spielobjekt ausgegeben wird. Aber das wäre halt deine aufgabe eine entsprechende funktion zu schreiben die das tut. Relevant ist erstmal das du code hast der als "Daten" angesehen wird. Ansonsten lass uns das Beispiel weiter ausbauen.

 

Als erstes, anstatt variablen wie "command1" und "command2" zu erzeugen kann man die Werte auch direkt übergeben. hier ist die Syntax dazu

 

eval EmptyLine
eval (Print "Hallo")

 

Im zweiten beispiel siehst du das nun klammern dazu gekommen sind. Klammern dienen aber wie oben nicht zum aufrufen einer funktion. Aber du benötigst klammern um sachen zu gruppieren. würdest du die klammern weg lassen dann würde das ein syntax fehler erzeugen. Den dann würdest du versuchen "Print" als ersten wert und "Hallo" als zweiten wert der "eval" funktion zu übergeben. Du musst also sachen mit den klammern gruppieren. Alles innerhlab von klammern wird dann zuerst ausgewertet. Du kannst es mit Mathe vergleichen.

 

2 * 3 + 4
2 * (3 + 4)

 

Ohne die Klammern wird halt zuerst "2 * 3" gerechnet und zum ergebnis 4 addiert. Also 10 ist das ergebnis. Mit den klammern wird logischerweise zuerst die klammer evaluiert. Also zuerst "3 + 4) was 7 ergibibt und dann * 2 gerechnet. Sprich 14 ist dann das ergebnis. So funktioniert es hier auch. (Print "Hallo") erzeugt also eine variable die eben Print ist mit einem string und dieses wird dann eval übergeben.

 

Nun will man aber nicht jeden einzelnen befehl direkt eval ausgeben. Sondern du willst ja eine reihe von befehlen haben die alle der reihe ausgeführt werden. Zuerstmal zeige ich die Lösung mit einer Liste. Zuerst erzeugen wir einen neuen Typ. Nämlich einen Typ namens "TerminalCommands" (s am ende für mehrzahl) dieser ist eine Liste von TerminalCommand

 

type TerminalCommands = TerminalCommand list

 

Danach bennen wir erstmal "eval" um nach "evalTerminalCommand". Ansonsten schreiben wir eine neue funktion "eval" die dann eine Liste von befehlen entgegen nimmt und durchlaufen kann. Bisher haben wir also folgenden Code.

 

type TerminalCommand =
   | EmptyLine
   | Print of string

type TerminalCommands = TerminalCommand list

let evalTerminalCommand command =
   match command with
   | EmptyLine ->
       printfn ""
   | Print text ->
       printfn "%s" text

let eval commands =
   for command in commands do
       evalTerminalCommand command

 

Wir können nun folgendes schreiben.

 

let program = [EmptyLine; Print "Hallo"; EmptyLine; Print "FooBar"; EmptyLine]
eval program

 

1) Eine Liste wird einfach durch Eckicke klammern erzeugt -> []

2) Listen Elemente werden durch ";" NICHT durch "," getrennt.

 

Die variable "program" ist also eine Liste das fünf Elemente enthält. Diese liste wird der funktion "eval" übergeben. Es loop die liste mit "for" durch und rüft für jedes einzelne element dann die Funktion "evalTerminalCommand" auf. Die variable "program" kann man sich natürlich auch sparen.

 

eval [EmptyLine; Print "Hallo"; EmptyLine; Print "FooBar"; EmptyLine]

 

Für einfache befehle die einfach nacheinander ausgeführt werden soll reicht das schon fast aus. Du kannst halt neue Befehle einfach hinzufügen. Ansonsten falls der Sinn des ganzen nicht ersichtlich sein sollte. Dein Terminal definiert halt mit dem Typ "TerminalCommand" alle verfügbaren befehle die du hast. Und dein Terminal program hat intern so eine "eval" funktion die die Datenstruktur entgegen nimmt und diese dann ausführen kann. Was du damit erreichst ist exakt die entkoppelung.

 

Daher du kannst einfach ein Terminal haben das dann einfach eine Liste von Terminal befehlen entgegen nimmt, und diese dann ausführt. Sagen wir Nutzer deiner "Sprache" sollen die Möglichkeit haben Linien zu zeichnen, so könntest du Beispielsweise auch ein "DrawLine" befehl hinzufügen. Ansonsten brauchst du dafür eine StartPosition und eine EndPosition. Ansonsten das was du dann brauchst ist ein case der zwei werte benötigt. Ansonsten ist eine Position selber wieder eine Sammlung von zwei werten (x,y). Das dann zum beispiel die Position auf deinen terminal angibt. Du könntest hier natürlich ein Vector2 nutzen. Du könntest aber auch eine Klasse/Struct für so eine Position erzeugen. F# hat noch ein paar mehr sachen wie Tuple oder Records. Aber hier reicht auch wieder ein Discriminated Union aus. Ein Discriminated Union kann auch "Single-Case" sein. sprich einfach nur

 

type Position =
| Position of int * int

 

1) Der Typ heißt Position, und ein einzelner Case ebenfalls.

2) anstatt nach dem "of" einen wert zu haben, benötigt man hier nun zwei int um diesen wert zu erzeugen

 

Das ganze muss aber nicht auf neuer Zeile stehen, und "|" ist bei einen einzigen eintrag auch überflüsig.

 

type Position = Position of int * int

 

Als nächstes einmal ein Beispiel wie man eine Position erzeugt

 

let point = Position(5,10)

 

Hat man einen DU case mit nur einem einzigen Parameter dann sind die Klammern optional. Wir könnten also zum beispiel auch

 

Print("Hallo")

 

schreiben. Hat man aber mehr als einen wert dann muss man die Klammern hinzufügen und die einzelnen werte mit kommas trennen. Ansonsten fügen wir so dann ein DrawLine Kommando dem Terminal hinzu das dann zwei solcher Positionen haben soll. Nämlich für Start und Ende. Das ganze sieht dann so aus.

 

type Position = Position of int * int

type TerminalCommand =
   | EmptyLine
   | Print    of string
   | DrawLine of Position * Position

type TerminalCommands = TerminalCommand list

 

Das ist die bisherige Datenstruktur. Ansonsten erstmal ein Beispiel wie ein Nutzer das ganze nutzen könnte.

 

eval [
EmptyLine
DrawLine(Position(0,1), Position(10,0))
Print "Hallo"
DrawLine(Position(0,3), Position(10,3))
EmptyLine
]

 

Die erste anmerkung. Wenn man eine Liste über mehrere Zeilen hinweg schreibt dann dient ein Newline automatisch als trenner, man muss also keine "extra" Semikolions mehr zum trennen hinschreiben. Und im grunde um es mal so anzumerken. Das obere sieht ja praktisch 1:1 wie normaler Programmcode aus. Nur das es eben einzelne Dateneinträge in einer liste ist. Und man diese einträge nach belieben auswerten kann.

 

Dies würde also

1) Eine Leere Zeile ausgeben

2) Eine Horizontale Linie von (0,1) nach (10,0) zeichnen

3) "Hallo" ausgeben

4) Eine weitere Horizontale Linie (0,3) nach (10,3) zeichnen

5) Wieder eine EmptyLine ausgeben

 

Das es das natürlich tut das ist dann aufgabe der eval funktion, und du musst natürlich die Logik dafür dann schreiben. Da ich in meinem Beispiel von einem simplen Kommandozeilen programm ausgehe kann ich da natürlich keine Linie zeichnen. Du müsstest natürlich den DrawLine Befehl entsprechend in deinem Terminal ausprogrammieren das er das tut was er eben tun soll. Das Pattern Matching für DrawLine innerhlab von F# würde übrigens so aussehen. Man kann nested DUs gleich in einem rutsch extrahieren. Man muss also nicht "DrawLine start end" machen und dann danach nochmal pattern matching auf "start" und "end".

 

let evalTerminalCommand command =
   match command with
   | EmptyLine ->
       printfn ""
   | Print text ->
       printfn "%s" text
   | DrawLine (Position(sx,sy),Position(tx,ty)) ->
       printfn "DrawLine from %d/%d to %d%d" sx sy tx ty

 

Relevant ist hier nur das die DUs beim Pattern matching extrahiert werden. Sprich die 4 int bei den Start/Endpositionen werden gleich gematcht und variablen zugewiesen. Während man damit schon relativ weit kommt, reicht es aber nicht ganz aus. Der letzte schritt ist es den Typ "TerminalCommands" zu entfernen. Was wir stattdessen erzeugen ist ein rekursivener Datentyp. Sprich TerminalCommand kann sich selber enthalten. Falls das nun zu komisch klingt. Man stelle sich einen normalen Binären Baum vor. Der funktioniert genau so. Ein Node eines Baums hat immer einen Left und einen Right Node. Und was ist ein Left und ein Right Node? Ebenfalls wieder ein Baum. Was wir also nun tun ist ein befehl innerhalb unseres commands zu erzeugen das eine liste von befehlen enthalten kann. Dadurch brauchen wir nur noch eine eval funktion. Um alle änderungen auf einen Schlag zu zeigen.

 

type Position = Position of int * int

type TerminalCommand =
   | EmptyLine
   | Print    of string
   | DrawLine of Position * Position
   | Commands of TerminalCommand list

let rec eval commands =
   match commands with
   | EmptyLine ->
       printfn ""
   | Print text ->
       printfn "%s" text
   | DrawLine (Position(sx,sy),Position(tx,ty)) ->
       printfn "DrawLine from %d/%d to %d%d" sx sy tx ty
   | Commands commands ->
       for command in commands do
           eval command

 

Wir haben also nun vier kommandos. Leere Zeile erzeugen, Text ausgeben, Linie zeichnen und eine liste von commandos. Der relevante teil ist nun das eval eine rekursive funktion ist. Stößt es auf "Commands []" dann nimmt es TerminalCoammand list entgegen und ruft eval selber wieder auf um alle unterelemente zu verarbeiten. Die Definition der Datenstruktur sieht nun so aus. Unser eval aufruf sieht nun so aus.

 

eval (
Commands [
	EmptyLine
	DrawLine(Position(0,1), Position(10,0))
	Print "Hallo"
	DrawLine(Position(0,3), Position(10,3))
	EmptyLine
]
)

 

Grundsätzlich hätten wir das nicht machen müssen, in dem was wir bisher hatten. Allerdings ist es wichtig die Rekursion zu verstehen und rekursive Datenstrukturen zu verstehen. Das wird relevant für den nächsten Schritt. Aber um mal auf rekursive datenstrukturen einzugehen. Im grunde sind XML, JSON etc. alles rekursive datenstrukturen. Man kann immer einen eintrag haben der weitere child elemente besitzt. Ansonsten das was wir zu eval übergeben ist ja eine Datenstruktur, und um auf das Thema Parser zurück zu kommen, wir könnten z.B. einfach unsere Datenstruktur als XML Serialisieren. Das ganze könnte z.B. so aussehen.

 

<Commands>

<EmptyLine />

<DrawLine><Position X="0" Y="1" /><Position X="10" Y="1"/></DrawLine>

<Print>Hallo</Print>

<DrawLine><Position X="0" Y="3" /><Position X="10" Y="3"/></DrawLine>

<EmptyLine />

</Commands>

 

Natürlich könntest du auch beliebige andere Serialisierungsformate nutzen. Oder aber deine eigene Sprache schreiben und einen dazugehörigen Parser. Eigentlich kannst du beliebige Formen nutzen. Relevant ist nur, du parst etwas und du musst den dazugehörigen AST generieren. Ansonsten dein terminal benutzt einen AST für seine Logik. Im grunde ist das eine Trennung der aufgaben (fast wie MVC ;)

 

Parser -> AST -> Funktion

 

Sprich du kannst beliebig viele Datenstrukturen oder auch eine DSL entwickeln. Alle verschiedene Darstellungsweisen werden dann in einer einheitlichen Datenstruktur AST gebracht. Du kannst also sehr viele unterschiedliche Parser haben, aber nur einen einzigen AST. Und danach kannst du wieder sehr viele Funktionen haben. z.B. könntest du einen AST wieder Serialisieren, ihn ausführen, in anzeigen, oder sonst etwas damit machen.

 

Rein theoretisch könntest du also auch wenn du dann mal irgendwann deine eigene Sprache hast und du zuvor XML genutzt hattest auch XML einlesen/parsen zu deinem AST. und dann den AST wieder in deiner Sprache ausspucken. Das ist übrigens exakt die Arbeitsweise von compilern. Den diese wandeln immer nur Code von einer Sprache zu einer anderen sprache um. Der C# oder auch F# Compiler liest im grunde auch nur jeglichen programmcode ein, wandelt ihn in einem AST um, und generiert dann sogennanten IL Code (Intermediate language). IL ist eine Assembler ähnliche sprache die dann letztendlich von der .NET Runtime oder Mono ausgeführt wird. Unity neues IL2CPP geht beispielsweise weiter hin und compiliert IL dann nach C++. Und dann gibt es "Emscripten" was C++ auf diesen weg nach JavaScript kompiliert. Und das ist ungefähr der weg wie WebGL in Unity funktioniert.

 

Aber zurück zum thema. Die Rekursion zu verstehen ist wichtig da du in der regel ja nicht nur befehle hast die nacheinander ausgeführt werden sollen. Sondern du hast ja befehle die zum beispiel unterbefehle haben. Um ein Beispiel zu nennen. Ein "If" statement. Ein If Statement kannst du praktisch in drei teile (oder mehr) zerlegen.

 

1) Condition. Eine Condition die einen wert evaluiert

2) Command List A -> Wird ausgeführt wenn Condition wahr ist

3) Command List B -> Wird ausgeführt wenn Condition falsch ist

 

Also lass und das mal einbauen. Zuerst lass uns mal über Datentypen sprechen, wir wollen Int und String haben, dazu Bool. Ansonsten möchten wir eine Condition haben. Die Frage ist wie man die Condition behandelt. In Programmiersprachen hat man beispielsweise

 

if x > 10 then

 

für einen vergleich. Man hat hier also "x > 10" als wert. "if" selber benötigt einen bool zum entscheiden was es tun soll. Aber das was "if" erlaubt ist das wir nicht direkt einen bool übergeben müssen, sondern wir können auch programmcode übergeben der dann ausgewertet wird und dieser wird dann geprüft. Als erstes ändere ich nun "TerminalCommand" in "Expr" um, da es kürzer ist. Und ich zeige das resultat. Ansonsten füge ich gleich auf einmal noch grundlegende Datentypen hinzu wie "Bool", "Int" und "String". Dazu noch "Unit", das sowas wie "Kein Wert" bedeutet. In C# kennst du ja das void schlüsselwört. Sowas ähnliches halt um zu symbolisieren das etwas keinen rückgabewert hat. Und dann fügen ich noch zwei Binäre Operationen ein. Print wird abgeändert das es ebenfalls eine "Expr" erwartet und kein String mehr. Unsere Datenstruktur sind nun folgendermaßen aus.

 

type Op =
   | Compare
   | Add

type Position = Position of int * int
type Expr =
   | Unit
   | EmptyLine
   | Bool     of bool
   | Int      of int
   | String   of string
   | Print    of Expr
   | DrawLine of Position * Position
   | BinaryOp of Expr * Op * Expr
   | Commands of Expr list
   | Cond     of Expr * Expr list * Expr list

 

Was wir nun damit machen können ist folgende Datenstruktur definieren und ausführen lassen.

 

eval (
Commands [
	// if
	Cond(BinaryOp(Int 5, Compare, Int 6), [
			// true branch
			Print (String "Both values are equal")
		],[
			// false branch
			Print (BinaryOp(String "5 is not", Add, String " equal to 6"))
			Print (BinaryOp(BinaryOp(Int 3, Add, Int 3), Add, BinaryOp(Int 6, Add, Int 6)))
		]
	)
	EmptyLine
	DrawLine(Position(0,1), Position(10,0))
	Print (String "Hallo")
	DrawLine(Position(0,3), Position(10,3))
]
) |> ignore

 

Das ganze ist also ein AST das zuerst eine Condition (if) enthält. Dort wird geprüft ob ein Int 5 identisch zu einem Int 6 ist. Als zweiter bzw dritter Wert wird Cond ja eine Liste von commandos übergeben. Im falle eines wahren wertes wird dann die erste liste ausgeführt. Im falle eines falschen wertes die zweite liste.

 

Insgesamt wenn man diese Datenstruktur evaluiert kommt dann folgendes bei heraus.

 

5 is not equal to 6
18

DrawLine from 0/1 to 10/0
Hallo
DrawLine from 0/3 to 10/3

 

Der code für die "eval" funktion sieht nun so aus.

 

let rec eval commands =
   match commands with
   | Unit       -> Unit
   | EmptyLine  -> printfn ""; Unit
   | Bool b     -> Bool b
   | Int  i     -> Int i
   | String str -> String str
   | Print expr -> 
       match eval expr with
       | Bool true  -> printfn "True"
       | Bool false -> printfn "False"
       | Int num    -> printfn "%d" num
       | String str -> printfn "%s" str
       | _          -> failwith "Only Bool, Int, String can be printed"
       Unit
   | DrawLine (Position(sx,sy),Position(tx,ty)) ->
       printfn "DrawLine from %d/%d to %d/%d" sx sy tx ty
       Unit
   | BinaryOp(e1,op,e2) ->
       let v1 = eval e1
       let v2 = eval e2
       match op with
       | Compare -> Bool (v1 = v2)
       | Add     -> 
           match v1,v2 with
           | Int x, Int y       -> Int (x + y)
           | String x, String y -> String (String.concat "" [x;y])
           | _                  -> failwith "Add only works with Int and String"
   | Commands commands ->
       for command in commands do
           eval command |> ignore
       Unit
   | Cond(expr,trueCommands,falseCommands) ->
       match eval expr with
       | Bool true  -> eval (Commands trueCommands)  |> ignore
       | Bool false -> eval (Commands falseCommands) |> ignore
       | _          -> failwith "Condition Expr didn't return a boolean"
       Unit

 

Ich werde nun nicht über den code gehen und ihn erklären, grob müsstest du ihn verstehen. Es wird einfach jedes kommando das man hat ausgeführt. Werte wie Bool, Int und String machen praktisch nichts und geben sich selber wieder zurück als wert. Ansonsten sachen wie "Print", "DrawLine" macht ja etwas, da alles aber einen rückgabewert haben muss, geben diese "Unit" im Pattern Matching zurück.

 

BinaryOp bekommt ja zwei "Expr" übergeben. Es wird eval auf diese aufgerufen um diese zu evaluieren. Ist der Expr zum beispiel ein "Int 6" dann kommt da auch "Int 6" zurück. Ist es aber wieder ein BinaryOp wird dieser rekursiv ausgeführt. Bis irgendwann eben ein wert zurück kommt. Beide expr bei einem BinaryOp werden so ausgeführt. Hat man dann werte anstatt eine Expr wird der "Op" geprüft der ausgeführt werden soll. In dem Beispiel gibt es bisher ja nur "Compare" oder "Add".

 

Cond evaluiert seinen ersten Expr und basierend darauf ob es true/false zurück liefert, führt es die zeite oder dritte Expr list als befehle aus.

 

Im grunde hat man so also eine kleine Mini-Sprache geschaffen die über unit, bool, int, string als datentyp verfügt, mit operationen für addieren und vergleichen und kommandos wie "Print" oder "DrawLine".

 

Wichtig zu verstehen ist das "eval" nur eine Funktion ist die ein AST Datenstruktur bekommt. Du kannst beliebig viele Funktionen bauen die eine AST Datenstruktur nimmt und verarbeitet. Dadurch werden vielleicht manche sachen sinvoller. Zum Beispiel im oberen Beispiel hatte ich "Bool b -> Bool b". Daher ein Bool evaluiert einfach zu einem Bool. Sprich es macht im grunde gar nichts. Stellt sich die Frage wofür man es also hat. Nun brauchen tut man es hier da wenn man eben einen Bool evaluiert er eben ein Wert ist und im falle eines "ausführens" eben nichts anderes passiert. Aber wenn du beispielsweise eine ganz andere Funktion schreibst. Wie ein Serializer dann musst du für "Bool" natürlich hingehen und entsprechend einen string oder ähnliches erzeugen der einen Bool repräsentiert.

 

Ansonsten alles im allen hat man mit ungefähr 50 zeilen Code eine AST Datenstruktur + Interpreter geschrieben der den AST ausführen kann.

 

Nun zum schweren Teil. Möchtest du das z.B. in C# lösen stehst du vor ein Problem, den C# hat weder Discriminated Unions noch Pattern Matching. Du musst diese Features also in C# anderwertig nachbauen. Was übrigens nett ist, falls unbekannt. Es gibt ein Programm names "ILSpy" man kann damit IL code wieder zurück nach C# compilieren lassen. Ansonsten kann man damit auch einfach mal sowas in F# schreiben

 

type Op =
   | Compare
   | Add

 

und kucken wie es dann in C# aussieht nachdem es zu IL kompiliert und zurück gewandert ist. Naja im großen werde da über 200 zeilen C# code generiert, allerdings enthält es auch eine menge extra sachen die wir hier nicht benötigen. DUs haben z.B. automatisch bereits Equal, Comparison und GetHashCode implementierungen, wie nahezu alles in F# so das man den kram nicht selber schreiben brauch. Ansonsten für den speziellen fall von Op könntest du vielleicht ja ein enum nutzen. Ansonsten wird diese Struktur so wie sie es (was kein enum ist) folgendermaßen vom F# Compiler umgesetzt.

 

1) Man hat eine "Op" klasse

2) Diese klasse enthält eine static klasse namens "Tags"

3) Die Tags klasse definiert jeweils ein "public const int" für jeden case.

public const int Compare = 0;

public const int Add = 1;

4) Wenn man ein Compare/Add erzeugt dann wird eigentlih ein object der "Op" Klasse erzeugt. Es wird "0" oder "1" dem Konstruktur übergeben, um was es sich handelt.

5) var x = new Op(0) // Erzeugt ein Compare

6) Die Op Klasse speichert die übergabe des Konstruktors in einem Feld.

7) Es werden Methoden "IsCompare" und "IsAdd" erzeugt. Man kann damit also einem "Op" objekt fragen ob er ein "Compare" oder ein "Add" ist.

8) Ein paar optimierung, man braucht nie mehr als ein objekt von beiden. Es gibt also static field wie "Op.Compare" und "Op.Add" um direkt ein objekt mit dem gesetzt flag zu bekommen.

 

Ansonsten für Cases die noch einen zusätzlichen wert mit sich tragen wird das nochmal etwas abgeändert. Der "Expr" typ sieht also so aus.

 

1) Expr wieder als Klasse, enthält wieder eine Tags klasse wie oben. Und jedem case wird wieder einfach ein int zugewiesen.

2) Die Expr Klasse selber wird wieder einfach beim Konstruktor eine Zahl übergeben und diese wird gespeichert.

3) Alle cases leiten jetzt aber von "Expr" ab. Das heißt du hast eine Klasse "Bool" die von "Expr" ableitet, "BinaryOp" die von "Expr" ableitet u.s.w.

4) Ansonsten enthält die "Expr" klasse wieder für jede unterklasse eine "IsBool", "IsPrint", "IsCond" property. Dieses prüft dann anhand des tag feldes und dem int um was es sich handelt. Du kannst also an beliebiger stelle hingehen und "Expr" als ein objekt erwarten. Und mit "obj.IsBool" dann eben prüfen um was es sich handelt. Das ersetzt also das Pattern matching.

5) Es gibt für jede Unterklasse übrigens auch eine static "New" Methode. Du hast also "Expr.NewBool(false)" um direkt ein "Expr.Bool" Objekt zu erzeugen. Diese Methode setzt dann bereits das richtige "int" für den Expr.

6) Jede Unterklassen fügt felder hinzu um seine nötige daten zu speichern. Die "Cond" Klasse hat beispielsweise drei properties einfach "Item1", "Item2" und "Item3" genannt. Naja wenn man soetwas selber schreibt dann kann man natürlich sinvollere namen vergeben.

 

Nunja der resultierende C# code ist jedenfalls relativ groß. Aleine der "Expr" type in F# was gerade mal 11 zeilen sind, werden so knapp ~1400 zeilen C# code. Du kannst in dem fall natürlich etliches an zeilen code sparen, etliche attribute fallen weg. Manche sachen wie Equals, GetHashCode u.s.w. kannst du auch weg lassen. Vielleicht fallen 3/4 zeilen weg da du diese hier nicht brauchst. Ist aber trotzdem noch ein erheblicher mehraufwand.

 

Ansonsten das Pattern matching ist dann letztendlich im IL code lediglich ein switch statement. Du switcht also auf dem "Tag" Property des objektes. Wenn es "0" ist, dann weißt du es ist Unit, "1" ist "EmptyLine" u.s.w. eben so wie es dann in der static "Expr.Tags" Klasse definiert ist.

 

Ansonsten das konstrukt Um ein BinaryOp zu erzeugen sieht dann sehr ähnlich aus. Anstatt

 

BinaryOp(Int 5, Compare, Int 6)

 

hast du dann eher sowas in C#

 

Expr.NewBinaryOp(Expr.NewInt(5), Op.Compare, Expr.NewInt(6))

 

Ansonsten ist es eine menge arbeit solche DUs in C# zu emulieren, wenn du sowas denn in C# programmieren willst. Alternativ kannst du aber auch die DUs in F# definieren und dann einfach eine DLL Library kompilieren. Setze es auf .Net 2.0 und du bekommst eine DLL die auch in Unity benutzbar ist. Es generiert dir halt dann so die Klassen wie ich es hier beschrieben habe und du kannst diese sofort nutzen. Zumindest um die DUs zu definieren würde es sich lohnen das in F# zu machen. Der Rest ist in C# ja dann ja annehmbar. eval ist in C# dann einfach eine static methode das ein "Expr" objekt übergeben bekommt. Mit switch schaust du welcher typ das objekt ist. Und evtl. musst du dich halt rekursiv neu aufrufen.

 

Wenn du alles in C# machen willst dann ist es am sinvollsten wohl trotzdem mal kurz den F# code zu kompilieren und dann nutzt du ein Tool wie eben "ILSpy" und öffnest einfach die .exe und schaust dir den generierten C# code an der generiert wird. Ansonsten zur vollständigkeit. Hier nochmal der komplette F# Code den do so in einem leeren F# project einfach herein pasten kannst.

 

type Op =
   | Compare
   | Add

type Position = Position of int * int
type Expr =
   | Unit
   | EmptyLine
   | Bool     of bool
   | Int      of int
   | String   of string
   | Print    of Expr
   | DrawLine of Position * Position
   | BinaryOp of Expr * Op * Expr
   | Commands of Expr list
   | Cond     of Expr * Expr list * Expr list

let rec eval commands =
   match commands with
   | Unit       -> Unit
   | EmptyLine  -> printfn ""; Unit
   | Bool b     -> Bool b
   | Int  i     -> Int i
   | String str -> String str
   | Print expr -> 
       match eval expr with
       | Bool true  -> printfn "True"
       | Bool false -> printfn "False"
       | Int num    -> printfn "%d" num
       | String str -> printfn "%s" str
       | _          -> failwith "Only Bool, Int, String can be printed"
       Unit
   | DrawLine (Position(sx,sy),Position(tx,ty)) ->
       printfn "DrawLine from %d/%d to %d/%d" sx sy tx ty
       Unit
   | BinaryOp(e1,op,e2) ->
       let v1 = eval e1
       let v2 = eval e2
       match op with
       | Compare -> Bool (v1 = v2)
       | Add     -> 
           match v1,v2 with
           | Int x, Int y       -> Int (x + y)
           | String x, String y -> String (String.concat "" [x;y])
           | _                  -> failwith "Add only works with Int and String"
   | Commands commands ->
       for command in commands do
           eval command |> ignore
       Unit
   | Cond(expr,trueCommands,falseCommands) ->
       match eval expr with
       | Bool true  -> eval (Commands trueCommands)  |> ignore
       | Bool false -> eval (Commands falseCommands) |> ignore
       | _          -> failwith "Condition Expr didn't return a boolean"
       Unit

[<EntryPoint>]
let main argv =
   eval (
       Commands [
           // if
           Cond(BinaryOp(Int 5, Compare, Int 6), [
                   // true branch
                   Print (String "Both values are equal")
               ],[
                   // false branch
                   Print (BinaryOp(String "5 is not", Add, String " equal to 6"))
                   Print (BinaryOp(BinaryOp(Int 3, Add, Int 3), Add, BinaryOp(Int 6, Add, Int 6)))
               ]
           )
           EmptyLine
           DrawLine(Position(0,1), Position(10,0))
           Print (String "Hallo")
           DrawLine(Position(0,3), Position(10,3))
       ]
   ) |> ignore
   0 // return an integer exit code

Link zu diesem Kommentar
Auf anderen Seiten teilen

Hi,

danke erstmal für die riesen Antwort(Mausrad brennt). Die lese ich mir morgen mal Ruhe durch. Das wird heute nix mehr.

F# hatte ich mir auch schon mal angeschaut. Aber nicht weiter verfolgt.

 

Bitte sag mir das Du den Text aus deinen Unterlagen oder so kopiert hast und nicht extra wegen dem Terminal geschrieben hast. Sonst komm ich mir blöd vor und setze das Terminal, das ich nicht brauche(schon öfters erwähnt), doch noch um :D.

 

Beim überfliegen sieht das schon interessant aus. Werde mir am We mal F# anschauen(nur anschauen). Vorteile, Nachteile, Einsatzmöglichkeiten usw. Kannst Du mir da Lesestoff empfehlen?

Link zu diesem Kommentar
Auf anderen Seiten teilen

Bitte sag mir das Du den Text aus deinen Unterlagen oder so kopiert hast und nicht extra wegen dem Terminal geschrieben hast. Sonst komm ich mir blöd vor und setze das Terminal, das ich nicht brauche(schon öfters erwähnt), doch noch um :D.

Ne, hatte das nicht in meinen Unterlagen. Naja vom programmcode her ist es ja auch simpel. Der ganze kram ist ja gerade mal 50 Zeilen. Sowas ist in F# schon so simpel das ich darüber gar nicht all zu groß nachdenken muss. Okay nur den Text und die beschreibung drum herum zu schreiben hat etwas gedauert.

 

Beim überfliegen sieht das schon interessant aus. Werde mir am We mal F# anschauen(nur anschauen). Vorteile, Nachteile, Einsatzmöglichkeiten usw. Kannst Du mir da Lesestoff empfehlen?

Klar, die beste Webseite rund um F# ist.

 

http://fsharpforfunandprofit.com/

 

Scott Washlin, der Autor der Webseite, erzeugt praktisch ganze Tutorials und Einführungen zu der Sprache. Man kann F# komplett von der Webseite lernen. Auf seiner "Why use F#" seite hat er praktisch eine Liste der Sprachfeatures die er "grob" anspricht.

 

http://fsharpforfunandprofit.com/why-use-fsharp/

Link zu diesem Kommentar
Auf anderen Seiten teilen

Join the conversation

You can post now and register later. If you have an account, sign in now to post with your account.

Gast
Auf dieses Thema antworten...

×   Du hast formatierten Text eingefügt.   Formatierung jetzt entfernen

  Only 75 emoji are allowed.

×   Dein Link wurde automatisch eingebettet.   Einbetten rückgängig machen und als Link darstellen

×   Dein vorheriger Inhalt wurde wiederhergestellt.   Clear editor

×   You cannot paste images directly. Upload or insert images from URL.

Lädt...
×
×
  • Neu erstellen...