Das Ein-Feature Framework

2026-02-22
Das Ein-Feature Framework - Teaser

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:

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:

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

captcha