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