/*
* This file is part of the Illarion project.
*
* Copyright © 2015 - Illarion e.V.
*
* Illarion is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Illarion is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*/
package illarion.client;
import ch.qos.logback.classic.LoggerContext;
import ch.qos.logback.classic.util.ContextInitializer;
import ch.qos.logback.core.joran.spi.JoranException;
import ch.qos.logback.core.util.StatusPrinter;
import illarion.client.crash.DefaultCrashHandler;
import illarion.client.net.client.LogoutCmd;
import illarion.client.resources.SongFactory;
import illarion.client.resources.SoundFactory;
import illarion.client.resources.loaders.SongLoader;
import illarion.client.resources.loaders.SoundLoader;
import illarion.client.util.ChatLog;
import illarion.client.util.GlobalExecutorService;
import illarion.client.util.Lang;
import illarion.client.util.translation.Translator;
import illarion.client.world.Player;
import illarion.client.world.World;
import illarion.client.world.events.ConnectionLostEvent;
import illarion.common.bug.CrashReporter;
import illarion.common.bug.ReportDialogFactorySwing;
import illarion.common.config.Config;
import illarion.common.config.ConfigChangedEvent;
import illarion.common.config.ConfigSystem;
import illarion.common.util.AppIdent;
import illarion.common.util.Crypto;
import illarion.common.util.DirectoryManager;
import illarion.common.util.DirectoryManager.Directory;
import illarion.common.util.TableLoader;
import org.bushe.swing.event.*;
import org.illarion.engine.Backend;
import org.illarion.engine.DesktopGameContainer;
import org.illarion.engine.EngineException;
import org.illarion.engine.EngineManager;
import org.illarion.engine.graphic.GraphicResolution;
import org.jetbrains.annotations.Contract;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.bridge.SLF4JBridgeHandler;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import javax.swing.*;
import java.awt.*;
import java.io.IOException;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Locale;
import java.util.Locale.Category;
import java.util.Objects;
import java.util.Timer;
import java.util.TimerTask;
/**
* Main Class of the Illarion Client, this loads up the whole game and runs the main loop of the Illarion Client.
*
* @author Nop
* @author Martin Karing <nitram@illarion.org>
*/
public final class IllaClient implements EventTopicSubscriber<ConfigChangedEvent> {
/**
* The identification of this application.
*/
@Nonnull
public static final AppIdent APPLICATION = new AppIdent("Illarion Client"); //$NON-NLS-1$
@Nonnull
public static final String CFG_FULLSCREEN = "fullscreen";
@Nonnull
public static final String CFG_RESOLUTION = "resolution";
/**
* The default server the client connects too. The client will always connect to this server.
*/
@Nonnull
public static final Servers DEFAULT_SERVER;
/**
* The singleton instance of this class.
*/
@Nonnull
private static final IllaClient INSTANCE = new IllaClient();
/**
* The error and debug logger of the client.
*/
@Nonnull
private static final Logger LOGGER = LoggerFactory.getLogger(IllaClient.class);
/**
* Stores if there currently is a exit requested to avoid that the question area is opened multiple times.
*/
private static boolean exitRequested;
static {
String server = System.getProperty("illarion.server", "realserver");
switch ((server == null) ? "" : server) {
case "testserver":
DEFAULT_SERVER = Servers.Testserver;
break;
case "devserver":
DEFAULT_SERVER = Servers.Devserver;
break;
default:
DEFAULT_SERVER = Servers.Illarionserver;
break;
}
}
/**
* The configuration of the client settings.
*/
private ConfigSystem cfg;
/**
* This is the reference to the Illarion Game instance.
*/
private Game game;
/**
* The container that is used to display the game.
*/
private DesktopGameContainer gameContainer;
/**
* Stores the server the client shall connect to.
*/
@Nonnull
private Servers usedServer = DEFAULT_SERVER;
/**
* The default empty constructor used to create the singleton instance of this class.
*/
private IllaClient() {
}
/**
* Show a question frame if the client shall really quit and exit the client in case the user selects yes.
*/
public static void ensureExit() {
if (exitRequested) {
return;
}
exitRequested = true;
INSTANCE.game.enterState(Game.STATE_ENDING);
}
/**
* Show an error message and leave the client.
*
* @param message the error message that shall be displayed.
*/
public static void errorExit(@Nonnull String message) {
World.cleanEnvironment();
LOGGER.info("Client terminated on user request.");
LOGGER.error(message);
LOGGER.error("Terminating client!");
INSTANCE.cfg.save();
new Thread(() -> {
JOptionPane.showMessageDialog(null, message, "Error", JOptionPane.ERROR_MESSAGE);
startFinalKiller();
}).start();
}
/**
* This method is the final one to be called before the client is killed for sure. It gives the rest of the client
* 10 seconds before it forcefully shuts down everything. This is used to ensure that the client quits when it has
* to, but in case it does so faster, it won't be killed like that.
*/
public static void startFinalKiller() {
Timer finalKiller = new Timer("Final Death", true);
finalKiller.schedule(new TimerTask() {
@Override
public void run() {
CrashReporter.getInstance().waitForReport();
System.err.println("Killed by final death");
System.exit(-1);
}
}, 10000);
}
/**
* Main function starts the client and sets up all data.
*
* @param args the arguments handed over to the client
*/
public static void main(String... args) {
// Setup the crash reporter so the client is able to crash properly.
CrashReporter.getInstance().setMessageSource(Lang.getInstance());
// in case the server is now known, update the files if needed and
// launch the client.
INSTANCE.init();
}
/**
* Prepares and sets up the entire client
*/
private void init() {
try {
EventServiceLocator.setEventService(EventServiceLocator.SERVICE_NAME_EVENT_BUS, null);
EventServiceLocator.setEventService(EventServiceLocator.SERVICE_NAME_SWING_EVENT_SERVICE, null);
EventServiceLocator
.setEventService(EventServiceLocator.SERVICE_NAME_EVENT_BUS, new ThreadSafeEventService());
EventServiceLocator.setEventService(EventServiceLocator.SERVICE_NAME_SWING_EVENT_SERVICE,
EventServiceLocator.getEventBusService());
} catch (EventServiceExistsException e1) {
LOGGER.error("Failed preparing the EventBus. Settings the Service handler happened too late");
}
prepareConfig();
assert cfg != null;
try {
initLogfiles();
} catch (IOException e) {
System.err.println("Failed to setup logging system!");
e.printStackTrace(System.err);
}
Lang.getInstance().recheckLocale(cfg.getString(Lang.LOCALE_CFG));
CrashReporter.getInstance().setConfig(getCfg());
// Report errors of the released version only
if (DEFAULT_SERVER != Servers.Illarionserver) {
CrashReporter.getInstance().setMode(CrashReporter.MODE_NEVER);
}
CrashReporter.getInstance().setDialogFactory(new ReportDialogFactorySwing());
// Preload sound and music
try {
new SongLoader().setTarget(SongFactory.getInstance()).call();
new SoundLoader().setTarget(SoundFactory.getInstance()).call();
} catch (Exception e) {
LOGGER.error("Failed to load sounds and music!");
}
game = new Game();
int width;
int height;
boolean fullScreen;
if (cfg.getBoolean(CFG_FULLSCREEN)) {
// Determine the dimensions of the window to create
GraphicResolution res = null;
String resolutionString = cfg.getString(CFG_RESOLUTION);
if (resolutionString != null) {
try {
res = new GraphicResolution(resolutionString);
} catch (@Nonnull IllegalArgumentException ex) {
LOGGER.error("Failed to initialize screen resolution. Falling back.");
}
}
if (res == null) {
res = new GraphicResolution(); // auto detection
}
width = res.getWidth();
height = res.getHeight();
fullScreen = true;
} else {
width = cfg.getInteger("windowWidth");
height = cfg.getInteger("windowHeight");
fullScreen = false;
}
try {
// Get the game container used to display the game from the engine, using the dimensions from earlier
gameContainer = EngineManager
.createDesktopGame(Backend.libGDX, game, width, height, fullScreen);
} catch (@Nonnull EngineException e) {
LOGGER.error("Fatal error creating game screen!!!", e);
System.exit(-1);
}
gameContainer.setTitle(APPLICATION.getApplicationIdentifier());
gameContainer.setIcons("illarion_client16.png", "illarion_client32.png", "illarion_client64.png", "illarion_client256.png");
EventBus.subscribe(CFG_FULLSCREEN, this);
EventBus.subscribe(CFG_RESOLUTION, this);
try {
gameContainer.setResizeable(true);
gameContainer.startGame();
} catch (@Nonnull Exception e) {
LOGGER.error("Exception while launching game.", e);
exitGameContainer();
}
}
/**
* Prepare the configuration system and the decryption system.
*/
private void prepareConfig() {
cfg = new ConfigSystem(getFile("Illarion.xcfgz"));
cfg.setDefault("debugLevel", 1);
cfg.setDefault("showIDs", false);
cfg.setDefault(Player.CFG_SOUND_ON, true);
cfg.setDefault(Player.CFG_SOUND_VOL, Player.MAX_CLIENT_VOL);
cfg.setDefault("musicOn", true);
cfg.setDefault("musicVolume", Player.MAX_CLIENT_VOL * 0.25f);
cfg.setDefault(ChatLog.CFG_TEXTLOG, true);
cfg.setDefault(CFG_FULLSCREEN, false);
GraphicResolution defaultResolution = new GraphicResolution();
cfg.setDefault(CFG_RESOLUTION, defaultResolution.toString());
cfg.setDefault("windowWidth", defaultResolution.getWidth());
cfg.setDefault("windowHeight", defaultResolution.getHeight());
cfg.setDefault("savePassword", false);
cfg.setDefault("showFps", false);
cfg.setDefault("showPing", false);
cfg.setDefault(CrashReporter.CFG_KEY, CrashReporter.MODE_ASK);
Locale locale = Locale.getDefault(Category.DISPLAY);
// If the system locale is german, set to german. Otherwise, default to English
if ("de".equals(locale.getLanguage())) {
cfg.setDefault(Lang.LOCALE_CFG, Lang.LOCALE_CFG_GERMAN);
} else {
cfg.setDefault(Lang.LOCALE_CFG, Lang.LOCALE_CFG_ENGLISH);
}
cfg.setDefault("inventoryPosX", "100px");
cfg.setDefault("inventoryPosY", "10px");
cfg.setDefault("bookDisplayPosX", "150px");
cfg.setDefault("bookDisplayPosY", "15px");
cfg.setDefault("skillWindowPosX", "200px");
cfg.setDefault("skillWindowPosY", "20px");
cfg.setDefault("backpackDisplayPosX", "100px");
cfg.setDefault("backpackDisplayPosY", "10px");
cfg.setDefault("depotDisplayPosX", "150px");
cfg.setDefault("depotDisplayPosY", "15px");
cfg.setDefault("bagDisplayPosX", "200px");
cfg.setDefault("bagDisplayPosY", "20px");
cfg.setDefault("questWindowPosX", "100px");
cfg.setDefault("questWindowPosY", "100px");
cfg.setDefault("questShowFinished", false);
cfg.setDefault("server", Servers.Devserver.getServerKey());
cfg.setDefault("serverAddress", Servers.Customserver.getServerHost());
cfg.setDefault("serverPort", Servers.Customserver.getServerPort());
cfg.setDefault("clientVersion", Servers.Customserver.getClientVersion());
cfg.setDefault("clientVersionOverwrite", false);
cfg.setDefault("serverAccountLogin", true);
cfg.setDefault("wasdWalk", true);
cfg.setDefault("disableChatAfterSending", true);
cfg.setDefault("showQuestsOnGameMap", true);
cfg.setDefault("showQuestsOnMiniMap", true);
/* Showing the avatar tag on a permanent base.
* 0 -> none are shown
* 1 -> other players only
* 2 -> other players and monsters
*/
cfg.setDefault("showAvatarTagPermanently", 0);
cfg.set("limitPathFindingToMouseDirection", true);
cfg.set("followMousePathFinding", true);
cfg.setDefault("preLoadBagCount", 2);
cfg.setDefault(Translator.CFG_KEY_PROVIDER, Translator.CFG_VALUE_PROVIDER_NONE);
cfg.setDefault(Translator.CFG_KEY_DIRECTION, Translator.CFG_VALUE_DIRECTION_DEFAULT);
@Nonnull Toolkit awtDefaultToolkit = Toolkit.getDefaultToolkit();
@Nullable Object doubleClick = awtDefaultToolkit.getDesktopProperty("awt.multiClickInterval");
if (doubleClick instanceof Number) {
cfg.set("doubleClickInterval", ((Number) doubleClick).intValue());
} else {
cfg.set("doubleClickInterval", 500);
}
Crypto crypt = new Crypto();
crypt.loadPublicKey();
TableLoader.setCrypto(crypt);
}
/**
* Basic initialization of the log files and the debug settings.
*/
private static void initLogfiles() throws IOException {
SLF4JBridgeHandler.removeHandlersForRootLogger();
SLF4JBridgeHandler.install();
Path userDir = DirectoryManager.getInstance().getDirectory(Directory.User);
if (!Files.isDirectory(userDir)) {
if (Files.exists(userDir)) {
Files.delete(userDir);
}
Files.createDirectories(userDir);
}
System.setProperty("log_dir", userDir.toAbsolutePath().toString());
//Reload:
LoggerContext lc = (LoggerContext) LoggerFactory.getILoggerFactory();
ContextInitializer ci = new ContextInitializer(lc);
try {
ClassLoader cl = Thread.currentThread().getContextClassLoader();
URL resource = cl.getResource("logback-to-file.xml");
if (resource != null) {
ci.configureByResource(resource);
}
} catch (JoranException ignored) {
}
StatusPrinter.printInCaseOfErrorsOrWarnings(lc);
Thread.setDefaultUncaughtExceptionHandler(DefaultCrashHandler.getInstance());
//noinspection UseOfSystemOutOrSystemErr
System.out.println("Startup done.");
LOGGER.info("{} started.", APPLICATION.getApplicationIdentifier());
LOGGER.info("VM: {}", System.getProperty("java.version"));
LOGGER.info("OS: {} {} {}", System.getProperty("os.name"), System.getProperty("os.version"),
System.getProperty("os.arch"));
}
/**
* Get the configuration handler of the basic client settings.
*
* @return the configuration handler
*/
@Nonnull
@Contract(pure = true)
public static Config getCfg() {
return Objects.requireNonNull(INSTANCE.cfg, "Config is not ready yet");
}
/**
* Save the current configuration and shutdown the client
*/
public static void exitGameContainer() {
INSTANCE.gameContainer.exitGame();
LOGGER.info("Client shutdown initiated.");
getInstance().quitGame();
World.cleanEnvironment();
getCfg().save();
GlobalExecutorService.shutdown();
LOGGER.info("Cleanup done.");
startFinalKiller();
}
/**
* Get the full path to a file. This includes the default path that was set up and the name of the file this
* function gets.
*
* @param name the name of the file that shall be append to the folder
* @return the full path to a file
*/
@Nonnull
public static Path getFile(@Nonnull String name) {
Path userDir = DirectoryManager.getInstance().getDirectory(Directory.User);
return userDir.resolve(name);
}
/**
* End the game by user request and send the logout command to the server.
*/
public void quitGame() {
try {
World.getNet().sendCommand(new LogoutCmd());
} catch (@Nonnull IllegalStateException ex) {
// the NET was not launched up yet. This does not really matter.
}
}
/**
* Get the singleton instance of this client main object.
*
* @return the singleton instance of this class
*/
@Nonnull
public static IllaClient getInstance() {
return INSTANCE;
}
public static void performLogout() {
LOGGER.info("Logout requested.");
getInstance().quitGame();
INSTANCE.game.enterState(Game.STATE_LOGOUT);
}
public static void returnToLogin() {
LOGGER.info("Returning to login initiated");
INSTANCE.game.enterState(Game.STATE_LOGIN);
}
/**
* Publishing a ConnectionLostEvent.
*
* @param message the message that shall be displayed
*/
public static void sendDisconnectEvent(@Nonnull String message, boolean tryToReconnect) {
LOGGER.warn("Disconnect received: {}", message);
EventBus.publish(new ConnectionLostEvent(message, tryToReconnect));
}
/**
* Get the container that is used to display the game inside.
*
* @return the are used to display the game inside
*/
public DesktopGameContainer getContainer() {
return gameContainer;
}
/**
* Get the server that was selected as connection target.
*
* @return the selected server
*/
@Nonnull
@Contract(pure = true)
public Servers getUsedServer() {
return usedServer;
}
/**
* Set the server that shall be used to login at.
*
* @param server the server that is used to connect with
*/
public void setUsedServer(Servers server) {
usedServer = server;
}
/**
* If the config is changed AND the changed config is either the resolution
* or the fullscreen boolean, updates the relevant config
*
* Otherwise, does nothing.
*
* Handling changes in other settings (like volume) should be done here
*
* @param topic indicates what in the config to change
* @param data the event being handled
*/
@Override
public void onEvent(String topic, ConfigChangedEvent data) {
if (CFG_RESOLUTION.equals(topic) || CFG_FULLSCREEN.equals(topic)) {
String resolutionString = cfg.getString(CFG_RESOLUTION);
if (resolutionString == null) {
LOGGER.error("Failed reading new resolution.");
return;
}
try {
GraphicResolution res = new GraphicResolution(resolutionString);
boolean fullScreen = cfg.getBoolean(CFG_FULLSCREEN);
if (fullScreen) {
gameContainer.setFullScreenResolution(res);
gameContainer.setFullScreen(true);
} else {
gameContainer.setWindowSize(res.getWidth(), res.getHeight());
gameContainer.setFullScreen(false);
}
if (!fullScreen) {
cfg.set("windowHeight", res.getHeight());
cfg.set("windowWidth", res.getWidth());
}
} catch (@Nonnull EngineException | IllegalArgumentException e) {
LOGGER.error("Failed to apply graphic mode: {}", resolutionString);
}
}
}
}