JAdventure - Kurs zur Softwareentwicklung
Toggle Dark/Light/Auto mode Toggle Dark/Light/Auto mode Toggle Dark/Light/Auto mode Back to homepage

2.6. Client Implementation

Nun ist es endlich an der Zeit, den eigentlichen Client zu schreiben. Bei GUI Anwendungen ist es üblich, die Funktionalität nach einem Entwurfsmusster aufzuteilen. In unserem Programm wollen wir das MVC (Model-View-Controller) Entwurfsmuster umsetzen.

MVC (Model-View-Controller)

Bei dem Model-View-Controller Entwurfsmuster haben wir eine Aufteilung der Funktionalität in drei Bereiche:

Model

Das Model enthält in erster Linie die Daten, die angezeigt werden bzw. die eingegeben werden. Die hier verwendeten Klassen können auch Business-Logik und andere Funktionalitätem beinhalten, aber diese spielen bei der Nutzung im MVC Pattern keien Rolle.

View

Die View ist zuständig für das Anzeigen der Daten und die Entgegennahme von Anwender Aktionen. Um die Daten anzeigen zu können, kennt die View das Model. Auf die Aktionen des Anwenders reagiert die View selbst nicht. Statt dessen stellt die View diese Aktionen über das Observer-Pattern zur Verfügung. Weiterhin registriert sich die View als Observer bei dem Model um bei Veränderungen des Models die Anzeige anpassen zu können.

Controller

Der Controller kennt das Model und die View. So stellt er alle benötigten Daten im Model zur Verfügung und stellt diese der View zur Verfügung. Um Aktionen des Anwenders verarbeiten zu können, registriert sich der Controller bei der View um die Events zu bearbeiten.

Das MVC Entwurfsmuster wird im Anhang im Detail erläutert. Ebenso findet sich die Dokumentation der bereitgestellten Klassen Model, View und Controller, auf die wir im weiteren Verlauf zurück greifen, im Anhang.

Entwicklung Anwendung

Datenmodell

Bei der Entwicklung ist die Basis immer das Datenmodell. Von den Anforderungen können wir ableiten, was wir benötigen:

  • Das Level, das angezeigt werden soll.
  • Es sollen ja auch Bilder angezeigt werden, daher brauchen wir die notwendigen Bilder
  • Da wir in die Anzeige hinein und heraus zoomen wollen, brauchen wir noch einen Faktor zu Skallierung
  • Und die Anzeige soll verschoben werden können, daher brauchen wir eine Information, welcher Punkt oben links angezeigt werden soll.

Eine kleine Grafik zur Veranschaulichung:

Damit erhalten wir direkt die ApplicationModel Klasse:

package org.jadv.client;

import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import org.jadv.framework.Model;
import org.jadv.model.level.Level;

/**
 * Model of the Application Window.
 */
@Getter
@Setter
@ToString
public class ApplicationModel extends Model {

    /**
     * Current level
     */
    private Level level;

    /**
     * ScaleFactor in percent
     */
    private int scaleFactor = 100;

    /**
     * X Coordinate of level in upper left corner
     */
    private int x = 0;

    /**
     * Y coordinate of level in upper left corner
     */
    private int y = 0;

    /**
     * Store for images
     */
    @Setter
    private ImageStore imageStore;
}

Die Klasse ImageStore fehlt nun noch. Diese speichert die zu Ressource-Namen die Grafiken. Dies kann in einer einfachen Map gespeichert werden, so dass die Verwaltung im ersten Schritt so aussehen kann:

public class ImageStore {

    private final Map<String, Image> images = new HashMap<>();

    public Image getImage(String name) {
        return images.get(name);
    }
}

Präsentation

Die Präsentation zeigt in erster Linie den Level an. Dazu wird ein LevelPanel als Content-Pane gesetzt. Für das Verschieben nutzen wir erst einmal nur Tasten: w, s, a, d und für das Zoomen o, p Diese Aktionen müssen wir als Actions der Programmsteuerung übergeben können, daher erstellen wir und eine Enumeration mit den Aktionen.

Damit wird die Präsentation relativ einfach umsetzbar:

  • wir haben Instanzvariablen für das Fenster (JFrame) und das GamePanel.
  • in init() bauen wir die Controls auf und registrieren und als KeyListener.
  • show() macht das Fenster einfach sichtbar
  • updateView muss dafür sorgen, dass das GamePanel sich neu malt - daher ruft es einfach ein repaint auf.
  • updateModel hat nichts zu machen, da wir keine direkte Benutzereingabe von Datenmodell Werten haben.
  • keyTyped wertet die gedrückte Taste aus und schickt dann die entsprechende Aktion an die Programmsteuerung (über das Beobachter-Entwurfsmuster)

Damit erhalten wir:

package org.jadv.client;

import org.jadv.framework.View;

import javax.swing.*;
import java.awt.event.KeyEvent;
import java.awt.event.KeyListener;

/**
 * Application View
 */
public class ApplicationView extends View implements KeyListener {

    /**
     * Window of this game view.
     */
    private JFrame gameFrame;

    /**
     * Panel that displays the Level.
     */
    private LevelPanel levelPanel;

    /**
     * Gets the model with the data.
     * @return The model with the data that is shown inside the view.
     */
    @Override
    public ApplicationModel getModel() {
        return (ApplicationModel) model;
    }

    @Override
    public void init() {
        gameFrame = new JFrame("JAdventure v0.1");
        gameFrame.setSize(500, 400);
        gameFrame.setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE);
        levelPanel = new LevelPanel(getModel());
        gameFrame.setContentPane(levelPanel);
        gameFrame.addKeyListener(this);
        levelPanel.setFocusable(true);
        gameFrame.setFocusable(true);
    }

    /**
     * Displays the window.
     */
    @Override
    public void show() {
        gameFrame.setVisible(true);
    }

    /**
     * Updates the view.
     */
    @Override
    protected void updateView() {
        super.updateView();
        gameFrame.repaint();
    }

    /**
     * Updates the model if required.
     */
    @Override
    protected void updateModel() {
        super.updateModel();
    }

    /**
     * Invoked when a key has been typed.
     * See the class description for {@link KeyEvent} for a definition of
     * a key typed event.
     *
     * @param e the event to be processed
     */
    @Override
    public void keyTyped(KeyEvent e) {
        switch (e.getKeyChar()) {
            case 'w' -> sendAction(ApplicationCommands.MOVE_UP);
            case 's' -> sendAction(ApplicationCommands.MOVE_DOWN);
            case 'a' -> sendAction(ApplicationCommands.MOVE_LEFT);
            case 'd' -> sendAction(ApplicationCommands.MOVE_RIGHT);
            case 'o' -> sendAction(ApplicationCommands.SCALE_IN);
            case 'p' -> sendAction(ApplicationCommands.SCALE_OUT);
        }
    }

    /**
     * Invoked when a key has been pressed.
     * See the class description for {@link KeyEvent} for a definition of
     * a key pressed event.
     *
     * @param e the event to be processed
     */
    @Override
    public void keyPressed(KeyEvent e) { }

    /**
     * Invoked when a key has been released.
     * See the class description for {@link KeyEvent} for a definition of
     * a key released event.
     *
     * @param e the event to be processed
     */
    @Override
    public void keyReleased(KeyEvent e) { }
}

LevelPanel

Das LevelPanel muss lediglich das Level und die darin enthaltenen Elemente malen. Die notwendigen Informationen finden sich im Model, daher wird dies mit übergeben.

In paintComponent muss dann lediglich das Scaling ausgewertet werden und dann das Bild vom Level und im Anschluss alle Spielobjekte gezeichnet werden:

  • Wir berechenen erst die reale Größe des ganzen Levels berechnet um dann über die Größe des Panels zu berechnen, was die größten Werte für x und y sein können. Damit wird verhindert, dass aus dem Level heraus gescrollt werden kann.
  • Wir malen den Hintergrund komplett schwarz. Falls das Fenster größer ist als die anzuzeigende Größe, dann ist der Rand schwarz.
  • Wir malen als erstes das Bild vom Level. Die Methode will immer den Mittelpunkt des Objekts, daher ist das die Hälfte der Breite / Höhe des Levels, weiterhin die Größe des Objektes, das Scaling und zuletzt den berechneten Punkt der Anzeige.
  • Im Anschluss malen wir alle Objekte, die im Level sind.

Damit erhalten wir ein LevelPanel:

package org.jadv.client;

import org.jadv.model.level.Level;
import org.jadv.model.objects.GameObject;

import javax.swing.*;
import java.awt.*;

public class LevelPanel extends JPanel {

    private final ApplicationModel model;

    /**
     * Creates a new <code>JPanel</code> with a double buffer
     * and a flow layout.
     */
    public LevelPanel(ApplicationModel model) {
        this.model = model;
    }

    /**
     * Calls the UI delegate's paint method, if the UI delegate
     * is non-<code>null</code>.  We pass the delegate a copy of the
     * <code>Graphics</code> object to protect the rest of the
     * paint code from irrevocable changes
     * (for example, <code>Graphics.translate</code>).
     * <p>
     * If you override this in a subclass you should not make permanent
     * changes to the passed in <code>Graphics</code>. For example, you
     * should not alter the clip <code>Rectangle</code> or modify the
     * transform. If you need to do these operations you may find it
     * easier to create a new <code>Graphics</code> from the passed in
     * <code>Graphics</code> and manipulate it. Further, if you do not
     * invoke super's implementation you must honor the opaque property, that is
     * if this component is opaque, you must completely fill in the background
     * in an opaque color. If you do not honor the opaque property you
     * will likely see visual artifacts.
     * <p>
     * The passed in <code>Graphics</code> object might
     * have a transform other than the identify transform
     * installed on it.  In this case, you might get
     * unexpected results if you cumulatively apply
     * another transform.
     *
     * @param g the <code>Graphics</code> object to protect
     * @see #paint
     */
    @Override
    protected void paintComponent(Graphics g) {
        super.paintComponent(g);

        checkViewPoint();

        Graphics2D g2d = (Graphics2D) g;
        g2d.setBackground(Color.BLACK);
        g2d.clearRect(0,0, getWidth(), getHeight());


        Level level = model.getLevel();

        drawImage(g2d, level.getGraphicResource(), level.getWidth()/2, level.getHeight()/2, level.getWidth(), level.getHeight(), model.getScaleFactor(), model.getX(), model.getY());

        for (GameObject object : level.getChildren()) {
            drawImage(g2d, object.getGraphicResource(), object.getPosition().getX(), object.getPosition().getY(), object.getSize().getWidth(), object.getSize().getHeight(), model.getScaleFactor(), model.getX(), model.getY());
        }
    }

    /**
     * Checks that the ViewPoint is valid.
     */
    private void checkViewPoint() {
        int realHeight = model.getLevel().getHeight() * model.getScaleFactor() / 100;
        int realWidth = model.getLevel().getWidth() * model.getScaleFactor() / 100;

        // To move: Calculate x/y
        int maxY = realHeight - getHeight();
        int maxX = realWidth - getWidth();
        int currY = model.getY() * model.getScaleFactor() / 100;
        int currX = model.getX() * model.getScaleFactor() / 100;
        if (maxY < currY && 0 <= maxY) model.setY(maxY * 100 / model.getScaleFactor());
        if (maxX < currX && 0 <= maxX) model.setX(maxX * 100 / model.getScaleFactor());
    }

    private void drawImage(Graphics g, String imgName, int x, int y, int dx, int dy, int scale, int viewX, int viewY) {
        Image img = model.getImageStore().getImage(imgName);
        if (img == null) return;

        int newX = scale * (x - viewX - dx/2) / 100;
        int newY = scale * (y - viewY - dy/2) / 100;
        int newDx = scale * dx / 100;
        int newDy = scale * dy / 100;

        g.drawImage(img, newX, newY, newDx, newDy, null);
    }

}

Programmsteuerung

In der Programmsteuerung haben wir

  • Um Level und Images zu laden brauchen wir sowohl ein ImageService als auch ein LevelService.
  • Im Konstruktor das Datenmodel und die Präsentation zu erstellen.
  • in show() laden wir erst ein Level um danach die Präsentation anzuzeigen.
  • wir müssen die Actions auswerten - das sind dann einfache Verschiebungen und scalings. Bei den Verschiebungen beachten wir auch das Scaling so dass in der Anzeige die Verschiebung immer gleich aussieht.

In der Klasse ImageStore fehlt noch die Funktionalität zum Laden der Bilder, die in einem Level notwendig sind. Dazu wird ein ImageService benötigt, so dass die Klasse nun wie folgt erweitert wird:

package org.jadv.client;

import org.jadv.model.level.Level;
import org.jadv.model.objects.GameObject;
import org.jadv.services.ImageService;

import java.awt.*;
import java.util.HashMap;
import java.util.Map;

public class ImageStore {

    private final ImageService imageService;

    /**
     * Store for all Images
     */
    private final Map<String, Image> images = new HashMap<>();

    public ImageStore(ImageService imageService) {
        this.imageService = imageService;
    }

    /**
     * Checks if an image is already inside the Map.
     * @param name Name of the image.
     * @return true if the image is inside the map, else false.
     */
    public boolean hasImage(String name) {
        return images.containsKey(name);
    }

    /**
     * Gets the image of a given name.
     * @param name Name of the image.
     * @return The image if available else null.
     */
    public Image getImage(String name) {
        return images.get(name);
    }

    /**
     * Adds an image to the store, if an image with that name already exists, then the image is replaced.
     * @param name Name of image.
     * @param image Image to put in store.
     */
    public void addImage(String name, Image image) {
        images.put(name, image);
    }

    /**
     * Checks all images used by a level and loads missing images.
     * @param level Level to check.
     */
    public void checkAndLoadImages(Level level) {
        checkAndLoadImage(level.getGraphicResource());

        for (GameObject child: level.getChildren()) {
            checkAndLoadImage(child.getGraphicResource());
        }
    }

    /**
     * Checks and loads Images if required.
     * @param name Name of the image.
     */
    public void checkAndLoadImage(String name) {
        if (hasImage(name)) return;

        Image image = imageService.loadImage(name);
        if (image != null) addImage(name, image);
    }

}

Damit können wir die Klasse ApplicationController aufbauen

package org.jadv.client;

import org.jadv.framework.Controller;
import org.jadv.model.level.Level;
import org.jadv.services.ImageService;
import org.jadv.services.LevelService;

/**
 * Controller of the Application Window.
 */
public class ApplicationController extends Controller {

    /**
     * Start level to load.
     */
    public static final String START_LEVEL = "testlevel";

    /**
     * ImageService to load images.
     */
    private final ImageService imageService;

    /**
     * LevelService to load level.
     */
    private final LevelService levelService;

    /**
     * Creates a new instance of ApplicationController
     * @param imageService ImageService to use to load images.
     * @param levelService LevelService to use to load level.
     */
    public ApplicationController(ImageService imageService, LevelService levelService) {
        super(new ApplicationModel(), new ApplicationView());
        this.imageService = imageService;
        this.levelService = levelService;
        getModel().setImageStore(new ImageStore(imageService));
    }

    /**
     * Load data and show View.
     */
    public void show() {
        loadModel();
        view.show();
    }

    /**
     * Loads the data into the view.
     */
    private void loadModel() {
        Level level = levelService.loadLevel(START_LEVEL);
        if (level == null) throw new IllegalStateException("Unable to load Level " + START_LEVEL + "!");

        // Set level
        getModel().setLevel(level);

        // Set scale factor
        getModel().setScaleFactor(100);

        // Set Corner
        getModel().setX(0);
        getModel().setY(0);

        getModel().getImageStore().checkAndLoadImages(level);
    }

    @Override
    public ApplicationView getView() {
        return (ApplicationView) view;
    }

    @Override
    public ApplicationModel getModel() {
        return (ApplicationModel) model;
    }

    @Override
    public void handleAction(Object action) {
        if (action instanceof ApplicationCommands command) {
            doCommand(command);
        }
    }

    protected void move(int dx, int dy) {
        getModel().setX(Math.max(0, getModel().getX() + dx * getModel().getScaleFactor()/100));
        getModel().setY(Math.max(0, getModel().getY() + dy * getModel().getScaleFactor()/100));
        getModel().hasChanged();
    }

    protected void scale(int ds) {
        int newScale = getModel().getScaleFactor() + ds;

        if (newScale < 25) newScale = 25;
        if (newScale > 1000) newScale = 10000;

        getModel().setScaleFactor(newScale);
        getModel().hasChanged();
    }

    private void doCommand(ApplicationCommands command) {
        switch (command) {
            case MOVE_UP -> move(0, -20);
            case MOVE_DOWN -> move(0, 20);
            case MOVE_LEFT -> move(-20, 0);
            case MOVE_RIGHT -> move(20, 0);
            case SCALE_OUT -> scale(10);
            case SCALE_IN -> scale(-10);
        }
    }

}

Start der Anwendung

Wenn wir nun die ANwendung starten wollen, dann müssen wir erstellen:

  • den ImageService
  • den LevelService
  • den ApplicationController

Das kann dann wie folgt aussehen:

package org.jadv.client;

import org.jadv.services.ImageService;
import org.jadv.services.LevelService;

/**
 * Main of the JAdventure Client..
 */
public class Main {

    /**
     * Startpoint of the JAdventure client.
     * @param args Commandline arguments, not used.
     */
    public static void main(String[] args) {
        ImageService imageService = new ImageService();
        LevelService levelService = new LevelService();
        ApplicationController controller =  new ApplicationController(imageService, levelService);
        controller.show();
    }
}

Der fertige Client

(Der fertige Client auf einem Mac!)