
Grüße Es geschah, dass ich drei Jahre hintereinander ein Spiel als Geschenk zum Neujahr für bestimmte Leute gemacht habe. Im Jahr 2018 war es ein Plattformer mit Puzzle-Elementen, über den ich auf einem Hub schrieb Im Jahr 2019 - ein Netzwerk RTS für zwei Spieler, über die ich nichts geschrieben habe. Und schließlich, in der 2020. - eine visuelle Kurzgeschichte, die später diskutiert wird, in sehr kurzer Zeit erstellt.
In diesem Artikel:
- Design und Implementierung der Engine für visuelle Kurzgeschichten,
- ein Spiel mit einer nichtlinearen Handlung in 8 Stunden,
- Entfernung der Logik des Spiels in Skripten in ihrer eigenen Sprache.
Interessant? Dann willkommen bei cat.
Achtung: Es gibt viel Text und ~ 3,5 MB Bilder
Inhalt:
0. Begründung für die Entwicklung des Motors.
- Die Wahl der Plattform.
- Motorarchitektur und deren Implementierung:
2.1. Erklärung des Problems.
2.2. Architektur und Implementierung. - Skriptsprache:
3.1. Sprache.
3.2. Dolmetscher - Spielentwicklung:
4.1. Die Geschichte und Entwicklung der Logik des Spiels.
4.2. Grafik - Statistiken und Ergebnisse.
Hinweis: Wenn Sie aus irgendeinem Grund nicht an den technischen Details interessiert sind, können Sie mit Schritt 4 "Spieleentwicklung" fortfahren. Der Großteil des Inhalts wird jedoch übersprungen
0. Begründung für die Entwicklung des Motors
Natürlich gibt es eine Vielzahl von vorgefertigten Engines für visuelle Kurzgeschichten, die zweifellos besser sind als die unten beschriebene Lösung. Egal was für ein Programmierer ich war, wenn ich keinen anderen geschrieben hätte. Nehmen wir daher an, dass seine Entwicklung gerechtfertigt war.
Die Auswahl war in der Tat gering: entweder Java oder C ++. Ohne nachzudenken, entschied ich mich, meinen Plan in Java umzusetzen, weil für eine schnelle Entwicklung bietet es alle Möglichkeiten (nämlich automatisches Speichermanagement und größere Einfachheit im Vergleich zu C ++, das viele Details auf niedriger Ebene verbirgt und dadurch weniger Betonung auf die Sprache selbst zulässt und nur an Geschäftslogik denkt), und bietet auch Unterstützung für Windows, Grafik und Audio von Anfang an.
Swing wurde ausgewählt, um die grafische Benutzeroberfläche zu implementieren, da ich Java 13 verwendet habe, bei dem JavaFX nicht mehr Teil der Bibliothek ist und das Hinzufügen von mehreren zehn Megabyte OpenJFX, je nachdem, zu faul war. Vielleicht war dies nicht die beste Lösung, aber dennoch.
Es stellt sich wahrscheinlich die Frage: Was für eine Spiel-Engine ist das, aber ohne Hardwarebeschleunigung? Die Antwort liegt in der fehlenden Zeit, um sich mit OpenGL zu befassen, sowie in seiner absoluten Sinnlosigkeit: FPS ist für einen visuellen Roman nicht wichtig (auf jeden Fall mit so viel Animation und Grafik wie in diesem Fall).
2. Die Architektur der Engine und ihre Implementierung
2.1 Darlegung des Problems
Um zu entscheiden, wie etwas zu tun ist, müssen Sie entscheiden, warum. Hier geht es mir um die Aussage des Problems, denn Die Architektur ist nicht universell, aber eine "domänenspezifische" Engine hängt per definitionem direkt vom beabsichtigten Spiel ab.
Unter Universal Engine verstehe ich die Engine, die Konzepte auf relativ niedriger Ebene unterstützt, wie "Game Object", "Scene", "Component". Es wurde beschlossen, es nicht zu einem Universalmotor zu machen, da dies die Entwicklungszeit erheblich verkürzen würde.
Das Spiel sollte wie geplant aus folgenden Teilen bestehen:

Das heißt, für jede Szene gibt es einen Hintergrund, den Haupttext sowie ein Textfeld für Benutzereingaben (der visuelle Roman wurde genau mit willkürlichen Benutzereingaben gedacht und nicht wie so oft aus den vorgeschlagenen Optionen ausgewählt. Später werde ich Ihnen sagen, warum es schlecht war Entscheidung). Das Diagramm zeigt auch, dass es im Spiel mehrere Szenen geben kann und daher Übergänge zwischen ihnen hergestellt werden können.
Hinweis: Mit Szene meine ich den logischen Teil des Spiels. Das Kriterium für die Szene kann genau in diesem Teil derselbe Hintergrund sein.
Zu den Anforderungen an die Engine gehörte auch die Fähigkeit, Audio abzuspielen und Nachrichten anzuzeigen (mit der optionalen Benutzereingabefunktion).
Der vielleicht wichtigste Wunsch war der, die Spielelogik nicht in Java, sondern in einer einfachen deklarativen Sprache zu schreiben.
Es bestand auch der Wunsch, die Möglichkeit einer prozeduralen Animation, nämlich der elementaren Bewegung von Bildern, zu realisieren, damit auf Java-Ebene die Funktion bestimmt werden kann, anhand derer die aktuelle Bewegungsgeschwindigkeit berücksichtigt wird (zum Beispiel so, dass der Geschwindigkeitsgraph direkt oder sinusförmig oder etwas anderes ist).
Die gesamte Benutzerinteraktion sollte wie geplant über ein Dialogsystem erfolgen. In diesem Fall wurde der Dialog nicht unbedingt als Dialog mit dem NPC oder ähnlichem betrachtet, sondern im Allgemeinen als Reaktion auf Benutzereingaben, für die der entsprechende Handler registriert wurde. Unverständlich? Es wird bald klarer.
2.2. Architektur und Implementierung
Aus all diesen Gründen können Sie die Engine in drei relativ große Teile aufteilen, die denselben Java-Paketen entsprechen:
display
- enthält alles, was die Ausgabe von Informationen (Grafik, Text und Ton) an den Benutzer sowie den Empfang von Eingaben von ihm betrifft. Eine Art (View), wenn wir über MVC / MVP / etc. sprechen.initializer
- enthält Klassen, in denen die Engine initialisiert und gestartet wird.sl
- enthält Tools zum Arbeiten mit der Skriptsprache (im Folgenden - SL).
In diesem Abschnitt werde ich die ersten beiden Teile betrachten. Ich werde mit dem zweiten beginnen.
Die Initialisierungsklasse verfügt über zwei Hauptmethoden: initialize()
und run()
. Die Steuerung erfolgt zunächst in der Launcher-Klasse, von der aus initialize()
aufgerufen wird. Nach dem Aufruf analysiert der Initialisierer die an das Programm übergebenen Parameter (den Pfad zum Verzeichnis mit den Aufgaben und den Namen der auszuführenden Aufgabe), lädt das Manifest der ausgewählten Aufgabe (etwas später), initialisiert die Anzeige und prüft, ob die für die Aufgabe erforderliche Sprachversion (SL) von den Daten unterstützt wird Der Interpreter startet schließlich einen separaten Thread für die Entwicklerkonsole.
Unmittelbar danach ruft der Launcher die run()
-Methode auf, die das eigentliche Laden der Quest in Gang setzt, wenn alles reibungslos lief. Zunächst gibt es alle Skripte, die sich auf die heruntergeladene Quest beziehen (zur Struktur der Questdatei - siehe unten). Sie werden dem Analysator zugeführt, dessen Ergebnis dem Interpreter übergeben wird. Dann wird die Initialisierung aller Szenen gestartet und der Initialisierer beendet die Ausführung seines Streams und hängt schließlich den Enter-Key-Handler auf dem Display auf. Wenn der Benutzer die Eingabetaste drückt, wird die erste Szene geladen, aber dazu später mehr.
Die Dateistruktur der Quest ist wie folgt:

Es gibt einen separaten Ordner für die Quest, in dessen Stammverzeichnis sich ein Manifest befindet, sowie drei zusätzliche Ordner: audio
- für Sound, graphics
- für den visuellen Teil und scenes
- für Skripte, die Szenen beschreiben.
Ich möchte das Manifest kurz beschreiben. Es enthält die folgenden Felder:
sl_version_req
- SL-Version, die zum Starten der Quest erforderlich ist,init_scene
- der Name der Szene, ab der die Quest beginnt,quest_name
- ein schöner quest_name
, der im Fenstertitel erscheint,resolution
- die Auflösung des Bildschirms, für den die Quest bestimmt ist (ein paar Worte dazu später),font_size
- Schriftgröße für den gesamten Text,font_name
ist der Schriftname für den gesamten Text.
Es ist anzumerken, dass während der Initialisierung der Anzeige unter anderem die Berechnung der Renderauflösung durchgeführt wurde: Das heißt, die erforderliche Auflösung wurde aus dem Manifest entnommen und in den für das Fenster verfügbaren Platz gedrückt, so dass:
- das Seitenverhältnis blieb das gleiche wie in der Auflösung aus dem Manifest,
- Der gesamte verfügbare Platz war entweder in der Breite oder in der Höhe belegt.
Dank dessen kann der Entwickler der Quest sicher sein, dass seine Bilder, zum Beispiel 16: 9, auf jedem Bildschirm in diesem Verhältnis angezeigt werden.
Wenn die Anzeige initialisiert wird, wird der Cursor ausgeblendet, da er nicht am Gameplay beteiligt ist.
Kurz gesagt zur Entwicklerkonsole. Es wurde aus folgenden Gründen entwickelt:
- Zum Debuggen.
- Wenn während des Spiels etwas schief geht, kann dies über die Entwicklerkonsole behoben werden.
Es wurden nur wenige Befehle implementiert, nämlich: Ausgeben von Deskriptoren eines bestimmten Typs und ihres Status, Ausgeben von Arbeitsthreads, Neustarten der Anzeige und des wichtigsten Befehls - exec
, mit dem jeder SL-Code in der aktuellen Szene ausgeführt werden konnte.
Dies beendet die Beschreibung des Initialisierers und verwandter Dinge, und wir können mit der Beschreibung der Anzeige fortfahren.
Seine endgültige Struktur ist wie folgt:

Aus der Erklärung des Problems können wir schließen, dass alles, was getan werden muss, ist, Bilder zu zeichnen, Text zu zeichnen und Audio abzuspielen.
Wie wird Text / Bild normalerweise in Universal Engines und darüber hinaus gezeichnet? Es gibt eine Methode vom Typ update()
, die bei jedem Häkchen / Schritt / Frame / Render / Frame / usw. aufgerufen wird und bei der eine Methode vom Typ drawText()
/ drawImage()
- dies stellt das Erscheinungsbild von Text / Bild in diesem Frame sicher. Sobald der Aufruf solcher Methoden beendet wird, wird das Rendern der entsprechenden Objekte jedoch beendet.
In meinem Fall wurde beschlossen, etwas anderes zu machen. Da für Bildromane Text und Bilder relativ dauerhaft sind und auch fast alles sind, was der Benutzer sieht (das heißt, sie sind wichtig genug), wurden sie als Spielobjekte erstellt - das heißt, Dinge, die man nur erzeugen muss und die nicht verschwinden bis du sie fragst. Darüber hinaus vereinfachte diese Lösung die Implementierung.
Ein Objekt (aus Sicht von OOP), das den Text / das Bild beschreibt, wird als Deskriptor bezeichnet. Das heißt, für den Benutzer der API-Engine gibt es nur Deskriptoren, die zum Anzeigestatus hinzugefügt und daraus entfernt werden können. Daher gibt es in der endgültigen Version der Anzeige die folgenden Deskriptoren (sie entsprechen den Klassen mit dem gleichen Namen):
Die Anzeige enthält auch Felder für den aktuellen Eingabeempfänger (Eingabedeskriptor) und ein Feld, das angibt, welcher Textdeskriptor jetzt den Fokus hat und dessen Text mit den entsprechenden Aktionen des Benutzers gescrollt wird.
Der Spielzyklus sieht ungefähr so aus:
- Audioverarbeitung - Aufrufen der
update()
-Methode für Audiodeskriptoren, die den aktuellen Status des Audios überprüft, Speicher freigibt (falls erforderlich) und andere technische Arbeiten ausführt. - Tastatureingaben verarbeiten - überträgt eingegebene Zeichen an einen Deskriptor zum Empfangen von Eingaben, verarbeitet Tastatureingaben für Bildlauftasten (Aufwärts- und Abwärtspfeile) und die Rücktaste.
- Animationsverarbeitung.
- Löschen des Hintergrunds im Rendering-Puffer (
BufferedImage
diente als Puffer). - Bilder zeichnen.
- Textwiedergabe.
- Zeichnen von Eingabefeldern.
- Die Ausgabe des Puffers auf den Bildschirm.
- Umgang mit
PostWorkDescriptor
's. - Einige Arbeiten zum Ersetzen von Anzeigezuständen, auf die ich später eingehen werde (im Abschnitt zum SL-Interpreter).
- Stoppen Sie den Fluss für eine dynamisch berechnete Zeit, damit die FPS dem angegebenen Wert entspricht (standardmäßig 30).
Hinweis: Möglicherweise stellt sich die Frage: "Warum Eingabefelder rendern, wenn für sie entsprechende Textdeskriptoren erstellt wurden, die einen Schritt früher gerendert werden?" Tatsächlich findet das Rendern in Absatz 7 nicht statt - nur die Parameter des InputDescriptor
werden mit den Parametern des InputDescriptor
synchronisiert - wie z. B. Sichtbarkeit des Bildschirms, Position, Größe und andere. Dies geschah, wie oben angegeben, aus dem Grund, dass der Benutzer den entsprechenden Eingabedeskriptor nicht direkt mit einem Textdeskriptor steuert und im Allgemeinen nichts darüber weiß.
Es ist zu beachten, dass die Größe und Position der Elemente auf dem Bildschirm nicht in Pixeln, sondern in relativen Größen festgelegt wird - Zahlen von 0 bis 1 (Abbildung unten). Das heißt, die gesamte Breite für das Rendern ist 1 und die gesamte Höhe ist 1 (und sie sind nicht gleich, was ich einige Male vergessen und später bereut habe). Es würde sich auch lohnen, (0,0) als Mittelpunkt festzulegen, und die Breite / Höhe sollte gleich zwei sein, aber aus irgendeinem Grund habe ich es vergessen / nicht darüber nachgedacht. Jedoch hat sogar die Option mit einer Breite / Höhe von 1 das Leben des Questentwicklers vereinfacht.

Ein paar Worte zum System zur Speicherfreigabe.
Jeder Deskriptor hatte eine setDoFree(boolean)
Methode, die der Benutzer aufrufen musste, um den angegebenen Deskriptor zu zerstören. Die Speicherbereinigung für Deskriptoren eines bestimmten Typs erfolgte unmittelbar nach der Verarbeitung aller Deskriptoren dieses Typs. Außerdem wurde einmal wiedergegebenes Audio nach Beendigung der Wiedergabe automatisch gelöscht. Genau das Gleiche wie eine Animation ohne Schleife.
So können Sie im Moment alles zeichnen, was Sie wollen, aber dies ist nicht das obige Bild, auf dem sich nur ein Hintergrund, der Haupttext und ein Eingabefeld befinden. Und hier kommt der Wrapper über das Display, der der Klasse DefaultDisplayToolkit
.
Bei der Initialisierung werden lediglich Deskriptoren für den Hintergrund, den Text usw. zur Anzeige hinzugefügt und es wird auch die Anzeige von Nachrichten mit dem optionalen Symbol, dem Eingabefeld und dem Rückruf erläutert.
Dann tauchte ein kleiner Fehler auf, dessen vollständige Korrektur das Wiederherstellen der Hälfte des Renderingsystems erfordern würde: Wenn Sie sich die Renderreihenfolge in der Spieleschleife ansehen, sehen Sie, dass zuerst die Bilder und dann erst der Text gezeichnet werden. Wenn das Toolkit das Bild anzeigt, wird es gleichzeitig in Breite und Höhe in der Mitte des Bildschirms platziert. Wenn die Nachricht viel Text enthält, sollte dieser den Haupttext der Szene teilweise überlappen. Da der Nachrichtenhintergrund jedoch ein Bild ist (vollständig schwarz, aber dennoch) und die Bilder vor dem Text gezeichnet werden, wird ein Text einem anderen überlagert (Abbildung unten). Das Problem wurde teilweise durch vertikale Zentrierung nicht auf dem Bildschirm, sondern im Bereich über dem Haupttext gelöst. Eine vollständige Lösung würde die Einführung eines Tiefenparameters und das Wiederherstellen der Renderer von dem Wort "vollständig" umfassen.
Vielleicht geht es hier letztendlich um die Anzeige von allem. Sie können zu der Sprache übergehen, mit der die gesamte API arbeitet, die im sl
Paket enthalten ist.
3. Skriptsprache
Hinweis: Wenn der angesehene% USERNAME% es hier liest, hat er es gut gemacht, und ich würde ihn bitten, nicht damit aufzuhören: Jetzt wird es viel interessanter als zuvor.
3.1. Sprache
Zunächst wollte ich eine deklarative Sprache erstellen, in der nur alle erforderlichen Parameter für die Szene angegeben werden müssen, und das ist alles. Der Motor würde die gesamte Logik übernehmen. Am Ende kam ich jedoch zu der prozeduralen Sprache, selbst mit OOP-Elementen (kaum unterscheidbar), und dies war eine gute Lösung, da sie im Vergleich zur deklarativen Option eine viel größere Flexibilität in der Spielelogik ermöglichte.
Die Sprachsyntax wurde so einfach wie möglich für das Parsen konzipiert, was angesichts der verfügbaren Zeit logisch ist.
Der Code wird also in Textdateien mit der Erweiterung SSF gespeichert. Jede Datei enthält eine Beschreibung einer oder mehrerer Szenen. Jede Szene enthält keine oder mehrere Aktionen. Jede Aktion enthält null oder mehr Operatoren.
Eine kleine Erklärung zu den Begriffen. Eine Aktion ist nur ein Vorgang ohne die Möglichkeit, Argumente weiterzugeben (was die Entwicklung des Spiels in keiner Weise behinderte). Der Operator ist anscheinend nicht ganz das, was dieses Wort in gewöhnlichen Sprachen bedeutet (+, -, /, *), aber die Form ist dieselbe: Der Operator ist die Gesamtheit seines Namens und aller seiner Argumente.
Vielleicht möchten Sie den Quellcode für SL endlich sehen, hier ist er:
scene dungeon { action init { load_image "background" "dungeon/background.png" load_image "key" "dungeon/key.png" load_audio "background" "dungeon/background.wav" load_audio "got_key" "dungeon/got_key.wav" } action first_come { play "background" loop set_background "background" set_text "some text" add_dialog "(||(|) (||-))" "dial_look_around" dial_look_around on } //some comment action dial_look_around { play "got_key" once show "some text 2" "key" none tag "key" switch_dialog "dial_look_around" off } }
Jetzt wird klar, was der Bediener ist. Es ist auch ersichtlich, dass jede Aktion ein Anweisungsblock ist (eine Anweisung kann ein Anweisungsblock sein) sowie die Tatsache, dass einzeilige Kommentare unterstützt werden (es war nicht sinnvoll, mehrzeilige Kommentare einzugeben, außerdem habe ich keine einzeiligen verwendet).
Der Einfachheit halber wurde ein solches Konzept als „Variable“ nicht in die Sprache eingeführt; Daher sind alle im Code verwendeten Werte Literale. Je nach Typ werden folgende Literale unterschieden:
Ein paar Worte zur Sprachanalyse. Es gibt verschiedene Ebenen zum "Laden" des Codes (Abbildung unten):
- Ein Tokenizer ist eine modulare Klasse zum Aufteilen von Quellcode in Token (die minimalen semantischen Einheiten der Sprache). Jeder Tokentyp ist mit einer Nummer verknüpft - seinem Typ. Warum modular? Denn die Teile des Tokenizers, die prüfen, ob ein Teil des Quellcodes ein Token eines bestimmten Typs ist, werden vom Tokenizer isoliert und von außen heruntergeladen (aus dem zweiten Absatz).
- Das Tokenizer-Add-On ist eine Klasse, die das Erscheinungsbild jedes Tokentyps in SL definiert. Auf der unteren Ebene wird ein Tokenizer verwendet. Auch hier ist die Überprüfung von Leerzeichen und das Weglassen von einzeiligen Kommentaren. Die Ausgabe liefert einen sauberen Strom von Token, die in ...
- ... ein Parser (auch modular), der am Ausgang einen abstrakten Syntaxbaum erzeugt. Modular - weil es von sich aus nur Szenen und Aktionen analysieren kann, aber nicht weiß, wie Operatoren analysiert werden. Daher werden Module in den Konstruktor geladen (tatsächlich lädt er sie selbst in den Konstruktor, was nicht sehr gut ist), wodurch jeder seiner Operatoren analysiert werden kann.

Nun kurz zu den Operatoren, damit eine Vorstellung von der Funktionalität der Sprache entsteht. Ursprünglich gab es 11 Betreiber, die das Spiel durchdachten, von denen einige zu einem verschmolzen, andere geändert und weitere 9 hinzugefügt wurden. Hier ist die Übersichtstabelle:
Operatoren für die Arbeit mit Zählern - Szenenspezifische Ganzzahlvariablen.
Es gab auch den Gedanken, eine return
einzuführen (die entsprechende Funktionalität wurde sogar auf der Kernebene des Interpreters hinzugefügt), aber ich habe es vergessen und es war nicht nützlich.
, , : show_motion
(, , 0.01) duration
.
, (lookup) ( ): ///, load_audio
/ load_image
/ counter_set
/ add_dialog
. , , , , — . . , . , : " scene_coast.dialog_1
" — dialog_1
scene_coast
.
SL-, . , , , — . : (-, ), , lookup
', , , . , goto
lookup
', .
- — - , , n
( ) . , , n
. , .
. :
add_dialog "regexp" "dialog_name" callback on/off
, . , : , , , ( ).
, , ( ) ( ) , ( ). : , , , "" "".

, ( , )
, "":
(||((|||) ( )?(||)?)|(||)( )?| )
***
, : — , , — .
, :
3.2.
: , — "" ( ). .
SL , - . :
init
— , ( , , , ).first_come
— , . , , .- , :
come
— , ( ).
: init
first_come
— , .
. : , , init
-. , ( ) .
, n
, first_come
- ( - - ). . , : , , first_come
come
, come
( ). : , , , .
(, "", " ", " " . .). , , - - . , ( ), .
(, , ). : ? , , . provideState
, ; , .
, , , , ( , ), (, , , ).
4.
. 2019- 2018-, , , .
4.1.
, , , — . , . ( ), , - , 9 (), - ( , ( , , ) .
, : , , , . , , .
, 25% (5) , : , ; ( animate
), ( call_extern
).
, - ( ), (, , — , "You won").

4.2. Grafik
, :

, , - - " ". :
- (4x2.23''), .
- : , , — .
- ////etc.


5.
( 11 ) 30 40 . 9 4 55 . ( ) 7 41 . — ~4-6 ( 45 ).
: "Darkyen's Time Tracker" JetBrains ( ).
: 2 , — . 45 8 .
: 4777, ( ) — 637.
: cloc
.
30 . ( ) : — ~8 , — ~24 , ( ) — ~8 . .
— 232 ( - , WAV).
WAV?javax.sound.sampled.AudioSystem
, WAV AU , WAV.
28 ( 3 ). — 17 /.
- : , . , , " ", " ". (, ), ( ""/"" - ).
?— , . : . Langweilig. , , "" : NPC, , (, — ..).
, : , .
— . , : , , , . . , , , , , .
. ( ), :
, , .
GitHub .
(assets) "Releases" "v1.0" .