Es gab Momente im Leben eines jeden Programmierers, in denen er davon träumte, ein interessantes Spiel zu machen. Viele Programmierer verwirklichen diese Träume und sogar erfolgreich, aber es geht nicht um sie. Es geht um diejenigen, die gerne Spiele spielen, die (auch ohne Wissen und Erfahrung) einmal versucht haben, sie zu erschaffen, inspiriert von Beispielen einzelner Helden, die weltweiten Ruhm (und enorme Gewinne) erlangt haben, dies aber tief im Inneren verstanden haben konkurriere mit dem Guru igrostroya, den er sich nicht leisten kann.
Und nicht ...
Kleine Einführung
Ich werde sofort reservieren: Unser Ziel ist es nicht, Geld zu verdienen - es gibt viele Artikel zu diesem Thema auf Habré. Nein, wir werden ein Traumspiel machen.
Lyrischer Exkurs über das Spiel der TräumeWie oft habe ich dieses Wort von einzelnen Entwicklern und kleinen Studios gehört. Wo auch immer Sie hinschauen, alle Neulinge igrodelov beeilen sich, der Welt ihre Träume und ihre „perfekte Vision“ zu offenbaren, und schreiben dann lange Artikel über ihre heldenhaften Bemühungen, ihren Arbeitsprozess, unvermeidliche finanzielle Schwierigkeiten, Probleme mit Verlagen und im Allgemeinen „Spieler-undankbare-Hunde-im- Gib-Grafik-und-Münzen-und-alles-frei-und-bezahle-will-kein-Spiel-Piraten-und-wir-haben-Gewinne-wegen-ihnen-hier verloren. "
Leute, lasst euch nicht täuschen. Sie machen kein Traumspiel, sondern ein Spiel, das sich gut verkaufen wird - das sind zwei verschiedene Dinge. Spieler (und besonders anspruchsvolle) kümmern sich nicht um Ihren Traum und werden ihn nicht bezahlen. Wenn Sie Gewinne erzielen möchten - Trends studieren, sehen, was jetzt beliebt ist, etwas Einzigartiges tun, besser, ungewöhnlicher als andere, Artikel lesen (es gibt viele), mit Verlagen kommunizieren - im Allgemeinen die Träume der Endbenutzer verwirklichen, nicht Ihrer.
Wenn Sie noch nicht weggelaufen sind und Ihr Traumspiel noch verwirklichen möchten, geben Sie den Gewinn im Voraus auf. Verkaufen Sie Ihren Traum überhaupt nicht - teilen Sie ihn kostenlos. Geben Sie den Menschen Ihren Traum, bringen Sie sie dazu, und wenn Ihr Traum etwas wert ist, erhalten Sie, wenn nicht Geld, aber Liebe und Anerkennung. Das ist manchmal viel wertvoller.
Viele Leute denken, dass Spiele Zeit- und Energieverschwendung sind und dass ernsthafte Leute überhaupt nicht über dieses Thema sprechen sollten. Aber die Leute, die sich hier versammelt haben, sind nicht ernst, deshalb sind wir uns nur teilweise einig - Spiele brauchen wirklich viel Zeit, wenn Sie sie spielen. Die Entwicklung von Spielen kann jedoch viele Vorteile bringen, obwohl sie um ein Vielfaches länger dauert. So können Sie sich beispielsweise mit den Prinzipien, Ansätzen und Algorithmen vertraut machen, die bei der Entwicklung von Nicht-Gaming-Anwendungen nicht zu finden sind. Oder vertiefen Sie die Fähigkeiten, Werkzeuge (z. B. eine Programmiersprache) zu besitzen und etwas Ungewöhnliches und Aufregendes zu tun. Ich selbst kann hinzufügen (und viele werden zustimmen), dass die Spieleentwicklung (auch wenn sie nicht erfolgreich ist) immer eine besondere, unvergleichliche Erfahrung ist, an die Sie sich dann mit Besorgnis und Liebe erinnern, die ich für jeden Entwickler mindestens einmal in meinem Leben erleben möchte.
Wir werden keine neuen Game-Engines, Frameworks und Bibliotheken verwenden - wir werden die Essenz des Gameplays betrachten und es von innen heraus spüren. Wir geben flexible Entwicklungsmethoden auf (die Aufgabe wird durch die Notwendigkeit vereinfacht, die Arbeit von nur einer Person zu organisieren). Wir werden keine Zeit und Energie darauf verwenden, nach Designern, Künstlern, Komponisten und Spezialisten für Klang zu suchen - wir werden alles selbst tun, wie wir können (aber gleichzeitig werden wir alles mit Bedacht tun - wenn wir plötzlich einen Künstler haben, werden wir uns nicht viel Mühe geben, das Modische zu befestigen Grafiken auf dem fertigen Rahmen). Am Ende werden wir die Tools nicht einmal wirklich studieren und das richtige auswählen - wir werden es mit dem tun, das wir kennen und wie wir es verwenden können. Zum Beispiel in Java, damit Sie es später, falls erforderlich, auf Android (oder eine Kaffeemaschine) übertragen können.
„Ah !!! Horror! Ein Albtraum! Wie kannst du Zeit mit so einem Unsinn verbringen! Verschwinde von hier, ich werde etwas Interessanteres lesen! “Warum das? Ich meine, das Rad neu erfinden? Warum nicht eine vorgefertigte Spiel-Engine verwenden? Die Antwort ist einfach: Wir wissen nichts über ihn, aber wir wollen das Spiel jetzt. Stellen Sie sich die Denkweise eines durchschnittlichen Programmierers vor: „Ich möchte ein Spiel machen! Es wird Fleisch und Explosionen geben und pumpen,
und Sie können einen Korovan ausrauben , und die Verschwörung bombardiert, und das ist nirgendwo anders passiert! Ich fange gleich an zu schreiben! .. Und worauf? Mal sehen, was jetzt bei uns beliebt ist ... Ja, X, Y und Z. Nehmen wir Z, jetzt schreibt jeder darüber ... ". Und beginnt den Motor zu studieren. Und er wirft die Idee auf, weil dafür schon nicht genug Zeit ist. Fin. Oder, okay, es gibt nicht auf, aber ohne die Engine wirklich zu lernen, wird es für das Spiel genommen. Nun, wenn er dann das Gewissen hat, niemandem sein erstes "Handwerk" zu zeigen. Normalerweise nicht (gehen Sie in einen Anwendungsspeicher, überzeugen Sie sich selbst) - nun, ich möchte Gewinne, keine Kraft zum Aushalten. Einmal war die Schaffung von Spielen die Menge der begeisterten kreativen Menschen. Leider ist diese Zeit unwiderruflich vergangen - jetzt ist die Hauptsache im Spiel nicht die Seele, sondern das Geschäftsmodell (zumindest gibt es viel mehr Gespräche darüber). Unser Ziel ist einfach: Wir werden mit der Seele spielen. Daher abstrahieren wir vom Tool (jeder wird es tun) und konzentrieren uns auf die Aufgabe.
Also, lass uns weitermachen.
Ich werde nicht auf die Details meiner eigenen bitteren Erfahrung eingehen, aber ich werde sagen, dass eines der Hauptprobleme für einen Programmierer bei der Entwicklung von Spielen die Grafik ist. Programmierer wissen normalerweise nicht, wie man zeichnet (obwohl es Ausnahmen gibt), und Künstler wissen normalerweise nicht, wie man programmiert (obwohl es Ausnahmen gibt). Und ohne Grafik muss man zugeben, dass ein seltenes Spiel umgangen wird. Was tun?
Es gibt Optionen:
1. Zeichnen Sie alles selbst in einem einfachen grafischen Editor
Screenshots des Spiels "Kill Him All", 2003 2. Zeichnen Sie alles selbst in einen Vektor
Screenshots des Spiels "Raven", 2001
Screenshots des Spiels "Inferno", 2002 3. Fragen Sie einen Bruder, der auch nicht zeichnen kann (aber etwas besser macht).
Screenshots des Spiels "Fucking", 2004 4. Laden Sie ein Programm für die 3D-Modellierung herunter und ziehen Sie Assets von dort
Screenshots des Spiels "Fucking 2. Demo", 2006 5. In der Verzweiflung Haare auf den Kopf reißen
Screenshots des Spiels "Fucking", 2004 6. Zeichnen Sie alles selbst in Pseudografien (ASCII)
Screenshots des Spiels "Fifa", 2000
Screenshots des Spiels "Sumo", 1998 Lassen Sie uns auf Letzteres eingehen (zum Teil, weil es nicht so deprimierend aussieht wie die anderen). Viele unerfahrene Spieler glauben, dass Spiele ohne coole moderne Grafik nicht die Herzen der Spieler erobern können - selbst der Name des Spiels macht sie nicht einmal zu Spielen. Entwickler von Meisterwerken wie
ADOM ,
NetHack und
Dwarf Fortress lehnen solche Argumente stillschweigend ab. Das Aussehen ist nicht immer ein entscheidender Faktor, die Verwendung von
ASCII bietet einige interessante Vorteile:
- Während des Entwicklungsprozesses konzentriert sich der Programmierer auf das Gameplay, die Spielmechanik, die Handlungskomponente und vieles mehr, ohne von kleinen Dingen abgelenkt zu werden.
- Das Entwickeln einer Grafikkomponente nimmt nicht zu viel Zeit in Anspruch - ein funktionierender Prototyp (dh eine Version durch Spielen, die Sie verstehen können, aber es lohnt sich, fortzufahren) wird viel früher fertig sein.
- keine Notwendigkeit, Frameworks und Grafik-Engines zu lernen;
- Ihre Grafiken werden in den fünf Jahren, in denen Sie das Spiel entwickeln, nicht veraltet sein.
- Hardcore-Mitarbeiter können Ihr Produkt auch auf Plattformen ohne grafische Umgebung bewerten.
- Wenn alles richtig gemacht ist, können die coolen Grafiken später, später befestigt werden.
Die obige lange Einführung sollte dem Anfänger igrodelov helfen, Ängste und Vorurteile zu überwinden, sich keine Sorgen mehr zu machen und dennoch zu versuchen, so etwas zu tun. Bist du bereit Dann fangen wir an.
Erster Schritt. Idee
Wie? Hast du noch keine Ahnung?
Schalten Sie den Computer aus, gehen Sie essen, gehen Sie, trainieren Sie. Oder im schlimmsten Fall schlafen. Ein Spiel zu entwickeln bedeutet nicht, Fenster zu waschen - Einsicht in den Prozess kommt nicht. Normalerweise entsteht die Idee eines Spiels plötzlich und unerwartet, wenn Sie überhaupt nicht darüber nachdenken. Wenn dies plötzlich passiert ist, schnappen Sie sich schneller einen Bleistift und schreiben Sie auf, bis die Idee wegflog. Jeder kreative Prozess wird auf diese Weise implementiert.
Und Sie können die Spiele anderer Leute kopieren. Nun, kopiere. Natürlich nicht schamlos reißen und an jeder Ecke sagen, wie schlau Sie sind, sondern die Erfahrung anderer in Ihrem Produkt nutzen. Wie viel danach speziell von Ihrem Traum übrig bleibt, ist eine sekundäre Frage, denn oft haben Spieler dies: Sie mögen alles im Spiel, bis auf zwei oder drei nervige Dinge, aber wenn es anders gemacht würde ... Wer weiß Vielleicht ist es Ihr Traum, sich an die gute Idee eines Menschen zu erinnern.
Aber wir werden den einfachen Weg gehen - nehmen wir an, wir haben bereits eine Idee und haben lange nicht darüber nachgedacht. Als unser erstes grandioses Projekt werden wir einen Klon eines guten Spiels aus Obsidian -
Pathfinder Adventures erstellen .
„Was zum Teufel ist das? Irgendwelche Tische? "Wie sie sagen,
pourquoi pas? Wir scheinen Vorurteile bereits aufgegeben zu haben und beginnen mutig, die Idee zu verfeinern. Natürlich werden wir das Spiel nicht eins zu eins klonen, aber wir werden die Grundmechanik ausleihen. Darüber hinaus hat die Implementierung eines rundenbasierten Brettspiels seine Vorteile:
- Es ist Schritt für Schritt - so können Sie sich keine Gedanken über Timer, Synchronisation, Optimierung, FPS und andere trostlose Dinge machen.
- Es ist kooperativ, dh der oder die Spieler treten nicht gegeneinander an, sondern gegen eine bestimmte "Umgebung", die nach deterministischen Regeln spielt. Dadurch entfällt die Notwendigkeit, KI ( KI ) zu programmieren - eine der schwierigsten Phasen der Spieleentwicklung.
- Es ist sinnvoll - die Tischplatten sind im Allgemeinen skurrile Leute, sie spielen nichts: Geben Sie ihnen durchdachte Mechanik und interessantes Gameplay - Sie werden nicht in einem schönen Bild ausgehen (es gibt Freunden etwas, oder?);
- es ist mit der Handlung - viele E-Sportler werden nicht zustimmen, aber für mich persönlich sollte das Spiel eine interessante Geschichte erzählen - wie ein Buch, nur mit seinen speziellen künstlerischen Mitteln.
- Sie ist unterhaltsam, was nicht jedermanns Sache ist. Die beschriebenen Ansätze können auf jeden nachfolgenden Traum angewendet werden, egal wie viele Sie haben.
Für diejenigen, die mit den Regeln nicht vertraut sind, eine kurze Einführung:Pathfinder Adventures ist eine digitale Version eines Brettspiels, das auf der Grundlage eines Brettspiels (oder vielmehr eines gesamten Rollenspielsystems) Pathfinder erstellt wurde. Spieler (in Höhe von 1 bis 6) wählen einen Charakter für sich selbst aus und begeben sich zusammen mit ihm auf ein Abenteuer, das in verschiedene Szenarien unterteilt ist. Jeder Charakter verfügt über Karten verschiedener Art (wie Waffen, Rüstungen, Zauber, Verbündete, Gegenstände usw.), mit deren Hilfe er in jedem Szenario den Schurken finden und brutal bestrafen muss - eine spezielle Karte mit besonderen Eigenschaften.
Jedes Szenario bietet eine Reihe von Orten oder Orten (deren Anzahl von der Anzahl der Spieler abhängt), die die Spieler besuchen und erkunden müssen. Jeder Ort enthält ein verdecktes Kartenspiel, das die Charaktere abwechselnd erkunden - das heißt, sie öffnen die oberste Karte und versuchen, sie gemäß den einschlägigen Regeln zu überwinden. Zusätzlich zu harmlosen Karten, die das Deck des Spielers auffüllen, enthalten diese Decks auch böse Feinde und Hindernisse - sie müssen besiegt werden, um weiter voranzukommen. Die Schurkenkarte liegt ebenfalls in einem der Decks, aber die Spieler wissen nicht, welches - sie muss gefunden werden.
Um die Karten zu besiegen (und neue zu erwerben), müssen die Charaktere den Test einer ihrer Eigenschaften (Standard für RPGs, Stärke, Geschicklichkeit, Weisheit usw.) bestehen, indem sie einen Würfel werfen, dessen Größe durch den Wert der entsprechenden Eigenschaft (von d4 bis d12) bestimmt wird, und Modifikatoren hinzufügen (definiert) Regeln und das Niveau der Charakterentwicklung) und spielen, um die Wirkung der entsprechenden Karten aus der Hand zu verbessern. Nach dem Sieg wird die getroffene Karte entweder aus dem Spiel entfernt (wenn es sich um einen Feind handelt) oder die Hand eines Spielers wird wieder aufgefüllt (wenn es sich um einen Gegenstand handelt) und der Zug geht an einen anderen Spieler. Wenn er verliert, wird der Charakter oft beschädigt, was dazu führt, dass er Karten von seiner Hand abwirft. Ein interessanter Mechanismus ist, dass die Gesundheit des Charakters durch die Anzahl der Karten in seinem Deck bestimmt wird. Sobald der Spieler eine Karte aus dem Deck ziehen muss, diese aber nicht da ist, stirbt sein Charakter.
Das Ziel ist es, den Schurken zu finden und zu besiegen, nachdem er zuvor seinen Weg zum Rückzug blockiert hatte (Sie können mehr darüber und vieles mehr erfahren, indem Sie die Regeln lesen). Dies muss für eine Weile gemacht werden, was die Hauptschwierigkeit des Spiels ist. Die Anzahl der Züge ist streng begrenzt und eine einfache Aufzählung aller verfügbaren Karten erreicht das Ziel nicht. Daher müssen Sie verschiedene Tricks und clevere Techniken anwenden.
Wenn die Szenarien erfüllt sind, werden die Charaktere wachsen und sich entwickeln, ihre Eigenschaften verbessern und neue nützliche Fähigkeiten erwerben. Das Verwalten des Decks ist auch ein sehr wichtiges Element des Spiels, da das Ergebnis des Szenarios (insbesondere in den späteren Phasen) normalerweise von richtig ausgewählten Karten abhängt (und von viel Glück, aber was wollen Sie von einem Spiel mit Würfeln?).
Im Allgemeinen ist das Spiel interessant, würdig, bemerkenswert und, was für uns wichtig ist, ziemlich kompliziert (beachten Sie, dass ich "schwierig" sage, nicht im Sinne von "schwierig"), um es interessant zu machen, seinen Klon zu implementieren.
In unserem Fall werden wir eine globale konzeptionelle Änderung vornehmen - wir werden die Karten aufgeben. Vielmehr werden wir uns überhaupt nicht weigern, aber wir werden die Karten durch Würfel ersetzen, die immer noch unterschiedliche Größen und Farben haben (technisch gesehen ist es nicht ganz richtig, ihre "Würfel" zu verwenden, da es neben dem richtigen Sechseck noch andere Formen gibt, aber es ist ungewöhnlich, dass ich sie "Knochen" nenne. und es ist unangenehm, aber amerikanisches Gänseblümchen zu verwenden ist ein Zeichen für schlechten Geschmack. Lassen wir es also so, wie es ist. Anstelle von Decks haben die Spieler jetzt Taschen. Und die Standorte werden auch Taschen haben, aus denen die Spieler im Forschungsprozess beliebige Würfel herausziehen werden. Die Farbe des Würfels bestimmt seinen Typ und dementsprechend die Regeln für das Bestehen des Tests. Die persönlichen Eigenschaften des Charakters (Stärke, Geschicklichkeit usw.) werden dadurch beseitigt, aber es werden neue interessante Mechaniken auftauchen (dazu später mehr).
Wird es Spaß machen zu spielen? Ich habe keine Ahnung, und niemand kann dies verstehen, bis ein funktionierender Prototyp fertig ist. Aber wir mögen nicht das Spiel, sondern die Entwicklung, oder? Daher sollte kein Zweifel am Erfolg bestehen.
Schritt zwei Design
Eine Idee zu haben ist nur ein Drittel der Geschichte. Jetzt ist es wichtig, diese Idee zu entwickeln. Das heißt, machen Sie keinen Spaziergang im Park oder nehmen Sie ein Dampfbad, sondern setzen Sie sich an den Tisch, nehmen Sie Papier mit einem Stift (oder öffnen Sie Ihren bevorzugten Texteditor) und schreiben Sie sorgfältig ein Designdokument, wobei Sie jeden Aspekt der Spielmechanik sorgfältig ausarbeiten. Die Zeit dafür wird einen Durchbruch bringen. Erwarten Sie also nicht, das Schreiben in einer Sitzung abzuschließen. Und hoffen Sie nicht einmal, alles auf einmal durchzudenken - während Sie implementieren, werden Sie die Notwendigkeit erkennen, eine Reihe von Änderungen und Änderungen vorzunehmen (und manchmal etwas global zu überarbeiten), aber einige Grundlagen müssen vorhanden sein, bevor der Entwicklungsprozess beginnt.
Ihr Designdokument sieht zunächst ungefähr so aus Und erst nachdem Sie mit der ersten Welle grandioser Ideen fertig geworden sind, nehmen Sie den Kopf auf, entscheiden sich für die Struktur des Dokuments und beginnen, es methodisch mit Inhalten zu füllen (überprüfen Sie jede Sekunde mit dem, was bereits geschrieben wurde, um unnötige Wiederholungen und insbesondere Widersprüche zu vermeiden). Schritt für Schritt erhalten Sie so etwas Sinnvolles und Prägnantes.
Wählen Sie bei der Beschreibung des Designs die Sprache, in der Sie Ihre Gedanken leichter ausdrücken können, insbesondere wenn Sie alleine arbeiten. Wenn Sie jemals Entwickler von Drittanbietern in das Projekt einbeziehen müssen, stellen Sie sicher, dass diese den ganzen kreativen Unsinn verstehen, der in Ihrem Kopf vor sich geht.
Um fortzufahren, empfehle ich dringend, dass Sie das zitierte Dokument zumindest diagonal lesen, da ich in Zukunft auf die dort vorgestellten Begriffe und Konzepte verweisen werde, ohne näher auf deren Interpretation einzugehen.
„Autor, töte dich gegen die Wand. Zu viele Buchstaben. "Schritt drei Modellierung
Das heißt, alle das gleiche Design, nur detaillierter.
Ich weiß, dass viele bereits darauf aus sind, eine IDE zu öffnen und mit dem Codieren zu beginnen, aber seien Sie etwas geduldiger. Wenn Ideen unsere Köpfe überwältigen, scheint es uns, dass wir nur die Tastatur berühren müssen und unsere Hände in himmelhohe Entfernungen rasen - bevor der Kaffee Zeit hat, auf dem Herd zu kochen, wenn die funktionierende Version der Anwendung fertig ist ... um in den Papierkorb zu gelangen. Um das Gleiche nicht viele Male neu zu schreiben (und insbesondere nach drei Stunden Entwicklungszeit nicht sicherzustellen, dass das Layout nicht funktioniert und neu gestartet werden muss), empfehle ich, zunächst die Hauptstruktur der Anwendung zu überdenken (und zu dokumentieren).
Da wir als Entwickler mit objektorientierter Programmierung (OOP) gut vertraut sind, werden wir deren Prinzipien in unserem Projekt verwenden. Für OOP gibt es jedoch nichts Besseres, als mit einer Reihe langweiliger UML-Diagramme mit der Entwicklung zu beginnen. (Sie wissen nicht, was
UML ist ? Ich habe es auch fast vergessen, aber ich werde mich gerne daran erinnern - nur um zu zeigen, was für ein fleißiger Programmierer ich bin, hehe.)
Beginnen wir mit dem Anwendungsfalldiagramm. Wir werden darauf zeigen, wie unser Benutzer (Spieler) mit dem zukünftigen System interagiert:
"Äh ... worum geht es?"Nur ein Scherz, nur ein Scherz ... und vielleicht höre ich auf, darüber zu scherzen - das ist eine ernste Angelegenheit (schließlich ein Traum). Im Diagramm der Anwendungsfälle müssen die Möglichkeiten angezeigt werden, die das System dem Benutzer bietet. Im Detail. Aber es ist historisch so passiert, dass diese Art von Diagrammen für mich das Schlimmste ist - Geduld ist anscheinend nicht genug. Und Sie müssen mich nicht so ansehen - wir sind nicht an der Universität, um das Diplom zu schützen, aber wir genießen den Arbeitsprozess. Und für diesen Prozess sind Anwendungsfälle nicht so wichtig. Es ist viel wichtiger, die Anwendung korrekt in unabhängige Module zu unterteilen, dh das Spiel so zu implementieren, dass die Funktionen der visuellen Oberfläche die Spielmechanik nicht beeinflussen und die grafische Komponente bei Bedarf leicht geändert werden kann.
Dieser Punkt kann im folgenden Komponentendiagramm detailliert beschrieben werden:
Hier haben wir bereits bestimmte Subsysteme identifiziert, die Teil unserer Anwendung sind, und wie später gezeigt wird, werden sie alle unabhängig voneinander entwickelt.Gleichzeitig werden wir herausfinden, wie der Hauptspielzyklus aussehen wird (oder besser gesagt, der interessanteste Teil ist der, der die Charaktere im Skript implementiert). Hierzu eignet sich ein Aktivitätsdiagramm für uns:Wenn Sie stehen, setzen Sie sich Und schließlich wäre es schön, die Abfolge der Interaktion des Endbenutzers mit der Spiel-Engine über ein Eingabe-Ausgabe-System allgemein darzustellen.Die Nacht ist lang, weit vor Sonnenaufgang. Nachdem Sie wie gewünscht am Tisch gesessen haben, werden Sie ruhig die anderen zwei Dutzend Diagramme zeichnen - glauben Sie mir, ihre Anwesenheit wird Ihnen in Zukunft helfen, auf dem gewählten Weg zu bleiben, Ihr Selbstwertgefühl zu steigern, das Innere des Raums zu aktualisieren, verblasste Tapeten mit bunten Postern aufzuhängen und in einfachen Worten Ihre Vision zu verwirklichen Kollegen, die bald in Scharen zu den Türen Ihres neuen Studios eilen werden (wir streben keinen Erfolg an, erinnerst du dich?).Bisher werden wir keine Klassendiagramme (Klassen) zitieren, die wir alle lieben - es wird erwartet, dass Klassen viel durchbrechen und das Bild in drei Bildschirmen der Klarheit wird zunächst nicht hinzugefügt. Es ist besser, es in Stücke zu zerbrechen und schrittweise auszulegen, während Sie mit der Entwicklung des entsprechenden Subsystems fortfahren.Schritt vier Werkzeugauswahl
Wie bereits vereinbart, werden wir eine plattformübergreifende Anwendung entwickeln, die sowohl auf Desktops mit verschiedenen Betriebssystemen als auch auf Mobilgeräten ausgeführt werden kann. Wir werden Java als Programmiersprache wählen, und Kotlin ist noch besser, da letzteres neuer und frischer ist und noch keine Zeit hatte, in den Wellen der Empörung zu schwimmen, die seinen Vorgänger überwältigten (gleichzeitig werde ich lernen, wenn jemand anderes es nicht besitzt). Wie Sie wissen, ist die JVM überall verfügbar (auf drei Milliarden Geräten, hehe). Wir werden sowohl Windows als auch UNIX unterstützen, und selbst auf einem Remote-Server können wir über eine SSH-Verbindung spielen (es ist niemandem bekannt, der sie benötigt, aber Wir werden eine solche Gelegenheit bieten). Wir werden es auch auf Android übertragen, wenn wir reich werden und einen Künstler einstellen, aber dazu später mehr.Bibliotheken (ohne sie kommen wir nicht weiter) wählen wir entsprechend unserer plattformübergreifenden Anforderung aus. Wir werden Maven als Build-System verwenden. Oder Gradle. Oder trotzdem, Maven, fangen wir damit an. Ich rate Ihnen sofort, ein Versionskontrollsystem einzurichten (je nachdem, welches Sie bevorzugen), damit Sie sich nach vielen Jahren leichter mit nostalgischen Gefühlen daran erinnern können, wie großartig es einmal war. IDE wählen auch die vertrauten, bevorzugten und bequemen.Eigentlich brauchen wir nichts anderes. Sie können mit der Entwicklung beginnen.Schritt fünf Projekt erstellen und einrichten
Wenn Sie eine IDE verwenden, ist das Erstellen eines Projekts trivial. Sie müssen nur einen klangvollen Namen (z. B.
Dice ) für unser zukünftiges Meisterwerk auswählen, die Maven-Unterstützung in den Einstellungen nicht aktivieren und die erforderlichen Bezeichner in die Datei
pom.xml
schreiben:
<modelVersion>4.0.0</modelVersion> <groupId>my.company</groupId> <artifactId>dice</artifactId> <version>1.0</version> <packaging>jar</packaging>
Fügen Sie auch die Kotlin-Unterstützung hinzu, die standardmäßig fehlt:
<dependency> <groupId>org.jetbrains.kotlin</groupId> <artifactId>kotlin-stdlib</artifactId> <version>${kotlin.version}</version> </dependency>
und einige Einstellungen, auf die wir nicht im Detail eingehen werden:
<properties> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <maven.compiler.source>1.8</maven.compiler.source> <maven.compiler.target>1.8</maven.compiler.target> <kotlin.version>1.3.20</kotlin.version> <kotlin.compiler.incremental>true</kotlin.compiler.incremental> </properties>
Ein paar Informationen zu HybridprojektenWenn Sie planen, sowohl Java als auch Kotlin in Ihrem Projekt zu verwenden, haben Sie zusätzlich zum
src/main/kotlin
src/main/java
. Kotlin-Entwickler behaupten, dass die Quelldateien aus dem ersten Ordner (
*.kt
) früher kompiliert werden müssen als die Quelldateien aus dem zweiten Ordner (
*.java
) und empfehlen daher dringend, die Einstellungen der Standard-Maven-Ziele zu ändern:
<build> <plugins> <plugin> <groupId>org.jetbrains.kotlin</groupId> <artifactId>kotlin-maven-plugin</artifactId> <version>${kotlin.version}</version> <executions> <execution> <id>compile</id> <phase>process-sources</phase> <goals> <goal>compile</goal> </goals> <configuration> <sourceDirs> <sourceDir>${project.basedir}/src/main/kotlin</sourceDir> <sourceDir>${project.basedir}/src/main/java</sourceDir> </sourceDirs> </configuration> </execution> <execution> <id>test-compile</id> <goals> <goal>test-compile</goal> </goals> <configuration> <sourceDirs> <sourceDir>${project.basedir}/src/test/kotlin</sourceDir> <sourceDir>${project.basedir}/src/test/java</sourceDir> </sourceDirs> </configuration> </execution> </executions> </plugin> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <version>3.5.1</version> <executions> <execution> <id>default-compile</id> <phase>none</phase> </execution> <execution> <id>default-testCompile</id> <phase>none</phase> </execution> <execution> <id>java-compile</id> <phase>compile</phase> <goals> <goal>compile</goal> </goals> </execution> <execution> <id>java-test-compile</id> <phase>test-compile</phase> <goals> <goal>testCompile</goal> </goals> </execution> </executions> </plugin> </plugins> </build>
Ich kann nicht sagen, wie wichtig dies ist - die Projekte laufen ohne dieses Blatt recht gut. Aber nur für den Fall, Sie werden gewarnt.
Lassen Sie uns drei Pakete gleichzeitig erstellen (warum etwas spielen?):
model
- für Klassen, die Objekte der Spielwelt beschreiben;game
- für Klassen, die das Gameplay implementieren;ui
- für Klassen, die für die Benutzerinteraktion verantwortlich sind.
Letzteres enthält nur Schnittstellen, deren Methoden wir zur Eingabe und Ausgabe von Daten verwenden. Wir werden bestimmte Implementierungen in einem separaten Projekt speichern, aber dazu später mehr. In der Zwischenzeit werden wir diese Klassen hier nebeneinander hinzufügen, um nicht zu viel zu sprühen.
Versuchen Sie nicht, es sofort perfekt zu machen: Denken Sie über die Details der Paketnamen, Schnittstellen, Klassen und Methoden nach. Verschreiben Sie die Interaktion von Objekten untereinander gründlich - all dies wird sich mehr als ein Dutzend Mal ändern. Während sich das Projekt entwickelt, werden Ihnen viele Dinge hässlich, sperrig, ineffektiv und dergleichen erscheinen - Sie können sie jederzeit ändern, da das Refactoring in modernen IDEs eine sehr billige Operation ist.
Wir werden auch eine Klasse mit der
main
erstellen und sind bereit für großartige Erfolge. Sie können die IDE selbst zum Starten verwenden. Wie Sie später sehen werden, ist diese Methode für unsere Zwecke nicht geeignet (die Standard-IDE-Konsole kann unsere grafischen Ergebnisse nicht wie gewünscht anzeigen). Daher konfigurieren wir den Start von außen mithilfe von Batch (oder Shell auf UNIX-Systemen). Datei. Vorher werden wir jedoch einige zusätzliche Einstellungen vornehmen.
Nachdem der
mvn package
abgeschlossen ist, erhalten wir die Ausgabe des JAR-Archivs mit allen kompilierten Klassen. Erstens enthält dieses Archiv standardmäßig nicht die Abhängigkeiten, die für das Funktionieren des Projekts erforderlich sind (bisher haben wir sie nicht, aber sie werden sicherlich in Zukunft angezeigt). Zweitens ist der Pfad zur Hauptklasse, die die
main
, nicht in der Archivmanifestdatei angegeben, sodass wir das Projekt nicht mit dem
java -jar dice-1.0.jar
. Beheben Sie dies, indem Sie
pom.xml
zusätzliche Einstellungen hinzufügen:
<build> <plugins> <plugin> <artifactId>maven-assembly-plugin</artifactId> <version>2.6</version> <executions> <execution> <phase>package</phase> <goals> <goal>single</goal> </goals> </execution> </executions> <configuration> <descriptorRefs> <descriptorRef>jar-with-dependencies</descriptorRef> </descriptorRefs> <archive> <manifest> <mainClass>my.company.dice.MainKt</mainClass> </manifest> </archive> </configuration> </plugin> </plugins> </build>
Achten Sie auf den Namen der Hauptklasse. Für Kotlin-Funktionen, die außerhalb von Klassen enthalten sind (wie zum Beispiel die Hauptfunktion), werden beim Kompilieren weiterhin Klassen erstellt (da die JVM nichts weiß und nicht wissen möchte). Der Name dieser Klasse ist der Name der Datei mit dem Zusatz von
Kt
. Das heißt, wenn Sie die Hauptklasse
Main
, wird sie in die Datei
MainKt.class
kompiliert. Es ist das letzte, das wir im Manifest der JAR-Datei angeben müssen.
Beim
dice-1.0.jar
des Projekts erhalten wir jetzt zwei JAR-Dateien am Ausgang:
dice-1.0.jar
und
dice-1.0-jar-with-dependencies.jar
. Wir interessieren uns für die zweite. Wir werden ein Startskript dafür schreiben.
dice.bat (für Windows)
@ECHO OFF rem Compiling call "path_to_maven\mvn.bat" -f "path_to_project\Dice\pom.xml" package if errorlevel 1 echo Project compilation failed! & pause & goto :EOF rem Running java -jar path_to_project\Dice\target\dice-1.0-jar-with-dependencies.jar pause
dice.sh (für UNIX)
Bitte beachten Sie, dass wir das Skript unterbrechen müssen, wenn die Kompilierung fehlschlägt. Andernfalls wird nicht die letzte Harfe gestartet, sondern die Datei, die von der vorherigen erfolgreichen Assembly übrig geblieben ist (manchmal finden wir nicht einmal den Unterschied). Oft verwenden Entwickler den Befehl
mvn clean package
, um alle zuvor kompilierten Dateien zu löschen. In diesem Fall beginnt der gesamte Kompilierungsprozess jedoch immer von vorne (auch wenn sich der Quellcode nicht geändert hat), was viel Zeit in Anspruch nimmt. Aber wir können es kaum erwarten - wir müssen ein Spiel machen.
Das Projekt startet also gut, macht aber bisher nichts. Keine Sorge, wir werden es bald beheben.
Schritt sechs Hauptobjekte
Allmählich werden wir beginnen, das
model
mit den für das Gameplay erforderlichen Klassen zu füllen.
Würfel sind unser Alles, fügen Sie sie zuerst hinzu. Jeder Würfel (eine Instanz der
Die
) ist durch seinen Typ (Farbe) und seine Größe gekennzeichnet. Für die
Die.Type
wir eine separate Aufzählung (
Die.Type
), markieren die Größe mit einer Ganzzahl von 4 bis 12. Wir implementieren auch die
roll()
-Methode, die eine beliebige, gleichmäßig verteilte Zahl aus dem für den Würfel verfügbaren Bereich (von 1 bis einschließlich des Größenwerts) erzeugt.
Die Klasse implementiert die Schnittstelle
Comparable
,
Comparable
die Cubes miteinander verglichen werden können (nützlich später, wenn mehrere Cubes in einer geordneten Zeile angezeigt werden). Größere Würfel werden früher platziert.
class Die(val type: Type, val size: Int) : Comparable<Die> { enum class Type { PHYSICAL,
Um keinen Staub zu sammeln, werden Würfel in Handtaschen (Kopien der
Bag
Klasse) aufbewahrt. Man kann nur raten, was in der Tasche vor sich geht, daher macht es keinen Sinn, eine bestellte Sammlung zu verwenden. So'ne Art. Sets (Sets) setzen die Idee, die wir brauchen, gut um, passen aber aus zwei Gründen nicht. Wenn Sie sie verwenden, müssen Sie zunächst die Methoden
equals()
und
hashCode()
implementieren, und es ist nicht klar, wie, da es falsch ist, die Typen und Größen von Cubes zu vergleichen. In unserem Set können beliebig viele identische Cubes gespeichert werden. Zweitens erwarten wir, wenn wir den Würfel aus dem Beutel ziehen, nicht nur etwas Nicht-Deterministisches, sondern Zufälliges, jedes Mal anders. Daher rate ich Ihnen trotzdem, eine geordnete Sammlung (Liste) zu verwenden und sie jedes Mal zu mischen, wenn Sie ein neues Element hinzufügen (in der
put()
-Methode) oder unmittelbar vor der Ausgabe (in der
draw()
-Methode).
Die Methode
examine()
eignet sich für Fälle, in denen ein Spieler, der keine Unsicherheit mehr hat, den Inhalt des Beutels auf dem Tisch in den Herzen schüttelt (achten Sie auf das Sortieren), und die Methode
clear()
, wenn die ausgeschüttelten Würfel nicht in den Beutel zurückkehren.
open class Bag { protected val dice = LinkedList<Die>() val size get() = dice.size fun put(vararg dice: Die) { dice.forEach(this.dice::addLast) this.dice.shuffle() } fun draw(): Die = dice.pollFirst() fun clear() = dice.clear() fun examine() = dice.sorted().toList() }
Neben Beuteln mit Würfeln benötigen Sie auch Haufen mit Würfeln (Instanzen der
Pile
Klasse). Von der ersten unterscheiden sich die zweiten darin, dass ihr Inhalt für die Spieler sichtbar ist. Entfernen Sie daher bei Bedarf einen Würfel vom Haufen, und der Spieler kann eine bestimmte interessierende Instanz auswählen. Wir implementieren diese Idee mit der Methode
removeDie()
.
class Pile : Bag() { fun removeDie(die: Die) = dice.remove(die) }
Jetzt wenden wir uns unseren Hauptfiguren zu - den Helden. Das heißt, Charaktere, die wir jetzt Helden nennen werden (es gibt einen guten Grund, Ihre Klasse nicht mit dem Namen
Character
in Java zu bezeichnen). Es gibt verschiedene Arten von Charakteren (um es in Klassen einzuteilen, obwohl es besser ist, die Wortklasse nicht zu verwenden), aber für unseren funktionierenden Prototyp nehmen wir nur zwei:
Brawler (
dh Kämpfer mit Schwerpunkt auf Stärke und Stärke) und
Hunter (auch bekannt als Ranger / Dieb, mit Schwerpunkt) Geschicklichkeit und Heimlichkeit). Die Klasse des Helden bestimmt seine Eigenschaften, Fähigkeiten und den anfänglichen Satz von Würfeln, aber wie später zu sehen sein wird, sind die Helden nicht streng an Klassen gebunden, und daher können ihre persönlichen Einstellungen leicht an einem einzigen Ort geändert werden.
Wir werden dem Helden die erforderlichen Eigenschaften gemäß dem Designdokument hinzufügen: Name, bevorzugter Würfeltyp, Würfelgrenzen, erlernte und nicht erlernte Fähigkeiten, Hand, Tasche und Stapel zum Zurücksetzen. Beachten Sie die Funktionen zur Implementierung von Sammlungseigenschaften. In der gesamten zivilisierten Welt wird es als schlechte Form angesehen, (mit Hilfe eines Getters) nach außen auf Sammlungen zuzugreifen, die im Objekt gespeichert sind. Skrupellose Programmierer können den Inhalt dieser Sammlungen ohne Wissen der Klasse ändern. Eine Möglichkeit, damit umzugehen, besteht darin, separate Methoden zum Hinzufügen und Entfernen von Elementen, zum Abrufen ihrer Nummer und zum Zugriff über den Index zu implementieren. Sie können Getter implementieren, aber gleichzeitig nicht die Sammlung selbst, sondern ihre unveränderliche Kopie zurückgeben - für eine kleine Anzahl von Elementen ist es nicht besonders beängstigend, genau das zu tun.
data class Hero(val type: Type) { enum class Type { BRAWLER HUNTER } var name = "" var isAlive = true var favoredDieType: Die.Type = Die.Type.ALLY val hand = Hand(0) val bag: Bag = Bag() val discardPile: Pile = Pile() private val diceLimits = mutableListOf<DiceLimit>() private val skills = mutableListOf<Skill>() private val dormantSkills = mutableListOf<Skill>() fun addDiceLimit(limit: DiceLimit) = diceLimits.add(limit) fun getDiceLimits(): List<DiceLimit> = Collections.unmodifiableList(diceLimits) fun addSkill(skill: Skill) = skills.add(skill) fun getSkills(): List<Skill> = Collections.unmodifiableList(skills) fun addDormantSkill(skill: Skill) = dormantSkills.add(skill) fun getDormantSkills(): List<Skill> = Collections.unmodifiableList(dormantSkills) fun increaseDiceLimit(type: Die.Type) { diceLimits.find { it.type == type }?.let { when { it.current < it.maximal -> it.current++ else -> throw IllegalArgumentException("Already at maximum") } } ?: throw IllegalArgumentException("Incorrect type specified") } fun hideDieFromHand(die: Die) { bag.put(die) hand.removeDie(die) } fun discardDieFromHand(die: Die) { discardPile.put(die) hand.removeDie(die) } fun hasSkill(type: Skill.Type) = skills.any { it.type == type } fun improveSkill(type: Skill.Type) { dormantSkills .find { it.type == type } ?.let { skills.add(it) dormantSkills.remove(it) } skills .find { it.type == type } ?.let { when { it.level < it.maxLevel -> it.level += 1 else -> throw IllegalStateException("Skill already maxed out") } } ?: throw IllegalArgumentException("Skill not found") } }
Die Hand des Helden (die Würfel, die er gerade hat) wird durch ein separates Objekt (
Hand
) beschrieben. Die Entwurfsentscheidung, die alliierten Würfel vom Hauptarm getrennt zu halten, war eine der ersten, die mir in den Sinn kamen. Anfangs schien es ein super cooles Feature zu sein, aber später verursachte es eine Vielzahl von Problemen und Unannehmlichkeiten. Trotzdem suchen wir nicht nach einfachen Wegen, und daher stehen uns die
dice
und
allies
mit allen Methoden zur Verfügung, die Sie zum Hinzufügen, Empfangen und Löschen benötigen (einige von ihnen bestimmen auf intelligente Weise, auf welche der beiden Listen Sie zugreifen können). Wenn Sie einen Würfel aus Ihrer Hand entfernen, werden alle nachfolgenden Würfel an den Anfang der Liste verschoben und füllen die Lücken aus. In Zukunft wird dies die Suche erheblich erleichtern (es ist nicht erforderlich, Situationen mit
null
).
class Hand(var capacity: Int) { private val dice = LinkedList<Die>() private val allies = LinkedList<Die>() val dieCount get() = dice.size val allyDieCount get() = allies.size fun dieAt(index: Int) = when { (index in 0 until dieCount) -> dice[index] else -> null } fun allyDieAt(index: Int) = when { (index in 0 until allyDieCount) -> allies[index] else -> null } fun addDie(die: Die) = when { die.type == Die.Type.ALLY -> allies.addLast(die) else -> dice.addLast(die) } fun removeDie(die: Die) = when { die.type == Die.Type.ALLY -> allies.remove(die) else -> dice.remove(die) } fun findDieOfType(type: Die.Type): Die? = when (type) { Die.Type.ALLY -> if (allies.isNotEmpty()) allies.first else null else -> dice.firstOrNull { it.type == type } } fun examine(): List<Die> = (dice + allies).sorted() }
Die Sammlung von Objekten der
DiceLimit
Klasse begrenzt die Anzahl der Würfel jedes Typs, die der Held zu Beginn des Skripts haben kann. Es gibt nichts Besonderes zu sagen, wir bestimmen zunächst die Maximal- und Stromwerte für jeden Typ.
class DiceLimit(val type: Die.Type, val initial: Int, val maximal: Int, var current: Int)
Aber mit Fähigkeiten ist es interessanter. Jeder von ihnen muss einzeln implementiert werden (worüber später), aber wir werden nur zwei betrachten:
Treffer und
Schießen (jeweils einen für jede Klasse). Fähigkeiten können von der anfänglichen bis zur maximalen Stufe entwickelt („gepumpt“) werden, was sich häufig auf die Modifikatoren auswirkt, die den Würfeln hinzugefügt werden. Dies
maxLevel
in den Eigenschaften
level
,
maxLevel
,
modifier1
und
modifier2
maxLevel
.
class Skill(val type: Type) { enum class Type {
Achten Sie auf die Hilfsmethoden der Heldenklasse, mit denen Sie einen Würfel aus Ihrer Hand verstecken oder werfen, prüfen können, ob der Held eine bestimmte Fähigkeit besitzt, und erhöhen Sie außerdem die Stufe der erlernten Fähigkeit oder lernen Sie eine neue. Alle werden früher oder später benötigt, aber jetzt werden wir nicht näher darauf eingehen.
Bitte haben Sie keine Angst vor der Anzahl der Klassen, die wir erstellen müssen. Für ein Projekt dieser Komplexität sind mehrere hundert eine häufige Sache. Hier fangen wir, wie bei jeder ernsthaften Beschäftigung, klein an, erhöhen allmählich das Tempo, in einem Monat haben wir Angst vor dem Umfang. Vergessen Sie nicht, wir sind immer noch ein kleines Studio von einer Person - wir stehen nicht vor überwältigenden Aufgaben.
„Etwas hat mich satt. Ich werde rauchen gehen oder so ... "Und wir werden weitermachen.
Die Helden und ihre Fähigkeiten werden beschrieben, es ist Zeit, sich den gegnerischen Kräften zuzuwenden - der großen und schrecklichen Spielmechanik. Oder vielmehr Objekte, mit denen unsere Helden interagieren müssen.
Ein weiteres Klassendiagramm Unsere tapferen Protagonisten werden mit drei Arten von Würfeln und Karten konfrontiert: Bösewichte (
Villain
), Feinde (
Enemy
) und Hindernisse (
Obstacle
), die unter dem allgemeinen Begriff „Bedrohungen“ zusammengefasst sind (
Threat
ist eine abstrakte „gesperrte“ Klasse, die Liste ihrer möglichen Erben ist streng begrenzt). Jede Bedrohung verfügt über eine Reihe von Besonderheiten (
Trait
), die spezielle Verhaltensregeln beschreiben, wenn sie einer solchen Bedrohung ausgesetzt sind, und dem Gameplay Abwechslung verleihen.
sealed class Threat { var name: String = "" var description: String = "" private val traits = mutableListOf<Trait>() fun addTrait(trait: Trait) = traits.add(trait) fun getTraits(): List<Trait> = traits } class Obstacle(val tier: Int, vararg val dieTypes: Die.Type) : Threat() class Villain : Threat() class Enemy : Threat() enum class Trait { MODIFIER_PLUS_ONE,
Beachten Sie, dass die Liste der Objekte der
Trait
Klasse als veränderbar (
MutableList
) definiert ist, jedoch als unveränderliche
MutableList
ausgegeben wird. Obwohl dies in Kotlin funktioniert, ist der Ansatz unsicher, da nichts daran hindert, die resultierende Liste in eine veränderbare Schnittstelle zu konvertieren und verschiedene Änderungen vorzunehmen. Dies ist besonders einfach, wenn Sie über Java-Code auf die Klasse zugreifen (wobei die
List
Schnittstelle veränderbar ist). Der paranoideste Weg, Ihre Sammlung zu schützen, besteht darin, Folgendes zu tun:
fun getTraits(): List<Trait> = Collections.unmodifiableList(traits)
Wir werden uns dem Problem jedoch nicht so gewissenhaft nähern (Sie werden jedoch gewarnt).
Aufgrund der Besonderheiten der Spielmechanik unterscheidet sich die
Obstacle
von ihren Gegenstücken durch das Vorhandensein zusätzlicher Felder, aber wir werden uns nicht auf diese konzentrieren.
Bedrohungskarten (und wenn Sie das Designdokument sorgfältig lesen, denken Sie daran, dass es sich um Karten handelt) werden zu Decks zusammengefasst, die von der
Deck
Klasse dargestellt werden:
class Deck<E: Threat> { private val cards = LinkedList<E>() val size get() = cards.size fun addToTop(card: E) = cards.addFirst(card) fun addToBottom(card: E) = cards.addLast(card) fun revealTop(): E = cards.first fun drawFromTop(): E = cards.removeFirst() fun shuffle() = cards.shuffle() fun clear() = cards.clear() fun examine() = cards.toList() }
Hier gibt es nichts Ungewöhnliches, außer dass die Klasse parametrisiert ist und eine geordnete Liste (oder vielmehr eine Zwei-Wege-Warteschlange) enthält, die mit der entsprechenden Methode gemischt werden kann. Decks mit Feinden und Hindernissen werden für uns buchstäblich in einer Sekunde benötigt, wenn wir zur Überlegung kommen ...
... der
Location
Klasse, von denen jede Instanz einen einzigartigen Ort beschreibt, den unsere Helden als Teil des Skripts besuchen müssen.
class Location { var name: String = "" var description: String = "" var isOpen = true var closingDifficulty = 0 lateinit var bag: Bag var villain: Villain? = null lateinit var enemies: Deck<Enemy> lateinit var obstacles: Deck<Obstacle> private val specialRules = mutableListOf<SpecialRule>() fun addSpecialRule(rule: SpecialRule) = specialRules.add(rule) fun getSpecialRules() = specialRules }
Jeder Ort hat einen Namen, eine Beschreibung, eine Schwierigkeit der Schließung und das Zeichen „offen / geschlossen“. Irgendwo hier lauert der Bösewicht möglicherweise (oder er lauert möglicherweise nicht, wodurch das Eigentum des
villain
möglicherweise
null
). In jedem Bereich gibt es eine Tasche mit Würfeln und ein Kartenspiel mit Bedrohungen. Außerdem kann das Gebiet über eigene Spielfunktionen (
SpecialRule
)
SpecialRule
, die wie die Eigenschaften von Bedrohungen das Gameplay abwechslungsreicher gestalten. Wie Sie sehen, legen wir den Grundstein für zukünftige Funktionen, auch wenn wir nicht vorhaben, diese in naher Zukunft zu implementieren (wofür wir tatsächlich die Modellierungsphase benötigen).
Schließlich müssen noch die Skripte (
Scenario
Klasse) implementiert werden:
class Scenario { var name = "" var description = "" var level = 0 var initialTimer = 0 private val allySkills = mutableListOf<AllySkill>() private val specialRules = mutableListOf<SpecialRule>() fun addAllySkill(skill: AllySkill) = allySkills.add(skill) fun getAllySkills(): List<AllySkill> = Collections.unmodifiableList(allySkills) fun addSpecialRule(rule: SpecialRule) = specialRules.add(rule) fun getSpecialRules(): List<SpecialRule> = Collections.unmodifiableList(specialRules) }
Jedes Szenario ist durch den Pegel und den Anfangswert des Timers gekennzeichnet. Ähnlich wie zuvor wurden spezielle Regeln (
specialRules
) und Fähigkeiten von Verbündeten festgelegt (wir werden sie nicht berücksichtigen). Sie könnten denken, dass das Skript auch eine Liste von Speicherorten (Objekten der
Location
) enthalten sollte, und logischerweise ist dies wirklich so. Wie wir später sehen werden, werden wir eine solche Verbindung nirgendwo verwenden und sie bietet keinen technischen Vorteil.
Ich erinnere Sie daran, dass alle bisher betrachteten Klassen im
model
enthalten sind - wir haben als Kind in Erwartung einer epischen Spielzeugschlacht Soldaten auf die Oberfläche des Tisches gelegt.
Und jetzt, nach ein paar schmerzhaften Momenten, werden wir auf das Signal des Oberbefehlshabers in die Schlacht eilen, unsere Spielsachen zusammenschieben und die Konsequenzen des Gameplays genießen. Aber vorher ein wenig über das Arrangement selbst."Nun sooo ..."Siebter Schritt. Muster und Generatoren
Stellen wir uns für eine Sekunde vor, wie der Prozess der Erzeugung eines der zuvor betrachteten Objekte aussehen wird, zum Beispiel der Ort (Gelände). Wir müssen eine Instanz der Klasse erstellen Location
, ihre Felder mit Werten initialisieren und so für jeden Ort, den wir im Spiel verwenden möchten. Aber warten Sie: Jeder Ort sollte eine Tasche haben, die auch generiert werden muss. Und Taschen haben Würfel - dies sind auch Instanzen der entsprechenden Klasse ( Die
). Ich spreche hier nicht von Feinden und Hindernissen - sie müssen im Allgemeinen in Decks gesammelt werden. Und der Bösewicht bestimmt nicht das Gelände selbst, sondern die Merkmale des Szenarios befinden sich eine Ebene höher. Nun, du verstehst, worum es geht. Der Quellcode für das Obige könnte folgendermaßen aussehen: val location = Location().apply { name = "Some location" description = "Some description" isOpen = true closingDifficulty = 4 bag = Bag().apply { put(Die(Die.Type.PHYSICAL, 4)) put(Die(Die.Type.SOMATIC, 4)) put(Die(Die.Type.MENTAL, 4)) put(Die(Die.Type.ENEMY, 6)) put(Die(Die.Type.OBSTACLE, 6)) put(Die(Die.Type.VILLAIN, 6)) } villain = Villain().apply { name = "Some villain" description = "Some description" addTrait(Trait.MODIFIER_PLUS_ONE) } enemies = Deck<Enemy>().apply { addToTop(Enemy().apply { name = "Some enemy" description = "Some description" }) addToTop(Enemy().apply { name = "Other enemy" description = "Some description" }) shuffle() } obstacles = Deck<Obstacle>().apply { addToTop(Obstacle(1, Die.Type.PHYSICAL, Die.Type.VERBAL).apply { name = "Some obstacle" description = "Some Description" }) } }
Dies ist auch der Sprache und dem Design von Kotlin zu verdanken apply{}
- in Java wäre der Code doppelt so umfangreich. Darüber hinaus wird es, wie gesagt, viele Orte geben, und neben ihnen gibt es auch Szenarien, Abenteuer und Helden mit ihren Fähigkeiten und Eigenschaften - im Allgemeinen gibt es für den Spieledesigner etwas zu tun.Der Spieledesigner schreibt jedoch keinen Code, und es ist für uns unpraktisch, das Projekt bei der geringsten Änderung in der Spielwelt neu zu kompilieren. Hier wird jeder kompetente Programmierer einwenden, dass die Beschreibungen von Objekten aus dem Klassencode getrennt werden sollten - idealerweise, damit Instanzen des letzteren bei Bedarf dynamisch basierend auf dem ersteren generiert werden, ähnlich wie ein Teil aus einer Zeichenanlage hergestellt wird. Wir implementieren auch solche Zeichnungen, nennen sie nur Vorlagen und stellen sie als Instanzen einer speziellen Klasse dar. Mit solchen Mustern erstellt ein spezieller Programmcode (Generator) die endgültigen Objekte aus dem zuvor beschriebenen Modell.Der Prozess des Generierens eines Objekts aus einer Vorlage Daher müssen für jede Klasse unserer Objekte zwei neue Entitäten definiert werden: die Vorlagenschnittstelle und die Generatorklasse. Und da sich eine anständige Anzahl von Objekten angesammelt hat, wird es auch eine Reihe von Entitäten geben ... unanständig:Bitte atmen Sie tiefer, hören Sie genau zu und lassen Sie sich nicht ablenken. Erstens zeigt das Diagramm nicht alle Objekte der Spielwelt, sondern nur die Hauptobjekte, auf die Sie zunächst nicht verzichten können. Zweitens wurden einige der bereits in anderen Diagrammen erwähnten Verbindungen weggelassen, um die Schaltung nicht mit unnötigen Details zu überlasten.Beginnen wir mit etwas Einfachem - dem Generieren von Würfeln. „Wie? - Du sagst. - Sind wir nicht genug Konstruktor? Ja, das ist der mit dem Typ und der Größe. " Nein, ich werde antworten, nicht genug. In der Tat müssen in vielen Fällen (lesen Sie die Regeln) Würfel willkürlich in einer beliebigen Menge erzeugt werden (zum Beispiel: „von einem bis drei Würfel entweder blau oder grün“). Darüber hinaus sollte die Größe abhängig von der Komplexität des Skripts ausgewählt werden. Deshalb führen wir eine spezielle Schnittstelle ein DieTypeFilter
. interface DieTypeFilter { fun test(type: Die.Type): Boolean }
Verschiedene Implementierungen dieser Schnittstelle prüfen, ob der Cube-Typ verschiedenen Regelsätzen entspricht (alle, die nur in den Sinn kommen). Zum Beispiel, ob der Typ einem genau festgelegten Wert ("blau") oder einem Wertebereich ("blau, gelb oder grün") entspricht; oder umgekehrt entspricht einem anderen Typ als dem angegebenen ("wenn es nur auf keinen Fall weiß wäre" - irgendetwas, nur nicht das). Auch wenn im Voraus nicht klar ist, welche spezifischen Implementierungen erforderlich sind, spielt es keine Rolle - sie können später hinzugefügt werden, das System wird nicht davon abweichen (Polymorphismus, erinnerst du dich?). class SingleDieTypeFilter(val type: Die.Type): DieTypeFilter { override fun test(type: Die.Type) = (this.type == type) } class InvertedSingleDieTypeFilter(val type: Die.Type): DieTypeFilter { override fun test(type: Die.Type) = (this.type != type) } class MultipleDieTypeFilter(vararg val types: Die.Type): DieTypeFilter { override fun test(type: Die.Type) = (type in types) } class InvertedMultipleDieTypeFilter(vararg val types: Die.Type): DieTypeFilter { override fun test(type: Die.Type) = (type !in types) }
Die Größe des Würfels wird ebenfalls willkürlich festgelegt, aber dazu später mehr. In der Zwischenzeit werden wir einen Cubes-Generator ( DieGenerator
) schreiben , der im Gegensatz zum Klassenkonstruktor Die
nicht den expliziten Typ und die Größe des Cubes akzeptiert, sondern den Filter und den Grad der Komplexität. private val DISTRIBUTION_LEVEL1 = intArrayOf(4, 4, 4, 4, 6, 6, 6, 6, 8) private val DISTRIBUTION_LEVEL2 = intArrayOf(4, 6, 6, 6, 6, 8, 8, 8, 8, 10) private val DISTRIBUTION_LEVEL3 = intArrayOf(6, 8, 8, 8, 10, 10, 10, 10, 12, 12, 12) private val DISTRIBUTIONS = arrayOf( intArrayOf(4), DISTRIBUTION_LEVEL1, DISTRIBUTION_LEVEL2, DISTRIBUTION_LEVEL3 ) fun getMaxLevel() = DISTRIBUTIONS.size - 1 fun generateDie(filter: DieTypeFilter, level: Int) = Die(generateDieType(filter), generateDieSize(level)) private fun generateDieType(filter: DieTypeFilter): Die.Type { var type: Die.Type do { type = Die.Type.values().random() } while (!filter.test(type)) return type } private fun generateDieSize(level: Int) = DISTRIBUTIONS[if (level < 1 || level > getMaxLevel()) 0 else level].random()
In Java wären diese Methoden statisch, aber da es sich um Kotlin handelt, benötigen wir die Klasse als solche nicht, was auch für die anderen unten diskutierten Generatoren gilt (auf logischer Ebene werden wir jedoch weiterhin das Konzept der Klasse verwenden).Zwei private Methoden erzeugen getrennt den Typ und die Größe des Würfels - über jeden kann etwas Interessantes gesagt werden. Die Methode generateDieType()
kann durch Übergeben eines Eingangsfilters mit in eine Endlosschleife getrieben werden override fun test(filter: DieTypeFilter) = false
( , , ).
generateDieSize()
, , ( ). ,
Dice , ( , ). , , . - ( ), , .
Und da es sich um Taschen handelt, werden wir eine Vorlage für sie entwickeln. Im Gegensatz zu Ihren Freunden ist diese Vorlage ( BagTemplate
) eine bestimmte Klasse. Es enthält andere Vorlagen - jede beschreibt die Regeln (oder Plan
), nach denen ein oder mehrere Würfel (erinnern Sie sich an die zuvor gestellten Anforderungen?) Der Tasche hinzugefügt werden. class BagTemplate { class Plan(val minQuantity: Int, val maxQuantity: Int, val filter: DieTypeFilter) val plans = mutableListOf<Plan>() fun addPlan(minQuantity: Int, maxQuantity: Int, filter: DieTypeFilter) { plans.add(Plan(minQuantity, maxQuantity, filter)) } }
Jeder Plan definiert ein Muster für die Art der Würfel sowie die Anzahl (Minimum und Maximum) der Würfel, die dieses Muster erfüllen. Dank dieses Ansatzes können Sie Taschen nach bizarren Regeln generieren (und ich weine wieder bitter um das Alter, weil mein Nachbar sich rundweg weigert, mir zu helfen). Irgendwie so:
private fun realizePlan(plan: BagTemplate.Plan, level: Int): Array<Die> { val count = (plan.minQuantity..plan.maxQuantity).shuffled().last() return (1..count).map { generateDie(plan.filter, level) }.toTypedArray() } fun generateBag(template: BagTemplate, level: Int): Bag { return template.plans.asSequence() .map { realizePlan(it, level) } .fold(Bag()) { b, d -> b.put(*d); b } } }
Wenn Sie, genau wie ich, diesen ganzen Funktionalismus satt haben, befestigen Sie sich - es wird nur noch schlimmer. Im Gegensatz zu vielen undeutlichen Tutorials im Internet haben wir jedoch die Möglichkeit, die Verwendung verschiedener cleverer Methoden in Bezug auf einen realen, verständlichen Themenbereich zu untersuchen.Alleine liegen die Taschen nicht auf dem Feld - Sie müssen sie den Helden und Orten geben. Beginnen wir mit letzterem. interface LocationTemplate { val name: String val description: String val bagTemplate: BagTemplate val basicClosingDifficulty: Int val enemyCardsCount: Int val obstacleCardsCount: Int val enemyCardPool: Collection<EnemyTemplate> val obstacleCardPool: Collection<ObstacleTemplate> val specialRules: List<SpecialRule> }
In der Kotlin-Sprache können Sie anstelle von Methoden get()
Schnittstelleneigenschaften verwenden - dies ist viel präziser. Wir sind bereits mit der Taschenvorlage vertraut, berücksichtigen Sie die verbleibenden Methoden. Die Eigenschaft basicClosingDifficulty
legt die grundlegende Komplexität der Prüfung zum Schließen des Geländes fest. Das Wort „grundlegend“ bedeutet hier nur, dass die endgültige Komplexität von der Ebene des Szenarios abhängt und zu diesem Zeitpunkt unklar ist. Außerdem müssen wir Muster für Feinde und Hindernisse (und gleichzeitig für Bösewichte) definieren. Darüber hinaus werden aus der Vielzahl der in der Vorlage beschriebenen Feinde und Hindernisse nicht alle verwendet, sondern nur eine begrenzte Anzahl (um den Wiederholungswert zu erhöhen). Bitte beachten Sie, dass die Sonderregeln ( SpecialRule
) des Bereichs durch eine einfache Aufzählung ( enum class
) implementiert werden und daher keine separate Vorlage erfordern. interface EnemyTemplate { val name: String val description: String val traits: List<Trait> } interface ObstacleTemplate { val name: String val description: String val tier: Int val dieTypes: Array<Die.Type> val traits: List<Trait> } interface VillainTemplate { val name: String val description: String val traits: List<Trait> }
Und lassen Sie den Generator nicht nur einzelne Objekte, sondern auch ganze Decks mit ihnen erstellen. fun generateVillain(template: VillainTemplate) = Villain().apply { name = template.name description = template.description template.traits.forEach { addTrait(it) } } fun generateEnemy(template: EnemyTemplate) = Enemy().apply { name = template.name description = template.description template.traits.forEach { addTrait(it) } } fun generateObstacle(template: ObstacleTemplate) = Obstacle(template.tier, *template.dieTypes).apply { name = template.name description = template.description template.traits.forEach { addTrait(it) } } fun generateEnemyDeck(types: Collection<EnemyTemplate>, limit: Int?): Deck<Enemy> { val deck = types .map { generateEnemy(it) } .shuffled() .fold(Deck<Enemy>()) { d, c -> d.addToTop(c); d } limit?.let { while (deck.size > it) deck.drawFromTop() } return deck } fun generateObstacleDeck(templates: Collection<ObstacleTemplate>, limit: Int?): Deck<Obstacle> { val deck = templates .map { generateObstacle(it) } .shuffled() .fold(Deck<Obstacle>()) { d, c -> d.addToTop(c); d } limit?.let { while (deck.size > it) deck.drawFromTop() } return deck }
Wenn sich mehr Karten im Deck befinden, als wir benötigen (Parameter limit
), werden wir sie von dort entfernen. Indem wir Taschen mit Würfeln und Kartenspielen erzeugen können, können wir endlich Terrain schaffen: fun generateLocation(template: LocationTemplate, level: Int) = Location().apply { name = template.name description = template.description bag = generateBag(template.bagTemplate, level) closingDifficulty = template.basicClosingDifficulty + level * 2 enemies = generateEnemyDeck(template.enemyCardPool, template.enemyCardsCount) obstacles = generateObstacleDeck(template.obstacleCardPool, template.obstacleCardsCount) template.specialRules.forEach { addSpecialRule(it) } }
Das Terrain, das wir am Anfang des Kapitels explizit im Code festgelegt haben, sieht jetzt ganz anders aus: class SomeLocationTemplate: LocationTemplate { override val name = "Some location" override val description = "Some description" override val bagTemplate = BagTemplate().apply { addPlan(1, 1, SingleDieTypeFilter(Die.Type.PHYSICAL)) addPlan(1, 1, SingleDieTypeFilter(Die.Type.SOMATIC)) addPlan(1, 2, SingleDieTypeFilter(Die.Type.MENTAL)) addPlan(2, 2, MultipleDieTypeFilter(Die.Type.ENEMY, Die.Type.OBSTACLE)) } override val basicClosingDifficulty = 2 override val enemyCardsCount = 2 override val obstacleCardsCount = 1 override val enemyCardPool = listOf( SomeEnemyTemplate(), OtherEnemyTemplate() ) override val obstacleCardPool = listOf( SomeObstacleTemplate() ) override val specialRules = emptyList<SpecialRule>() } class SomeEnemyTemplate: EnemyTemplate { override val name = "Some enemy" override val description = "Some description" override val traits = emptyList<Trait>() } class OtherEnemyTemplate: EnemyTemplate { override val name = "Other enemy" override val description = "Some description" override val traits = emptyList<Trait>() } class SomeObstacleTemplate: ObstacleTemplate { override val name = "Some obstacle" override val description = "Some description" override val traits = emptyList<Trait>() override val tier = 1 override val dieTypes = arrayOf( Die.Type.PHYSICAL, Die.Type.VERBAL ) } val location = generateLocation(SomeLocationTemplate(), 1)
Die Szenariogenerierung erfolgt auf ähnliche Weise. interface ScenarioTemplate { val name: String val description: String val initialTimer: Int val staticLocations: List<LocationTemplate> val dynamicLocationsPool: List<LocationTemplate> val villains: List<VillainTemplate> val specialRules: List<SpecialRule> fun calculateDynamicLocationsCount(numberOfHeroes: Int) = numberOfHeroes + 2 }
In Übereinstimmung mit den Regeln hängt die Anzahl der dynamisch generierten Orte von der Anzahl der Helden ab. Die Schnittstelle definiert eine Standardberechnungsfunktion, die bei Bedarf in bestimmten Implementierungen neu definiert werden kann. In Verbindung mit dieser Anforderung generiert der Szenario-Generator auch Terrain für diese Szenarien - an derselben Stelle werden die Bösewichte zufällig auf die Orte verteilt. fun generateScenario(template: ScenarioTemplate, level: Int) = Scenario().apply { name =template.name description = template.description this.level = level initialTimer = template.initialTimer template.specialRules.forEach { addSpecialRule(it) } } fun generateLocations(template: ScenarioTemplate, level: Int, numberOfHeroes: Int): List<Location> { val locations = template.staticLocations.map { generateLocation(it, level) } + template.dynamicLocationsPool .map { generateLocation(it, level) } .shuffled() .take(template.calculateDynamicLocationsCount(numberOfHeroes)) val villains = template.villains .map(::generateVillain) .shuffled() locations.forEachIndexed { index, location -> if (index < villains.size) { location.villain = villains[index] location.bag.put(generateDie(SingleDieTypeFilter(Die.Type.VILLAIN), level)) } } return locations }
Viele aufmerksame Leser werden einwenden, dass die Vorlagen nicht im Quellcode der Klassen, sondern in einigen Textdateien (Skripten) gespeichert werden müssen, damit auch diejenigen, die weit von der Programmierung entfernt sind, sie erstellen und verwalten können. Ich stimme zu, ich nehme meinen Hut ab, aber ich streue keine Asche auf meinen Kopf - denn einer stört den anderen nicht. Wenn Sie möchten, definieren Sie einfach eine spezielle Implementierung der Vorlage, deren Eigenschaftswerte aus einer externen Datei geladen werden. Der Generierungsprozess ändert kein Jota davon.Nun, es scheint, als hätten sie nichts vergessen ... Oh ja, Helden - sie müssen auch generiert werden, was bedeutet, dass sie auch ihre eigenen Vorlagen benötigen. Hier sind einige zum Beispiel: interface HeroTemplate { val type: Hero.Type val initialHandCapacity: Int val favoredDieType: Die.Type val initialDice: Collection<Die> val initialSkills: List<SkillTemplate> val dormantSkills: List<SkillTemplate> fun getDiceCount(type: Die.Type): Pair<Int, Int>? }
Und sofort bemerken wir zwei Kuriositäten. Erstens verwenden wir keine Vorlagen, um darin Taschen und Würfel zu generieren. Warum?
Ja, da für jeden Heldentyp (jede Klasse) die Liste der Anfangswürfel streng definiert ist - es macht keinen Sinn, den Prozess ihrer Erstellung zu verkomplizieren. Zweitens getDiceCount()
- was für ein Bodensatz ist das ??? Beruhige dich, dies sind diejenigen DiceLimit
, die die Einschränkungen für die Würfel definieren. Und die Vorlage für sie wurde in einer so bizarren Form ausgewählt, dass bestimmte Werte klarer aufgezeichnet wurden. Überzeugen Sie sich anhand des Beispiels: class BrawlerHeroTemplate : HeroTemplate { override val type = Hero.Type.BRAWLER override val favoredDieType = PHYSICAL override val initialHandCapacity = 4 override val initialDice = listOf( Die(PHYSICAL, 6), Die(PHYSICAL, 6), Die(PHYSICAL, 4), Die(PHYSICAL, 4), Die(PHYSICAL, 4), Die(PHYSICAL, 4), Die(PHYSICAL, 4), Die(PHYSICAL, 4), Die(SOMATIC, 6), Die(SOMATIC, 4), Die(SOMATIC, 4), Die(SOMATIC, 4), Die(MENTAL, 4), Die(VERBAL, 4), Die(VERBAL, 4) ) override fun getDiceCount(type: Die.Type) = when (type) { PHYSICAL -> 8 to 12 SOMATIC -> 4 to 7 MENTAL -> 1 to 2 VERBAL -> 2 to 4 else -> null } override val initialSkills = listOf( HitSkillTemplate() ) override val dormantSkills = listOf<SkillTemplate>() } class HunterHeroTemplate : HeroTemplate { override val type = Hero.Type.HUNTER override val favoredDieType = SOMATIC override val initialHandCapacity = 5 override val initialDice = listOf( Die(PHYSICAL, 4), Die(PHYSICAL, 4), Die(PHYSICAL, 4), Die(SOMATIC, 6), Die(SOMATIC, 6), Die(SOMATIC, 4), Die(SOMATIC, 4), Die(SOMATIC, 4), Die(SOMATIC, 4), Die(SOMATIC, 4), Die(MENTAL, 6), Die(MENTAL, 4), Die(MENTAL, 4), Die(MENTAL, 4), Die(VERBAL, 4) ) override fun getDiceCount(type: Die.Type) = when (type) { PHYSICAL -> 3 to 5 SOMATIC -> 7 to 11 MENTAL -> 4 to 7 VERBAL -> 1 to 2 else -> null } override val initialSkills = listOf( ShootSkillTemplate() ) override val dormantSkills = listOf<SkillTemplate>() }
Bevor wir jedoch einen Generator schreiben, definieren wir eine Vorlage für Fähigkeiten. interface SkillTemplate { val type: Skill.Type val maxLevel: Int val modifier1: Int val modifier2: Int val isActive get() = true } class HitSkillTemplate : SkillTemplate { override val type = Skill.Type.HIT override val maxLevel = 3 override val modifier1 = +1 override val modifier2 = +3 } class ShootSkillTemplate : SkillTemplate { override val type = Skill.Type.SHOOT override val maxLevel = 3 override val modifier1 = +0 override val modifier2 = +2 }
Leider wird es uns nicht gelingen, Fertigkeiten in Stapeln wie Feinde und Skripte zu vernetzen. Jede neue Fertigkeit erfordert die Erweiterung der Spielmechanik und das Hinzufügen eines neuen Codes zur Spiel-Engine - selbst bei Helden ist diesbezüglich einfacher. Vielleicht kann dieser Prozess abstrahiert werden, aber ich habe noch keinen Weg gefunden. Ja, und nicht zu versucht, um ehrlich zu sein. fun generateSkill(template: SkillTemplate, initialLevel: Int = 1): Skill { val skill = Skill(template.type) skill.isActive = template.isActive skill.level = initialLevel skill.maxLevel = template.maxLevel skill.modifier1 = template.modifier1 skill.modifier2 = template.modifier2 return skill } fun generateHero(type: Hero.Type, name: String = ""): Hero { val template = when (type) { BRAWLER -> BrawlerHeroTemplate() HUNTER -> HunterHeroTemplate() } val hero = Hero(type) hero.name = name hero.isAlive = true hero.favoredDieType = template.favoredDieType hero.hand.capacity = template.initialHandCapacity template.initialDice.forEach { hero.bag.put(it) } for ((t, l) in Die.Type.values().map { it to template.getDiceCount(it) }) { l?.let { hero.addDiceLimit(DiceLimit(t, it.first, it.second, it.first)) } } template.initialSkills .map { generateSkill(it) } .forEach { hero.addSkill(it) } template.dormantSkills .map { generateSkill(it, 0) } .forEach { hero.addDormantSkill(it) } return hero }
Nur ein paar Momente sind auffällig. Zunächst wählt die Generierungsmethode selbst die gewünschte Vorlage in Abhängigkeit von der Klasse des Helden aus. Zweitens ist es nicht erforderlich, einen Namen sofort anzugeben (manchmal wissen wir ihn in der Generierungsphase noch nicht). Drittens brachte Kotlin eine beispiellose Menge syntaktischen Zuckers ein, die einige Entwickler unangemessen missbrauchen. Und kein bisschen beschämt.Schritt acht. Spielzyklus
Schließlich kamen wir zum interessantesten - der Implementierung des Spielzyklus. In einfachen Worten, sie fingen an, "das Spiel zu machen". Viele anfängliche Entwickler beginnen oft genau in dieser Phase, abgesehen vom Spielemachen und allem anderen. Vor allem alle möglichen bedeutungslosen kleinen Pläne zum Zeichnen, pfff ... Aber wir werden uns nicht beeilen (es ist noch weit vom Morgen entfernt) und daher etwas mehr modellieren. Ja nochmal.Wie Sie sehen können, ist das gegebene Fragment des Spielzyklus eine Größenordnung kleiner als das, was wir oben zitiert haben. Wir werden nur den Prozess der Übertragung des Kurses, der Erkundung des Geländes (und wir werden das Treffen mit nur zwei Arten von Würfeln beschreiben) und des Verwerfens der Würfel am Ende der Runde betrachten. Und das Szenario mit einem Verlust abzuschließen (ja, es wird uns noch nicht gelingen, unser Spiel zu gewinnen) - aber wie gefällt es Ihnen? Der Timer wird mit jeder Runde kürzer und nach Abschluss muss etwas getan werden. Zeigen Sie zum Beispiel eine Nachricht an und beenden Sie das Spiel - alles ist so, wie es in den Regeln geschrieben steht. Ein weiteres Spiel muss nach dem Tod der Helden abgeschlossen sein, aber niemand wird ihnen Schaden zufügen, deshalb werden wir es verlassen. Um zu gewinnen, müssen Sie alle Bereiche schließen, was schwierig ist, auch wenn es nur einer ist. Lassen wir deshalb diesen Moment. Es macht keinen Sinn, zu viel zu sprühen - es ist wichtig, dass wir die Essenz verstehen und den Rest später in meiner Freizeit erledigen (oder besser gesagt, um ihn zu beenden).und du - schreib ein Spieldeiner Träume).Als erstes müssen wir entscheiden, welche Objekte wir benötigen.Helden Das Skript. Standorte.Wir haben den Entstehungsprozess bereits überprüft - wir werden ihn nicht wiederholen. Wir notieren nur das Geländemuster, das wir in unserem kleinen Beispiel verwenden werden. class TestLocationTemplate : LocationTemplate { override val name = "Test" override val description = "Some Description" override val basicClosingDifficulty = 0 override val enemyCardsCount = 0 override val obstacleCardsCount = 0 override val bagTemplate = BagTemplate().apply { addPlan(2, 2, SingleDieTypeFilter(Die.Type.PHYSICAL)) addPlan(2, 2, SingleDieTypeFilter(Die.Type.SOMATIC)) addPlan(2, 2, SingleDieTypeFilter(Die.Type.MENTAL)) addPlan(2, 2, SingleDieTypeFilter(Die.Type.VERBAL)) addPlan(2, 2, SingleDieTypeFilter(Die.Type.DIVINE)) } override val enemyCardPool = emptyList<EnemyTemplate>() override val obstacleCardPool = emptyList<ObstacleTemplate>() override val specialRules = emptyList<SpecialRule>() }
Wie Sie sehen können, befinden sich in der Tasche nur "positive" Würfel - blau, grün, lila, gelb und blau. Es gibt keine Feinde und Hindernisse in der Gegend, Bösewichte und Wunden werden nicht gefunden. Es gibt auch keine speziellen Regeln - ihre Implementierung ist sehr zweitrangig.Haufen für zurückbehaltene Würfel.Oder ein abschreckender Haufen. Da wir die blauen Würfel in den Beutel des Geländes legen, können sie für Schecks verwendet und nach Gebrauch auf einem speziellen Haufen aufbewahrt werden. Eine Instanz der Klasse ist hierfür nützlich Pile
.Modifikatoren.Das heißt, die numerischen Werte, die zum Ergebnis des Würfelwurfs addiert oder subtrahiert werden müssen. Sie können für jeden Cube entweder einen globalen oder einen separaten Modifikator implementieren. Wir werden die zweite Option wählen (also klarer), daher werden wir eine einfache Klasse erstellen DiePair
. class DiePair(val die: Die, var modifier: Int = 0)
Die Position der Zeichen im Bereich.In guter Weise muss dieser Moment mithilfe einer speziellen Struktur verfolgt werden. Zum Beispiel Karten des Formulars, Map<Location, List<Hero>>
in dem jeder Ort eine Liste der aktuell darin enthaltenen Helden enthält (sowie eine Methode für das Gegenteil - Bestimmung des Ortes, an dem sich ein bestimmter Held befindet). Wenn Sie sich für diesen Pfad entscheiden, vergessen Sie nicht Location
, der Implementierungsklasse Methoden hinzuzufügen , equals()
und hashCode()
ich hoffe, Sie müssen nicht erklären, warum. Wir werden keine Zeit damit verschwenden, da das Gebiet nur eines ist und die Helden es nirgendwo lassen.Überprüfen Sie die Hände des Helden.Während des Spiels müssen die Helden ständig Prüfungen durchlaufen (die unten beschrieben werden), dh Würfel aus der Hand nehmen, werfen (Modifikatoren hinzufügen), die Ergebnisse aggregieren, wenn mehrere Würfel vorhanden sind (zusammenfassen, Maximum / Minimum, Durchschnitt usw. nehmen), sie mit dem Wurf vergleichen einen anderen Würfel (einer, der aus dem Beutel des Bereichs entfernt wird) und führen Sie je nach Ergebnis die folgenden Aktionen aus. Zuallererst muss man verstehen, ob der Held die Prüfung grundsätzlich bestehen kann, dh ob er die notwendigen Würfel in der Hand hat. Dafür bieten wir eine einfache Schnittstelle HandFilter
. interface HandFilter { fun test(hand: Hand): Boolean }
Schnittstellenimplementierungen nehmen die Hand des Helden (Klassenobjekt Hand
) als Eingabe und geben je nach Ergebnis der Prüfung true
entweder zurück false
. Für unser Fragment des Spiels benötigen wir eine einzige Implementierung: Wenn ein blauer, grüner, lila oder gelber Würfel getroffen wird, müssen wir feststellen, ob die Hand des Helden einen Würfel derselben Farbe hat. class SingleDieHandFilter(private vararg val types: Die.Type) : HandFilter { override fun test(hand: Hand) = (0 until hand.dieCount).mapNotNull { hand.dieAt(it) }.any { it.type in types } || (Die.Type.ALLY in types && hand.allyDieCount > 0) }
Ja, wieder Funktionalismus.Aktive / ausgewählte Elemente.Nachdem wir sichergestellt haben, dass die Hand des Helden für die Durchführung des Tests geeignet ist, muss der Spieler aus der Hand die Würfel (oder Würfel) auswählen, mit denen er diesen Test bestehen wird. Zunächst müssen Sie die entsprechenden Positionen markieren (hervorheben) (an denen sich Würfel des gewünschten Typs befinden). Zweitens müssen Sie die ausgewählten Würfel irgendwie markieren. Für diese beiden Anforderungen ist eine Klasse geeignet HandMask
, die tatsächlich eine Reihe von Ganzzahlen (Anzahl der ausgewählten Positionen) und Methoden zum Hinzufügen und Entfernen dieser enthält. class HandMask { private val positions = mutableSetOf<Int>() private val allyPositions = mutableSetOf<Int>() val positionCount get() = positions.size val allyPositionCount get() = allyPositions.size fun addPosition(position: Int) = positions.add(position) fun removePosition(position: Int) = positions.remove(position) fun addAllyPosition(position: Int) = allyPositions.add(position) fun removeAllyPosition(position: Int) = allyPositions.remove(position) fun checkPosition(position: Int) = position in positions fun checkAllyPosition(position: Int) = position in allyPositions fun switchPosition(position: Int) { if (!removePosition(position)) { addPosition(position) } } fun switchAllyPosition(position: Int) { if (!removeAllyPosition(position)) { addAllyPosition(position) } } fun clear() { positions.clear() allyPositions.clear() } }
Ich habe bereits gesagt, wie ich unter der „genialen“ Idee leide, weiße Würfel in einer separaten Hand aufzubewahren? Aufgrund dieser Dummheit müssen Sie sich mit zwei Sätzen befassen und jede der vorgestellten Methoden duplizieren. Wenn jemand Ideen hat, wie die Implementierung dieser Anforderung vereinfacht werden kann (verwenden Sie beispielsweise einen Satz, aber für weiße Würfel beginnen die Indizes mit hundert - oder etwas anderem, das ebenfalls dunkel ist), teilen Sie diese in den Kommentaren mit.Übrigens muss eine ähnliche Klasse implementiert werden, um Cubes aus dem heap ( PileMask
) auszuwählen , aber diese Funktionalität liegt außerhalb des Bereichs dieses Beispiels.Die Auswahl der Würfel aus der Hand.Es reicht jedoch nicht aus, akzeptable Positionen hervorzuheben, sondern es ist wichtig, diese Hervorhebung bei der Auswahl der Würfel zu ändern. Das heißt, wenn ein Spieler nur einen Würfel von seiner Hand nehmen muss, sollten bei Auswahl dieses Würfels alle anderen Positionen unzugänglich werden. Darüber hinaus ist es in jeder Phase erforderlich, die Erfüllung des Ziels durch den Spieler zu kontrollieren, dh zu verstehen, ob die ausgewählten Würfel ausreichen, um den einen oder anderen Test zu bestehen. Eine solch schwierige Aufgabe erfordert eine komplexe Instanz einer komplexen Klasse. abstract class HandMaskRule(val hand: Hand) { abstract fun checkMask(mask: HandMask): Boolean abstract fun isPositionActive(mask: HandMask, position: Int): Boolean abstract fun isAllyPositionActive(mask: HandMask, position: Int): Boolean fun getCheckedDice(mask: HandMask): List<Die> { return ((0 until hand.dieCount).filter(mask::checkPosition).map(hand::dieAt)) .plus((0 until hand.allyDieCount).filter(mask::checkAllyPosition).map(hand::allyDieAt)) .filterNotNull() } }
Ziemlich komplizierte Logik, ich werde Sie verstehen und Ihnen vergeben, wenn diese Klasse für Sie unverständlich ist. Und versuche immer noch zu erklären. Implementierungen dieser Klasse speichern immer einen Verweis auf die Hand (das Objekt Hand
), mit der sie sich befassen. Jede der Methoden erhält eine Maske ( HandMask
), die den aktuellen Status der Auswahl widerspiegelt (welche Positionen vom Spieler ausgewählt werden und welche nicht). Die Methode gibt an checkMask()
, ob die ausgewählten Würfel ausreichen, um den Test zu bestehen. Die Methode isPositionActive()
gibt an, ob eine bestimmte Position hervorgehoben werden muss - ob es möglich ist, dem Test einen Würfel an dieser Position hinzuzufügen (oder einen bereits ausgewählten Würfel zu entfernen). Die Methode isAllyPositionActive()
ist die gleiche für weiße Würfel (ja, ich weiß, ich bin ein Idiot). Nun und die HilfsmethodegetCheckedDice()
es gibt einfach eine Liste aller Würfel aus der Hand zurück, die der Maske entsprechen - dies ist notwendig, um sie alle auf einmal zu nehmen, sie auf den Tisch zu werfen und das lustige Klopfen zu genießen, mit dem sie sich in verschiedene Richtungen zerstreuen.Wir brauchen zwei Realisierungen dieser abstrakten Klasse (Überraschung, Überraschung!). Der erste steuert den Prozess des Bestehens des Tests beim Erwerb eines neuen Würfels eines bestimmten Typs (nicht weiß). Wie Sie sich erinnern, können einer solchen Prüfung beliebig viele blaue Würfel hinzugefügt werden. class StatDieAcquireHandMaskRule(hand: Hand, private val requiredType: Die.Type) : HandMaskRule(hand) { private fun checkedDieCount(mask: HandMask) = (0 until hand.dieCount) .filter(mask::checkPosition) .mapNotNull(hand::dieAt) .count { it.type === requiredType } override fun checkMask(mask: HandMask) = (mask.allyPositionCount == 0 && checkedDieCount(mask) == 1) override fun isPositionActive(mask: HandMask, position: Int) = with(hand.dieAt(position)) { when { mask.checkPosition(position) -> true this == null -> false this.type === Die.Type.DIVINE -> true this.type === requiredType && checkedDieCount(mask) < 1 -> true else -> false } } override fun isAllyPositionActive(mask: HandMask, position: Int) = false }
Die zweite Implementierung ist komplizierter. Sie kontrolliert den Würfelwurf am Ende des Zuges. In diesem Fall sind zwei Optionen möglich. Wenn die Anzahl der Würfel in der Hand die maximal zulässige Größe (Kapazität) überschreitet, müssen wir alle zusätzlichen Würfel plus eine beliebige Anzahl zusätzlicher Würfel verwerfen (wenn wir möchten). Wenn die Größe nicht überschritten wird, können Sie nichts zurücksetzen (oder, falls gewünscht, zurücksetzen). In keinem Fall können graue Würfel weggeworfen werden. class DiscardExtraDiceHandMaskRule(hand: Hand) : HandMaskRule(hand) { private val minDiceToDiscard = if (hand.dieCount > hand.capacity) min(hand.dieCount - hand.woundCount, hand.dieCount - hand.capacity) else 0 private val maxDiceToDiscard = hand.dieCount - hand.woundCount override fun checkMask(mask: HandMask) = (mask.positionCount in minDiceToDiscard..maxDiceToDiscard) && (mask.allyPositionCount in 0..hand.allyDieCount) override fun isPositionActive(mask: HandMask, position: Int) = when { mask.checkPosition(position) -> true hand.dieAt(position) == null -> false hand.dieAt(position)!!.type == Die.Type.WOUND -> false mask.positionCount < maxDiceToDiscard -> true else -> false } override fun isAllyPositionActive(mask: HandMask, position: Int) = hand.allyDieAt(position) != null }
Nezhdanchik: In der Klasse Hand
tauchte plötzlich eine Eigenschaft auf woundCount
, die vorher nicht existierte. Sie können die Implementierung selbst schreiben, es ist ganz einfach. Übe gleichzeitig.Schecks bestehen.Endlich zu ihnen gekommen. Wenn die Würfel aus der Hand genommen werden, ist es Zeit, sie zu werfen. Für jeden Würfel muss Folgendes berücksichtigt werden: seine Größe, seine Modifikatoren, das Ergebnis seines Wurfs. Obwohl jeweils nur ein Würfel aus dem Beutel genommen werden kann, können mehrere Würfel dagegen gelegt werden, um die Ergebnisse ihrer Würfe zu aggregieren. Lassen Sie uns im Allgemeinen von den Würfeln abstrahieren und die Truppen auf dem Schlachtfeld darstellen. Einerseits haben wir einen Feind - er ist nur einer, aber er ist stark und wild. Auf der anderen Seite ein Gegner, der genauso stark ist wie er, aber mit Unterstützung. Der Ausgang des Kampfes wird in einem kurzen Gefecht entschieden, der Gewinner kann nur einer sein ...Entschuldigung, weggetragen. Um unseren allgemeinen Kampf zu simulieren, implementieren wir eine spezielle Klasse. class DieBattleCheck(val method: Method, opponent: DiePair? = null) { enum class Method { SUM, AVG_UP, AVG_DOWN, MAX, MIN } private inner class Wrap(val pair: DiePair, var roll: Int) private infix fun DiePair.with(roll: Int) = Wrap(this, roll) private val opponent: Wrap? = opponent?.with(0) private val heroics = ArrayList<Wrap>() var isRolled = false var result: Int? = null val heroPairCount get() = heroics.size fun getOpponentPair() = opponent?.pair fun getOpponentResult() = when { isRolled -> opponent?.roll ?: 0 else -> throw IllegalStateException("Not rolled yet") } fun addHeroPair(pair: DiePair) { if (method == Method.SUM && heroics.size > 0) { pair.modifier = 0 } heroics.add(pair with 0) } fun addHeroPair(die: Die, modifier: Int) = addHeroPair(DiePair(die, modifier)) fun clearHeroPairs() = heroics.clear() fun getHeroPairAt(index: Int) = heroics[index].pair fun getHeroResultAt(index: Int) = when { isRolled -> when { (index in 0 until heroics.size) -> heroics[index].roll else -> 0 } else -> throw IllegalStateException("Not rolled yet") } fun roll() { fun roll(wrap: Wrap) { wrap.roll = wrap.pair.die.roll() } isRolled = true opponent?.let { roll(it) } heroics.forEach { roll(it) } } fun calculateResult() { if (!isRolled) { throw IllegalStateException("Not rolled yet") } val opponentResult = opponent?.let { it.roll + it.pair.modifier } ?: 0 val stats = heroics.map { it.roll + it.pair.modifier } val heroResult = when (method) { DieBattleCheck.Method.SUM -> stats.sum() DieBattleCheck.Method.AVG_UP -> ceil(stats.average()).toInt() DieBattleCheck.Method.AVG_DOWN -> floor(stats.average()).toInt() DieBattleCheck.Method.MAX -> stats.max() ?: 0 DieBattleCheck.Method.MIN -> stats.min() ?: 0 } result = heroResult - opponentResult } }
Da jeder Cube einen Modifikator haben kann, speichern wir Daten in Objekten DiePair
. So'ne Art. Nein, denn neben dem Cube und dem Modifikator müssen Sie auch das Ergebnis seines Wurfs speichern (denken Sie daran, dass der Cube selbst diesen Wert generiert, ihn jedoch nicht in seinen Eigenschaften speichert). Wickeln Sie daher jedes Paar in einen Wrapper ( Wrap
). with
Achten Sie auf die Infix-Methode , hehe.Der Klassenkonstruktor definiert die Aggregationsmethode (eine Instanz der internen Aufzählung Method
) und den Gegner (der möglicherweise nicht vorhanden ist). Die Liste der Heldenwürfel wird mit den entsprechenden Methoden erstellt. Es bietet auch eine Reihe von Methoden, um die Paare in den Test einzubeziehen, und die Ergebnisse ihrer Würfe (falls vorhanden).Methoderoll()
Ruft die gleichnamige Methode jedes Cubes auf, speichert die Zwischenergebnisse und markiert die Ausführung mit einem Flag isRolled
. Bitte beachten Sie, dass das Endergebnis des Wurfs nicht sofort berechnet wird. Hierfür gibt es eine spezielle Methode calculateResult()
, bei der der endgültige Wert in die Eigenschaft geschrieben wird result
. Warum wird das benötigt? Für einen dramatischen Effekt. Die Methode roll()
wird mehrmals ausgeführt, wobei jedes Mal auf den Flächen der Würfel unterschiedliche Werte angezeigt werden (genau wie im wirklichen Leben). Und erst wenn sich die Würfel auf dem Tisch beruhigen, erfahren wir über unser Schicksal das Endergebnis (den Unterschied zwischen den Werten der Würfel des Helden und den Würfeln des Gegners). Um Stress abzubauen, werde ich sagen, dass ein Ergebnis von 0 als erfolgreicher Test bestanden wird.Der Status der Spiel-Engine.Anspruchsvolle Objekte aussortiert, jetzt sind die Dinge einfacher. Es wird keine große Entdeckung sein zu sagen, dass wir den aktuellen „Fortschritt“ der Spiel-Engine, die Phase oder Phase, in der sie sich befindet, kontrollieren müssen. Hierfür ist eine spezielle Aufzählung hilfreich. enum class GamePhase { SCENARIO_START, HERO_TURN_START, HERO_TURN_END, LOCATION_BEFORE_EXPLORATION, LOCATION_ENCOUNTER_STAT, LOCATION_ENCOUNTER_DIVINE, LOCATION_AFTER_EXPLORATION, GAME_LOSS }
Eigentlich gibt es mehr Phasen, aber wir haben nur diejenigen ausgewählt, die in unserem Beispiel verwendet werden. Um die Phase der Spiel-Engine zu ändern, verwenden wir Methoden changePhaseX()
, wobei X
der Wert aus der obigen Liste ist. Bei diesen Methoden werden alle internen Variablen des Motors auf Werte reduziert, die für den Beginn der entsprechenden Phase angemessen sind, aber dazu später mehr.NachrichtenEs reicht nicht aus, den Status der Spiel-Engine beizubehalten. Es ist auch wichtig, dass der Benutzer irgendwie über ihn informiert - sonst woher weiß dieser, was auf seinem Bildschirm passiert? Deshalb brauchen wir eine andere Auflistung. enum class StatusMessage { EMPTY, CHOOSE_DICE_PERFORM_CHECK, END_OF_TURN_DISCARD_EXTRA, END_OF_TURN_DISCARD_OPTIONAL, CHOOSE_ACTION_BEFORE_EXPLORATION, CHOOSE_ACTION_AFTER_EXPLORATION, ENCOUNTER_PHYSICAL, ENCOUNTER_SOMATIC, ENCOUNTER_MENTAL, ENCOUNTER_VERBAL, ENCOUNTER_DIVINE, DIE_ACQUIRE_SUCCESS, DIE_ACQUIRE_FAILURE, GAME_LOSS_OUT_OF_TIME }
Wie Sie sehen können, werden alle möglichen Zustände aus unserem Beispiel durch die Werte dieser Aufzählung beschrieben. Für jeden von ihnen wird eine Textzeile bereitgestellt, die auf dem Bildschirm angezeigt wird (außer EMPTY
- dies ist eine besondere Bedeutung), aber wir werden dies etwas später erfahren.AktionenFür die Kommunikation zwischen dem Benutzer und der Spiel-Engine reichen einfache Nachrichten nicht aus. Es ist auch wichtig, den ersten über die Maßnahmen zu informieren, die er im Moment ergreifen kann (um zu recherchieren, die Blöcke zu passieren, den Umzug abzuschließen - das ist alles gut). Dazu werden wir eine spezielle Klasse entwickeln. class Action( val type: Type, var isEnabled: Boolean = true, val data: Int = 0 ) { enum class Type { NONE,
Eine interne Aufzählung Type
beschreibt die Art der ausgeführten Aktion. Das Feld ist isEnabled
erforderlich, um Aktionen in einem inaktiven Zustand anzuzeigen. Das heißt, um zu melden, dass diese Aktion normalerweise verfügbar ist, aber aus irgendeinem Grund derzeit nicht ausgeführt werden kann (eine solche Anzeige ist viel informativer als wenn die Aktion überhaupt nicht angezeigt wird). Die Eigenschaft data
(für einige Arten von Aktionen erforderlich) speichert einen speziellen Wert, der einige zusätzliche Details übermittelt (z. B. den Index der vom Benutzer ausgewählten Position oder die Nummer des ausgewählten Elements aus der Liste).KlasAction
ist die Haupt- "Schnittstelle" zwischen der Spiel-Engine und den Eingabe-Ausgabe-Systemen (über die unten). Da es häufig mehrere Aktionen gibt (andernfalls warum dann wählen?), Werden sie zu Gruppen (Listen) zusammengefasst. Anstatt Standardsammlungen zu verwenden, schreiben wir unsere eigene erweiterte. class ActionList : Iterable<Action> { private val actions = mutableListOf<Action>() val size get() = actions.size fun add(action: Action): ActionList { actions.add(action) return this } fun add(type: Action.Type, enabled: Boolean = true): ActionList { add(Action(type, enabled)) return this } fun addAll(actions: ActionList): ActionList { actions.forEach { add(it) } return this } fun remove(type: Action.Type): ActionList { actions.removeIf { it.type == type } return this } operator fun get(index: Int) = actions[index] operator fun get(type: Action.Type) = actions.find { it.type == type } override fun iterator(): Iterator<Action> = ActionListIterator() private inner class ActionListIterator : Iterator<Action> { private var position = -1 override fun hasNext() = (actions.size > position + 1) override fun next() = actions[++position] } companion object { val EMPTY get() = ActionList() } }
Die Klasse enthält viele verschiedene Methoden zum Hinzufügen und Entfernen von Aktionen zur Liste (die miteinander verkettet werden können) sowie zum Abrufen sowohl nach Index als auch nach Typ (beachten Sie die „Überladung“ get()
- der Operator in eckigen Klammern gilt für unsere Liste). Die Implementierung der Schnittstelle Iterator
ermöglicht es uns, verschiedene Stream-Manipulationen (Funktionalität, Aha) mit unserer allerlei verrückten Scheißklasse durchzuführen . Ein leerer Wert wird ebenfalls bereitgestellt, um schnell eine leere Liste zu erstellen.Bildschirme.Zum Schluss noch eine Auflistung, die die verschiedenen Arten von Inhalten beschreibt, die derzeit angezeigt werden ... Sie sehen mich an und blinzeln mit den Augen, ich weiß. Als ich mir überlegte, wie ich diese Klasse klarer beschreiben könnte, schlug ich meinen Kopf auf den Tisch, weil ich nichts wirklich herausfinden konnte. Verstehe dich, hoffe ich. enum class GameScreen { HERO_TURN_START, LOCATION_INTERIOR, GAME_LOSS }
Nur die im Beispiel verwendeten ausgewählt. Für jeden von ihnen wird eine separate Rendering-Methode bereitgestellt ... Ich erkläre es noch einmal unerklärlich."Anzeige" und "Eingabe".Und jetzt kommen wir endlich zum wichtigsten Punkt - der Interaktion der Spiel-Engine mit dem Benutzer (Spieler). Wenn Sie eine so lange Einführung noch nicht gelangweilt hat, erinnern Sie sich wahrscheinlich daran, dass wir uns darauf geeinigt haben, diese beiden Teile funktional voneinander zu trennen. Daher werden wir anstelle einer spezifischen Implementierung des E / A-Systems nur eine Schnittstelle bereitstellen. Genauer gesagt, zwei.Erste SchnittstelleGameRenderer
, entwickelt, um Bilder auf dem Bildschirm anzuzeigen. Ich erinnere Sie daran, dass wir von Bildschirmgrößen, bestimmten Grafikbibliotheken usw. abstrahieren. Wir senden einfach den Befehl: "Zeichne mir das" - und diejenigen unter Ihnen, die unsere verschwommene Konversation über Bildschirme verstanden haben, haben bereits vermutet, dass jeder dieser Bildschirme seine eigene Methode innerhalb der Benutzeroberfläche hat. interface GameRenderer { fun drawHeroTurnStart(hero: Hero) fun drawLocationInteriorScreen( location: Location, heroesAtLocation: List<Hero>, timer: Int, currentHero: Hero, battleCheck: DieBattleCheck?, encounteredDie: DiePair?, pickedDice: HandMask, activePositions: HandMask, statusMessage: StatusMessage, actions: ActionList ) fun drawGameLoss(message: StatusMessage) }
Ich denke, hier sind keine zusätzlichen Erklärungen erforderlich - der Zweck aller übertragenen Objekte wurde oben ausführlich erörtert.Für Benutzereingaben implementieren wir eine andere Oberfläche - GameInteractor
(ja, Skripte zur Rechtschreibprüfung betonen dieses Wort immer, obwohl es so scheint ...). Seine Methoden werden den Spieler nach den erforderlichen Befehlen für verschiedene Situationen fragen: Wählen Sie eine Aktion aus der Liste der vorgeschlagenen aus, wählen Sie ein Element aus der Liste aus, wählen Sie Würfel aus der Hand aus, drücken Sie zumindest etwas usw. Es sollte sofort beachtet werden, dass die Eingabe synchron erfolgt (das Spiel ist Schritt für Schritt), dh die Ausführung der Spielschleife wird unterbrochen, bis der Benutzer auf die Anfrage reagiert. interface GameInteractor{ fun anyInput() fun pickAction(list: ActionList): Action fun pickDiceFromHand(activePositions: HandMask, actions: ActionList): Action }
Über die letzte Methode etwas mehr. Wie der Name schon sagt, lädt from den Benutzer ein, Würfel aus der Hand auszuwählen und ein Objekt bereitzustellen HandMask
- die Anzahl der aktiven Positionen. Die Ausführung der Methode wird fortgesetzt, bis einige von ihnen ausgewählt sind. In diesem Fall gibt die Methode eine Aktion vom Typ HAND_POSITION
(oder HAND_ALLY_POSITION
mda) mit der Nummer der ausgewählten Position im Feld zurück data
. Darüber hinaus ist es möglich, eine andere Aktion (z. B. CONFIRM
oder CANCEL
) aus dem Objekt auszuwählen ActionList
. Implementierungen von Eingabemethoden sollten zwischen Situationen unterscheiden, in denen das Feld auf isEnabled
gesetzt ist, false
und Benutzereingaben solcher Aktionen ignorieren.Game Engine Klasse.Wir haben alles untersucht, was für die Arbeit notwendig ist, die Zeit ist gekommen und der Motor zu implementieren. Erstellen Sie eine KlasseGame
mit folgendem Inhalt:Entschuldigung, dies ist nicht für eindrucksvolle Personen zu zeigen. class Game( private val renderer: GameRenderer, private val interactor: GameInteractor, private val scenario: Scenario, private val locations: List<Location>, private val heroes: List<Hero>) { private var timer = 0 private var currentHeroIndex = -1 private lateinit var currentHero: Hero private lateinit var currentLocation: Location private val deterrentPile = Pile() private var encounteredDie: DiePair? = null private var battleCheck: DieBattleCheck? = null private val activeHandPositions = HandMask() private val pickedHandPositions = HandMask() private var phase: GamePhase = GamePhase.SCENARIO_START private var screen = GameScreen.SCENARIO_INTRO private var statusMessage = StatusMessage.EMPTY private var actions: ActionList = ActionList.EMPTY fun start() { if (heroes.isEmpty()) throw IllegalStateException("Heroes list is empty!") if (locations.isEmpty()) throw IllegalStateException("Location list is empty!") heroes.forEach { it.isAlive = true } timer = scenario.initialTimer
Methode start()
- der Einstiegspunkt in das Spiel. Hier werden Variablen initialisiert, Helden gewogen, Hände mit Würfeln gefüllt und Reporter mit Kameras von allen Seiten. Der Hauptzyklus wird jede Minute gestartet, danach kann er nicht mehr gestoppt werden. Die Methode drawInitialHand()
spricht für sich selbst (wir schienen den Code der drawOfType()
Klassenmethode nicht zu berücksichtigen Bag
, aber nachdem Sie so weit zusammen gegangen sind, können Sie diesen Code selbst schreiben). Die Methode refillHeroHand()
hat zwei Optionen (abhängig vom Wert des Arguments redrawScreen
): schnell und leise (wenn Sie zu Beginn des Spiels die Hände aller Helden füllen müssen) und laut mit einem Haufen Pathos, wenn Sie am Ende des Zuges die Würfel gezielt aus der Tasche entfernen müssen, um die Hand auf die richtige Größe zu bringen.Eine Reihe von Methoden mit Namen, die mit beginnenchangePhase
, - wie bereits gesagt, dienen sie dazu, die aktuelle Spielphase zu ändern und sind mit der Zuordnung der entsprechenden Werte der Spielvariablen beschäftigt. Hier wird eine Liste erstellt, actions
in der die für diese Phase charakteristischen Aktionen hinzugefügt werden.Die Gebrauchsmethode pickDiceFromHand()
in verallgemeinerter Form befasst sich mit der Auswahl von Würfeln aus der Hand. Hier wird ein Objekt einer vertrauten Klasse übergeben HandMaskRule
, das die Auswahlregeln definiert. Es zeigt auch die Möglichkeit an, die Auswahl ( allowCancel
) abzulehnen , sowie eine Funktion, onEachLoop
deren Code jedes Mal aufgerufen werden muss, wenn die Liste der ausgewählten Cubes geändert wird (normalerweise ein erneutes Zeichnen des Bildschirms). Die mit dieser Methode ausgewählten Würfel können mit den Methoden collectPickedDice()
und aus der Hand zusammengesetzt werden collectPickedAllyDice()
.Eine andere DienstprogrammmethodeperformStatDieAcquireCheck()
implementiert den Helden, der den Test für den Erwerb eines neuen Würfels besteht, vollständig. Die zentrale Rolle bei dieser Methode spielt das Objekt DieBattleCheck
. Der Prozess beginnt mit der Auswahl der Würfel nach der Methode pickDiceFromHand()
(bei jedem Schritt wird die Liste der „Teilnehmer“ aktualisiert DieBattleCheck
). Die ausgewählten Würfel werden aus der Hand entfernt, woraufhin ein „Wurf“ auftritt - jeder Würfel aktualisiert seinen Wert (achtmal hintereinander), wonach das Ergebnis berechnet und angezeigt wird. Bei einem erfolgreichen Wurf fällt dem Helden ein neuer Würfel in die Hand. Die am Test teilnehmenden Würfel werden entweder gehalten (wenn sie blau sind) oder weggeworfen (wenn shouldDiscard = true
) oder sind im Beutel versteckt (wenn shouldDiscard = false
).HauptmethodeprocessCycle()
enthält eine Endlosschleife (ich frage ohne Ohnmacht), in der der Bildschirm zuerst gezeichnet wird, dann der Benutzer zur Eingabe aufgefordert wird, dann wird diese Eingabe verarbeitet - mit allen sich daraus ergebenden Konsequenzen. Die Methode drawScreen()
ruft die gewünschte Schnittstellenmethode auf GameRenderer
(abhängig vom aktuellen Wert screen
) und übergibt ihr die erforderlichen Objekte an die Eingabe.Außerdem enthält die Klasse mehrere Hilfsmethoden: checkLocationCanBeExplored()
, checkHeroCanAttemptStatCheck()
und checkHeroCanAcquireDie()
. Ihre Namen sprechen für sich selbst, deshalb werden wir nicht im Detail auf sie eingehen. Außerdem gibt es Klassenmethodenaufrufe Audio
, die durch eine rote Wellenlinie unterstrichen werden. Kommentieren Sie sie vorerst - wir werden später über ihren Zweck nachdenken.Wer überhaupt nichts versteht, hier ein Diagramm (aus Gründen der Klarheit sozusagen): Das ist alles, das Spiel ist fertig (hehe). Es gab wirklich kleine Dinge darüber unten.Schritt neun. Bild anzeigen
Wir kommen also zum Hauptthema des heutigen Gesprächs - der grafischen Komponente der Anwendung. Wie Sie sich erinnern, besteht unsere Aufgabe darin, die Schnittstelle GameRenderer
und ihre drei Methoden zu implementieren. Da es in unserem Team noch keinen talentierten Künstler gibt, werden wir dies selbst mithilfe von Pseudografien tun. Aber zunächst wäre es schön zu verstehen, was wir allgemein am Ausgang erwarten. Und wir möchten drei Bildschirme mit ungefähr den folgenden Inhalten sehen:Bildschirm 1. Spieler-Turn-ID Bildschirm 2. Informationen über das Gebiet und den aktuellen Helden Bildschirm 3. Skriptverlustmeldung Ich denke, die Mehrheit hat bereits erkannt, dass sich die präsentierten Bilder von allem unterscheiden, was wir normalerweise in der Konsole von Java-Anwendungen sehen, und dass die üblichen Funktionen prinltn()
für uns offensichtlich nicht ausreichen werden. Ich möchte auch in der Lage sein, zu beliebigen Stellen auf dem Bildschirm zu springen und Symbole in verschiedenen Farben zu zeichnen. Chip- und Dale- ANSI-Codeshelfen uns zu Hilfe . Durch das Senden bizarrer Zeichenfolgen zur Ausgabe können Sie nicht weniger bizarre Effekte erzielen: Ändern Sie die Farbe des Texts / Hintergrunds, die Art und Weise, wie die Zeichen gezeichnet werden, die Position des Cursors auf dem Bildschirm und vieles mehr. Natürlich werden wir sie nicht in ihrer reinen Form vorstellen - wir werden die Implementierung hinter den Methoden der Klasse verbergen. Und wir werden die Klasse selbst nicht von Grund auf neu schreiben - zum Glück haben es kluge Leute für uns getan. Wir müssen nur eine leichte Bibliothek herunterladen und mit dem Projekt verbinden, zum Beispiel Jansi : <dependency> <groupId>org.fusesource.jansi</groupId> <artifactId>jansi</artifactId> <version>1.17.1</version> <scope>compile</scope> </dependency>
Und Sie können anfangen zu erstellen. Diese Bibliothek bietet uns ein Klassenobjekt Ansi
(das als Ergebnis eines statischen Aufrufs erhalten wurde Ansi.ansi()
) mit einer Reihe praktischer Methoden, die verkettet werden können. Es funktioniert nach dem Prinzip StringBuilder
'a - zuerst bilden wir das Objekt und senden es dann zum Drucken. Von den nützlichen Methoden werden wir nützlich finden:a()
- um Zeichen anzuzeigen;cursor()
- um den Cursor auf dem Bildschirm zu bewegen;eraseLine()
- als ob für sich selbst spricht;eraseScreen()
- ähnlich;fg(), bg(), fgBright(), bgBright()
- sehr unpraktische Methoden für die Arbeit mit Text- und Hintergrundfarben - wir werden unsere eigenen, angenehmer machen;reset()
- um die eingestellten Farbeinstellungen, das Flimmern usw. zurückzusetzen.
Lassen Sie uns eine Klasse ConsoleRenderer
mit Dienstprogrammmethoden erstellen, die für uns in unserer Arbeit nützlich sein können. Die erste Version sieht ungefähr so aus: abstract class ConsoleRenderer() { protected lateinit var ansi: Ansi init { AnsiConsole.systemInstall() clearScreen() resetAnsi() } private fun resetAnsi() { ansi = Ansi.ansi() } fun clearScreen() { print(Ansi.ansi().eraseScreen(Ansi.Erase.ALL).cursor(1, 1)) } protected fun render() { print(ansi.toString()) resetAnsi() } }
Die Methode resetAnsi()
erstellt ein neues (leeres) Objekt Ansi
, das mit den erforderlichen Befehlen (Verschieben, Ausgeben usw.) gefüllt wird. Nach Abschluss des Füllvorgangs wird das generierte Objekt von der Methode zum Drucken gesendet render()
und die Variable mit einem neuen Objekt initialisiert. Noch nichts kompliziertes, oder? Und wenn ja, dann werden wir beginnen, diese Klasse mit anderen nützlichen Methoden zu füllen.Beginnen wir mit den Größen. Die Standardkonsole der meisten Terminals ist 80 x 24 groß. Wir notieren diese Tatsache mit zwei Konstanten CONSOLE_WIDTH
und CONSOLE_HEIGHT
. Wir werden nicht an bestimmte Werte gebunden sein und versuchen, das Design so gummiartig wie möglich zu gestalten (wie im Internet). Die Nummerierung der Koordinaten beginnt mit eins, die erste Koordinate ist eine Zeile, die zweite ist eine Spalte. In diesem Wissen schreiben wir eine Utility-MethodedrawHorizontalLine()
um die angegebene Zeichenfolge mit dem angegebenen Zeichen zu füllen. protected fun drawHorizontalLine(offsetY: Int, filler: Char) { ansi.cursor(offsetY, 1) (1..CONSOLE_WIDTH).forEach { ansi.a(filler) }
Ich möchte Sie noch einmal daran erinnern, dass das Aufrufen von Befehlen a()
oder cursor()
keine sofortige Auswirkung hat, sondern nur die Ansi
entsprechende Befehlsfolge zum Objekt hinzufügt . Nur wenn diese Sequenzen zum Drucken gesendet werden, werden sie auf dem Bildschirm angezeigt.Es gibt keinen grundsätzlichen Unterschied zwischen der Verwendung des klassischen Zyklus for
und des funktionalen Ansatzes mit ClosedRange
und forEach{}
- jeder Entwickler entscheidet für sich, was für ihn bequemer ist. Ich werde Ihre Köpfe jedoch weiterhin mit Funktionalismus täuschen, einfach weil ich ein Affe bin, der alles liebt, was neu ist und glänzende Klammern nicht in eine neue Zeile eingeschlossen sind und der Code kompakter aussieht.Wir implementieren eine andere Dienstprogrammmethode drawBlankLine()
, die dasselbe tut wiedrawHorizontalLine(offsetY, ' ')
, nur mit Verlängerung. Manchmal müssen wir die Linie nicht vollständig leer machen, sondern am Anfang und am Ende eine vertikale Linie lassen (Frame, ja). Der Code sieht ungefähr so aus: protected fun drawBlankLine(offsetY: Int, drawBorders: Boolean = true) { ansi.cursor(offsetY, 1) if (drawBorders) { ansi.a('│') (2 until CONSOLE_WIDTH).forEach { ansi.a(' ') } ansi.a('│') } else { ansi.eraseLine(Ansi.Erase.ALL) } }
Wie, Sie haben nie Frames aus Pseudografien gezeichnet? Symbole können direkt in den Quellcode eingefügt werden. Halten Sie die Alt-Taste gedrückt und geben Sie den Zeichencode auf dem Ziffernblock ein. Dann lass los. Die ASCII-Codes, die wir für jede Codierung benötigen, sind die gleichen. Hier ist der minimale Gentleman-Satz:Und dann, wie in Minecraft, sind die Möglichkeiten nur durch die Grenzen Ihrer Vorstellungskraft begrenzt. Und die Bildschirmgröße. protected fun drawCenteredCaption(offsetY: Int, text: String, color: Color, drawBorders: Boolean = true) { val center = (CONSOLE_WIDTH - text.length) / 2 ansi.cursor(offsetY, 1) ansi.a(if (drawBorders) '│' else ' ') (2 until center).forEach { ansi.a(' ') } ansi.color(color).a(text).reset() (text.length + center until CONSOLE_WIDTH).forEach { ansi.a(' ') } ansi.a(if (drawBorders) '│' else ' ') }
Reden wir ein wenig über die Blumen. Die Klasse Ansi
enthält Konstanten Color
für acht Primärfarben (Schwarz, Blau, Grün, Cyan, Rot, Violett, Gelb, Grau), die Sie an die Eingabe von Methoden fg()/bg()
für die dunkle Version oder fgBright()/bgBright()
für die helle übergeben müssen, was für die Identifizierung der Farbe furchtbar unpraktisch ist Übrigens reicht uns ein Wert nicht - wir brauchen mindestens zwei (Farbe und Helligkeit). Daher werden wir unsere Liste der Konstanten und unsere Erweiterungsmethoden erstellen (sowie Kartenbindungsfarben für Würfeltypen und Heldenklassen): protected enum class Color { BLACK, DARK_BLUE, DARK_GREEN, DARK_CYAN, DARK_RED, DARK_MAGENTA, DARK_YELLOW, LIGHT_GRAY, DARK_GRAY, LIGHT_BLUE, LIGHT_GREEN, LIGHT_CYAN, LIGHT_RED, LIGHT_MAGENTA, LIGHT_YELLOW, WHITE } protected fun Ansi.color(color: Color?): Ansi = when (color) { Color.BLACK -> fgBlack() Color.DARK_BLUE -> fgBlue() Color.DARK_GREEN -> fgGreen() Color.DARK_CYAN -> fgCyan() Color.DARK_RED -> fgRed() Color.DARK_MAGENTA -> fgMagenta() Color.DARK_YELLOW -> fgYellow() Color.LIGHT_GRAY -> fg(Ansi.Color.WHITE) Color.DARK_GRAY -> fgBrightBlack() Color.LIGHT_BLUE -> fgBrightBlue() Color.LIGHT_GREEN -> fgBrightGreen() Color.LIGHT_CYAN -> fgBrightCyan() Color.LIGHT_RED -> fgBrightRed() Color.LIGHT_MAGENTA -> fgBrightMagenta() Color.LIGHT_YELLOW -> fgBrightYellow() Color.WHITE -> fgBright(Ansi.Color.WHITE) else -> this } protected fun Ansi.background(color: Color?): Ansi = when (color) { Color.BLACK -> ansi.bg(Ansi.Color.BLACK) Color.DARK_BLUE -> ansi.bg(Ansi.Color.BLUE) Color.DARK_GREEN -> ansi.bgGreen() Color.DARK_CYAN -> ansi.bg(Ansi.Color.CYAN) Color.DARK_RED -> ansi.bgRed() Color.DARK_MAGENTA -> ansi.bgMagenta() Color.DARK_YELLOW -> ansi.bgYellow() Color.LIGHT_GRAY -> ansi.bg(Ansi.Color.WHITE) Color.DARK_GRAY -> ansi.bgBright(Ansi.Color.BLACK) Color.LIGHT_BLUE -> ansi.bgBright(Ansi.Color.BLUE) Color.LIGHT_GREEN -> ansi.bgBrightGreen() Color.LIGHT_CYAN -> ansi.bgBright(Ansi.Color.CYAN) Color.LIGHT_RED -> ansi.bgBrightRed() Color.LIGHT_MAGENTA -> ansi.bgBright(Ansi.Color.MAGENTA) Color.LIGHT_YELLOW -> ansi.bgBrightYellow() Color.WHITE -> ansi.bgBright(Ansi.Color.WHITE) else -> this } protected val dieColors = mapOf( Die.Type.PHYSICAL to Color.LIGHT_BLUE, Die.Type.SOMATIC to Color.LIGHT_GREEN, Die.Type.MENTAL to Color.LIGHT_MAGENTA, Die.Type.VERBAL to Color.LIGHT_YELLOW, Die.Type.DIVINE to Color.LIGHT_CYAN, Die.Type.WOUND to Color.DARK_GRAY, Die.Type.ENEMY to Color.DARK_RED, Die.Type.VILLAIN to Color.LIGHT_RED, Die.Type.OBSTACLE to Color.DARK_YELLOW, Die.Type.ALLY to Color.WHITE ) protected val heroColors = mapOf( Hero.Type.BRAWLER to Color.LIGHT_BLUE, Hero.Type.HUNTER to Color.LIGHT_GREEN )
Jetzt wird jede der 16 verfügbaren Farben durch eine einzelne Konstante eindeutig identifiziert. Wir werden ein paar weitere Dienstprogrammmethoden schreiben, aber vorher werden wir noch eines herausfinden:Wo werden die Konstanten für Textzeichenfolgen gespeichert?„String-Konstanten müssen in separaten Dateien entfernt werden, damit sie alle an einem Ort gespeichert werden. Dies erleichtert die Wartung. Und es ist auch wichtig für die Lokalisierung ... "String-Konstanten müssen in separate Dateien verschoben werden ... na ja. Wir werden es aushalten. Der Standard-Java-Mechanismus für die Arbeit mit dieser Art von Ressourcen sind die Objekte java.util.ResourceBundle
, die mit Dateien arbeiten .properties
. Hier beginnen wir mit einer solchen Datei: # Game status messages choose_dice_perform_check=Choose dice to perform check: end_of_turn_discard_extra=END OF TURN: Discard extra dice: end_of_turn_discard_optional=END OF TURN: Discard any dice, if needed: choose_action_before_exploration=Choose your action: choose_action_after_exploration=Already explored this turn. Choose what to do now: encounter_physical=Encountered PHYSICAL die. Need to pass respective check or lose this die. encounter_somatic=Encountered SOMATIC die. Need to pass respective check or lose this die. encounter_mental=Encountered MENTAL die. Need to pass respective check or lose this die. encounter_verbal=Encountered VERBAL die. Need to pass respective check or lose this die. encounter_divine=Encountered DIVINE die. Can be acquired automatically (no checks needed): die_acquire_success=You have acquired the die! die_acquire_failure=You have failed to acquire the die. game_loss_out_of_time=You ran out of time # Die types physical=PHYSICAL somatic=SOMATIC mental=MENTAL verbal=VERBAL divine=DIVINE ally=ALLY wound=WOUND enemy=ENEMY villain=VILLAIN obstacle=OBSTACLE # Hero types and descriptions brawler=Brawler hunter=Hunter # Various labels avg=avg bag=Bag bag_size=Bag size class=Class closed=Closed discard=Discard empty=Empty encountered=Encountered fail=Fail hand=Hand heros_turn=%s's turn max=max min=min perform_check=Perform check: pile=Pile received_new_die=Received new die result=Result success=Success sum=sum time=Time total=Total # Action names and descriptions action_confirm_key=ENTER action_confirm_name=Confirm action_cancel_key=ESC action_cancel_name=Cancel action_explore_location_key=E action_explore_location_name=xplore action_finish_turn_key=F action_finish_turn_name=inish action_hide_key=H action_hide_name=ide action_discard_key=D action_discard_name=iscard action_acquire_key=A action_acquire_name=cquire action_leave_key=L action_leave_name=eave action_forfeit_key=F action_forfeit_name=orfeit
Jede Zeile enthält ein Schlüssel-Wert-Paar, das durch ein Zeichen getrennt ist =
. Sie können die Datei an einer beliebigen Stelle ablegen. Hauptsache, der Pfad dazu ist Teil des Klassenpfads. Bitte beachten Sie, dass der Text für Aktionen aus zwei Teilen besteht: Der erste Buchstabe wird nicht nur gelb hervorgehoben, wenn er auf dem Bildschirm angezeigt wird, sondern bestimmt auch die Taste, die gedrückt werden muss, um diese Aktion auszuführen. Daher ist es zweckmäßig, sie separat aufzubewahren.Wir abstrahieren jedoch von einem bestimmten Format (in Android werden beispielsweise Zeichenfolgen unterschiedlich gespeichert) und beschreiben die Schnittstelle zum Laden von Zeichenfolgenkonstanten. interface StringLoader { fun loadString(key: String): String }
Der Schlüssel wird an den Eingang übertragen, der Ausgang ist eine bestimmte Zeile. Die Implementierung ist so einfach wie die Schnittstelle selbst (angenommen, die Datei liegt auf dem Pfad src/main/resources/text/strings.properties
). class PropertiesStringLoader() : StringLoader { private val properties = ResourceBundle.getBundle("text.strings") override fun loadString(key: String) = properties.getString(key) ?: "" }
Jetzt wird es nicht schwierig sein, eine Methode drawStatusMessage()
zum Anzeigen des aktuellen Status der Spiel-Engine ( StatusMessage
) auf dem Bildschirm und eine Methode drawActionList()
zum Anzeigen einer Liste verfügbarer Aktionen ( ActionList
) zu implementieren . Sowie andere offizielle Methoden, die nur die Seele wünscht.Es gibt eine Menge Code, einen Teil davon haben wir bereits gesehen ... also hier ist ein Spoiler für Sie abstract class ConsoleRenderer(private val strings: StringLoader) { protected lateinit var ansi: Ansi init { AnsiConsole.systemInstall() clearScreen() resetAnsi() } protected fun loadString(key: String) = strings.loadString(key) private fun resetAnsi() { ansi = Ansi.ansi() } fun clearScreen() { print(Ansi.ansi().eraseScreen(Ansi.Erase.ALL).cursor(1, 1)) } protected fun render() { ansi.cursor(CONSOLE_HEIGHT, CONSOLE_WIDTH) System.out.print(ansi.toString()) resetAnsi() } protected fun drawBigNumber(offsetX: Int, offsetY: Int, number: Int): Unit = with(ansi) { var currentX = offsetX cursor(offsetY, currentX) val text = number.toString() text.forEach { when (it) { '0' -> { cursor(offsetY, currentX) a(" ███ ") cursor(offsetY + 1, currentX) a("█ █ ") cursor(offsetY + 2, currentX) a("█ █ ") cursor(offsetY + 3, currentX) a("█ █ ") cursor(offsetY + 4, currentX) a(" ███ ") } '1' -> { cursor(offsetY, currentX) a(" █ ") cursor(offsetY + 1, currentX) a(" ██ ") cursor(offsetY + 2, currentX) a("█ █ ") cursor(offsetY + 3, currentX) a(" █ ") cursor(offsetY + 4, currentX) a("█████ ") } '2' -> { cursor(offsetY, currentX) a(" ███ ") cursor(offsetY + 1, currentX) a("█ █ ") cursor(offsetY + 2, currentX) a(" █ ") cursor(offsetY + 3, currentX) a(" █ ") cursor(offsetY + 4, currentX) a("█████ ") } '3' -> { cursor(offsetY, currentX) a("████ ") cursor(offsetY + 1, currentX) a(" █ ") cursor(offsetY + 2, currentX) a(" ██ ") cursor(offsetY + 3, currentX) a(" █ ") cursor(offsetY + 4, currentX) a("████ ") } '4' -> { cursor(offsetY, currentX) a(" █ ") cursor(offsetY + 1, currentX) a(" ██ ") cursor(offsetY + 2, currentX) a(" █ █ ") cursor(offsetY + 3, currentX) a("█████ ") cursor(offsetY + 4, currentX) a(" █ ") } '5' -> { cursor(offsetY, currentX) a("█████ ") cursor(offsetY + 1, currentX) a("█ ") cursor(offsetY + 2, currentX) a("████ ") cursor(offsetY + 3, currentX) a(" █ ") cursor(offsetY + 4, currentX) a("████ ") } '6' -> { cursor(offsetY, currentX) a(" ███ ") cursor(offsetY + 1, currentX) a("█ ") cursor(offsetY + 2, currentX) a("████ ") cursor(offsetY + 3, currentX) a("█ █ ") cursor(offsetY + 4, currentX) a(" ███ ") } '7' -> { cursor(offsetY, currentX) a("█████ ") cursor(offsetY + 1, currentX) a(" █ ") cursor(offsetY + 2, currentX) a(" █ ") cursor(offsetY + 3, currentX) a(" █ ") cursor(offsetY + 4, currentX) a(" █ ") } '8' -> { cursor(offsetY, currentX) a(" ███ ") cursor(offsetY + 1, currentX) a("█ █ ") cursor(offsetY + 2, currentX) a(" ███ ") cursor(offsetY + 3, currentX) a("█ █ ") cursor(offsetY + 4, currentX) a(" ███ ") } '9' -> { cursor(offsetY, currentX) a(" ███ ") cursor(offsetY + 1, currentX) a("█ █ ") cursor(offsetY + 2, currentX) a(" ████ ") cursor(offsetY + 3, currentX) a(" █ ") cursor(offsetY + 4, currentX) a(" ███ ") } } currentX += 6 } } protected fun drawHorizontalLine(offsetY: Int, filler: Char) { ansi.cursor(offsetY, 1) (1..CONSOLE_WIDTH).forEach { ansi.a(filler) } } protected fun drawBlankLine(offsetY: Int, drawBorders: Boolean = true) { ansi.cursor(offsetY, 1) if (drawBorders) { ansi.a('│') (2 until CONSOLE_WIDTH).forEach { ansi.a(' ') } ansi.a('│') } else { ansi.eraseLine(Ansi.Erase.ALL) } } protected fun drawCenteredCaption(offsetY: Int, text: String, color: Color, drawBorders: Boolean = true) { val center = (CONSOLE_WIDTH - text.length) / 2 ansi.cursor(offsetY, 1) ansi.a(if (drawBorders) '│' else ' ') (2 until center).forEach { ansi.a(' ') } ansi.color(color).a(text).reset() (text.length + center until CONSOLE_WIDTH).forEach { ansi.a(' ') } ansi.a(if (drawBorders) '│' else ' ') } protected fun drawStatusMessage(offsetY: Int, message: StatusMessage, drawBorders: Boolean = true) {
Warum haben wir das alle gemacht, fragst du? Ja, um unsere Schnittstellenimplementierung von dieser wunderbaren Klasse zu erben GameRenderer
.So sieht die Implementierung der ersten, einfachsten Methode aus: override fun drawGameLoss(message: StatusMessage) { val centerY = CONSOLE_HEIGHT / 2 (1 until centerY).forEach { drawBlankLine(it, false) } val data = loadString(message.toString().toLowerCase()).toUpperCase() drawCenteredCaption(centerY, data, LIGHT_RED, false) (centerY + 1..CONSOLE_HEIGHT).forEach { drawBlankLine(it, false) } render() }
Nichts Übernatürliches, nur eine data
rot gezeichnete Textzeile ( ) in der Mitte des Bildschirms ( drawCenteredCaption()
). Der Rest des Codes füllt den Rest des Bildschirms mit Leerzeilen. Vielleicht wird jemand fragen, warum dies notwendig ist - schließlich gibt es eine Methode clearScreen()
, die ausreicht, um sie am Anfang der Methode aufzurufen, den Bildschirm zu löschen und dann den gewünschten Text zu zeichnen. Leider ist dies ein fauler Ansatz, den wir nicht verwenden werden. Der Grund ist sehr einfach: Bei diesem Ansatz werden einige Positionen auf dem Bildschirm zweimal gezeichnet, was zu einem merklichen Flackern führt, insbesondere wenn der Bildschirm nacheinander mehrmals hintereinander gezeichnet wird (während Animationen). Daher ist es unsere Aufgabe, nicht nur die richtigen Zeichen an den richtigen Stellen zu zeichnen, sondern das Ganze auszufüllenDer Rest des Bildschirms enthält leere Zeichen (damit keine Artefakte aus anderen Renderings darauf verbleiben). Und diese Aufgabe ist nicht so einfach.Die folgende Methode folgt diesem Prinzip: override fun drawHeroTurnStart(hero: Hero) { val centerY = (CONSOLE_HEIGHT - 5) / 2 (1 until centerY).forEach { drawBlankLine(it, false) } ansi.color(heroColors[hero.type]) drawHorizontalLine(centerY, '─') drawHorizontalLine(centerY + 4, '─') ansi.reset() ansi.cursor(centerY + 1, 1).eraseLine() ansi.cursor(centerY + 3, 1).eraseLine() ansi.cursor(centerY + 2, 1) val text = String.format(loadString("heros_turn"), hero.name.toUpperCase()) val index = text.indexOf(hero.name.toUpperCase()) val center = (CONSOLE_WIDTH - text.length) / 2 ansi.cursor(centerY + 2, center) ansi.eraseLine(Ansi.Erase.BACKWARD) ansi.a(text.substring(0, index)) ansi.color(heroColors[hero.type]).a(hero.name.toUpperCase()).reset() ansi.a(text.substring(index + hero.name.length)) ansi.eraseLine(Ansi.Erase.FORWARD) (centerY + 5..CONSOLE_HEIGHT).forEach { drawBlankLine(it, false) } render() }
Hier gibt es neben dem zentrierten Text auch zwei horizontale Linien (siehe Screenshots oben). Bitte beachten Sie, dass die mittlere Beschriftung in zwei Farben angezeigt wird. Und stellen Sie sicher, dass das Erlernen von Mathematik in der Schule immer noch nützlich ist.Nun, wir haben uns die einfachsten Methoden angesehen und es ist Zeit, die Implementierung kennenzulernen drawLocationInteriorScreen()
. Wie Sie selbst verstehen, wird es hier eine Größenordnung mehr Code geben. Darüber hinaus ändert sich der Inhalt des Bildschirms dynamisch als Reaktion auf Benutzeraktionen und muss ständig neu gezeichnet werden (manchmal mit Animation). Nun, um Sie endgültig fertig zu machen: Stellen Sie sich vor, dass im Rahmen dieser Methode zusätzlich zum obigen Screenshot die Anzeige von drei weiteren implementiert werden muss:1. Treffen mit dem aus dem Beutel entfernten Würfel 2. Würfel auswählen, um den Test zu bestehen 3. Testergebnisse anzeigen Daher ist hier mein großer Rat an Sie: Schieben Sie nicht den gesamten Code in eine Methode. Teilen Sie die Implementierung in mehrere Methoden auf (auch wenn jede nur einmal aufgerufen wird). Vergessen Sie nicht den "Gummi".Wenn es in Ihren Augen zu kräuseln beginnt, blinken Sie ein paar Sekunden lang - dies sollte helfen class ConsoleGameRenderer(loader: StringLoader) : ConsoleRenderer(loader), GameRenderer { private fun drawLocationTopPanel(location: Location, heroesAtLocation: List<Hero>, currentHero: Hero, timer: Int) { val closedString = loadString("closed").toLowerCase() val timeString = loadString("time") val locationName = location.name.toString().toUpperCase() val separatorX1 = locationName.length + if (location.isOpen) { 6 + if (location.bag.size >= 10) 2 else 1 } else { closedString.length + 7 } val separatorX2 = CONSOLE_WIDTH - timeString.length - 6 - if (timer >= 10) 1 else 0
Es gibt ein kleines Problem bei der Überprüfung der Funktionsweise dieses gesamten Codes. Da die integrierte IDE-Konsole keine ANSI-Escape-Sequenzen unterstützt, müssen Sie die Anwendung in einem externen Terminal starten (wir haben bereits ein Skript zum früheren Starten geschrieben). Darüber hinaus ist mit ANSI-Unterstützung unter Windows nicht alles in Ordnung - soweit ich weiß, kann uns die Standard-cmd.exe nur mit der 10. Version mit einem hochwertigen Display zufrieden stellen (und das mit einigen Problemen, auf die wir uns nicht konzentrieren werden). Und PowerShell hat nicht sofort gelernt, Sequenzen zu erkennen (trotz der aktuellen Nachfrage). Wenn Sie Pech haben, lassen Sie sich nicht entmutigen - es gibt immer alternative Lösungen ( zum Beispiel ). Und wir gehen weiter.Schritt zehn Benutzereingabe
Das Anzeigen des Bildes auf dem Bildschirm ist immer noch die halbe Miete. Ebenso wichtig ist es, Steuerbefehle vom Benutzer korrekt zu empfangen. Und diese Aufgabe, möchte ich Ihnen sagen, kann sich als technisch viel schwieriger herausstellen als alle vorherigen. Aber das Wichtigste zuerst.
Wie Sie sich erinnern, stehen wir vor der Notwendigkeit, Klassenmethoden zu implementieren GameInteractor
. Es gibt nur drei von ihnen, aber sie erfordern besondere Aufmerksamkeit. Erstens die Synchronisation. Die Spiel-Engine sollte angehalten werden, bis der Spieler eine Taste drückt. Zweitens klicken Sie auf Verarbeitung. Leider ist die Kapazität von Standardklassen Reader
, Scanner
, Console
ist nicht genug , um diese dringendsten zu erkennen: Wir brauchen nicht um den Benutzer zu drücken nach jedem Befehl ENTER. Wir brauchen so etwas wie KeyListener
'a', aber es ist eng mit dem Swing-Framework verbunden, und unsere Konsolenanwendung ist ohne all dieses grafische Lametta.Was tun?
Die Suche nach Bibliotheken und diesmal ihre Arbeit basieren natürlich ausschließlich auf nativem Code. Was bedeutet "Auf Wiedersehen, plattformübergreifend" ... oder nicht? Leider habe ich noch keine Bibliothek gefunden, die einfache Funktionen in einer leichten, plattformunabhängigen Form implementiert. Lassen Sie uns in der Zwischenzeit auf das Monster jLine achten , das einen Harvester zum Erstellen erweiterter Benutzeroberflächen (in der Konsole) implementiert. Ja, es hat eine native Implementierung, ja, es unterstützt sowohl Windows als auch Linux / UNIX (durch Bereitstellung der entsprechenden Bibliotheken). Und ja, verwendet auf den meisten seiner Funktionalität, wissen wir nicht hundert Jahre brauchen. Alles, was benötigt wird, ist eine kleine, schlecht dokumentierte Gelegenheit, deren Arbeit wir nun analysieren werden. <dependency> <groupId>jline</groupId> <artifactId>jline</artifactId> <version>2.14.6</version> <scope>compile</scope> </dependency>
Bitte beachten Sie, dass wir nicht die dritte, neueste Version benötigen, sondern die zweite, in der es eine Klasse ConsoleReader
mit einer Methode gibt readCharacter()
. Wie der Name schon sagt, gibt diese Methode den Code des auf der Tastatur gedrückten Zeichens zurück (während wir synchron arbeiten, was wir brauchen). Der Rest ist eine technische Angelegenheit: Erstellen Sie eine Korrespondenztabelle zwischen Symbolen und Aktionstypen ( Action.Type
) und geben Sie durch Klicken auf eines das andere zurück.„Wissen Sie, dass nicht alle Tasten auf der Tastatur mit einem Zeichen dargestellt werden können? Viele Schlüssel verwenden Escape-Sequenzen mit zwei, drei, vier verschiedenen Zeichen. Wie kann man mit ihnen zusammen sein? "Es sollte beachtet werden, dass die Eingabeaufgabe kompliziert ist, wenn wir "Nicht-Zeichen-Tasten" erkennen möchten: Pfeile, F-Tasten, Home, Einfügen, PgUp / Dn, Ende, Löschen, Nummernblock und andere. Aber wir wollen nicht, deshalb werden wir weitermachen. Erstellen wir eine Klasse ConsoleInteractor
mit den erforderlichen Servicemethoden. abstract class ConsoleInteractor { private val reader = ConsoleReader() private val mapper = mapOf( CONFIRM to 13.toChar(), CANCEL to 27.toChar(), EXPLORE_LOCATION to 'e', FINISH_TURN to 'f', ACQUIRE to 'a', LEAVE to 'l', FORFEIT to 'f', HIDE to 'h', DISCARD to 'd', ) protected fun read() = reader.readCharacter().toChar() protected open fun getIndexForKey(key: Char) = "1234567890abcdefghijklmnopqrstuvw".indexOf(key) }
Stellen Sie die Karte mapper
und Methode ein read()
. Darüber hinaus stellen wir eine Methode getIndexForKey()
zur Verfügung, die in Situationen verwendet wird, in denen ein Element aus einer Liste oder Würfel aus einer Hand ausgewählt werden müssen. Es bleibt, unsere Schnittstellenimplementierung von dieser Klasse zu erben GameInteractor
.Und in der Tat der Code: class ConsoleGameInteractor : ConsoleInteractor(), GameInteractor { override fun anyInput() { read() } override fun pickAction(list: ActionList): Action { while (true) { val key = read() list .filter(Action::isEnabled) .find { mapper[it.type] == key } ?.let { return it } } } override fun pickDiceFromHand(activePositions: HandMask, actions: ActionList) : Action { while (true) { val key = read() actions.forEach { if (mapper[it.type] == key && it.isEnabled) return it } when (key) { in '1'..'9' -> { val index = key - '1' if (activePositions.checkPosition(index)) { return Action(HAND_POSITION, data = index) } } '0' -> { if (activePositions.checkPosition(9)) { return Action(HAND_POSITION, data = 9) } } in 'a'..'f' -> { val allyIndex = key - 'a' if (activePositions.checkAllyPosition(allyIndex)) { return Action(HAND_ALLY_POSITION, data = allyIndex) } } } } } }
Die Umsetzung unserer Methoden ist sehr höflich und gutmütig, um nicht verschiedene unangemessene Unsinn hervorzubringen. Sie selbst überprüfen, ob die ausgewählte Aktion aktiv ist und die ausgewählte Handposition im gültigen Satz enthalten ist. Und ich möchte, dass wir alle so höflich zu den Menschen um uns herum sind.Schritt elf. Klänge und Musik
Aber wie kann es ohne sie sein? Wenn Sie jemals Spiele mit ausgeschaltetem Ton gespielt haben (z. B. mit einem Tablet unter der Decke, während niemand zu Hause etwas sieht), haben Sie möglicherweise festgestellt, wie viel Sie verlieren. Es ist, als würde man nur die Hälfte des Spiels spielen. Viele Spiele sind ohne Klangbegleitung nicht vorstellbar, für viele ist dies eine unveräußerliche Voraussetzung, obwohl es umgekehrte Situationen gibt (zum Beispiel, wenn im Prinzip keine Klänge vorhanden sind oder sie so elend sind, dass es ohne sie besser wäre). Gute Arbeit zu leisten ist eigentlich nicht so einfach, wie es auf den ersten Blick scheint (nicht ohne Grund tun dies hochqualifizierte Spezialisten in großen Studios), aber wie auch immer, in den meisten Fällen ist es viel besser, eine Audiokomponente (zumindest einige) in Ihrem Spiel zu haben als sie überhaupt nicht zu haben. Als letztes Mittel kann die Klangqualität später verbessert werden.wenn es Zeit und Stimmung erlauben.Aufgrund der Besonderheiten des Genres wird unser Spiel nicht durch meisterhafte Soundeffekte gekennzeichnet sein. Wenn Sie digitale Anpassungen von Brettspielen gespielt haben, verstehen Sie, was ich meine. Klänge stoßen ihre Monotonie ab, werden bald langweilig und nach einiger Zeit scheint das Spielen ohne sie kein ernsthafter Verlust mehr zu sein. Das Problem wird durch die Tatsache verschärft, dass es keine wirksamen Möglichkeiten gibt, mit diesem Phänomen umzugehen. Ersetzen Sie Spielgeräusche durch völlig andere, und im Laufe der Zeit werden sie angewidert. In guten Spielen ergänzen Sounds das Gameplay, enthüllen die Atmosphäre der laufenden Action, machen sie lebendig - dies ist schwer zu erreichen, wenn die Atmosphäre nur ein Tisch mit einem Haufen staubiger Taschen ist und das gesamte Gameplay aus Würfeln besteht. Trotzdem werden wir genau das sagen: Die Seide ist hier, die Besetzung ist hier,Rascheln und Rascheln zu lauten Schreien - als ob wir kein Bild auf dem Bildschirm beobachten würden, sondern wirklich mit realen physischen Objekten interagieren würden. Sie müssen vollständig, aber unauffällig geäußert werden - während des gesamten Skripts werden Sie sie hundertmal hören, damit die Sounds nicht in den Vordergrund treten - schattieren Sie das Gameplay nur sanft. Wie kann dies kompetent erreicht werden? Ich habe keine Ahnung, ich bin kein besonderer Klang. Ich kann Ihnen nur raten, Ihr Spiel so oft wie möglich zu spielen und auffällige Fehler zu bemerken und zu beseitigen (dieser Rat gilt übrigens nicht nur für Sounds).Wie kann dies kompetent erreicht werden? Ich habe keine Ahnung, ich bin kein besonderer Klang. Ich kann Ihnen nur raten, Ihr Spiel so oft wie möglich zu spielen und auffällige Fehler zu bemerken und zu beseitigen (dieser Rat gilt übrigens nicht nur für Sounds).Wie kann dies kompetent erreicht werden? Ich habe keine Ahnung, ich bin kein besonderer Klang. Ich kann Ihnen nur raten, Ihr Spiel so oft wie möglich zu spielen und auffällige Fehler zu bemerken und zu beseitigen (dieser Rat gilt übrigens nicht nur für Sounds).Mit der Theorie, so scheint es, ist es an der Zeit, mit der Praxis fortzufahren. Und vorher müssen Sie eine Frage stellen: Wo sollen eigentlich Spieledateien abgelegt werden? Der einfachste und sicherste Weg - sie selbst in hässlicher Qualität aufzunehmen, mit einem alten Mikrofon oder sogar mit dem Telefon. Das Internet ist voll von Videos darüber, wie das Abschrauben der Ananasspitzen oder das Brechen von Eis mit einem Stiefel den Effekt erzielen kann, Knochen und einen knusprigen Rücken zu zerquetschen. Wenn Sie der Ästhetik des Surrealismus nicht fremd sind, können Sie Ihre eigene Stimme oder Küchenutensilien als Musikinstrument verwenden (es gibt Beispiele - und sogar erfolgreiche -, wo dies getan wurde). Oder Sie können zu freesound.org gehenwo hundert andere Leute das vor langer Zeit für dich getan haben. Achten Sie nur auf die Lizenz: Viele Autoren reagieren sehr empfindlich auf die Audioaufnahmen ihres lauten Hustens oder der auf den Boden geworfenen Münzen - Sie möchten die Früchte ihrer Arbeit keinesfalls skrupellos verwenden, ohne den ursprünglichen Schöpfer zu bezahlen oder sein kreatives Pseudonym nicht zu erwähnen (manchmal sehr bizarr). in den Kommentaren.Ziehen Sie die gewünschten Dateien und platzieren Sie sie irgendwo im Klassenpfad. Um sie zu identifizieren, verwenden wir die Aufzählung, wobei jede Instanz einem Soundeffekt entspricht. enum class Sound { TURN_START,
Da die Methode zur Wiedergabe von Sounds je nach Hardwareplattform unterschiedlich ist, können wir über die Schnittstelle von einer bestimmten Implementierung abstrahieren. Zum Beispiel dieses: interface SoundPlayer { fun play(sound: Sound) }
Wie die zuvor diskutierten Schnittstellen GameRenderer
und GameInteractor
muss auch ihre Implementierung an die Eingabe an die Klasseninstanz übergeben werden Game
. Für den Anfang könnte eine Implementierung folgendermaßen aussehen: class MuteSoundPlayer : SoundPlayer { override fun play(sound: Sound) {
Anschließend werden wir weitere interessante Implementierungen betrachten, aber jetzt wollen wir über Musik sprechen.Wie Soundeffekte spielt es eine große Rolle bei der Schaffung der Atmosphäre des Spiels, und auf die gleiche Weise kann ein ausgezeichnetes Spiel durch unangemessene Musik ruiniert werden. Wie Klänge sollte Musik unauffällig sein, nicht in den Vordergrund treten (außer wenn dies für einen künstlerischen Effekt erforderlich ist) und der Handlung auf dem Bildschirm angemessen entsprechen (hoffen Sie nicht, dass jemand ernsthaft vom Schicksal eines überfallenen und gnadenlos getöteten Hauptdarstellers durchdrungen ist Held, wenn die Szene seines tragischen Todes von einer lustigen kleinen Musik aus einem Kinderlied begleitet wird). Dies ist sehr schwer zu erreichen, speziell ausgebildete Leute beschäftigen sich mit solchen Problemen (wir sind mit ihnen nicht vertraut), aber wir als Anfänger des Gamebuilding-Genies können auch etwas tun. Zum Beispiel irgendwohin gehenfreemusicarchive.org oder soundcloud.com (oder sogar YouTube) und finden Sie etwas nach Ihren Wünschen. Für Desktops ist Ambient eine gute Wahl - leise, sanfte Musik ohne ausgeprägte Melodie, die sich gut zum Erstellen eines Hintergrunds eignet. Achten Sie doppelt auf die Lizenz: Selbst freie Musik wird manchmal von talentierten Komponisten geschrieben, die eine finanzielle Anerkennung verdienen, wenn nicht sogar eine finanzielle Belohnung.Lassen Sie uns noch eine Aufzählung erstellen: enum class Music { SCENARIO_MUSIC_1, SCENARIO_MUSIC_2, SCENARIO_MUSIC_3, }
Ebenso definieren wir die Schnittstelle und ihre Standardimplementierung. interface MusicPlayer { fun play(music: Music) fun stop() } class MuteMusicPlayer : MusicPlayer { override fun play(music: Music) {
Bitte beachten Sie, dass in diesem Fall zwei Methoden erforderlich sind: eine zum Starten der Wiedergabe und die andere zum Stoppen der Wiedergabe. Es ist auch durchaus möglich, dass zusätzliche Methoden (Pause / Fortsetzen, Zurückspulen usw.) in Zukunft nützlich sein werden, aber bisher reichen diese beiden aus.Es scheint nicht sehr praktisch zu sein, jedes Mal Verweise auf Spielerklassen zwischen Objekten zu übergeben. Zu einer Zeit, brauchen wir nur einen ekzepmlyar Spieler, so würde ich vorschlagen wagen alle notwendigen spielen Töne und Musik Methoden in einem separaten Objekt zu machen und machen ein Einzelgänger (Singleton). Somit ist das verantwortliche Audio-Subsystem immer von überall in der Anwendung verfügbar, ohne ständig Links zu derselben Instanz zu übertragen. Es wird so aussehen:Klassendiagramm des Audiowiedergabesystems Klasse Audio
ist unser Singleton. Es bietet dem Subsystem eine einzige Fassade ... übrigens, hier ist die Fassade (Fassade) - ein weiteres Entwurfsmuster, das in diesen Bereichen Ihres Internets sorgfältig entworfen und wiederholt (mit Beispielen) beschrieben wurde. Nachdem ich bereits unzufriedene Schreie aus den hinteren Reihen gehört habe, höre ich auf, die seit langem bekannten Dinge zu erklären, und gehe weiter. Der Code lautet: object Audio { private var soundPlayer: SoundPlayer = MuteSoundPlayer() private var musicPlayer: MusicPlayer = MuteMusicPlayer() fun init(soundPlayer: SoundPlayer, musicPlayer: MusicPlayer) { this.soundPlayer = soundPlayer this.musicPlayer = musicPlayer } fun playSound(sound: Sound) = this.soundPlayer.play(sound) fun playMusic(music: Music) = this.musicPlayer.play(music) fun stopMusic() = this.musicPlayer.stop() }
Es reicht aus, es init()
nur einmal ganz am Anfang aufzurufen (indem es mit den erforderlichen Objekten initialisiert wird) und in Zukunft bequeme Methoden zu verwenden, wobei die Implementierungsdetails völlig vergessen werden. Auch wenn Sie dies nicht tun, keine Sorge, das System stirbt - das Objekt wird durch Standardklassen initialisiert.Das ist alles.
Es bleibt die eigentliche Wiedergabe zu behandeln. Für das Abspielen von Sounds (oder, wie kluge Leute sagen, Samples ) verfügt Java über eine praktische Klasse AudioSystem
und Oberfläche Clip
. Alles was wir brauchen ist, den Pfad zur Audiodatei korrekt festzulegen (was in unserem Klassenpfad liegt, erinnerst du dich?): import javax.sound.sampled.AudioSystem class BasicSoundPlayer : SoundPlayer { private fun pathToFile(sound: Sound) = "/sound/${sound.toString().toLowerCase()}.wav" override fun play(sound: Sound) { val url = javaClass.getResource(pathToFile(sound)) val audioIn = AudioSystem.getAudioInputStream(url) val clip = AudioSystem.getClip() clip.open(audioIn) clip.start() } }
Die Methode open()
kann es wegwerfen IOException
(insbesondere, wenn ihm das Dateiformat mit etwas nicht gefallen hat - in diesem Fall empfehle ich, die Datei in einem Audio-Editor zu öffnen und erneut zu speichern). Es wäre also schön, sie in einen Block zu packen try-catch
, aber zuerst tun wir es nicht, damit die Anwendung laut ist stürzte jedes Mal mit Tonproblemen ab."Ich weiß nicht einmal, was ich sagen soll ..."Mit Musik ist es viel schlimmer. Soweit ich weiß, gibt es in Java keine Standardmethode zum Abspielen von Musikdateien (z. B. im MP3-Format). Sie müssen daher auf jeden Fall eine Bibliothek eines Drittanbieters verwenden (es gibt Dutzende verschiedener). Jedes Leichtgewicht mit minimaler Funktionalität ist für uns geeignet, zum Beispiel der recht beliebte JLayer . Fügen Sie es hinzu, abhängig von: <dependencies> <dependency> <groupId>com.googlecode.soundlibs</groupId> <artifactId>jlayer</artifactId> <version>1.0.1.4</version> <scope>compile</scope> </dependency> </dependencies>
Und wir implementieren unseren Player mit seiner Hilfe. class BasicMusicPlayer : MusicPlayer { private var currentMusic: Music? = null private var thread: PlayerThread? = null private fun pathToFile(music: Music) = "/music/${music.toString().toLowerCase()}.mp3" override fun play(music: Music) { if (currentMusic == music) { return } currentMusic = music thread?.finish() Thread.yield() thread = PlayerThread(pathToFile(music)) thread?.start() } override fun stop() { currentMusic = null thread?.finish() }
Erstens führt diese Bibliothek die Wiedergabe synchron durch und blockiert den Hauptstrom, bis das Ende der Datei erreicht ist. Daher müssen wir einen separaten Thread ( PlayerThread
) implementieren und ihn "optional" (Daemon) machen, damit die Anwendung in keinem Fall vorzeitig beendet wird. Zweitens wird die Kennung der aktuell wiedergegebenen Musikdatei ( currentMusic
) im Player-Code gespeichert . Wenn plötzlich ein zweiter Befehl zum Abspielen kommt, werden wir die Wiedergabe nicht von vorne beginnen. Drittens wird die Wiedergabe nach Erreichen des Endes der Musikdatei erneut gestartet - und so weiter, bis der Stream durch den Befehl explizit gestoppt wirdfinish()
(oder bis andere Threads abgeschlossen sind, wie bereits erwähnt). Viertens, obwohl der obige Code mit scheinbar unnötigen Flags und Befehlen gefüllt ist, wird er gründlich getestet und getestet - der Player arbeitet wie erwartet, verlangsamt das System nicht, unterbricht nicht plötzlich auf halbem Weg, führt nicht zu Speicherlecks, enthält keine genetisch veränderten Objekte, leuchtet Frische und Reinheit. Nehmen Sie es und verwenden Sie es mutig in Ihren Projekten.Schritt zwölf. Lokalisierung
Unser Spiel ist fast fertig, aber niemand wird es spielen. Warum?
"Es gibt kein Russisch! .. Es gibt kein Russisch! .. Fügen Sie die russische Sprache hinzu! .. Entwickelt von Hunden!"Öffnen Sie die Seite eines interessanten Story-Spiels (insbesondere für Handys) auf der Website des Shops und lesen Sie die Rezensionen. Werden sie anfangen, erstaunliche, handgezeichnete Grafiken zu loben? Oder den atmosphärischen Klang bestaunen? Oder eine spannende Geschichte diskutieren, die von der ersten Minute an süchtig macht und erst am Ende loslässt?Nein.
Unzufriedene "Spieler" weisen eine Reihe von Einheiten an und löschen das Spiel im Allgemeinen. Und dann brauchen sie auch Geld zurück - und das alles aus einem einfachen Grund. Ja, Sie haben vergessen, Ihr Meisterwerk in alle 95 Weltsprachen zu übersetzen. Oder besser gesagt, derjenige, dessen Träger am lautesten schreien. Und alle! Verstehst du
Monate harter Arbeit, lange schlaflose Nächte, ständige Nervenzusammenbrüche - all dies ist ein Hamster unter dem Schwanz. Sie haben eine große Anzahl von Spielern verloren und dies kann nicht behoben werden.Denken Sie also voraus. Entscheiden Sie sich für Ihre Zielgruppe, wählen Sie mehrere Hauptsprachen aus, bestellen Sie Übersetzungsdienste ... tun Sie im Allgemeinen alles, was andere mehr als einmal in thematischen Artikeln beschrieben haben (schlauer als ich). Wir werden uns auf die technische Seite des Problems konzentrieren und darüber sprechen, wie wir unser Produkt schmerzlos lokalisieren können.Zuerst kommen wir zu den Vorlagen. Denken Sie daran, bevor die Namen und Beschreibungen als einfach gespeichert wurden String
? Jetzt wird es nicht mehr funktionieren. Zusätzlich zur Standardsprache müssen Sie auch eine Übersetzung in alle Sprachen bereitstellen, die Sie unterstützen möchten. Zum Beispiel so: class TestEnemyTemplate : EnemyTemplate { override val name = "Test enemy" override val description = "Some enemy standing in your way." override val nameLocalizations = mapOf( "ru" to " -", "ar" to "بعض العدو", "iw" to "איזה אויב", "zh" to "一些敵人", "ua" to "і " ) override val descriptionLocalizations = mapOf( "ru" to " - .", "ar" to "وصف العدو", "iw" to "תיאור האויב", "zh" to "一些敵人的描述", "ua" to " ї і ." ) override val traits = listOf<Trait>() }
Für Vorlagen ist dieser Ansatz durchaus geeignet. Wenn Sie keine Übersetzung für eine Sprache angeben möchten, müssen Sie dies nicht tun - es gibt immer einen Standardwert. In den endgültigen Objekten möchte ich jedoch nicht mehrere verschiedene Felder anordnen. Daher werden wir einen belassen, aber seinen Typ ersetzen. class LocalizedString(defaultValue: String, localizations: Map<String, String>) { private val default: String = defaultValue private val values: Map<String, String> = localizations.toMap() operator fun get(lang: String) = values.getOrDefault(lang, default) override fun equals(other: Any?) = when { this === other -> true other !is LocalizedString -> false else -> default == other.default } override fun hashCode(): Int { return default.hashCode() } }
Und korrigieren Sie den Generatorcode entsprechend. fun generateEnemy(template: EnemyTemplate) = Enemy().apply { name = LocalizedString(template.name, template.nameLocalizations) description = LocalizedString(template.description, template.descriptionLocalizations) template.traits.forEach { addTrait(it) } }
Natürlich sollte der gleiche Ansatz auf die verbleibenden Vorlagentypen angewendet werden. Wenn die Änderungen fertig sind, können sie problemlos verwendet werden. val language = Locale.getDefault().language val enemyName = enemy.name[language]
In unserem Beispiel haben wir eine vereinfachte Version der Lokalisierung bereitgestellt, bei der nur die Sprache berücksichtigt wird. Im Allgemeinen Locale
definieren Klassenobjekte auch das Land und die Region. Wenn dies in Ihrer Bewerbung wichtig ist, LocalizedString
sieht Ihre Bewerbung etwas anders aus, aber wir sind trotzdem damit zufrieden.Wir haben uns mit den Vorlagen befasst, es bleibt die Lokalisierung der in unserer Anwendung verwendeten Servicezeilen. Zum Glück ResourceBundle
enthält es bereits alle notwendigen Mechanismen. Es ist nur erforderlich, Dateien mit Übersetzungen vorzubereiten und die Art und Weise zu ändern, in der sie heruntergeladen werden. # Game status messages choose_dice_perform_check= : end_of_turn_discard_extra= : : end_of_turn_discard_optional= : : choose_action_before_exploration=, : choose_action_after_exploration= . ? encounter_physical= . . encounter_somatic= . . encounter_mental= . . encounter_verbal= . . encounter_divine= . : die_acquire_success= ! die_acquire_failure= . game_loss_out_of_time= # Die types physical= somatic= mental= verbal= divine= ally= wound= enemy= villain= obstacle= # Hero types and descriptions brawler= hunter= # Various labels avg= bag= bag_size= class= closed= discard= empty= encountered= fail= hand= heros_turn= %s max= min= perform_check= : pile= received_new_die= result= success= sum= time= total= # Action names and descriptions action_confirm_key=ENTER action_confirm_name= action_cancel_key=ESC action_cancel_name= action_explore_location_key=E action_explore_location_name= action_finish_turn_key=F action_finish_turn_name= action_hide_key=H action_bag_name= action_discard_key=D action_discard_name= action_acquire_key=A action_acquire_name= action_leave_key=L action_leave_name= action_forfeit_key=F action_forfeit_name=
Ich werde nicht für die Aufzeichnung sagen: Das Schreiben von Phrasen auf Russisch ist viel schwieriger als auf Englisch. Wenn es erforderlich ist, ein Substantiv in einem endgültigen Fall zu verwenden oder sich vom Geschlecht zu lösen (und diese Anforderungen müssen unbedingt bestehen bleiben), müssen Sie viel schwitzen, bevor Sie ein Ergebnis erhalten, das zum einen den Anforderungen entspricht und zum anderen nicht wie eine mechanische Übersetzung eines Cyborgs aussieht mit Hühnerhirn. Beachten Sie auch, dass wir die Aktionstasten nicht ändern - wie zuvor werden dieselben Zeichen verwendet, um letztere auszuführen wie in der englischen Sprache (die übrigens nicht in einem anderen Tastaturlayout als dem lateinischen funktioniert, aber das ist nicht unsere Sache - lassen wir es jetzt so wie es ist). class PropertiesStringLoader(locale: Locale) : StringLoader { private val properties = ResourceBundle.getBundle("text.strings", locale) override fun loadString(key: String) = properties.getString(key) ?: "" }
.
Wie bereits erwähnt, ResourceBundle
übernimmt er selbst die Verantwortung, unter den Lokalisierungsdateien diejenige zu finden, die dem aktuellen Gebietsschema am ehesten entspricht. Und wenn er es nicht findet, nimmt er die Standarddatei ( string.properties
). Und alles wird gut ...Ja! Da war es!, Unicode
.properties
Java 9. ISO-8859-1 —
ResourceBundle
. , , — . Unicode- — , , :
'\uXXXX'
. , , Java
native2ascii , . :
# Game status messages choose_dice_perform_check=\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u043a\u0443\u0431\u0438\u043a\u0438 \u0434\u043b\u044f \u043f\u0440\u043e\u0445\u043e\u0436\u0434\u0435\u043d\u0438\u044f \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0438: end_of_turn_discard_extra=\u041a\u041e\u041d\u0415\u0426 \u0425\u041e\u0414\u0410: \u0421\u0431\u0440\u043e\u0441\u044c\u0442\u0435 \u043b\u0438\u0448\u043d\u0438\u0435 \u043a\u0443\u0431\u0438\u043a\u0438: end_of_turn_discard_optional=\u041a\u041e\u041d\u0415\u0426 \u0425\u041e\u0414\u0410: \u0421\u0431\u0440\u043e\u0441\u044c\u0442\u0435 \u043a\u0443\u0431\u0438\u043a\u0438 \u043f\u043e \u0436\u0435\u043b\u0430\u043d\u0438\u044e: choose_action_before_exploration=\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435, \u0447\u0442\u043e \u0434\u0435\u043b\u0430\u0442\u044c: choose_action_after_exploration=\u0418\u0441\u0441\u043b\u0435\u0434\u043e\u0432\u0430\u043d\u0438\u0435 \u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043d\u043e. \u0427\u0442\u043e \u0434\u0435\u043b\u0430\u0442\u044c \u0434\u0430\u043b\u044c\u0448\u0435? encounter_physical=\u0412\u0441\u0442\u0440\u0435\u0447\u0435\u043d \u0424\u0418\u0417\u0418\u0427\u0415\u0421\u041a\u0418\u0419 \u043a\u0443\u0431\u0438\u043a. \u041d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e \u043f\u0440\u043e\u0439\u0442\u0438 \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0443. encounter_somatic=\u0412\u0441\u0442\u0440\u0435\u0447\u0435\u043d \u0421\u041e\u041c\u0410\u0422\u0418\u0427\u0415\u0421\u041a\u0418\u0419 \u043a\u0443\u0431\u0438\u043a. \u041d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e \u043f\u0440\u043e\u0439\u0442\u0438 \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0443. encounter_mental=\u0412\u0441\u0442\u0440\u0435\u0447\u0435\u043d \u041c\u0415\u041d\u0422\u0410\u041b\u042c\u041d\u042b\u0419 \u043a\u0443\u0431\u0438\u043a. \u041d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e \u043f\u0440\u043e\u0439\u0442\u0438 \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0443. encounter_verbal=\u0412\u0441\u0442\u0440\u0435\u0447\u0435\u043d \u0412\u0415\u0420\u0411\u0410\u041b\u042c\u041d\u042b\u0419 \u043a\u0443\u0431\u0438\u043a. \u041d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e \u043f\u0440\u043e\u0439\u0442\u0438 \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0443. encounter_divine=\u0412\u0441\u0442\u0440\u0435\u0447\u0435\u043d \u0411\u041e\u0416\u0415\u0421\u0422\u0412\u0415\u041d\u041d\u042b\u0419 \u043a\u0443\u0431\u0438\u043a. \u041c\u043e\u0436\u043d\u043e \u0432\u0437\u044f\u0442\u044c \u0431\u0435\u0437 \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0438: die_acquire_success=\u0412\u044b \u043f\u043e\u043b\u0443\u0447\u0438\u043b\u0438 \u043d\u043e\u0432\u044b\u0439 \u043a\u0443\u0431\u0438\u043a! die_acquire_failure=\u0412\u0430\u043c \u043d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c \u043a\u0443\u0431\u0438\u043a. game_loss_out_of_time=\u0423 \u0432\u0430\u0441 \u0437\u0430\u043a\u043e\u043d\u0447\u0438\u043b\u043e\u0441\u044c \u0432\u0440\u0435\u043c\u044f
. — . — . , IDE ( ) « », — - ( ), IDE, .
, .
getBundle()
, , ,
ResourceBundle.Control
— - .
class PropertiesStringLoader(locale: Locale) : StringLoader { private val properties = ResourceBundle.getBundle( "text.strings", locale, Utf8ResourceBundleControl()) override fun loadString(key: String) = properties.getString(key) ?: "" }
, , :
class Utf8ResourceBundleControl : ResourceBundle.Control() { @Throws(IllegalAccessException::class, InstantiationException::class, IOException::class) override fun newBundle(baseName: String, locale: Locale, format: String, loader: ClassLoader, reload: Boolean): ResourceBundle? { val bundleName = toBundleName(baseName, locale) return when (format) { "java.class" -> super.newBundle(baseName, locale, format, loader, reload) "java.properties" -> with((if ("://" in bundleName) null else toResourceName(bundleName, "properties")) ?: return null) { when { reload -> reload(this, loader) else -> loader.getResourceAsStream(this) }?.let { stream -> InputStreamReader(stream, "UTF-8").use { r -> PropertyResourceBundle(r) } } } else -> throw IllegalArgumentException("Unknown format: $format") } } @Throws(IOException::class) private fun reload(resourceName: String, classLoader: ClassLoader): InputStream { classLoader.getResource(resourceName)?.let { url -> url.openConnection().let { connection -> connection.useCaches = false return connection.getInputStream() } } throw IOException("Unable to load data!") } }
, … , ( ) — ( Kotlin ). — ,
.properties
UTF-8 - .
Um den Betrieb der Anwendung in verschiedenen Sprachen zu testen, müssen Sie die Einstellungen des Betriebssystems nicht ändern. Geben Sie beim Starten der JRE lediglich die erforderliche Sprache an: java -Duser.language=ru -jar path_to_project\Dice\target\dice-1.0-jar-with-dependencies.jar
Wenn Sie noch unter Windows arbeiten, erwarten Sie Probleme, Windows (cmd.exe) 437 ( DOSLatinUS), — . , UTF-8 , :
chcp 65001
Java , , . :
java -Dfile.encoding=UTF-8 -Duser.language=ru -jar path_to_project\Dice\target\dice-1.0-jar-with-dependencies.jar
, , Unicode- (, Lucida Console)
Nach all unseren aufregenden Abenteuern kann das Ergebnis der Öffentlichkeit stolz demonstriert und laut erklärt werden: „Wir sind keine Hunde!“Und das ist gut.
Schritt 13 Alles zusammenfügen
Aufmerksame Leser müssen bemerkt haben, dass ich die Namen bestimmter Pakete nur einmal erwähnt habe und nie zu ihnen zurückgekehrt bin. Erstens hat jeder Entwickler seine eigenen Überlegungen, welche Klasse sich in welchem Paket befinden soll. Zweitens werden sich Ihre Gedanken ändern, wenn Sie an dem Projekt arbeiten und immer mehr neue Klassen hinzufügen. Drittens ist das Ändern der Struktur der Anwendung einfach und kostengünstig (und moderne Versionskontrollsysteme erkennen die Migration, sodass Sie nicht den Verlauf verlieren). Sie können also die Namen von Klassen, Paketen, Methoden und Variablen ändern. Vergessen Sie nicht, nur die Dokumentation zu aktualisieren (Sie behalten sie) richtig?).Und alles, was uns bleibt, ist, unser Projekt zusammenzustellen und zu starten. Wie Sie sich erinnern, haben main()
wir bereits eine Methode erstellt , die wir jetzt mit Inhalten füllen werden. Wir werden brauchen:
- Drehbuch und Terrain;
- Helden
- Schnittstellenimplementierung
GameInteractor
; - Implementierung von Schnittstellen
GameRenderer
und StringLoader
; - Implementierung von Schnittstellen
SoundPlayer
und MusicPlayer
; - Klassenobjekt
Game
; - eine Flasche Champagner.
Lass uns gehen! fun main(args: Array<String>) { Audio.init(BasicSoundPlayer(), BasicMusicPlayer()) val loader = PropertiesStringLoader(Locale.getDefault()) val renderer = ConsoleGameRenderer(loader) val interactor = ConsoleGameInteractor() val template = TestScenarioTemplate() val scenario = generateScenario(template, 1) val locations = generateLocations(template, 1, heroes.size) val heroes = listOf( generateHero(Hero.Type.BRAWLER, "Brawler"), generateHero(Hero.Type.HUNTER, "Hunter") ) val game = Game(renderer, interactor, scenario, locations, heroes) game.start() }
Wir starten und genießen den ersten funktionierenden Prototyp. Los geht's.Schritt vierzehn. Spielbalance
Ummm ...Schritt fünfzehn. Tests
Nachdem der Großteil des Codes für den ersten funktionierenden Prototyp geschrieben wurde, wäre es schön, ein paar Komponententests hinzuzufügen ..."Wie? Nur jetzt? Ja, Tests mussten ganz am Anfang geschrieben werden und dann Code! “Viele Leser bemerken zu Recht, dass das Schreiben von Komponententests der Entwicklung von Arbeitscode ( TDD) vorausgehen sollteund andere modische Methoden). Andere werden empört sein: Es gibt nichts für Menschen, die ihr Gehirn mit ihren Tests täuschen könnten, selbst wenn sie zumindest anfangen, etwas zu entwickeln, sonst geht jede Motivation verloren. Ein paar andere Leute kriechen aus der Lücke im Baseboard und sagen schüchtern: "Ich verstehe nicht, warum diese Tests benötigt werden - alles funktioniert für mich" ... Dann werden sie mit einem Stiefel ins Gesicht gedrückt und schnell zurückgeschoben. Ich werde nicht anfangen, ideologische Konfrontationen zu initiieren (sie sind bereits im Internet voll davon), und deshalb stimme ich teilweise allen zu. Ja, Tests sind manchmal nützlich (insbesondere bei Code, der sich häufig ändert oder mit komplexen Berechnungen verbunden ist). Ja, Unit-Tests sind nicht für den gesamten Code geeignet (z. B. werden Interaktionen mit dem Benutzer oder externen Systemen nicht abgedeckt). Ja, neben Unit-Tests gibt es diese viele andere Arten davon (naja, mindestens fünf wurden benannt),und ja, wir werden uns nicht darauf konzentrieren, Tests zu schreiben - unser Artikel handelt von etwas anderem.Sagen wir einfach: Viele Programmierer (insbesondere Anfänger) vernachlässigen Tests. Viele begründen sich damit, dass die Funktionalität ihrer Anwendungen durch Tests nur unzureichend abgedeckt wird. Zum Beispiel ist es viel einfacher, die Anwendung zu starten und zu prüfen, ob alles in Ordnung mit dem Erscheinungsbild und der Interaktion ist, als komplexe Konstruktionen unter Beteiligung spezieller Frameworks zum Testen der Benutzeroberfläche (und solcher) zu fechten. Und ich werde Ihnen sagen, wann ich die Schnittstellen implementiert habe Renderer
- genau das habe ich getan. Es gibt jedoch Methoden in unserem Code, für die das Konzept des Unit-Tests großartig ist.Zum Beispiel Generatoren. Und das ist alles. Dies ist eine ideale Black Box: Vorlagen werden an die Eingabe gesendet, Objekte der Spielwelt werden an der Ausgabe abgerufen. Im Inneren ist etwas los, aber wir müssen es testen. Zum Beispiel so: public class DieGeneratorTest { @Test public void testGetMaxLevel() { assertEquals("Max level should be 3", 3, DieGeneratorKt.getMaxLevel()); } @Test public void testDieGenerationSize() { DieTypeFilter filter = new SingleDieTypeFilter(Die.Type.ALLY); List<? extends List<Integer>> allowedSizes = Arrays.asList( null, Arrays.asList(4, 6, 8), Arrays.asList(4, 6, 8, 10), Arrays.asList(6, 8, 10, 12) ); IntStream.rangeClosed(1, 3).forEach(level -> { for (int i = 0; i < 10; i++) { int size = DieGeneratorKt.generateDie(filter, level).getSize(); assertTrue("Incorrect level of die generated: " + size, allowedSizes.get(level).contains(size)); assertTrue("Incorrect die size: " + size, size >= 4); assertTrue("Incorrect die size: " + size, size <= 12); assertTrue("Incorrect die size: " + size, size % 2 == 0); } }); } @Test public void testDieGenerationType() { List<Die.Type> allowedTypes1 = Arrays.asList(Die.Type.PHYSICAL); List<Die.Type> allowedTypes2 = Arrays.asList(Die.Type.PHYSICAL, Die.Type.SOMATIC, Die.Type.MENTAL, Die.Type.VERBAL); List<Die.Type> allowedTypes3 = Arrays.asList(Die.Type.ALLY, Die.Type.VILLAIN, Die.Type.ENEMY); for (int i = 0; i < 10; i++) { Die.Type type1 = DieGeneratorKt.generateDie(new SingleDieTypeFilter(Die.Type.PHYSICAL), 1).getType(); assertTrue("Incorrect die type: " + type1, allowedTypes1.contains(type1)); Die.Type type2 = DieGeneratorKt.generateDie(new StatsDieTypeFilter(), 1).getType(); assertTrue("Incorrect die type: " + type2, allowedTypes2.contains(type2)); Die.Type type3 = DieGeneratorKt.generateDie(new MultipleDieTypeFilter(Die.Type.ALLY, Die.Type.VILLAIN, Die.Type.ENEMY), 1).getType(); assertTrue("Incorrect die type: " + type3, allowedTypes3.contains(type3)); } } }
Oder so:
public class BagGeneratorTest { @Test public void testGenerateBag() { BagTemplate template1 = new BagTemplate(); template1.addPlan(0, 10, new SingleDieTypeFilter(Die.Type.PHYSICAL)); template1.addPlan(5, 5, new SingleDieTypeFilter(Die.Type.SOMATIC)); template1.setFixedDieCount(null); BagTemplate template2 = new BagTemplate(); template2.addPlan(10, 10, new SingleDieTypeFilter(Die.Type.DIVINE)); template2.setFixedDieCount(5); BagTemplate template3 = new BagTemplate(); template3.addPlan(10, 10, new SingleDieTypeFilter(Die.Type.ALLY)); template3.setFixedDieCount(50); for (int i = 0; i < 10; i++) { Bag bag1 = BagGeneratorKt.generateBag(template1, 1); assertTrue("Incorrect bag size: " + bag1.getSize(), bag1.getSize() >= 5 && bag1.getSize() <= 15); assertEquals("Incorrect number of SOMATIC dice", 5, bag1.examine().stream().filter(d -> d.getType() == Die.Type.SOMATIC).count()); Bag bag2 = BagGeneratorKt.generateBag(template2, 1); assertEquals("Incorrect bag size", 5, bag2.getSize()); Bag bag3 = BagGeneratorKt.generateBag(template3, 1); assertEquals("Incorrect bag size", 50, bag3.getSize()); List<Die.Type> dieTypes3 = bag3.examine().stream().map(Die::getType).distinct().collect(Collectors.toList()); assertEquals("Incorrect die types", 1, dieTypes3.size()); assertEquals("Incorrect die types", Die.Type.ALLY, dieTypes3.get(0)); } } }
Oder sogar so: public class LocationGeneratorTest { private void testLocationGeneration(String name, LocationTemplate template) { System.out.println("Template: " + template.getName()); assertEquals("Incorrect template type", name, template.getName()); IntStream.rangeClosed(1, 3).forEach(level -> { Location location = LocationGeneratorKt.generateLocation(template, level); assertEquals("Incorrect location type", name, location.getName().get("")); assertTrue("Location not open by default", location.isOpen()); int closingDifficulty = location.getClosingDifficulty(); assertTrue("Closing difficulty too small", closingDifficulty > 0); assertEquals("Incorrect closing difficulty", closingDifficulty, template.getBasicClosingDifficulty() + level * 2); Bag bag = location.getBag(); assertNotNull("Bag is null", bag); assertTrue("Bag is empty", location.getBag().getSize() > 0); Deck<Enemy> enemies = location.getEnemies(); assertNotNull("Enemies are null", enemies); assertEquals("Incorrect enemy threat count", enemies.getSize(), template.getEnemyCardsCount()); if (bag.drawOfType(Die.Type.ENEMY) != null) { assertTrue("Enemy cards not specified", enemies.getSize() > 0); } Deck<Obstacle> obstacles = location.getObstacles(); assertNotNull("Obstacles are null", obstacles); assertEquals("Incorrect obstacle threat count", obstacles.getSize(), template.getObstacleCardsCount()); List<SpecialRule> specialRules = location.getSpecialRules(); assertNotNull("SpecialRules are null", specialRules); }); } @Test public void testGenerateLocation() { testLocationGeneration("Test Location", new TestLocationTemplate()); testLocationGeneration("Test Location 2", new TestLocationTemplate2()); } }
"Halt, hör auf, hör auf!" Was ist das? Java ??? "Du hast es verstanden. Darüber hinaus ist es gut, solche Tests zu Beginn zu schreiben, bevor Sie mit der Implementierung des Generators selbst beginnen. Natürlich ist der zu testende Code recht einfach und höchstwahrscheinlich funktioniert die Methode beim ersten Mal und ohne Tests. Wenn Sie jedoch einen Test schreiben, wenn Sie ihn für immer vergessen, schützen Sie sich vor möglichen Problemen in der Zukunft (deren Lösung viel Zeit in Anspruch nimmt, insbesondere ab dem Moment der Entwicklung Fünf Jahre sind vergangen und Sie haben bereits vergessen, wie alles in der Methode dort funktioniert. Und wenn Ihr Projekt eines Tages plötzlich aufgrund fehlgeschlagener Tests nicht mehr erfasst wird, wissen Sie definitiv den Grund: Die Anforderungen an das System haben sich geändert und Ihre alten Tests erfüllen sie nicht mehr (woran haben Sie gedacht?).Und noch etwas.
Erinnerst HandMaskRule
du dich an die Klasse und ihre Erben? Stellen Sie sich nun vor, dass der Held irgendwann drei Würfel aus seiner Hand nehmen muss, um die Fähigkeit zu nutzen, und die Arten dieser Würfel unterliegen strengen Einschränkungen (zum Beispiel: „Die ersten Würfel müssen blau, grün oder weiß sein, die zweiten - gelb, weiß oder blau, und der dritte - blau oder lila "- fühlst du die Schwierigkeit?). Wie gehe ich mit der Klassenimplementierung um? Nun ... für den Anfang können Sie die Eingabe- und Ausgabeparameter festlegen. Offensichtlich muss die Klasse drei Arrays (oder Mengen) akzeptieren, von denen jedes gültige Typen für den ersten, zweiten und dritten Würfel enthält. Und was dann?
Busting? Rekursionen? Was ist, wenn ich etwas vermisse? Machen Sie einen tiefen Eingang. Verschieben Sie nun die Implementierung von Klassenmethoden und schreiben Sie einen Test - da die Anforderungen einfach, verständlich und gut formalisierbar sind. Und schreiben Sie besser einige Tests ... Aber wir werden einen betrachten, hier zum Beispiel: public class TripleDieHandMaskRuleTest { private Hand hand; @Before public void init() { hand = new Hand(10); hand.addDie(new Die(Die.Type.PHYSICAL, 4));
Das ist anstrengend, aber nicht so sehr, wie es scheint, bis Sie anfangen (irgendwann macht es sogar Spaß). Aber wenn Sie einen solchen Test (und einige andere zu verschiedenen Anlässen) geschrieben haben, werden Sie sich plötzlich ruhig und selbstbewusst fühlen. Jetzt wird kein kleiner Tippfehler Ihre Methode verderben und zu unangenehmen Überraschungen führen, die viel schwieriger manuell zu testen sind. Nach und nach beginnen wir langsam, die notwendigen Methoden der Klasse zu implementieren. Und am Ende führen wir den Test durch, um sicherzustellen, dass wir irgendwo einen Fehler gemacht haben. Finden Sie den Problempunkt und schreiben Sie ihn neu. Wiederholen, bis fertig. class TripleDieHandMaskRule( hand: Hand, types1: Array<Die.Type>, types2: Array<Die.Type>, types3: Array<Die.Type>) : HandMaskRule(hand) { private val types1 = types1.toSet() private val types2 = types2.toSet() private val types3 = types3.toSet() override fun checkMask(mask: HandMask): Boolean { if (mask.positionCount + mask.allyPositionCount != 3) { return false } return getCheckedDice(mask).asSequence() .filter { it.type in types1 } .any { d1 -> getCheckedDice(mask) .filter { d2 -> d2 !== d1 } .filter { it.type in types2 } .any { d2 -> getCheckedDice(mask) .filter { d3 -> d3 !== d1 } .filter { d3 -> d3 !== d2 } .any { it.type in types3 } } } } override fun isPositionActive(mask: HandMask, position: Int): Boolean { if (mask.checkPosition(position)) { return true } val die = hand.dieAt(position) ?: return false return when (mask.positionCount + mask.allyPositionCount) { 0 -> die.type in types1 || die.type in types2 || die.type in types3 1 -> with(getCheckedDice(mask).first()) { (this.type in types1 && (die.type in types2 || die.type in types3)) || (this.type in types2 && (die.type in types1 || die.type in types3)) || (this.type in types3 && (die.type in types1 || die.type in types2)) } 2-> with(getCheckedDice(mask)) { val d1 = this[0] val d2 = this[1] (d1.type in types1 && d2.type in types2 && die.type in types3) || (d2.type in types1 && d1.type in types2 && die.type in types3) || (d1.type in types1 && d2.type in types3 && die.type in types2) || (d2.type in types1 && d1.type in types3 && die.type in types2) || (d1.type in types2 && d2.type in types3 && die.type in types1) || (d2.type in types2 && d1.type in types3 && die.type in types1) } 3 -> false else -> false } } override fun isAllyPositionActive(mask: HandMask, position: Int): Boolean { if (mask.checkAllyPosition(position)) { return true } if (hand.allyDieAt(position) == null) { return false } return when (mask.positionCount + mask.allyPositionCount) { 0 -> ALLY in types1 || ALLY in types2 || ALLY in types3 1 -> with(getCheckedDice(mask).first()) { (this.type in types1 && (ALLY in types2 || ALLY in types3)) || (this.type in types2 && (ALLY in types1 || ALLY in types3)) || (this.type in types3 && (ALLY in types1 || ALLY in types2)) } 2-> with(getCheckedDice(mask)) { val d1 = this[0] val d2 = this[1] (d1.type in types1 && d2.type in types2 && ALLY in types3) || (d2.type in types1 && d1.type in types2 && ALLY in types3) || (d1.type in types1 && d2.type in types3 && ALLY in types2) || (d2.type in types1 && d1.type in types3 && ALLY in types2) || (d1.type in types2 && d2.type in types3 && ALLY in types1) || (d2.type in types2 && d1.type in types3 && ALLY in types1) } 3 -> false else -> false } } }
Wenn Sie Ideen haben, wie Sie solche Funktionen einfacher implementieren können, können Sie dies gerne kommentieren. Und ich bin unglaublich froh, dass ich klug genug war, diese Klasse mit dem Schreiben eines Tests zu implementieren.„Und ich <...> bin auch <...> sehr <...> froh <...>. Steig ein! <...> zurück! <...> in die Lücke! "Schritt sechzehn. Modularität
Wie erwartet können reife Kinder nicht ihr ganzes Leben lang im Schutz ihrer Eltern sein - früher oder später müssen sie ihren eigenen Weg wählen und ihm mutig folgen, um Schwierigkeiten und Störungen zu überwinden. So reiften die von uns entwickelten Komponenten so stark, dass sie unter einem Dach eng wurden. Es ist an der Zeit, sie in mehrere Teile zu teilen.Wir stehen vor einer eher trivialen Aufgabe. Es ist notwendig, alle bisher erstellten Klassen in drei Gruppen zu unterteilen:- Grundfunktionalität: Modul, Game Engine, Connector-Schnittstellen und plattformunabhängige Implementierungen ( Kern );
- Vorlagen von Szenarien, Gelände, Feinden und Hindernissen - Bestandteile des sogenannten "Abenteuers" ( Abenteuers );
- spezifische Implementierungen von Schnittstellen, die für eine bestimmte Plattform spezifisch sind: in unserem Fall eine Konsolenanwendung ( cli ).
Das Ergebnis dieser Trennung sieht letztendlich wie folgt aus:Wie die Schauspieler am Ende der Show treten unsere heutigen Helden mit voller Wucht wieder in die Szene ein Erstellen Sie zusätzliche Projekte und übertragen Sie die entsprechende Klasse. Und wir müssen nur die Interaktion von Projekten untereinander richtig konfigurieren.KernprojektDieses Projekt ist ein reiner Motor. Alle spezifischen Klassen wurden auf andere Projekte übertragen - nur die Grundfunktionalität, der Kern, blieb übrig. Bibliothek, wenn Sie wollen. Es gibt keine Startklasse mehr, es muss nicht einmal ein Paket erstellt werden. Assemblies dieses Projekts werden im lokalen Maven-Repository gehostet (dazu später mehr) und von anderen Projekten als Abhängigkeiten verwendet.Die Datei pom.xml
lautet wie folgt: <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>my.company</groupId> <artifactId>dice-core</artifactId> <version>1.0</version> <packaging>jar</packaging> <dependencies> <dependency> <groupId>org.jetbrains.kotlin</groupId> <artifactId>kotlin-stdlib</artifactId> <version>${kotlin.version}</version> </dependency> <dependency> <groupId>junit</groupId> <artifactId>junit-dep</artifactId> <version>4.8.2</version> <scope>test</scope> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.jetbrains.kotlin</groupId> </plugin> </plugins> </build> <properties> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <maven.compiler.source>1.8</maven.compiler.source> <maven.compiler.target>1.8</maven.compiler.target> <kotlin.version>1.3.20</kotlin.version> <kotlin.compiler.incremental>true</kotlin.compiler.incremental> </properties> </project>
Von nun an werden wir es so sammeln: mvn -f "path_to_project/DiceCore/pom.xml" install
Cli-ProjektHier ist der Einstiegspunkt in die Anwendung - mit diesem Projekt interagiert der Endbenutzer. Der Kernel wird als Abhängigkeit verwendet. Da wir in unserem Beispiel mit der Konsole arbeiten, enthält das Projekt die für die Arbeit erforderlichen Klassen (wenn wir das Spiel plötzlich auf einer Kaffeemaschine starten möchten, ersetzen wir dieses Projekt einfach durch ein ähnliches mit den entsprechenden Implementierungen). Wir werden sofort Ressourcen (Zeilen, Audiodateien usw.) hinzufügen. Abhängigkeiten von externen Bibliotheken werden in dieDatei übertragen pom.xml
: <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>my.company</groupId> <artifactId>dice-cli</artifactId> <version>1.0</version> <packaging>jar</packaging> <dependencies> <dependency> <groupId>org.jetbrains.kotlin</groupId> <artifactId>kotlin-stdlib</artifactId> <version>${kotlin.version}</version> </dependency> <dependency> <groupId>my.company</groupId> <artifactId>dice-core</artifactId> <version>1.0</version> <scope>compile</scope> </dependency> <dependency> <groupId>org.fusesource.jansi</groupId> <artifactId>jansi</artifactId> <version>1.17.1</version> <scope>compile</scope> </dependency> <dependency> <groupId>jline</groupId> <artifactId>jline</artifactId> <version>2.14.6</version> <scope>compile</scope> </dependency> <dependency> <groupId>com.googlecode.soundlibs</groupId> <artifactId>jlayer</artifactId> <version>1.0.1.4</version> <scope>compile</scope> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.jetbrains.kotlin</groupId> </plugin> <plugin> <artifactId>maven-assembly-plugin</artifactId> <version>2.6</version> <executions> <execution> <phase>package</phase> <goals> <goal>single</goal> </goals> </execution> </executions> <configuration> <descriptorRefs> <descriptorRef>jar-with-dependencies</descriptorRef> </descriptorRefs> <archive> <manifest> <mainClass>my.company.dice.MainKt</mainClass> </manifest> </archive> </configuration> </plugin> </plugins> </build> <properties> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <maven.compiler.source>1.8</maven.compiler.source> <maven.compiler.target>1.8</maven.compiler.target> <kotlin.version>1.3.20</kotlin.version> <kotlin.compiler.incremental>true</kotlin.compiler.incremental> </properties> </project>
Wir haben bereits das Skript zum Erstellen und Ausführen dieses Projekts gesehen - wir werden es nicht wiederholen.AbenteuerNun, schließlich nehmen wir in einem separaten Projekt die Handlung heraus. Das heißt, alle Szenarien, Gelände, Feinde und andere einzigartige Objekte der Spielwelt, die sich die Mitarbeiter der Szenarioabteilung Ihres Unternehmens vorstellen können (oder bislang nur unsere eigene kranke Vorstellungskraft - wir sind immer noch der einzige Spieledesigner in der Region). Die Idee ist, die Skripte in Sets (Abenteuer) zu gruppieren und jedes Set als separates Projekt zu verteilen (ähnlich wie in der Welt der Brettspiele und Videospiele). Sammeln Sie also JAR-Archive und legen Sie sie in einem separaten Ordner ab, damit die Game Engine diesen Ordner scannt und automatisch alle dort enthaltenen Abenteuer verbindet. Die technische Umsetzung dieses Ansatzes ist jedoch mit enormen Schwierigkeiten verbunden.Wo soll ich anfangen? Nun, erstens aufgrund der Tatsache, dass wir Vorlagen in Form spezifischer Java-Klassen verteilen (ja, schlagen Sie mich und schelten Sie mich - ich habe das vorausgesehen). In diesem Fall sollten sich diese Klassen beim Start im Klassenpfad der Anwendung befinden. Die Durchsetzung dieser Anforderung ist nicht schwierig - Sie registrieren Ihre JAR-Dateien explizit in der entsprechenden Umgebungsvariablen (ab Java 6 können Sie sogar * - Platzhalter verwenden ). java -classpath "path_to_project/DiceCli/target/adventures/*" -jar path_to_project/DiceCli/target/dice-1.0-jar-with-dependencies.jar
„Ein Idiot, oder was? Bei Verwendung des Schalters -jar wird der Schalter -classpath ignoriert! ”Dies wird jedoch nicht funktionieren. Der Klassenpfad für ausführbare JAR-Archive muss explizit in die interne Datei geschrieben werden META-INF/MANIFEST.MF
(der Abschnitt heißt - Claspath:
). Es ist okay, es gibt sogar spezielle Plugins dafür ( Maven-Compiler-Plugin oder im schlimmsten Fall Maven-Assembly-Plugin ). Aber die Platzhalter im Manifest funktionieren leider nicht - Sie müssen die Namen der abhängigen JAR-Dateien explizit angeben. Das heißt, sie im Voraus zu kennen, was in unserem Fall problematisch ist.Und das wollte ich sowieso nicht. Ich wollte, dass das Projekt nicht neu kompiliert werden muss. Zum Ordneradventures/
Sie können eine beliebige Anzahl von Abenteuern werfen, sodass alle während der Ausführung für die Spiel-Engine sichtbar sind. Leider geht die scheinbar offensichtliche Funktionalität über die Standarddarstellungen der Java-Welt hinaus. Daher ist es nicht erwünscht. Ein anderer Ansatz muss gewählt werden, um unabhängige Abenteuer zu verbreiten. Welches? Ich weiß nicht, schreibe in die Kommentare - sicher hat jemand kluge Ideen.In der Zwischenzeit gibt es keine Ideen. Hier ist ein kleiner (oder großer, je nach Aussehen) Trick, mit dem Sie dem Klassenpfad dynamisch Abhängigkeiten hinzufügen können, ohne deren Namen zu kennen und ohne das Projekt neu kompilieren zu müssen:In Windows: @ECHO OFF call "path_to_maven\mvn.bat" -f "path_to_project\DiceCore\pom.xml" install call "path_to_maven\mvn.bat" -f "path_to_project\DiceCli\pom.xml" package call "path_to_maven\mvn.bat" -f "path_to_project\TestAdventure\pom.xml" package mkdir path_to_project\DiceCli\target\adventures copy "path_to_project\TestAdventure\target\test-adventure-1.0.jar" path_to_project\DiceCli\target\adventures\ chcp 65001 cd path_to_project\DiceCli\target\ java -Dfile.encoding=UTF-8 -cp "dice-cli-1.0-jar-with-dependencies.jar;adventures\*" my.company.dice.MainKt pause
Und unter Unix:
Und hier ist der Trick. Anstatt den Schlüssel zu verwenden, -jar
fügen wir das Cli- Projekt dem Klassenpfad hinzu und geben die darin enthaltene Klasse explizit als Einstiegspunkt an MainKt
. Außerdem verbinden wir hier alle Archive aus dem Ordner adventures/
.Sie müssen nicht noch einmal angeben, wie viel diese krumme Entscheidung ist - ich selbst weiß, danke. Schlagen Sie Ihre Ideen besser in den Kommentaren vor. Bitte . (ಥ﹏ಥ)Schritt siebzehn. Handlung
Ein bisschen Text.In unserem Artikel geht es um die technische Seite des Workflows, aber Spiele sind nicht nur Software-Code. Dies sind aufregende Welten mit interessanten Ereignissen und lebhaften Charakteren, in die Sie mit Ihrem Kopf eintauchen und auf die reale Welt verzichten. Jede solche Welt ist auf ihre Weise ungewöhnlich und auf ihre Weise interessant, an die Sie sich nach vielen Jahren noch viele erinnern. Wenn Sie möchten, dass Ihre Welt auch mit warmen Gefühlen in Erinnerung bleibt, machen Sie sie ungewöhnlich und interessant.Ich weiß, dass wir hier Programmierer sind, keine Drehbuchautoren, aber wir haben einige grundlegende Vorstellungen über die narrative Komponente des Spielgenres (Spieler mit Erfahrung, oder?). Wie in jedem Buch sollte die Geschichte ein Auge (in dem wir nach und nach das Problem der Helden beschreiben), eine Entwicklung, zwei oder drei interessante Wendungen, einen Höhepunkt (den akutesten Moment der Handlung, wenn die Leser vor Aufregung erstarren und vergessen zu atmen) und eine Auflösung (in) haben welche Ereignisse allmählich zu ihrem logischen Abschluss kommen). Vermeiden Sie Understatement, logische Grundlosigkeit und Handlungslöcher - alle gestarteten Linien sollten zu einem angemessenen Ergebnis kommen.Lassen Sie uns unsere Geschichte anderen vorlesen - ein unvoreingenommener Blick von der Seite hilft sehr oft, die gemachten Fehler zu verstehen und sie rechtzeitig zu korrigieren.Die Handlung des Spiels, , . , : ( ) ( ), . , .
— , . , , .
, , - . , , , , . .
Glücklicherweise bin ich kein Tolkien, ich habe die Spielwelt nicht zu detailliert ausgearbeitet, aber ich habe versucht, sie interessant genug und vor allem logisch gerechtfertigt zu machen. Gleichzeitig erlaubte er sich, einige Unklarheiten einzuführen, die jeder Spieler auf seine Weise interpretieren kann. Nirgends konzentrierte er sich zum Beispiel auf den Stand der technologischen Entwicklung der beschriebenen Welt: das Feudalsystem und moderne demokratische Institutionen, böse Tyrannen und organisierte kriminelle Gruppen, das höchste Ziel und das banale Überleben, Busfahrten und Kämpfe in Tavernen - selbst die Charaktere schießen aus irgendeinem Grund: von Bögen / Armbrüsten oder von Sturmgewehren. In der Welt gibt es einen Anschein von Magie (seine Präsenz fügt den taktischen Fähigkeiten Gameplay hinzu) und Elementen der Mystik (nur um zu sein).Ich wollte mich von Handlungsklischees und Fantasy-Konsumgütern entfernen - all diesen Elfen, Gnomen, Drachen, schwarzen Lords und dem absoluten Bösen der Welt (sowie: ausgewählten Helden, alten Prophezeiungen, Superartefakten, epischen Schlachten ... obwohl letztere übrig bleiben können). Ich wollte auch wirklich die Welt lebendig machen, damit jeder getroffene Charakter (auch ein kleinerer) seine eigene Geschichte und Motivation hat, dass Elemente der Spielmechanik in die Gesetze der Welt passen, dass die Entwicklung von Helden auf natürliche Weise erfolgt, dass die Anwesenheit von Feinden und Hindernissen an Orten logisch durch die Merkmale des Ortes selbst gerechtfertigt ist … usw. Leider war dieser Wunsch ein grausamer Witz, der den Entwicklungsprozess sehr verlangsamte, und es war nicht immer möglich, von Spielekonventionen abzuweichen. Trotzdem stellte sich heraus, dass die Zufriedenheit mit dem Endprodukt um eine Größenordnung höher war.Was möchte ich zu all dem sagen? Eine gut durchdachte interessante Handlung mag nicht so notwendig sein, aber Ihr Spiel wird nicht unter ihrer Präsenz leiden: Im besten Fall werden die Spieler sie genießen, im schlimmsten Fall werden sie sie einfach ignorieren. Und diejenigen, die besonders begeistert sind, werden Ihrem Spiel sogar einige Funktionsmängel verzeihen, nur um herauszufinden, wie die Geschichte endet.Was weiter?
Die weitere Programmierung endet und das Spieldesign beginnt . Jetzt ist es an der Zeit, nicht den Code zu schreiben, sondern Szenarien, Orte und Feinde zu überdenken - Sie verstehen, dieser ganze Bodensatz. Wenn Sie immer noch alleine arbeiten, gratuliere ich Ihnen - Sie haben das Stadium erreicht, in dem die meisten Spielprojekte in Eile sind. In großen AAA-Studios arbeiten spezielle Leute als Designer und Drehbuchautoren, die dafür Geld erhalten - sie haben einfach nichts zu tun. Aber wir haben viele Möglichkeiten: spazieren gehen, essen, banal schlafen - aber was kann man dort tun, sogar um ein neues Projekt zu starten, indem man die gesammelten Erfahrungen und Kenntnisse nutzt.Wenn Sie noch hier sind und um jeden Preis weitermachen möchten, dann bereiten Sie sich auf Schwierigkeiten vor. Zeitmangel, Faulheit, Mangel an kreativer Inspiration - etwas wird Sie ständig ablenken. Es ist nicht einfach, all diese Hindernisse zu überwinden (wieder wurden viele Artikel zu diesem Thema geschrieben), aber es ist möglich. Zunächst rate ich Ihnen, die weitere Entwicklung des Projekts sorgfältig zu planen. Glücklicherweise arbeiten wir zu unserem Vergnügen, die Verlage drängen uns nicht, niemand fordert die Einhaltung bestimmter Fristen - was bedeutet, dass es die Möglichkeit gibt, ohne allzu große Eile ins Geschäft zu kommen. Erstellen Sie eine „Roadmap“ des Projekts, bestimmen Sie die Hauptphasen und (wenn Sie den Mut haben) ungefähre Bedingungen für deren Umsetzung. Holen Sie sich ein Notizbuch (Sie können es elektronisch machen) und schreiben Sie ständig Ideen auf, die darin entstehen (und sogar mitten in der Nacht plötzlich aufwachen).Markieren Sie Ihren Fortschritt mit Tabellen (zum Beispiel solche ) oder andere Hilfsmittel. Starten Sie die Dokumentation: sowohl extern, öffentlich ( z. B. Wiki ) für die zukünftige große Community von Fans als auch intern für sich selbst (ich werde den Link nicht teilen) - glauben Sie mir, ohne ihn werden Sie sich nach einer einmonatigen Pause nicht mehr daran erinnern, was genau und wie Sie es getan haben. Schreiben Sie im Allgemeinen so viele Begleitinformationen wie möglich zu Ihrem Spiel. Denken Sie daran, das Spiel selbst zu schreiben. Ich habe grundlegende Optionen vorgeschlagen, aber ich gebe keine spezifischen Ratschläge - jeder entscheidet für sich selbst, wie es für ihn bequemer ist, seinen Arbeitsprozess zu organisieren."Aber trotzdem willst du nicht über Spielbalance sprechen?"Bereiten Sie sich sofort darauf vor, dass das erstmalige Erstellen des perfekten Spiels nicht funktioniert. Ein funktionierender Prototyp ist gut - er zeigt zunächst die Realisierbarkeit des Projekts, überzeugt oder enttäuscht Sie und gibt eine Antwort auf eine sehr wichtige Frage: „Lohnt es sich, fortzufahren?“ Er wird jedoch nicht viele andere Fragen beantworten, von denen die wichtigste wahrscheinlich lautet: "Wird es interessant sein, mein Spiel langfristig zu spielen?" Es gibt eine große Anzahl von Theorien und Artikeln (wieder) zu diesem Thema. Ein interessantes Spiel sollte mäßig schwierig sein, da ein zu einfaches Spiel den Spieler nicht herausfordert. Wenn andererseits die Komplexität unerschwinglich ist, bleiben nur hartnäckige Hardcore-Spieler oder Leute, die versuchen, jemandem etwas zu beweisen, vom Spielpublikum übrig. Das Spiel sollte im Idealfall sehr vielfältig sein - bieten Sie mehrere Möglichkeiten, um das Ziel zu erreichen.so dass jeder Spieler eine Option nach Geschmack wählt. Eine Passing-Strategie sollte den Rest nicht dominieren, sonst wird sie nur verwendet ... Und so weiter.Mit anderen Worten, das Spiel muss ausgeglichen sein. Dies gilt insbesondere für das Brettspiel, bei dem die Regeln klar formalisiert sind. Wie kann man das machen?
Ich habe keine Ahnung. Wenn Sie keinen Mathematikfreund haben, der ein mathematisches Modell erstellen kann (ich habe es gesehen, sie tun es), und Sie selbst nichts davon verstehen (und wir verstehen es nicht), besteht der einzige Ausweg darin , sich auf die Intuition des Spieltests zu verlassen . Spielen Sie das Spiel zuerst selbst. Wenn Sie müde werden - bieten Sie an, Ihre Frau zu spielen. Laden Sie nach der Scheidung andere Verwandte, Freunde, Bekannte und zufällige Personen auf der Straße zum Spielen ein. Wenn Sie völlig alleine sind, laden Sie die Baugruppe ins Internet hoch. Die Leute werden interessiert sein, spielen wollen und Sie werden ihnen antworten: "Feedback von Ihnen!". Vielleicht wird jemand Ihren Traum genauso lieben wie Sie und mit Ihnen arbeiten wollen - Sie werden Gleichgesinnte oder zumindest eine Selbsthilfegruppe finden (warum habe ich diesen Artikel wohl geschrieben?) (Hehe).Scherz beiseite, ich wünsche uns ... euch allen viel Erfolg. Lesen Sie mehr (wer hätte das gedacht!) - über Spieledesign und mehr. Alle Themen, die wir untersucht haben, wurden bereits auf die eine oder andere Weise in Artikeln und Literatur behandelt (obwohl es offensichtlich unnötig ist, Sie zum Lesen zu drängen, wenn Sie noch hier sind). Teilen Sie Ihre Eindrücke mit, kommunizieren Sie in den Foren - im Allgemeinen kennen Sie mich immer besser. Sei nicht faul und du wirst Erfolg haben.Lassen Sie mich in diesem optimistischen Sinne Abschied nehmen. Vielen Dank für Ihre Aufmerksamkeit. Bis bald!„Eh! Welche sehen dich? Wie kann ich das alles jetzt auf einem Mobiltelefon starten? Habe ich vergebens gewartet oder was? "Nachwort. Android
Um die Integration unserer Game Engine in die Android-Plattform zu beschreiben, lassen wir die Klasse in Ruhe Game
und betrachten eine ähnliche, aber viel einfachere Klasse MainMenu
. Wie der Name schon sagt, soll das Hauptmenü der Anwendung implementiert werden. Tatsächlich ist es die erste Klasse, mit der der Benutzer zu interagieren beginnt.In der Konsolenoberfläche sieht es so aus Wie eine Klasse Game
definiert sie eine Endlosschleife, bei der bei jeder Iteration ein Bildschirm gezeichnet und ein Befehl vom Benutzer angefordert wird. Nur gibt es hier keine komplizierte Logik und diese Befehle sind viel kleiner. Wir implementieren im Wesentlichen eine Sache - "Exit".Aktivitätstabelle für Hauptmenü Einfach, oder? Darüber und über die Rede.
Der Code ist auch um eine Größenordnung einfacher. class MainMenu( private val renderer: MenuRenderer, private val interactor: MenuInteractor ) { private var actions = ActionList.EMPTY fun start() { Audio.playMusic(Music.MENU_MAIN) actions = ActionList() actions.add(Action.Type.NEW_ADVENTURE) actions.add(Action.Type.CONTINUE_ADVENTURE, false) actions.add(Action.Type.MANUAL, false) actions.add(Action.Type.EXIT) processCycle() } private fun processCycle() { while (true) { renderer.drawMainMenu(actions) when (interactor.pickAction(actions).type) { Action.Type.NEW_ADVENTURE -> TODO() Action.Type.CONTINUE_ADVENTURE -> TODO() Action.Type.MANUAL -> TODO() Action.Type.EXIT -> { Audio.stopMusic() Audio.playSound(Sound.LEAVE) renderer.clearScreen() Thread.sleep(500) return } else -> throw AssertionError("Should not happen") } } } }
Die Interaktion mit dem Benutzer wird über Schnittstellen implementiert MenuRenderer
und MenuInteractor
funktioniert ähnlich wie zuvor. interface MenuRenderer: Renderer { fun drawMainMenu(actions: ActionList) } interface Interactor { fun anyInput() fun pickAction(list: ActionList): Action }
Wie Sie bereits verstanden haben, haben wir Schnittstellen wissentlich von bestimmten Implementierungen getrennt. Jetzt müssen wir nur noch das Cli- Projekt durch ein neues Projekt ersetzen (nennen wir es Droid ) und eine Abhängigkeit vom Core- Projekt hinzufügen . Lass es uns tun.Starten Sie Android Studio (normalerweise werden darin Projekte für Android entwickelt), erstellen Sie ein einfaches Projekt, entfernen Sie alle unnötigen Standard-Lametta und lassen Sie nur die Kotlin-Sprache unterstützt. Wir fügen auch eine Abhängigkeit vom Core- Projekt hinzu , das im lokalen Maven-Repository unseres Computers gespeichert ist. apply plugin: 'com.android.application' apply plugin: 'kotlin-android' apply plugin: 'kotlin-android-extensions' android { compileSdkVersion 28 defaultConfig { applicationId "my.company.dice" minSdkVersion 14 targetSdkVersion 28 versionCode 1 versionName "1.0" } } dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" implementation "my.company:dice-core:1.0" }
Standardmäßig wird jedoch niemand unsere Abhängigkeit sehen. Sie müssen ausdrücklich angeben, dass beim Erstellen des Projekts ein lokales Repository (mavenLocal) verwendet werden muss. buildscript { ext.kotlin_version = '1.3.20' repositories { google() jcenter() mavenLocal() } dependencies { classpath 'com.android.tools.build:gradle:3.3.0' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" } } allprojects { repositories { google() jcenter() mavenLocal() } }
, , — . , , :
SoundPlayer
,
MusicPlayer
,
MenuInteractor
(
GameInteractor
),
MenuRenderer
(
GameRenderer
)
StringLoader
, , . , .
(, , ) Android —
Canvas
. -
View
- Dies wird unsere "Leinwand" sein. Bei der Eingabe ist es etwas komplizierter, da wir keine Tastatur mehr haben und die Benutzeroberfläche so gestaltet sein muss, dass Benutzereingaben auf bestimmten Teilen des Bildschirms als Eingabe von Befehlen betrachtet werden. Dazu verwenden wir denselben Erben View
- auf diese Weise fungiert er als Vermittler zwischen dem Benutzer und der Spiel-Engine (ähnlich wie die Systemkonsole als solcher Vermittler fungierte).Lassen Sie uns die Hauptaktivität für unsere Ansicht erstellen und in das Manifest schreiben. <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="my.company.dice"> <application android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:theme="@style/AppTheme"> <activity android:name=".ui.MainActivity" android:screenOrientation="sensorLandscape" android:configChanges="orientation|keyboardHidden|screenSize"> <intent-filter> <category android:name="android.intent.category.LAUNCHER"/> <action android:name="android.intent.action.MAIN"/> </intent-filter> </activity> </application> </manifest>
Wir korrigieren die Aktivität im Querformat - wie bei den meisten anderen Spielen können wir kein Porträt porträtieren. Darüber hinaus erweitern wir es auf den gesamten Bildschirm des Geräts und schreiben das Hauptthema entsprechend vor. <resources> <style name="AppTheme" parent="android:Theme.Black.NoTitleBar.Fullscreen"/> </resources>
Und da wir uns mit Ressourcen befasst haben, übertragen wir die lokalisierten Zeichenfolgen, die wir benötigen, aus dem Cli- Projekt und bringen sie in das gewünschte Format: <resources> <string name="action_new_adventure_key">N</string> <string name="action_new_adventure_name">ew adventure</string> <string name="action_continue_adventure_key">C</string> <string name="action_continue_adventure_name">ontinue adventure</string> <string name="action_manual_key">M</string> <string name="action_manual_name">anual</string> <string name="action_exit_key">X</string> <string name="action_exit_name">Exit</string> </resources>
Sowie die im Hauptmenü verwendeten Dateien mit Sounds und Musik (eine von jedem Typ), platzieren Sie sie in /assets/sound/leave.wav
und /assets/music/menu_main.mp3
.Als wir die Ressourcen aussortierten, war es Zeit, uns dem Design zu widmen (ja, wieder). Im Gegensatz zur Konsole verfügt die Android-Plattform über eigene Architekturfunktionen, die uns dazu zwingen, bestimmte Ansätze und Methoden zu verwenden.Klassen- und Schnittstellendiagramm Warten Sie, nicht in Ohnmacht fallen, jetzt werde ich alles im Detail erklären.Wir werden vielleicht mit dem Schwierigsten beginnen - der Klasse DiceSurface
- dem Erben, View
der berufen ist, die unabhängigen Teile unseres Systems zusammenzubinden (wenn Sie möchten, können Sie es von der Klasse erben SurfaceView
- oder sogar GlSurfaceView
- und in einem separaten Thread zeichnen, aber wir haben ein rundenbasiertes Spiel, das schlecht in der Animation ist (was keine komplexe grafische Ausgabe erfordert, daher werden wir es nicht komplizieren). Wie bereits erwähnt, werden durch die Implementierung zwei Probleme gleichzeitig gelöst: Bildausgabe und Klickverarbeitung, von denen jedes seine eigenen unerwarteten Schwierigkeiten hat. Betrachten wir sie der Reihe nach.Als wir auf die Konsole gemalt haben, hat unser Renderer Ausgabebefehle gesendet und ein Bild auf dem Bildschirm erstellt. Bei Android ist die Situation umgekehrt: Das Rendern wird von der Ansicht selbst initiiert, die zum Zeitpunkt der Ausführung der Methode onDraw()
bereits wissen sollte, was, wie und wo gezeichnet werden soll. Aber was ist mit der drawMainMenu()
Schnittstellenmethode MainMenu
? Kontrolliert er jetzt nicht die Ausgabe?Versuchen wir, dieses Problem mithilfe funktionaler Schnittstellen zu lösen. Die Klasse DiceSurface
enthält einen speziellen Parameter instructions
- einen Codeblock, der bei jedem Aufruf der Methode ausgeführt werden muss onDraw()
. Der Renderer gibt mithilfe einer öffentlichen Methode an, welche spezifischen Anweisungen befolgt werden sollen. Wenn Sie interessiert sind, verwenden Sie ein Muster , das die genannte Strategie (Strategie). Es sieht so aus: typealias RenderInstructions = (Canvas, Paint) -> Unit class DiceSurface(context: Context) : View(context) { private var instructions: RenderInstructions = { _, _ -> } private val paint = Paint().apply { color = Color.YELLOW style = Paint.Style.STROKE isAntiAlias = true } fun updateInstructions(instructions: RenderInstructions) { this.instructions = instructions this.postInvalidate() } override fun onDraw(canvas: Canvas) { super.onDraw(canvas) canvas.drawColor(Color.BLACK)
Das heißt, die gesamte grafische Funktionalität befindet sich noch in der Renderer-Klasse. Diesmal führen wir die Befehle jedoch nicht direkt aus, sondern bereiten sie für die Ausführung durch unsere Ansicht vor. Achten Sie auf die Art der Eigenschaft instructions
- Sie können eine separate Schnittstelle erstellen und die einzige Methode aufrufen, aber Kotlin kann die Codemenge erheblich reduzieren.Nun zu Interactor. Bisher erfolgte die Dateneingabe synchron: Als wir Daten von der Konsole (Tastatur) anforderten, wurde die Anwendung (Zyklen) angehalten, bis der Benutzer eine Taste drückte. Mit Android funktioniert ein solcher Trick nicht - er hat einen eigenen Looper, dessen Arbeit wir auf keinen Fall stören dürfen, was bedeutet, dass die Eingabe asynchron sein muss. Das heißt, die Interactor-Schnittstellenmethoden halten die Engine weiterhin an und warten auf Befehle, während Activity und alle seine Ansichten weiter funktionieren, bis sie diesen Befehl früher oder später senden.Dieser Ansatz ist mithilfe einer Standardschnittstelle recht einfach zu implementieren BlockingQueue
. Die Klasse DroidMenuInteractor
ruft die Methode auftake()
Dadurch wird die Ausführung des Spiel-Streams angehalten, bis die Elemente (Instanzen der bekannten Klasse Action
) in der Warteschlange angezeigt werden . DiceSurface
Im Gegenzug wird auf Benutzer klicken (eine Standardmethode der regirovat onTouchEvent()
einer Klasse View
), Objekte zu erzeugen und sie in die Warteschlange Methode hinzufügen offer()
. Es wird so aussehen: class DiceSurface(context: Context) : View(context) { private val actionQueue: BlockingQueue<Action> = LinkedBlockingQueue<Action>() fun awaitAction(): Action = actionQueue.take() override fun onTouchEvent(event: MotionEvent): Boolean { if (event.action == MotionEvent.ACTION_UP) { actionQueue.offer(Action(Action.Type.NONE), 200, TimeUnit.MILLISECONDS) } return true } } class DroidMenuInteractor(private val surface: DiceSurface) : Interactor { override fun anyInput() { surface.awaitAction() } override fun pickAction(list: ActionList): Action { while (true) { val type = surface.awaitAction().type list .filter(Action::isEnabled) .find { it.type == type } ?.let { return it } } } }
Das heißt, Interactor ruft die Methode auf awaitAction()
und verarbeitet den empfangenen Befehl, wenn sich etwas in der Warteschlange befindet. Achten Sie darauf, wie Teams zur Warteschlange hinzugefügt werden. Da der UI-Stream kontinuierlich ausgeführt wird, kann der Benutzer mehrmals hintereinander auf den Bildschirm klicken, was zu Hängen führen kann, insbesondere wenn die Spiel-Engine nicht bereit ist, Befehle zu empfangen (z. B. während Animationen). In diesem Fall hilft es, die Kapazität der Warteschlange zu erhöhen und / oder den Timeout-Wert zu verringern.Natürlich übertragen wir die Befehle, aber nur den einen und einzigen. Wir müssen zwischen den Koordinaten des Drückens unterscheiden und abhängig von ihren Werten diesen oder jenen Befehl aufrufen. Dies ist jedoch ein Pech - Interactor hat keine Ahnung, wo an welcher Stelle auf dem Bildschirm die aktiven Schaltflächen gezeichnet sind - Renderer ist für das Rendern verantwortlich. Wir werden ihre Interaktion wie folgt festlegen. Die Klasse DiceSurface
speichert eine spezielle Sammlung - eine Liste aktiver Rechtecke (oder anderer Formen, falls wir diesen Punkt jemals erreichen). Solche Rechtecke enthalten die Koordinaten der Eckpunkte und des gebundenen Action
. Der Renderer generiert diese Rechtecke und fügt sie der Liste hinzu. Die Methode onTouchEvent()
ermittelt, welches der Rechtecke gedrückt wurde, und fügt das entsprechende zur Warteschlange hinzu Action
. private class ActiveRect(val action: Action, left: Float, top: Float, right: Float, bottom: Float) { val rect = RectF(left, top, right, bottom) fun check(x: Float, y: Float, w: Float, h: Float) = rect.contains(x / w, y / h) }
Die Methode check()
überprüft, ob sich die angegebenen Koordinaten innerhalb des Rechtecks befinden. Bitte beachten Sie, dass wir zum Zeitpunkt der Arbeit von Renderer (und genau in diesem Moment, in dem die Rechtecke erstellt werden) nicht die geringste Ahnung von der Größe der Leinwand haben. Daher müssen wir die Koordinaten in relativen Werten (Prozentsatz der Breite oder Höhe des Bildschirms) mit Werten von 0 bis 1 speichern und zum Zeitpunkt des Drücken erneut zählen. Dieser Ansatz ist nicht ganz korrekt, da das Seitenverhältnis nicht berücksichtigt wird - in Zukunft muss er überarbeitet werden. Für unsere Bildungsaufgabe reicht es jedoch zunächst aus.Wir werden DiceSurface
ein zusätzliches Feld in der Klasse implementieren , zwei Methoden ( addRectangle()
und clearRectangles()
) hinzufügen , um es von außen (von Renderers Seite) zu steuern, und erweitern, onTouchEvent()
indem wir die Koordinaten der Rechtecke berücksichtigen. class DiceSurface(context: Context) : View(context) { private val actionQueue: BlockingQueue<Action> = LinkedBlockingQueue<Action>() private val rectangles: MutableSet<ActiveRect> = Collections.newSetFromMap(ConcurrentHashMap<ActiveRect, Boolean>()) private var instructions: RenderInstructions = { _, _ -> } private val paint = Paint().apply { color = Color.YELLOW style = Paint.Style.STROKE isAntiAlias = true } fun updateInstructions(instructions: RenderInstructions) { this.instructions = instructions this.postInvalidate() } fun clearRectangles() { rectangles.clear() } fun addRectangle(action: Action, left: Float, top: Float, right: Float, bottom: Float) { rectangles.add(ActiveRect(action, left, top, right, bottom)) } fun awaitAction(): Action = actionQueue.take() override fun onTouchEvent(event: MotionEvent): Boolean { if (event.action == MotionEvent.ACTION_UP) { with(rectangles.firstOrNull { it.check(event.x, event.y, width.toFloat(), height.toFloat()) }) { if (this != null) { actionQueue.put(action) } else { actionQueue.offer(Action(Action.Type.NONE), 200, TimeUnit.MILLISECONDS) } } } return true } override fun onDraw(canvas: Canvas) { super.onDraw(canvas) canvas.drawColor(Color.BLACK) instructions(canvas, paint) } }
Zum Speichern der Rechtecke wird eine wettbewerbsfähige Sammlung verwendet. Dadurch kann das Auftreten vermieden werden, ConcurrentModificationException
wenn der Satz gleichzeitig von verschiedenen Threads aktualisiert und verschoben wird (was in unserem Fall der Fall sein wird).Der Klassencode DroidMenuInteractor
bleibt unverändert, DroidMenuRenderer
ändert sich jedoch. Fügen Sie der Anzeige für jedes Element vier Schaltflächen hinzu ActionList
. Platzieren Sie sie unter der Überschrift Würfel, gleichmäßig über die Breite des Bildschirms verteilt. Vergessen wir nicht die aktiven Rechtecke. class DroidMenuRenderer ( private val surface: DiceSurface, private val loader: StringLoader ) : MenuRenderer { protected val helper = StringLoadHelper(loader) override fun clearScreen() { surface.clearRectangles() surface.updateInstructions { _, _ -> } } override fun drawMainMenu(actions: ActionList) {
Hier kehrten wir wieder zur Schnittstelle StringLoader
und den Funktionen der Hilfsklasse zurück StringLoadHelper
(im Diagramm nicht dargestellt). Die Implementierung der ersten hat einen Namen ResourceStringLoader
und lädt lokalisierte Zeichenfolgen aus (offensichtlich) Anwendungsressourcen. Dies geschieht jedoch dynamisch, da wir die Ressourcenkennungen nicht im Voraus kennen - wir sind gezwungen, sie unterwegs zu erstellen. class ResourceStringLoader(context: Context) : StringLoader { private val packageName = context.packageName private val resources = context.resources override fun loadString(key: String): String = resources.getString(resources.getIdentifier(key, "string", packageName)) }
Es bleibt über Geräusche und Musik zu sprechen. Es gibt eine wunderbare Klasse in Android MediaPlayer
, die sich mit diesen Dingen befasst. Es gibt nichts Besseres zum Musikspielen: class DroidMusicPlayer(private val context: Context): MusicPlayer { private var currentMusic: Music? = null private val player = MediaPlayer() override fun play(music: Music) { if (currentMusic == music) { return } currentMusic = music player.setAudioStreamType(AudioManager.STREAM_MUSIC) val afd = context.assets.openFd("music/${music.toString().toLowerCase()}.mp3") player.setDataSource(afd.fileDescriptor, afd.startOffset, afd.length) player.setOnCompletionListener { it.seekTo(0) it.start() } player.prepare() player.start() } override fun stop() { currentMusic = null player.release() } }
Zwei Punkte. Erstens wird die Methode prepare()
synchron ausgeführt, wodurch das System bei einer großen Dateigröße (aufgrund von Pufferung) angehalten wird. Es wird empfohlen, dass Sie es entweder in einem separaten Thread ausführen oder die asynchrone Methode prepareAsync()
und verwenden OnPreparedListener
. Zweitens wäre es schön, die Wiedergabe mit dem Aktivitätslebenszyklus zu verknüpfen (Pause, wenn der Benutzer die Anwendung minimiert und bei der Wiederherstellung fortgesetzt wird), aber wir haben dies nicht getan. Ai-ai-ai ...Es ist MediaPlayer
auch für Sounds geeignet, aber wenn es nur wenige und einfache sind (wie in unserem Fall), reicht es aus SoundPool
. Der Vorteil ist, dass die Wiedergabe von Audiodateien sofort beginnt, wenn sie bereits in den Speicher geladen sind. Der Nachteil liegt auf der Hand - es gibt möglicherweise nicht genügend Speicher (aber genug für uns, wir sind bescheiden). class DroidSoundPlayer(context: Context) : SoundPlayer { private val soundPool: SoundPool = SoundPool(2, AudioManager.STREAM_MUSIC, 100) private val sounds = mutableMapOf<Sound, Int>() private val rate = 1f private val lock = ReentrantReadWriteLock() init { Thread(SoundLoader(context)).start() } override fun play(sound: Sound) { if (lock.readLock().tryLock()) { try { sounds[sound]?.let { s -> soundPool.play(s, 1f, 1f, 1, 0, rate) } } finally { lock.readLock().unlock() } } } private inner class SoundLoader(private val context: Context) : Runnable { override fun run() { val assets = context.assets lock.writeLock().lock() try { Sound.values().forEach { s -> sounds[s] = soundPool.load( assets.openFd("sound/${s.toString().toLowerCase()}.wav"), 1 ) } } finally { lock.writeLock().unlock() } } } }
Beim Erstellen einer Klasse werden alle Sounds aus der Aufzählung Sound
in einem separaten Stream in das Repository geladen. Dieses Mal verwenden wir keine synchronisierte Sammlung, aber wir implementieren den Mutex mit der Standardklasse ReentrantReadWriteLock
.Jetzt, endlich, blenden wir alle Komponenten in unserem zusammen MainActivity
- haben Sie das nicht vergessen? Bitte beachten Sie, dass MainMenu
(und Game
anschließend) in einem separaten Thread gestartet werden muss. class MainActivity : Activity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) Audio.init(DroidSoundPlayer(this), DroidMusicPlayer(this)) val surface = DiceSurface(this) val renderer = DroidMenuRenderer(surface) val interactor = DroidMenuInteractor(surface, ResourceStringLoader(this)) setContentView(surface) Thread { MainMenu(renderer, interactor).start() finish() }.start() } override fun onBackPressed() { } }
Das ist in der Tat alles.
Nach all den Qualen sieht der Hauptbildschirm unserer Anwendung einfach fantastisch aus:Das Hauptmenü in voller Breite des mobilen Bildschirms Nun, das heißt, es wird erstaunlich aussehen, wenn ein intelligenter Künstler in unseren Reihen erscheint, und mit seiner Hilfe wird dieser Elend komplett neu gezeichnet.Nützliche Links
Ich weiß, viele haben direkt zu diesem Punkt gescrollt. Es ist okay - die meisten Leser haben den Tab vollständig geschlossen. Jene Einheiten, die dennoch all diesem Strom inkohärenten Geschwätzes standhielten - Respekt und Respekt, unendliche Liebe und Dankbarkeit. Na und Links natürlich, wo ohne sie. Zunächst zum Quellcode der Projekte (denken Sie daran, dass der aktuelle Status der Projekte weit über den im Artikel berücksichtigten Zustand hinausgegangen ist):Nun, plötzlich wird jemand den Wunsch haben, das Projekt zu starten und zu sehen und Faulheit selbst zu sammeln. Hier ist ein Link zur Arbeitsversion: LINK!Hier wird ein praktischer Launcher zum Starten verwendet (Sie können einen separaten Artikel über seine Erstellung schreiben). Es verwendet JavaFX und startet daher möglicherweise nicht auf Computern mit OpenJDK (Schreiben und Hilfe), macht jedoch die manuelle Registrierung von Dateipfaden zumindest überflüssig. Die Installationshilfe ist in der Datei readme.txt enthalten (erinnern Sie sich an diese?). Herunterladen, ansehen, verwenden und schließlich schweige ich.Wenn Sie an einem Projekt, einem verwendeten Tool, einer Mechanik, einer interessanten Lösung oder, ich weiß nicht, Überlieferungsspielen interessiert sind, können Sie dies in einem separaten Artikel genauer untersuchen. Wenn Sie wollen. Und wenn Sie nicht möchten, senden Sie einfach Kommentare, Bedauern und Vorschläge. Ich werde gerne reden.Alles Gute.