Für die Projekte der Ein-Feature-Reihe verwende ich mein eigenes kleines Framework. Hier erkläre ich seinen Aufbau einmal unabhängig von einem konkreten Projekt. Die Beispiele stammen aus dem Windows Stars Screensaver, dem offiziell ersten Projekt der Reihe.
Die Werkzeuge
Git und GitHub
Den Quellcode verwalte ich mit Git, die Repositories liegen auf meinem GitHub-Account. Meistens bediene ich Git über Sourcetree, manchmal direkt in der Konsole. Anfängern empfehle ich eine grafische Oberfläche – sie macht Fehlbedienungen deutlich schwerer.
Yarn
Als Paketmanager nutze ich Yarn, das auf npm aufbaut. Abseits einiger Caching-Vorteile ist die ehrliche Begründung: Gewohnheit.
TypeScript
Alle Projekte schreibe ich in TypeScript (wird über die Projekt-Abhängigkeiten installiert). Ich bevorzuge statisch typisierte Sprachen, und weil die Projekte im Browser auf meiner Website laufen sollen, passt eine Sprache, die zu JavaScript übersetzt wird. Elm hätte mich auch gereizt, aber dann hätte ich mehr Zeit mit dem Lernen der Sprache verbracht als mit den Projekten selbst.
Parcel
Damit der auf viele Dateien verteilte Code im Browser läuft, muss er zu einem Bundle zusammengefasst werden. Dafür nutze ich Parcel (ebenfalls eine Projekt-Abhängigkeit). Im Vergleich zu den Alternativen, die ich kenne, ist es angenehm unkompliziert und läuft bis auf gelegentliche Neustarts ohne weiteres Zutun. Für die Website muss ich Parcel nur sagen, dass Assets über relative Pfade geladen werden.
Installation und Build
Zuerst wird das passende Repository geklont – bei einem neuen Projekt das, auf dem es aufbaut. Projekte der Ein-Feature-Reihe tragen das Präfix ef--, damit sie erkennbar bleiben.
git clone <repository-url> <projekt-pfad>
Danach werden die Abhängigkeiten installiert, darunter meine ts-game-toolbox, die mit jedem Projekt um ein paar Klassen wächst:
yarn install
Für die Entwicklung genügt dann:
yarn run start
Damit läuft ein lokaler Server, der das Projekt unter localhost:1234 ausliefert. Codeänderungen lösen automatisch einen neuen Build aus. Nur wenn neue externe Ressourcen wie Bilder oder Musik dazukommen, muss der Befehl mit Ctrl + C beendet und neu gestartet werden.
Nebenher lasse ich in einem zweiten Konsolenfenster den TypeScript-Compiler laufen. Er reagiert ebenfalls auf jede Änderung und meldet Typfehler sofort:
yarn run tsc
Zum Schluss erzeugt ein letzter Befehl die Version für den Upload:
yarn run release
Danach liegt das komplette Projekt lauffähig im Ordner dist/. Sobald dieser Ordner über einen Webserver erreichbar ist, läuft das Programm genau wie lokal.
Das Framework
Das Framework folgt dem MVC-Prinzip und teilt den Code in drei Gruppen:
- Models bilden die Logik des Programms ab.
- Views stellen den Programmzustand dar.
- Controller steuern das Zusammenspiel zwischen Models und Views.
Collections
Jede Gruppe hat ihren eigenen Ordner: game/models, game/views und game/controller. Instanziiert werden die Klassen immer in einer Collection:
export interface ModelCollection extends ModelCollectionBase {
game: GameModel,
stars: ModelTable<ModelCollection, StarModel>,
}
Bewährt hat sich, die Collection in einer Funktion direkt unter ihrer Definition zu befüllen. Jedes Model, jede View und jeder Controller erhält Zugriff auf die eigene Collection – also auf eine Sammlung, in der es selbst steckt. Dafür muss man TypeScript ein wenig austricksen:
export function create_models(): ModelCollection {
const collection: ModelCollection = {} as ModelCollection;
return Object.assign(collection, {
game: new GameModel(collection),
stars: new ModelTable(collection, StarModel),
});
}
Models
Models sind der flexibelste Teil des Frameworks. Sie dürfen beliebige Properties und Methoden haben, alle Properties sind aber public:
export class StarModel extends Model<ModelCollection> {
public position: Vector2 = new Vector2(0, 0);
public z: number = 0;
public color: RgbColor = new RgbColor(255, 255, 255);
public size: number = 0;
public update(delta_seconds: number) {
// ...
}
}
Controller und andere Models sollen für größtmögliche Flexibilität vollen Zugriff auf das Innenleben eines Models haben:
export class GameModel extends Model<ModelCollection> {
public update(delta_seconds: number) {
this.models.stars.for_each((star) => {
star.z += delta_seconds;
if (star.z > 100) star.z = 0;
});
}
}
Wichtig: Der Konstruktor eines Models erwartet nie zusätzliche Argumente. Ein Model muss sich immer ohne Weiteres erzeugen lassen.
ModelTable
Die ModelTable aus der Collection oben ist ein Helfer für Models, die mehrfach existieren. Sie ist an eine SQL-Tabelle angelehnt, sodass sich etwa alle Einträge mit einem bestimmten Wert schnell abfragen lassen:
const small_stars = this.models.stars.where('size', 1);
Das Konzept hat noch viel Potenzial und ist bisher wenig ausgereift.
ModelAdapter
Damit Model-Klassen nicht mit Methoden überladen werden, gibt es ModelAdapter. Zuerst beschreibt ein Interface, für welche Models der Adapter funktioniert:
export interface PhysicsAdaptable {
position: Vector2;
z: number;
size: number;
}
Der Adapter bündelt thematisch zusammenhängende Funktionen, die so von mehreren Models gemeinsam genutzt werden können – ähnlich einem Trait in PHP. Er braucht Zugriff auf das Model, mit dem er arbeitet. Im Zweifel lohnt es sich, ihn als Singleton umzusetzen, um nicht ständig neue Objekte zu erzeugen:
export class PhysicsModelAdapter {
protected constructor(public target: PhysicsAdaptable) {
}
public update(delta_seconds: number) {
this.target.z += delta_seconds * this.target.size;
if (this.target.z > 100) this.target.z = 0;
}
private static instance?: PhysicsModelAdapter;
public static for(target: PhysicsAdaptable): PhysicsModelAdapter {
if (!PhysicsModelAdapter.instance) {
PhysicsModelAdapter.instance = new PhysicsModelAdapter(target);
} else {
PhysicsModelAdapter.instance.target = target;
}
return PhysicsModelAdapter.instance;
}
}
Verwendet wird er dann so:
this.models.stars.for_each((star) => {
PhysicsModelAdapter.for(star).update(delta_seconds);
});
Im Beispiel bringt das zugegeben noch wenig. Interessant wird es, sobald die Physik komplizierter wird und Hilfsmethoden braucht, die sonst die Model-Klasse füllen würden.
Views
Mir ist wichtig, dass der Input einer View nicht von den Models abhängt. Alle Properties sind entweder primitive Werte oder Strukturen, die in derselben Datei wie die View definiert sind:
interface ViewStarAttr {
position: Vector2I;
z: number;
size: number;
color: RgbColor;
}
Das Interface darf dem Model ruhig ähneln, damit sich ein Model direkt übergeben lässt, ohne es umzuwandeln. Alle Properties einer View sind außerdem verkettbar, was das Zuweisen angenehm macht:
this.views.main.stars.set(this.models.stars.all());
Die komplette View des Sternenfelds:
export class MainView extends CanvasView<ViewCollection> {
public bg_color = new ChainProperty<this, RgbColor>(this, tools.commons.Colors.BLACK);
public fg_color = new ChainProperty<this, RgbColor>(this, tools.commons.Colors.WHITE);
public stars = new ChainProperty<this, Array<ViewStarAttr>>(this, []);
public draw(): void {
this.reset_canvas_state();
this.stars.get().forEach((star) => {
const real_position = new Vector2(star.position).mul(33 / star.z).add({x: 400, y: 300});
const size = star.size * 100 / star.z;
this.context.fillStyle = star.color.to_hex();
this.context.fillRect(real_position.x - size / 2, real_position.y - size / 2, size, size);
});
}
protected reset_canvas_state() {
super.reset_canvas_state();
this.context.fillStyle = this.bg_color.get().to_hex();
this.context.fillRect(0, 0, 800, 600);
this.context.imageSmoothingEnabled = false;
}
}
Was noch fehlt, ist ein gutes System für Partials: Ich möchte Views wiederverwenden können, ohne die Werte der Teil-Views jedes Mal einzeln setzen zu müssen.
Controller
Controller sind am schwierigsten zu erklären. Grob gesagt regeln sie das Zusammenspiel zwischen Models und Views.
Sie sind die einzigen Elemente mit Zugriff auf alle drei Collections: Sie lesen den Zustand der Models und reichen ihn an die Views weiter, und sie können Methoden anderer Controller aufrufen.
Die Methodennamen beschreiben oft den Zustand, in dem sich das Programm gerade befindet. Fast immer gibt es eine Methode new_game, die das Spiel aufsetzt:
export class GameController extends BaseController {
public new_game(): ControllerRouteResponse {
for (let i = 0; i < 50; ++i) {
const star = this.models.stars.insert_new();
const intensity = Math.random() * 100 + 155;
star.color = new RgbColor(
Math.min(intensity + 50 * Math.random(), 255),
Math.min(intensity + 50 * Math.random(), 255),
Math.min(intensity + 50 * Math.random(), 255),
);
star.z = Math.random() * 100;
star.position.set({
x: Math.random() * 800 - 400,
y: Math.random() * 600 - 300,
});
}
return {
view: this.views.main,
controller: this.controllers.for_event.game_controller,
};
}
}
EventController
Daneben gibt es EventController. Sie stellen Methoden für Events wie key_down, mouse_down, update oder eigene Events bereit:
export class GameEventController extends BaseController implements EventControllerInterface {
public update(delta_seconds: number): ControllerRouteResponse {
this.models.game.update(delta_seconds);
this.views.main.stars.set(this.models.stars.all());
return null;
}
}
Es verarbeitet immer genau ein EventController die Events.
Der Rückgabewert eines Controllers
Entscheidend ist, was ein Controller zurückgibt:
nullbedeutet, dass er nur die interne Logik verändert hat.viewlegt fest, welche View ab jetzt angezeigt wird. Andere Views können weiter Daten bekommen, sichtbar ist aber nur diese.controllerwird der neue EventController und verarbeitet ab sofort alle Events.eventwird vom aktiven EventController abgefangen und ausgewertet. Dieses Feature ist noch nicht ganz durchdacht.
Der Code
Das Framework wird sich im Laufe der Reihe stark verändern, und die neueste Version wird irgendwann nicht mehr dem entsprechen, was hier beschrieben ist. Den Stand dieses Artikels zeigt das Repository ef--1-windows-stars-screensaver.
Comments