diff --git a/engine/build.gradle b/engine/build.gradle index 9ae999ffb05..e9cf4fcc349 100644 --- a/engine/build.gradle +++ b/engine/build.gradle @@ -129,6 +129,9 @@ dependencies { } compile group: 'net.logstash.logback', name: 'logstash-logback-encoder', version: '4.10' + // Discord RPC + compile 'com.jagrosh:DiscordIPC:0.4' + // Our developed libs compile group: 'org.terasology', name: 'gestalt-module', version: '5.1.3' compile group: 'org.terasology', name: 'gestalt-asset-core', version: '5.1.3' diff --git a/engine/src/main/java/org/terasology/config/PlayerConfig.java b/engine/src/main/java/org/terasology/config/PlayerConfig.java index 0b8beedb41f..9d052465627 100644 --- a/engine/src/main/java/org/terasology/config/PlayerConfig.java +++ b/engine/src/main/java/org/terasology/config/PlayerConfig.java @@ -18,6 +18,7 @@ import java.util.List; +import org.terasology.engine.subsystem.rpc.DiscordRPCSubSystem; import org.terasology.rendering.nui.Color; import org.terasology.rendering.nui.layers.mainMenu.settings.CieCamColors; import org.terasology.utilities.random.FastRandom; @@ -29,6 +30,8 @@ public class PlayerConfig { private static final float DEFAULT_PLAYER_EYE_HEIGHT = 0.7f; + private static final boolean DEFAULT_DISCORD_PRESENCE = true; + private String name = defaultPlayerName(); private Color color = defaultPlayerColor(); @@ -39,6 +42,8 @@ public class PlayerConfig { private boolean hasEnteredUsername; + private boolean discordPresence = DEFAULT_DISCORD_PRESENCE; + public String getName() { return name; } @@ -81,6 +86,21 @@ public void setHasEnteredUsername(boolean entered) { this.hasEnteredUsername = entered; } + public void setDiscordPresence(boolean discordPresence) { + this.discordPresence = discordPresence; + if (DiscordRPCSubSystem.isEnabled() != discordPresence) { + if (discordPresence) { + DiscordRPCSubSystem.enable(); + } else { + DiscordRPCSubSystem.disable(); + } + } + } + + public boolean isDiscordPresence() { + return discordPresence; + } + /** * Generates the player's default name. The default name is the string "Player" followed by a random 5 digit code ranging from 10000 to 99999. * diff --git a/engine/src/main/java/org/terasology/engine/subsystem/rpc/DiscordRPCSubSystem.java b/engine/src/main/java/org/terasology/engine/subsystem/rpc/DiscordRPCSubSystem.java new file mode 100644 index 00000000000..b88f69b815a --- /dev/null +++ b/engine/src/main/java/org/terasology/engine/subsystem/rpc/DiscordRPCSubSystem.java @@ -0,0 +1,305 @@ +/* + * Copyright 2018 MovingBlocks + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.terasology.engine.subsystem.rpc; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.LoggerContext; +import com.jagrosh.discordipc.IPCClient; +import com.jagrosh.discordipc.IPCListener; +import com.jagrosh.discordipc.entities.RichPresence; +import com.jagrosh.discordipc.entities.pipe.Pipe; +import com.jagrosh.discordipc.entities.pipe.WindowsPipe; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.terasology.config.Config; +import org.terasology.context.Context; +import org.terasology.engine.GameEngine; +import org.terasology.engine.subsystem.EngineSubsystem; + +import java.time.OffsetDateTime; + +/** + * Subsystem that manages Discord RPC in the game client, such as status or connection. + * This subsystem can be enhanced further to improve game presentation in rich presence. + * + * @see EngineSubsystem + */ +public class DiscordRPCSubSystem implements EngineSubsystem, IPCListener, Runnable { + + private static final Logger logger = LoggerFactory.getLogger(DiscordRPCSubSystem.class); + private static final long DISCORD_APP_CLIENT_ID = 515274721080639504L; + private static final String DISCORD_APP_LARGE_IMAGE = "ss_6"; + private static final int RECONNECT_TRIES = 5; + private static DiscordRPCSubSystem instance; + + private IPCClient ipcClient; + private boolean ready; + private boolean autoReconnect; + private Thread reconnectThread; + private RichPresence lastRichPresence; + private boolean reconnecting; + private int reconnectTries = 1; + private boolean connectedBefore; + private int lastPing; + private Config config; + private String lastState; + private boolean dontTryAgain; + private boolean enabled; + + public DiscordRPCSubSystem() throws IllegalStateException { + if (instance != null) { + throw new IllegalStateException("More then one instance in the DiscordRPC"); + } + lastRichPresence = null; + ipcClient = new IPCClient(DISCORD_APP_CLIENT_ID); + ipcClient.setListener(this); + autoReconnect = true; + reconnectThread = new Thread(this); + reconnectThread.setName("DISCORD-RPC-RECONNECT"); + reconnectThread.start(); + instance = this; + enabled = false; + dontTryAgain = true; + } + + public void sendRichPresence(RichPresence richPresence) { + this.lastRichPresence = richPresence; + if (!ready || lastRichPresence == null || !enabled) { + return; + } + ipcClient.sendRichPresence(lastRichPresence); + } + + @Override + public void onReady(IPCClient client) { + if (reconnecting) { + logger.info("Discord RPC >> Reconnected!"); + reconnectTries = 1; + } else { + logger.info("Discord RPC >> Connected!"); + connectedBefore = true; + } + this.ipcClient = client; + if (!ready) { + ready = true; + } + if (lastRichPresence == null) { + RichPresence.Builder builder = new RichPresence.Builder(); + builder.setLargeImage(DISCORD_APP_LARGE_IMAGE); + lastRichPresence = builder.build(); + } + client.sendRichPresence(lastRichPresence); + } + + @Override + public void onDisconnect(IPCClient client, Throwable t) { + if (ready) { + ready = false; + } + logger.info("Discord RPC >> Disconnected!"); + } + + @Override + public void run() { + while (autoReconnect) { + try { + // Ignore if the Discord RPC is not enabled + if (!enabled) { + if (ready) { + getInstance().ipcClient.close(); + } + Thread.sleep(1); + continue; + } + + // Don't retry to do any connect to the RPC till something happen to do it + if (dontTryAgain) { + Thread.sleep(1); + continue; + } + + // Connect if the connect on init didn't connect successfully + if (!connectedBefore && !ready) { + lastPing = 0; + try { + ipcClient.connect(); + } catch (Exception ex) { } // Ignore the not able to connect to continue our process + Thread.sleep(15 * 1000); + if (!ready) { + reconnectTries += 1; + if (reconnectTries >= RECONNECT_TRIES) { + dontTryAgain = true; + } + } + continue; + } + + + // Ping to make sure that the RPC is alive + if (ready) { + Thread.sleep(1); + lastPing += 1; + if (lastPing >= RECONNECT_TRIES * 1000) { + ipcClient.sendRichPresence(this.lastRichPresence); + this.lastPing = 0; + } + } else { + lastPing = 0; + reconnecting = true; + int timeout = (reconnectTries * 2) * 1000; + logger.info("Discord RPC >> Reconnecting... (Timeout: " + timeout + "ms)"); + try { + ipcClient.connect(); + } catch (Exception ex) { + if (reconnectTries <= RECONNECT_TRIES) { + reconnectTries += 1; + } + if (reconnectTries >= RECONNECT_TRIES) { + dontTryAgain = true; + } + Thread.sleep(timeout); + } + } + } catch (InterruptedException ex) { // Ignore the interrupted exceptions + } catch (Exception ex) { + logger.trace(ex.getMessage(), ex.getCause()); + } + } + } + + @Override + public void initialise(GameEngine engine, Context rootContext) { + disableLogger(IPCClient.class); + disableLogger(WindowsPipe.class); + disableLogger(Pipe.class); + Config c = rootContext.get(Config.class); + enabled = c.getPlayer().isDiscordPresence(); + if (!enabled) { + return; + } + try { + logger.info("Discord RPC >> Connecting..."); + ipcClient.connect(); + dontTryAgain = false; + } catch (Exception ex) { } // Ignore due to reconnect thread + } + + @Override + public void postInitialise(Context context) { + config = context.get(Config.class); + setState("In Lobby"); + } + + @Override + public void preShutdown() { + autoReconnect = false; + reconnectThread.interrupt(); + if (ready) { + ipcClient.close(); + } + } + + /** + * To disable the logger from some classes that throw errors and some other spam stuff into our console. + * + */ + private void disableLogger(Class clazz) { + LoggerContext loggerContext = (LoggerContext) LoggerFactory.getILoggerFactory(); + Logger l = loggerContext.getLogger(clazz); + ((ch.qos.logback.classic.Logger) l).setLevel(Level.OFF); + } + + @Override + public String getName() { + return "DiscordRPC"; + } + + public static DiscordRPCSubSystem getInstance() { + return instance; + } + + public static void setState(String state) { + setState(state, true); + } + + public static void setState(String state, boolean timestamp) { + if (instance == null) { + return; + } + RichPresence.Builder builder = new RichPresence.Builder(); + if (state != null) { + builder.setState(state); + if (getInstance().lastState == null || (getInstance().lastState != null && !getInstance().lastState.equals(state))) { + getInstance().lastState = state; + } + } + if (getInstance().config != null) { + String playerName = getInstance().config.getPlayer().getName(); + builder.setDetails("Name: " + playerName); + } + if (timestamp) { + builder.setStartTimestamp(OffsetDateTime.now()); + } + + builder.setLargeImage(DISCORD_APP_LARGE_IMAGE); + getInstance().sendRichPresence(builder.build()); + } + + public static void updateState() { + if (getInstance() == null) { + return; + } + setState(getInstance().lastState); + } + + public static void tryToDiscover() { + if (getInstance() == null) { + return; + } + if (getInstance().dontTryAgain && getInstance().enabled) { + getInstance().dontTryAgain = false; + getInstance().reconnectTries = 0; + } + } + + public static void enable() { + setEnabled(true); + } + + public static void disable() { + setEnabled(false); + } + + public static void setEnabled(boolean enable) { + if (getInstance() == null) { + return; + } + getInstance().enabled = enable; + if (!enable) { + getInstance().reconnectTries = 0; + } else { + tryToDiscover(); + } + } + + public static boolean isEnabled() { + if (getInstance() == null) { + return false; + } + return getInstance().enabled; + } + +} diff --git a/engine/src/main/java/org/terasology/entitySystem/systems/DiscordRPCSystem.java b/engine/src/main/java/org/terasology/entitySystem/systems/DiscordRPCSystem.java new file mode 100644 index 00000000000..5fca61108f5 --- /dev/null +++ b/engine/src/main/java/org/terasology/entitySystem/systems/DiscordRPCSystem.java @@ -0,0 +1,69 @@ +/* + * Copyright 2018 MovingBlocks + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.terasology.entitySystem.systems; + +import org.terasology.engine.subsystem.rpc.DiscordRPCSubSystem; +import org.terasology.game.Game; +import org.terasology.network.NetworkMode; +import org.terasology.network.NetworkSystem; +import org.terasology.registry.In; + +/** + * It's a system that runs when a single player or multi player game has been started to process some stuff + * throw the {@link DiscordRPCSubSystem}. + * + * @see DiscordRPCSubSystem + */ +@RegisterSystem(RegisterMode.CLIENT) +public class DiscordRPCSystem extends BaseComponentSystem { + + @In + private Game game; + + @In + private NetworkSystem networkSystem; + + public String getGame() { + NetworkMode networkMode = networkSystem.getMode(); + String mode = "Playing Online"; + if (networkMode == NetworkMode.DEDICATED_SERVER) { + mode = "Hosting | " + game.getName(); + } else if (networkMode == NetworkMode.NONE) { + mode = "Solo | " + game.getName(); + } + return mode; + } + + @Override + public void initialise() { + DiscordRPCSubSystem.tryToDiscover(); + } + + @Override + public void preBegin() { + DiscordRPCSubSystem.setState(getGame(), false); + } + + @Override + public void postBegin() { + DiscordRPCSubSystem.setState(getGame(), true); + } + + @Override + public void shutdown() { + DiscordRPCSubSystem.setState("In Lobby"); + } +} diff --git a/engine/src/main/java/org/terasology/rendering/nui/layers/mainMenu/settings/PlayerSettingsScreen.java b/engine/src/main/java/org/terasology/rendering/nui/layers/mainMenu/settings/PlayerSettingsScreen.java index 830f37a91a3..c518c1c1b55 100644 --- a/engine/src/main/java/org/terasology/rendering/nui/layers/mainMenu/settings/PlayerSettingsScreen.java +++ b/engine/src/main/java/org/terasology/rendering/nui/layers/mainMenu/settings/PlayerSettingsScreen.java @@ -21,11 +21,18 @@ import com.google.common.collect.Lists; import com.google.common.math.DoubleMath; import org.terasology.context.Context; +import org.terasology.engine.subsystem.rpc.DiscordRPCSubSystem; import org.terasology.identity.storageServiceClient.StorageServiceWorker; import org.terasology.identity.storageServiceClient.StorageServiceWorkerStatus; import org.terasology.rendering.nui.layers.mainMenu.StorageServiceLoginPopup; import org.terasology.rendering.nui.layers.mainMenu.ThreeButtonPopup; +import org.terasology.rendering.nui.widgets.UIButton; +import org.terasology.rendering.nui.widgets.UICheckbox; +import org.terasology.rendering.nui.widgets.UIDropdownScrollable; +import org.terasology.rendering.nui.widgets.UIImage; import org.terasology.rendering.nui.widgets.UILabel; +import org.terasology.rendering.nui.widgets.UISlider; +import org.terasology.rendering.nui.widgets.UIText; import org.terasology.utilities.Assets; import org.terasology.assets.ResourceUrn; import org.terasology.config.Config; @@ -41,11 +48,6 @@ import org.terasology.rendering.nui.animation.MenuAnimationSystems; import org.terasology.rendering.nui.databinding.DefaultBinding; import org.terasology.rendering.nui.databinding.ReadOnlyBinding; -import org.terasology.rendering.nui.widgets.UIButton; -import org.terasology.rendering.nui.widgets.UIDropdownScrollable; -import org.terasology.rendering.nui.widgets.UIImage; -import org.terasology.rendering.nui.widgets.UISlider; -import org.terasology.rendering.nui.widgets.UIText; import java.math.RoundingMode; import java.util.ArrayList; @@ -88,6 +90,7 @@ public class PlayerSettingsScreen extends CoreScreenLayer { private UISlider heightSlider; private UISlider eyeHeightSlider; private UIImage img; + private UICheckbox discordPresence; private UIDropdownScrollable language; private StorageServiceWorkerStatus storageServiceWorkerStatus; @@ -108,6 +111,9 @@ public void onOpened() { if (eyeHeightSlider != null) { eyeHeightSlider.bindValue(new NotifyingBinding(config.getPlayer().getEyeHeight())); } + if (discordPresence != null) { + discordPresence.setChecked(config.getPlayer().isDiscordPresence()); + } if (language != null) { language.setSelection(config.getSystem().getLocale()); } @@ -162,6 +168,8 @@ public String get() { eyeHeightSlider.setPrecision(1); } + discordPresence = find("discord-presence", UICheckbox.class); + language = find("language", UIDropdownScrollable.class); if (language != null) { SimpleUri menuUri = new SimpleUri("engine:menu"); @@ -314,9 +322,11 @@ private void savePlayerSettings() { config.getPlayer().setHeight(height); Float eyeHeight = getEyeHeight(); config.getPlayer().setEyeHeight(eyeHeight); + config.getPlayer().setDiscordPresence(discordPresence.isChecked()); if (nametext != null) { config.getPlayer().setName(nametext.getText().trim()); config.getPlayer().setHasEnteredUsername(true); + DiscordRPCSubSystem.updateState(); } if (!config.getSystem().getLocale().equals(language.getSelection())) { config.getSystem().setLocale(language.getSelection()); diff --git a/engine/src/main/resources/assets/i18n/menu.lang b/engine/src/main/resources/assets/i18n/menu.lang index fb24074b6ef..04c5ac5b8fe 100644 --- a/engine/src/main/resources/assets/i18n/menu.lang +++ b/engine/src/main/resources/assets/i18n/menu.lang @@ -107,6 +107,7 @@ "dialog-ok": "dialog-ok", "dialog-yes": "dialog-yes", "disable-all-modules": "disable-all-modules", + "discord-presence": "discord-presence", "drop-item": "drop-item", "download-module": "download-module", "downloading-server-list": "downloading-server-list", diff --git a/engine/src/main/resources/assets/i18n/menu_ar.lang b/engine/src/main/resources/assets/i18n/menu_ar.lang index 7ef367b62ee..9a35f180f97 100644 --- a/engine/src/main/resources/assets/i18n/menu_ar.lang +++ b/engine/src/main/resources/assets/i18n/menu_ar.lang @@ -80,6 +80,7 @@ "dialog-cancel": "إلغاء", "dialog-ok": "حسناً", "disable-all-modules": "تعطيل الكل", + "discord-presence": "خاصية الديسكورد", "download-module": "تحميل", "edit-server": "تعديل", "edit-server-title": "أضف/عدل السرفر", diff --git a/engine/src/main/resources/assets/i18n/menu_en.lang b/engine/src/main/resources/assets/i18n/menu_en.lang index 1a24edbda06..f5247039c1c 100644 --- a/engine/src/main/resources/assets/i18n/menu_en.lang +++ b/engine/src/main/resources/assets/i18n/menu_en.lang @@ -110,6 +110,7 @@ "dialog-yes": "Yes", "disable-all-modules": "Disable All", "disable-launch-popup": "Remember and don't show again", + "discord-presence": "Discord Rich Presence", "download-module": "Download", "downloading-server-list": "Downloading server list ..", "drop-item": "Drop Item", diff --git a/engine/src/main/resources/assets/ui/menu/playerMenuScreen.ui b/engine/src/main/resources/assets/ui/menu/playerMenuScreen.ui index f23696b66ac..d83f32e604f 100644 --- a/engine/src/main/resources/assets/ui/menu/playerMenuScreen.ui +++ b/engine/src/main/resources/assets/ui/menu/playerMenuScreen.ui @@ -145,6 +145,20 @@ "id": "eye-height" } ] + }, + { + "type": "UILabel", + "text": "${engine:menu#discord-presence}:" + }, + { + "type": "RowLayout", + "horizontalSpacing": 10, + "contents": [ + { + "type": "UICheckbox", + "id": "discord-presence" + } + ] } ] }, diff --git a/facades/PC/src/main/java/org/terasology/engine/Terasology.java b/facades/PC/src/main/java/org/terasology/engine/Terasology.java index 38cbcd89931..54017b178db 100644 --- a/facades/PC/src/main/java/org/terasology/engine/Terasology.java +++ b/facades/PC/src/main/java/org/terasology/engine/Terasology.java @@ -39,6 +39,7 @@ import org.terasology.engine.subsystem.lwjgl.LwjglInput; import org.terasology.engine.subsystem.lwjgl.LwjglTimer; import org.terasology.engine.subsystem.openvr.OpenVRInput; +import org.terasology.engine.subsystem.rpc.DiscordRPCSubSystem; import org.terasology.game.GameManifest; import org.terasology.network.NetworkMode; import org.terasology.rendering.nui.layers.mainMenu.savedGames.GameInfo; @@ -380,6 +381,7 @@ private static void populateSubsystems(TerasologyEngineBuilder builder) { .add(new LwjglInput()) .add(new BindsSubsystem()) .add(new OpenVRInput()); + builder.add(new DiscordRPCSubSystem()); } builder.add(new HibernationSubsystem()); } diff --git a/git b/git new file mode 100644 index 00000000000..e69de29bb2d