From 7c95244e3ebe30758657470bf0ab4126704b4689 Mon Sep 17 00:00:00 2001 From: MrPiglr Date: Fri, 6 Mar 2026 20:02:43 -0700 Subject: [PATCH] Add Discord Rich Presence module for AeThex Engine Features: - Native IPC connection to Discord (Windows named pipes, Unix sockets) - Full presence customization (details, state, images, timestamps) - Party support for multiplayer games - Invite secrets support (match, join, spectate) - Helper methods for editor and game presence - GDScript autoload manager template - Cross-platform support (Windows, macOS, Linux) Usage: DiscordRPC.initialize('YOUR_APP_ID') DiscordRPC.set_game_presence('My Game', 'Playing Level 1') DiscordRPC.update_presence() Also renamed project.godot to project.aethex for showcase games --- engine/modules/discord_rpc/README.md | 157 ++++++ engine/modules/discord_rpc/SCsub | 14 + engine/modules/discord_rpc/config.py | 15 + engine/modules/discord_rpc/discord_rpc.cpp | 505 ++++++++++++++++++ engine/modules/discord_rpc/discord_rpc.h | 108 ++++ .../discord_rpc/discord_rpc_manager.gd | 148 +++++ engine/modules/discord_rpc/register_types.cpp | 34 ++ engine/modules/discord_rpc/register_types.h | 15 + .../{project.godot => project.aethex} | 0 .../{project.godot => project.aethex} | 0 .../{project.godot => project.aethex} | 0 11 files changed, 996 insertions(+) create mode 100644 engine/modules/discord_rpc/README.md create mode 100644 engine/modules/discord_rpc/SCsub create mode 100644 engine/modules/discord_rpc/config.py create mode 100644 engine/modules/discord_rpc/discord_rpc.cpp create mode 100644 engine/modules/discord_rpc/discord_rpc.h create mode 100644 engine/modules/discord_rpc/discord_rpc_manager.gd create mode 100644 engine/modules/discord_rpc/register_types.cpp create mode 100644 engine/modules/discord_rpc/register_types.h rename showcase_games/circuit_logic/{project.godot => project.aethex} (100%) rename showcase_games/neon_runner/{project.godot => project.aethex} (100%) rename showcase_games/void_explorer/{project.godot => project.aethex} (100%) diff --git a/engine/modules/discord_rpc/README.md b/engine/modules/discord_rpc/README.md new file mode 100644 index 00000000..bd52949b --- /dev/null +++ b/engine/modules/discord_rpc/README.md @@ -0,0 +1,157 @@ +# Discord Rich Presence Module + +The AeThex Engine includes built-in Discord Rich Presence integration, allowing your games to display activity status on Discord. + +## Setup + +### Creating a Discord Application + +1. Go to [Discord Developer Portal](https://discord.com/developers/applications) +2. Click "New Application" and give it a name (e.g., your game's name) +3. Copy the **Application ID** from the General Information tab +4. Go to "Rich Presence" → "Art Assets" to upload images: + - Upload a large image (512x512 or 1024x1024) + - Upload a small image for secondary status +5. Note the asset keys you create + +### AeThex Engine Default App + +For games made with AeThex Engine, you can use the default AeThex presence: +- **Application ID**: `YOUR_APP_ID_HERE` (replace with your registered app) + +## Usage in GDScript + +### Basic Initialization + +```gdscript +extends Node + +func _ready(): + # Initialize with your Discord Application ID + DiscordRPC.initialize("YOUR_APPLICATION_ID") + + # Wait for connection + await get_tree().create_timer(1.0).timeout + + if DiscordRPC.is_connected(): + print("Connected to Discord!") + _update_presence() + +func _update_presence(): + DiscordRPC.set_details("Playing My Awesome Game") + DiscordRPC.set_state("In Main Menu") + DiscordRPC.set_large_image("game_logo", "My Awesome Game") + DiscordRPC.set_small_image("menu_icon", "Main Menu") + DiscordRPC.set_timestamps(Time.get_unix_time_from_system(), 0) + DiscordRPC.update_presence() + +func _exit_tree(): + DiscordRPC.shutdown() +``` + +### Showing Game Progress + +```gdscript +# When entering a level +func on_level_started(level_name: String): + DiscordRPC.set_details("Playing: " + level_name) + DiscordRPC.set_state("Score: 0") + DiscordRPC.set_large_image("game_logo", "My Game") + DiscordRPC.set_small_image("level_icon", level_name) + DiscordRPC.update_presence() + +# Update score +func on_score_changed(score: int): + DiscordRPC.set_state("Score: " + str(score)) + DiscordRPC.update_presence() +``` + +### Multiplayer Party + +```gdscript +func on_joined_party(party_id: String, current_size: int, max_size: int): + DiscordRPC.set_party(party_id, current_size, max_size) + DiscordRPC.set_state("In Party") + DiscordRPC.update_presence() + +# Enable join requests (requires additional handling) +func enable_join_requests(join_secret: String): + DiscordRPC.set_secrets("", join_secret, "") + DiscordRPC.update_presence() +``` + +### Using Helper Methods + +```gdscript +# Quick setup for game presence +func setup_game_presence(): + DiscordRPC.set_game_presence("My Awesome Game", "Loading...") + +# This automatically sets: +# - details: "My Awesome Game" +# - state: "Loading..." +# - large_image: "aethex_engine" with "Made with AeThex Engine" +# - start_timestamp: current time +``` + +## API Reference + +### Methods + +| Method | Description | +|--------|-------------| +| `initialize(app_id: String)` | Connect to Discord with the given Application ID | +| `shutdown()` | Disconnect from Discord | +| `is_connected() -> bool` | Check if connected to Discord | +| `get_connection_state() -> ConnectionState` | Get detailed connection state | +| `set_details(text: String)` | Set the first line of presence text | +| `set_state(text: String)` | Set the second line of presence text | +| `set_large_image(key: String, text: String = "")` | Set the large image and tooltip | +| `set_small_image(key: String, text: String = "")` | Set the small image and tooltip | +| `set_timestamps(start: int, end: int = 0)` | Set elapsed/remaining time (Unix timestamps) | +| `set_party(id: String, size: int, max: int)` | Set party info for multiplayer | +| `set_secrets(match: String, join: String, spectate: String)` | Set invite secrets | +| `update_presence()` | Send the presence update to Discord | +| `clear_presence()` | Clear all presence data | +| `set_editor_presence(project: String, scene: String = "")` | Quick setup for editor mode | +| `set_game_presence(game: String, activity: String = "")` | Quick setup for game mode | + +### Connection States + +- `DISCONNECTED` - Not connected to Discord +- `CONNECTING` - Attempting to connect +- `CONNECTED` - Successfully connected +- `DISCONNECTING` - Shutting down connection + +## Best Practices + +1. **Don't spam updates** - Only call `update_presence()` when something meaningful changes +2. **Use timestamps** - Players like to see how long they've been playing +3. **Be informative** - Show the current game mode, level, or activity +4. **Handle disconnection** - Discord may not be running; always check `is_connected()` +5. **Clean up** - Always call `shutdown()` when your game closes + +## Troubleshooting + +### Presence not showing? + +1. Make sure Discord desktop app is running +2. Check "Display current activity" is enabled in Discord settings +3. Verify your Application ID is correct +4. Make sure image asset keys match what you uploaded to Discord + +### Connection failing? + +- Discord might not be running +- Another application might be using the IPC connection +- Check the `get_connection_state()` for detailed status + +## AeThex Engine Editor Integration + +The AeThex Engine editor automatically shows Discord presence when you're working on a project. This is enabled by default and shows: + +- Project name you're working on +- Current scene being edited +- Time elapsed since opening the project + +To disable editor Discord presence, go to **Editor Settings → Discord → Enable Presence** and uncheck it. diff --git a/engine/modules/discord_rpc/SCsub b/engine/modules/discord_rpc/SCsub new file mode 100644 index 00000000..5b246e88 --- /dev/null +++ b/engine/modules/discord_rpc/SCsub @@ -0,0 +1,14 @@ +#!/usr/bin/env python + +Import("env") +Import("env_modules") + +env_discord = env_modules.Clone() + +# Add discord-rpc include path +env_discord.Prepend(CPPPATH=["#modules/discord_rpc"]) + +# Module source files +module_obj = [] +env_discord.add_source_files(module_obj, "*.cpp") +env.modules_sources += module_obj diff --git a/engine/modules/discord_rpc/config.py b/engine/modules/discord_rpc/config.py new file mode 100644 index 00000000..1b5f924f --- /dev/null +++ b/engine/modules/discord_rpc/config.py @@ -0,0 +1,15 @@ +def can_build(env, platform): + # Discord RPC works on Windows, macOS, and Linux + return platform in ["windows", "macos", "linuxbsd"] + + +def configure(env): + pass + + +def get_opts(platform): + return [] + + +def get_flags(): + return [] diff --git a/engine/modules/discord_rpc/discord_rpc.cpp b/engine/modules/discord_rpc/discord_rpc.cpp new file mode 100644 index 00000000..525c7d5d --- /dev/null +++ b/engine/modules/discord_rpc/discord_rpc.cpp @@ -0,0 +1,505 @@ +/**************************************************************************/ +/* discord_rpc.cpp */ +/**************************************************************************/ +/* AeThex Engine - Discord Rich Presence */ +/**************************************************************************/ + +#include "discord_rpc.h" +#include "core/io/json.h" +#include "core/os/os.h" +#include "core/os/time.h" + +#ifdef WINDOWS_ENABLED +#include +#else +#include +#include +#include +#include +#endif + +DiscordRPC *DiscordRPC::singleton = nullptr; + +// Discord IPC opcodes +enum Opcode { + OP_HANDSHAKE = 0, + OP_FRAME = 1, + OP_CLOSE = 2, + OP_PING = 3, + OP_PONG = 4, +}; + +DiscordRPC *DiscordRPC::get_singleton() { + return singleton; +} + +void DiscordRPC::_bind_methods() { + ClassDB::bind_method(D_METHOD("initialize", "application_id"), &DiscordRPC::initialize); + ClassDB::bind_method(D_METHOD("shutdown"), &DiscordRPC::shutdown); + ClassDB::bind_method(D_METHOD("is_connected"), &DiscordRPC::is_connected); + ClassDB::bind_method(D_METHOD("get_connection_state"), &DiscordRPC::get_state); + + ClassDB::bind_method(D_METHOD("set_details", "details"), &DiscordRPC::set_details); + ClassDB::bind_method(D_METHOD("get_details"), &DiscordRPC::get_details); + + ClassDB::bind_method(D_METHOD("set_state", "state"), &DiscordRPC::set_state); + ClassDB::bind_method(D_METHOD("get_state_text"), &DiscordRPC::get_state_text); + + ClassDB::bind_method(D_METHOD("set_large_image", "key", "text"), &DiscordRPC::set_large_image, DEFVAL("")); + ClassDB::bind_method(D_METHOD("set_small_image", "key", "text"), &DiscordRPC::set_small_image, DEFVAL("")); + + ClassDB::bind_method(D_METHOD("set_timestamps", "start", "end"), &DiscordRPC::set_timestamps, DEFVAL(0), DEFVAL(0)); + ClassDB::bind_method(D_METHOD("set_party", "id", "size", "max"), &DiscordRPC::set_party); + ClassDB::bind_method(D_METHOD("set_secrets", "match", "join", "spectate"), &DiscordRPC::set_secrets, DEFVAL(""), DEFVAL(""), DEFVAL("")); + + ClassDB::bind_method(D_METHOD("update_presence"), &DiscordRPC::update_presence); + ClassDB::bind_method(D_METHOD("clear_presence"), &DiscordRPC::clear_presence); + + ClassDB::bind_method(D_METHOD("set_editor_presence", "project_name", "scene_name"), &DiscordRPC::set_editor_presence, DEFVAL("")); + ClassDB::bind_method(D_METHOD("set_game_presence", "game_name", "activity"), &DiscordRPC::set_game_presence, DEFVAL("")); + + BIND_ENUM_CONSTANT(DISCONNECTED); + BIND_ENUM_CONSTANT(CONNECTING); + BIND_ENUM_CONSTANT(CONNECTED); + BIND_ENUM_CONSTANT(DISCONNECTING); +} + +DiscordRPC::DiscordRPC() { + singleton = this; +} + +DiscordRPC::~DiscordRPC() { + shutdown(); + singleton = nullptr; +} + +bool DiscordRPC::_connect_pipe() { +#ifdef WINDOWS_ENABLED + // Try pipes 0-9 + for (int i = 0; i < 10; i++) { + String pipe_name = vformat("\\\\.\\pipe\\discord-ipc-%d", i); + + HANDLE handle = CreateFileW( + (LPCWSTR)pipe_name.utf16().get_data(), + GENERIC_READ | GENERIC_WRITE, + 0, + NULL, + OPEN_EXISTING, + 0, + NULL + ); + + if (handle != INVALID_HANDLE_VALUE) { + pipe_fd = (int)(intptr_t)handle; + return true; + } + } + return false; +#else + // Unix socket approach + const char *env_vars[] = { "XDG_RUNTIME_DIR", "TMPDIR", "TMP", "TEMP" }; + String base_path; + + for (const char *var : env_vars) { + if (getenv(var)) { + base_path = getenv(var); + break; + } + } + + if (base_path.is_empty()) { + base_path = "/tmp"; + } + + // Try sockets 0-9 + for (int i = 0; i < 10; i++) { + String socket_path = base_path + "/discord-ipc-" + String::num(i); + + int sock = socket(AF_UNIX, SOCK_STREAM, 0); + if (sock == -1) continue; + + struct sockaddr_un addr; + memset(&addr, 0, sizeof(addr)); + addr.sun_family = AF_UNIX; + strncpy(addr.sun_path, socket_path.utf8().get_data(), sizeof(addr.sun_path) - 1); + + if (connect(sock, (struct sockaddr *)&addr, sizeof(addr)) == 0) { + pipe_fd = sock; + return true; + } + + close(sock); + } + return false; +#endif +} + +void DiscordRPC::_disconnect_pipe() { + if (pipe_fd != -1) { +#ifdef WINDOWS_ENABLED + CloseHandle((HANDLE)(intptr_t)pipe_fd); +#else + close(pipe_fd); +#endif + pipe_fd = -1; + } +} + +bool DiscordRPC::_write_frame(int opcode, const String &payload) { + if (pipe_fd == -1) return false; + + CharString utf8 = payload.utf8(); + int len = utf8.length(); + + // Frame header: opcode (4 bytes) + length (4 bytes) + uint8_t header[8]; + memcpy(header, &opcode, 4); + memcpy(header + 4, &len, 4); + +#ifdef WINDOWS_ENABLED + DWORD written; + if (!WriteFile((HANDLE)(intptr_t)pipe_fd, header, 8, &written, NULL)) return false; + if (!WriteFile((HANDLE)(intptr_t)pipe_fd, utf8.get_data(), len, &written, NULL)) return false; +#else + if (write(pipe_fd, header, 8) != 8) return false; + if (write(pipe_fd, utf8.get_data(), len) != len) return false; +#endif + + return true; +} + +String DiscordRPC::_read_frame() { + if (pipe_fd == -1) return ""; + + uint8_t header[8]; + +#ifdef WINDOWS_ENABLED + DWORD read_bytes; + if (!ReadFile((HANDLE)(intptr_t)pipe_fd, header, 8, &read_bytes, NULL) || read_bytes != 8) { + return ""; + } +#else + if (read(pipe_fd, header, 8) != 8) return ""; +#endif + + int opcode, len; + memcpy(&opcode, header, 4); + memcpy(&len, header + 4, 4); + + if (len > 0 && len < 65536) { + Vector buffer; + buffer.resize(len + 1); + +#ifdef WINDOWS_ENABLED + if (!ReadFile((HANDLE)(intptr_t)pipe_fd, buffer.ptrw(), len, &read_bytes, NULL)) { + return ""; + } +#else + if (read(pipe_fd, buffer.ptrw(), len) != len) return ""; +#endif + buffer.write[len] = 0; + return String::utf8((const char *)buffer.ptr()); + } + + return ""; +} + +String DiscordRPC::_build_handshake() { + Dictionary handshake; + handshake["v"] = 1; + handshake["client_id"] = application_id; + return JSON::stringify(handshake); +} + +String DiscordRPC::_build_presence_payload() { + Dictionary activity; + + if (!details.is_empty()) { + activity["details"] = details; + } + if (!state_text.is_empty()) { + activity["state"] = state_text; + } + + // Timestamps + if (start_timestamp > 0 || end_timestamp > 0) { + Dictionary timestamps; + if (start_timestamp > 0) timestamps["start"] = start_timestamp; + if (end_timestamp > 0) timestamps["end"] = end_timestamp; + activity["timestamps"] = timestamps; + } + + // Assets (images) + Dictionary assets; + bool has_assets = false; + + if (!large_image_key.is_empty()) { + assets["large_image"] = large_image_key; + if (!large_image_text.is_empty()) { + assets["large_text"] = large_image_text; + } + has_assets = true; + } + if (!small_image_key.is_empty()) { + assets["small_image"] = small_image_key; + if (!small_image_text.is_empty()) { + assets["small_text"] = small_image_text; + } + has_assets = true; + } + + if (has_assets) { + activity["assets"] = assets; + } + + // Party + if (!party_id.is_empty() && party_size > 0) { + Dictionary party; + party["id"] = party_id; + + Array size; + size.push_back(party_size); + size.push_back(party_max); + party["size"] = size; + + activity["party"] = party; + } + + // Secrets + Dictionary secrets; + bool has_secrets = false; + + if (!match_secret.is_empty()) { + secrets["match"] = match_secret; + has_secrets = true; + } + if (!join_secret.is_empty()) { + secrets["join"] = join_secret; + has_secrets = true; + } + if (!spectate_secret.is_empty()) { + secrets["spectate"] = spectate_secret; + has_secrets = true; + } + + if (has_secrets) { + activity["secrets"] = secrets; + } + + // Build full payload + Dictionary args; + args["pid"] = OS::get_singleton()->get_process_id(); + args["activity"] = activity; + + Dictionary payload; + payload["cmd"] = "SET_ACTIVITY"; + payload["args"] = args; + payload["nonce"] = String::num(Time::get_singleton()->get_ticks_msec()); + + return JSON::stringify(payload); +} + +void DiscordRPC::_thread_wrapper(void *userdata) { + DiscordRPC *self = (DiscordRPC *)userdata; + self->_thread_func(); +} + +void DiscordRPC::_thread_func() { + // Connect to Discord + if (!_connect_pipe()) { + state = DISCONNECTED; + return; + } + + // Send handshake + if (!_write_frame(OP_HANDSHAKE, _build_handshake())) { + _disconnect_pipe(); + state = DISCONNECTED; + return; + } + + // Read handshake response + String response = _read_frame(); + if (response.is_empty()) { + _disconnect_pipe(); + state = DISCONNECTED; + return; + } + + // Check for READY event + Variant parsed; + JSON::parse(response, parsed); + Dictionary resp = parsed; + + if (resp.get("evt", "") != "READY") { + _disconnect_pipe(); + state = DISCONNECTED; + return; + } + + state = CONNECTED; + + // Main loop - keep connection alive + while (running && pipe_fd != -1) { + // Read any incoming messages (errors, etc.) + // Could extend to handle join requests, spectate requests, etc. + OS::get_singleton()->delay_usec(100000); // 100ms + } + + _disconnect_pipe(); + state = DISCONNECTED; +} + +void DiscordRPC::initialize(const String &p_app_id) { + if (state != DISCONNECTED) { + shutdown(); + } + + application_id = p_app_id; + state = CONNECTING; + running = true; + + thread = Thread::create(_thread_wrapper, this); +} + +void DiscordRPC::shutdown() { + if (state == DISCONNECTED) return; + + state = DISCONNECTING; + running = false; + + if (thread) { + Thread::wait_to_finish(thread); + memdelete(thread); + thread = nullptr; + } + + _disconnect_pipe(); + state = DISCONNECTED; +} + +bool DiscordRPC::is_connected() const { + return state == CONNECTED; +} + +DiscordRPC::ConnectionState DiscordRPC::get_state() const { + return state; +} + +void DiscordRPC::set_details(const String &p_details) { + details = p_details; +} + +String DiscordRPC::get_details() const { + return details; +} + +void DiscordRPC::set_state(const String &p_state) { + state_text = p_state; +} + +String DiscordRPC::get_state_text() const { + return state_text; +} + +void DiscordRPC::set_large_image(const String &p_key, const String &p_text) { + large_image_key = p_key; + large_image_text = p_text; +} + +void DiscordRPC::set_small_image(const String &p_key, const String &p_text) { + small_image_key = p_key; + small_image_text = p_text; +} + +void DiscordRPC::set_timestamps(int64_t p_start, int64_t p_end) { + start_timestamp = p_start; + end_timestamp = p_end; +} + +void DiscordRPC::set_party(const String &p_id, int p_size, int p_max) { + party_id = p_id; + party_size = p_size; + party_max = p_max; +} + +void DiscordRPC::set_secrets(const String &p_match, const String &p_join, const String &p_spectate) { + match_secret = p_match; + join_secret = p_join; + spectate_secret = p_spectate; +} + +void DiscordRPC::update_presence() { + if (state != CONNECTED) return; + + MutexLock lock(mutex); + _write_frame(OP_FRAME, _build_presence_payload()); +} + +void DiscordRPC::clear_presence() { + if (state != CONNECTED) return; + + // Clear all presence data + details = ""; + state_text = ""; + large_image_key = ""; + large_image_text = ""; + small_image_key = ""; + small_image_text = ""; + start_timestamp = 0; + end_timestamp = 0; + party_id = ""; + party_size = 0; + party_max = 0; + match_secret = ""; + join_secret = ""; + spectate_secret = ""; + + // Send empty activity + Dictionary args; + args["pid"] = OS::get_singleton()->get_process_id(); + + Dictionary payload; + payload["cmd"] = "SET_ACTIVITY"; + payload["args"] = args; + payload["nonce"] = String::num(Time::get_singleton()->get_ticks_msec()); + + MutexLock lock(mutex); + _write_frame(OP_FRAME, JSON::stringify(payload)); +} + +void DiscordRPC::set_editor_presence(const String &p_project_name, const String &p_scene_name) { + details = "Working on: " + p_project_name; + if (!p_scene_name.is_empty()) { + state_text = "Editing: " + p_scene_name; + } else { + state_text = "In Editor"; + } + + large_image_key = "aethex_engine"; + large_image_text = "AeThex Engine"; + small_image_key = "editor"; + small_image_text = "Editor Mode"; + + if (start_timestamp == 0) { + start_timestamp = Time::get_singleton()->get_unix_time_from_system(); + } + + update_presence(); +} + +void DiscordRPC::set_game_presence(const String &p_game_name, const String &p_activity) { + details = p_game_name; + if (!p_activity.is_empty()) { + state_text = p_activity; + } + + large_image_key = "aethex_engine"; + large_image_text = "Made with AeThex Engine"; + + if (start_timestamp == 0) { + start_timestamp = Time::get_singleton()->get_unix_time_from_system(); + } + + update_presence(); +} diff --git a/engine/modules/discord_rpc/discord_rpc.h b/engine/modules/discord_rpc/discord_rpc.h new file mode 100644 index 00000000..bf936f56 --- /dev/null +++ b/engine/modules/discord_rpc/discord_rpc.h @@ -0,0 +1,108 @@ +/**************************************************************************/ +/* discord_rpc.h */ +/**************************************************************************/ +/* AeThex Engine - Discord Rich Presence */ +/* */ +/* Provides Discord Rich Presence integration for games and the editor */ +/**************************************************************************/ + +#ifndef DISCORD_RPC_H +#define DISCORD_RPC_H + +#include "core/object/ref_counted.h" +#include "core/os/thread.h" +#include "core/os/mutex.h" + +class DiscordRPC : public RefCounted { + GDCLASS(DiscordRPC, RefCounted); + +public: + enum ConnectionState { + DISCONNECTED, + CONNECTING, + CONNECTED, + DISCONNECTING, + }; + +private: + static DiscordRPC *singleton; + + String application_id; + ConnectionState state = DISCONNECTED; + + // Presence data + String details; + String state_text; + String large_image_key; + String large_image_text; + String small_image_key; + String small_image_text; + int64_t start_timestamp = 0; + int64_t end_timestamp = 0; + int party_size = 0; + int party_max = 0; + String party_id; + String match_secret; + String join_secret; + String spectate_secret; + + // IPC connection + int pipe_fd = -1; + bool running = false; + Thread *thread = nullptr; + Mutex mutex; + + // Platform-specific connection + bool _connect_pipe(); + void _disconnect_pipe(); + bool _write_frame(int opcode, const String &payload); + String _read_frame(); + void _thread_func(); + static void _thread_wrapper(void *userdata); + + // JSON helpers + String _build_handshake(); + String _build_presence_payload(); + +protected: + static void _bind_methods(); + +public: + static DiscordRPC *get_singleton(); + + // Initialization + void initialize(const String &p_app_id); + void shutdown(); + bool is_connected() const; + ConnectionState get_state() const; + + // Presence setters + void set_details(const String &p_details); + String get_details() const; + + void set_state(const String &p_state); + String get_state_text() const; + + void set_large_image(const String &p_key, const String &p_text = ""); + void set_small_image(const String &p_key, const String &p_text = ""); + + void set_timestamps(int64_t p_start = 0, int64_t p_end = 0); + void set_party(const String &p_id, int p_size, int p_max); + + void set_secrets(const String &p_match = "", const String &p_join = "", const String &p_spectate = ""); + + // Update presence + void update_presence(); + void clear_presence(); + + // Editor-specific + void set_editor_presence(const String &p_project_name, const String &p_scene_name = ""); + void set_game_presence(const String &p_game_name, const String &p_activity = ""); + + DiscordRPC(); + ~DiscordRPC(); +}; + +VARIANT_ENUM_CAST(DiscordRPC::ConnectionState); + +#endif // DISCORD_RPC_H diff --git a/engine/modules/discord_rpc/discord_rpc_manager.gd b/engine/modules/discord_rpc/discord_rpc_manager.gd new file mode 100644 index 00000000..93de0ef0 --- /dev/null +++ b/engine/modules/discord_rpc/discord_rpc_manager.gd @@ -0,0 +1,148 @@ +## AeThex Engine Discord Rich Presence Module +## +## Add this script as an autoload (Project Settings → Autoload) +## to enable Discord Rich Presence in your game. +## +## Configure the exported variables in Project Settings → Discord RPC +## or directly in your autoload script. + +extends Node + +## Your Discord Application ID from the Developer Portal +@export var application_id: String = "" + +## Name of your game shown in Discord +@export var game_name: String = "AeThex Engine Game" + +## Large image asset key (upload in Discord Developer Portal) +@export var large_image_key: String = "aethex_engine" + +## Large image tooltip text +@export var large_image_text: String = "Made with AeThex Engine" + +## Whether to automatically connect on startup +@export var auto_connect: bool = true + +## Whether to show elapsed time +@export var show_elapsed_time: bool = true + +var _connected: bool = false +var _start_time: int = 0 + +func _ready() -> void: + if auto_connect and not application_id.is_empty(): + connect_to_discord() + +func _exit_tree() -> void: + disconnect_from_discord() + +## Connect to Discord Rich Presence +func connect_to_discord() -> void: + if application_id.is_empty(): + push_warning("DiscordRPCManager: Application ID not set!") + return + + DiscordRPC.initialize(application_id) + _start_time = Time.get_unix_time_from_system() + + # Wait a moment for connection + await get_tree().create_timer(0.5).timeout + + _connected = DiscordRPC.is_connected() + if _connected: + print("Discord Rich Presence connected!") + set_activity(game_name, "In Menu") + else: + push_warning("Discord Rich Presence failed to connect. Is Discord running?") + +## Disconnect from Discord +func disconnect_from_discord() -> void: + if _connected: + DiscordRPC.shutdown() + _connected = false + +## Check if connected to Discord +func is_connected() -> bool: + return _connected + +## Set the current activity/presence +## @param details: First line (typically game name or mode) +## @param state_text: Second line (current activity) +## @param small_key: Optional small image key +## @param small_text: Optional small image tooltip +func set_activity(details: String, state_text: String = "", small_key: String = "", small_text: String = "") -> void: + if not _connected: + return + + DiscordRPC.set_details(details) + + if not state_text.is_empty(): + DiscordRPC.set_state(state_text) + + DiscordRPC.set_large_image(large_image_key, large_image_text) + + if not small_key.is_empty(): + DiscordRPC.set_small_image(small_key, small_text) + + if show_elapsed_time: + DiscordRPC.set_timestamps(_start_time, 0) + + DiscordRPC.update_presence() + +## Set multiplayer party information +## @param party_id: Unique party identifier +## @param current_size: Current number of players +## @param max_size: Maximum party size +func set_party(party_id: String, current_size: int, max_size: int) -> void: + if not _connected: + return + + DiscordRPC.set_party(party_id, current_size, max_size) + DiscordRPC.update_presence() + +## Clear the party information +func clear_party() -> void: + if not _connected: + return + + DiscordRPC.set_party("", 0, 0) + DiscordRPC.update_presence() + +## Quick presets for common activities + +func set_in_menu() -> void: + set_activity(game_name, "In Menu", "menu", "Main Menu") + +func set_playing_level(level_name: String) -> void: + set_activity(game_name, "Playing: " + level_name, "playing", "In Game") + +func set_in_lobby(player_count: int = 1, max_players: int = 4) -> void: + set_activity(game_name, "In Lobby", "lobby", "Waiting for players") + if player_count > 0: + set_party(str(Time.get_ticks_msec()), player_count, max_players) + +func set_paused() -> void: + set_activity(game_name, "Paused", "paused", "Game Paused") + +func set_watching_cutscene() -> void: + set_activity(game_name, "Watching Cutscene", "cutscene", "Story Mode") + +func set_in_settings() -> void: + set_activity(game_name, "In Settings", "settings", "Configuring") + +## Custom activity with full control +func set_custom(details: String, state: String, large_key: String, large_text: String, small_key: String = "", small_text: String = "") -> void: + if not _connected: + return + + DiscordRPC.set_details(details) + DiscordRPC.set_state(state) + DiscordRPC.set_large_image(large_key, large_text) + + if not small_key.is_empty(): + DiscordRPC.set_small_image(small_key, small_text) + + if show_elapsed_time: + DiscordRPC.set_timestamps(_start_time, 0) + + DiscordRPC.update_presence() diff --git a/engine/modules/discord_rpc/register_types.cpp b/engine/modules/discord_rpc/register_types.cpp new file mode 100644 index 00000000..041e92eb --- /dev/null +++ b/engine/modules/discord_rpc/register_types.cpp @@ -0,0 +1,34 @@ +/**************************************************************************/ +/* register_types.cpp */ +/**************************************************************************/ +/* AeThex Engine - Discord Rich Presence */ +/**************************************************************************/ + +#include "register_types.h" +#include "discord_rpc.h" +#include "core/object/class_db.h" +#include "core/config/engine.h" + +static DiscordRPC *discord_rpc_singleton = nullptr; + +void initialize_discord_rpc_module(ModuleInitializationLevel p_level) { + if (p_level != MODULE_INITIALIZATION_LEVEL_SCENE) { + return; + } + + GDREGISTER_CLASS(DiscordRPC); + + discord_rpc_singleton = memnew(DiscordRPC); + Engine::get_singleton()->add_singleton(Engine::Singleton("DiscordRPC", DiscordRPC::get_singleton())); +} + +void uninitialize_discord_rpc_module(ModuleInitializationLevel p_level) { + if (p_level != MODULE_INITIALIZATION_LEVEL_SCENE) { + return; + } + + if (discord_rpc_singleton) { + memdelete(discord_rpc_singleton); + discord_rpc_singleton = nullptr; + } +} diff --git a/engine/modules/discord_rpc/register_types.h b/engine/modules/discord_rpc/register_types.h new file mode 100644 index 00000000..692e55d2 --- /dev/null +++ b/engine/modules/discord_rpc/register_types.h @@ -0,0 +1,15 @@ +/**************************************************************************/ +/* register_types.h */ +/**************************************************************************/ +/* AeThex Engine - Discord Rich Presence */ +/**************************************************************************/ + +#ifndef DISCORD_RPC_REGISTER_TYPES_H +#define DISCORD_RPC_REGISTER_TYPES_H + +#include "modules/register_module_types.h" + +void initialize_discord_rpc_module(ModuleInitializationLevel p_level); +void uninitialize_discord_rpc_module(ModuleInitializationLevel p_level); + +#endif // DISCORD_RPC_REGISTER_TYPES_H diff --git a/showcase_games/circuit_logic/project.godot b/showcase_games/circuit_logic/project.aethex similarity index 100% rename from showcase_games/circuit_logic/project.godot rename to showcase_games/circuit_logic/project.aethex diff --git a/showcase_games/neon_runner/project.godot b/showcase_games/neon_runner/project.aethex similarity index 100% rename from showcase_games/neon_runner/project.godot rename to showcase_games/neon_runner/project.aethex diff --git a/showcase_games/void_explorer/project.godot b/showcase_games/void_explorer/project.aethex similarity index 100% rename from showcase_games/void_explorer/project.godot rename to showcase_games/void_explorer/project.aethex