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
This commit is contained in:
parent
782e1d35e0
commit
7c95244e3e
11 changed files with 996 additions and 0 deletions
157
engine/modules/discord_rpc/README.md
Normal file
157
engine/modules/discord_rpc/README.md
Normal file
|
|
@ -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.
|
||||
14
engine/modules/discord_rpc/SCsub
Normal file
14
engine/modules/discord_rpc/SCsub
Normal file
|
|
@ -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
|
||||
15
engine/modules/discord_rpc/config.py
Normal file
15
engine/modules/discord_rpc/config.py
Normal file
|
|
@ -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 []
|
||||
505
engine/modules/discord_rpc/discord_rpc.cpp
Normal file
505
engine/modules/discord_rpc/discord_rpc.cpp
Normal file
|
|
@ -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 <windows.h>
|
||||
#else
|
||||
#include <sys/socket.h>
|
||||
#include <sys/un.h>
|
||||
#include <unistd.h>
|
||||
#include <fcntl.h>
|
||||
#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<uint8_t> 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();
|
||||
}
|
||||
108
engine/modules/discord_rpc/discord_rpc.h
Normal file
108
engine/modules/discord_rpc/discord_rpc.h
Normal file
|
|
@ -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
|
||||
148
engine/modules/discord_rpc/discord_rpc_manager.gd
Normal file
148
engine/modules/discord_rpc/discord_rpc_manager.gd
Normal file
|
|
@ -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()
|
||||
34
engine/modules/discord_rpc/register_types.cpp
Normal file
34
engine/modules/discord_rpc/register_types.cpp
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
15
engine/modules/discord_rpc/register_types.h
Normal file
15
engine/modules/discord_rpc/register_types.h
Normal file
|
|
@ -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
|
||||
Loading…
Reference in a new issue