Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ repositories {
url "https://jitpack.io"
content {
includeModule('com.github.everit-org.json-schema', 'org.everit.json.schema')
includeModule('com.github.heroiclabs.nakama-java', 'nakama-java')
}
}

Expand Down
3 changes: 2 additions & 1 deletion engine/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,8 @@ dependencies {
api "org.terasology.nui:nui-gestalt:$nuiVersion"
api "org.terasology.nui:nui-reflect:$nuiVersion"

implementation group: 'com.google.protobuf', name: 'protobuf-java', version: '3.4.0'
implementation group: 'com.google.protobuf', name: 'protobuf-java', version: '4.28.2'
implementation 'com.github.heroiclabs.nakama-java:nakama-java:2.5.3'

implementation "com.github.zafarkhaja:java-semver:0.10.0" // gestalt lost this...
api "com.github.everit-org.json-schema:org.everit.json.schema:1.11.1"
Expand Down
25 changes: 25 additions & 0 deletions engine/src/main/java/org/destinationsol/SolApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ public class SolApplication implements ApplicationListener {
private NUIManager nuiManager;
private float timeAccumulator = 0;
private boolean isMobile;
private org.destinationsol.game.chat.NakamaClient nakamaClient;
private ComponentManager componentManager;
private BeanContext appContext;
private BeanContext gameContext;
Expand Down Expand Up @@ -183,6 +184,14 @@ public void create() {
menuScreens = new MenuScreens(layouts, isMobile(), options, nuiManager);

nuiManager.pushScreen(menuScreens.main);

// Nakama integration (optional, loaded from nakama.ini)
org.destinationsol.game.chat.NakamaConfig nakamaConfig = org.destinationsol.game.chat.NakamaConfig.load();
if (nakamaConfig.isEnabled()) {
nakamaClient = new org.destinationsol.game.chat.NakamaClient(nakamaConfig);
nakamaClient.connect();
org.destinationsol.game.chat.SayCommandHandler.setNakamaClient(nakamaClient);
}
}

@Override
Expand Down Expand Up @@ -267,6 +276,19 @@ private void update() {
solGame.update();
}

// Poll Nakama for incoming cross-game chat messages
if (nakamaClient != null && nakamaClient.isConnected() && solGame != null) {
String msg;
while ((msg = nakamaClient.pollMessage()) != null) {
solGame.getScreens().consoleScreen.getConsole().addMessage(msg);
org.destinationsol.game.chat.NakamaAnnouncer announcer =
solGame.getScreens().mainGameScreen.getNakamaAnnouncer();
if (announcer != null) {
announcer.announce(msg);
}
}
}

SolMath.checkVectorsTaken(null);
}

Expand Down Expand Up @@ -378,6 +400,9 @@ public MenuScreens getMenuScreens() {

@Override
public void dispose() {
if (nakamaClient != null) {
nakamaClient.disconnect();
}
commonDrawer.dispose();

if (solGame != null) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// Copyright 2026 The Terasology Foundation
// SPDX-License-Identifier: Apache-2.0

package org.destinationsol.game.chat;

import com.badlogic.gdx.graphics.Color;
import com.badlogic.gdx.math.Rectangle;
import org.destinationsol.Const;
import org.destinationsol.SolApplication;
import org.destinationsol.ui.DisplayDimensions;
import org.destinationsol.ui.FontSize;
import org.destinationsol.ui.UiDrawer;

/**
* Displays incoming Nakama cross-game chat messages as a fading banner
* on the game HUD, similar to zone name announcements.
*/
public class NakamaAnnouncer {
private static final float FADE_TIME = 5f;
private static final float DISPLAY_Y = 0.06f;
private static final float BG_PADDING_X = 0.08f;
private static final float BG_PADDING_Y = 0.008f;
private static final float BG_HEIGHT = 0.03f;

private final DisplayDimensions displayDimensions;
private final Color textColor = new Color(0.6f, 0.9f, 1f, 0f);
private final Color bgColor = new Color(0f, 0f, 0f, 0f);
private String text = "";

public NakamaAnnouncer() {
displayDimensions = SolApplication.displayDimensions;
}

/**
* Show a new message. Resets the fade timer.
*/
public void announce(String message) {
text = message;
textColor.a = 1f;
bgColor.a = 0.8f;
}

public void update() {
if (textColor.a > 0) {
textColor.a -= Const.REAL_TIME_STEP / FADE_TIME;
bgColor.a = Math.min(0.8f, textColor.a) * 0.8f;
}
}

public void drawText(UiDrawer uiDrawer) {
if (textColor.a <= 0) {
return;
}
float centerX = displayDimensions.getRatio() / 2;
Rectangle bg = new Rectangle(
centerX - BG_PADDING_X - displayDimensions.getRatio() * 0.15f,
DISPLAY_Y - BG_PADDING_Y,
displayDimensions.getRatio() * 0.3f + BG_PADDING_X * 2,
BG_HEIGHT + BG_PADDING_Y * 2
);
uiDrawer.draw(bg, bgColor);
uiDrawer.drawString(text, centerX, DISPLAY_Y + BG_HEIGHT / 2, FontSize.MENU, true, textColor);
}
}
247 changes: 247 additions & 0 deletions engine/src/main/java/org/destinationsol/game/chat/NakamaClient.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
// Copyright 2026 The Terasology Foundation
// SPDX-License-Identifier: Apache-2.0

package org.destinationsol.game.chat;

import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.heroiclabs.nakama.AbstractSocketListener;
import com.heroiclabs.nakama.Channel;
import com.heroiclabs.nakama.ChannelType;
import com.heroiclabs.nakama.Client;
import com.heroiclabs.nakama.DefaultClient;
import com.heroiclabs.nakama.Session;
import com.heroiclabs.nakama.SocketClient;
import com.heroiclabs.nakama.api.ChannelMessage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.atomic.AtomicReference;

/**
* Lightweight Nakama integration for DestinationSol.
* Connects to a shared chat channel for cross-game messaging.
*
* Enable via: -Dnakama.enabled=true -Dnakama.host=192.168.x.x -Dnakama.playerName=Bob
*/
public class NakamaClient {
private static final Logger logger = LoggerFactory.getLogger(NakamaClient.class);
private static final String GAME_ID = "destinationsol";
private static final Map<String, String> GAME_PREFIXES;
static {
Map<String, String> m = new HashMap<>();
m.put("terasology", "TS");
m.put("destinationsol", "DS");
m.put("minecraft", "MC");
GAME_PREFIXES = Collections.unmodifiableMap(m);
}

private final NakamaConfig config;
private Client client;
private Session session;
private SocketClient socket;
private Channel channel;

// Thread-safe queue for incoming messages to be consumed on the game thread
private final ConcurrentLinkedQueue<String> incomingMessages = new ConcurrentLinkedQueue<>();

// Last received item link — consumed by Beam In
private final AtomicReference<JsonObject> lastItemLink = new AtomicReference<>();

public NakamaClient(NakamaConfig config) {
this.config = config;
}

/**
* Connect to Nakama and join the chat channel.
* Call during game startup.
*/
public void connect() {
if (!config.isEnabled()) {
logger.info("Nakama client disabled");
return;
}
try {
String deviceId = getOrCreateDeviceId();
client = new DefaultClient("defaultkey", config.getHost(), config.getGrpcPort(), false);
session = client.authenticateDevice(deviceId).get();

if (!config.getPlayerName().isEmpty()) {
client.updateAccount(session, null, config.getPlayerName()).get();
}

logger.info("Nakama: authenticated as {}", session.getUserId());

socket = client.createSocket(config.getHost(), config.getWsPort(), false);
socket.connect(session, new AbstractSocketListener() {
@Override
public void onChannelMessage(ChannelMessage message) {
handleIncomingMessage(message);
}
}).get();

channel = socket.joinChat(config.getChannel(), ChannelType.ROOM).get();
logger.info("Nakama: joined channel '{}'", config.getChannel());

} catch (Exception e) {
logger.warn("Nakama: connection failed, continuing without cross-game chat", e);
cleanup();
}
}

private void handleIncomingMessage(ChannelMessage message) {
try {
JsonObject content = JsonParser.parseString(message.getContent()).getAsJsonObject();
String game = content.has("game") ? content.get("game").getAsString() : "";
if (GAME_ID.equals(game)) {
return; // Echo filter
}
String player = content.has("player") ? content.get("player").getAsString() : "???";
String text = content.has("text") ? content.get("text").getAsString() : "";
String prefix = "[" + GAME_PREFIXES.getOrDefault(game,
game.toUpperCase().substring(0, Math.min(game.length(), 2))) + "]";

// Check for item link message
String type = content.has("type") ? content.get("type").getAsString() : "chat";
if ("item_link".equals(type)) {
lastItemLink.set(content);
String itemName = content.has("name") ? content.get("name").getAsString() : "???";
String formatted = prefix + " " + player + " beamed: [" + itemName + "]";
incomingMessages.add(formatted);
return;
}

String formatted = prefix + " " + player + ": " + text;
incomingMessages.add(formatted);
} catch (Exception e) {
logger.warn("Nakama: failed to parse incoming message", e);
}
}

/**
* Send a chat message. Called from the /say console command.
*/
public boolean sendMessage(String text) {
if (socket == null || channel == null) {
return false;
}
try {
String playerName = config.getPlayerName().isEmpty()
? session.getUserId().substring(0, 8)
: config.getPlayerName();

JsonObject content = new JsonObject();
content.addProperty("game", GAME_ID);
content.addProperty("player", playerName);
content.addProperty("text", text);
socket.writeChatMessage(channel.getId(), content.toString()).get();
return true;
} catch (Exception e) {
logger.warn("Nakama: failed to send message", e);
return false;
}
}

/**
* Send an item link to the Nakama channel.
*/
public boolean sendItemLink(String itemName, String description, float price) {
if (socket == null || channel == null) {
return false;
}
try {
String playerName = config.getPlayerName().isEmpty()
? session.getUserId().substring(0, 8)
: config.getPlayerName();

JsonObject content = new JsonObject();
content.addProperty("game", GAME_ID);
content.addProperty("player", playerName);
content.addProperty("type", "item_link");
content.addProperty("name", itemName);
content.addProperty("description", description);
content.addProperty("price", price);

socket.writeChatMessage(channel.getId(), content.toString()).get();
return true;
} catch (Exception e) {
logger.warn("Nakama: failed to send item link", e);
return false;
}
}

/**
* Consume the last received item link (returns null if none pending).
* Once consumed, the same link cannot be consumed again.
*/
public JsonObject consumeItemLink() {
return lastItemLink.getAndSet(null);
}

/**
* Check if there's a pending item link without consuming it.
*/
public boolean hasItemLink() {
return lastItemLink.get() != null;
}

/**
* Poll for incoming messages. Call from the game loop.
* Returns null if no messages are pending.
*/
public String pollMessage() {
return incomingMessages.poll();
}

public boolean isConnected() {
return socket != null && channel != null;
}

public void disconnect() {
cleanup();
}

private void cleanup() {
if (socket != null) {
try { socket.disconnect(); } catch (Exception ignored) { }
socket = null;
}
channel = null;
session = null;
client = null;
}

private String getOrCreateDeviceId() {
String id = System.getProperty("nakama.deviceId", "");
if (!id.isEmpty()) {
return id;
}
Path idFile = Paths.get(System.getProperty("user.home"), ".bifrost", "device-id");
try {
if (Files.exists(idFile)) {
id = new String(Files.readAllBytes(idFile), StandardCharsets.UTF_8).trim();
if (!id.isEmpty()) {
return id;
}
}
id = UUID.randomUUID().toString();
Files.createDirectories(idFile.getParent());
Files.write(idFile, id.getBytes(StandardCharsets.UTF_8));
logger.info("Nakama: created device ID {}", id);
} catch (IOException e) {
id = UUID.randomUUID().toString();
logger.warn("Nakama: could not persist device ID, using ephemeral {}", id);
}
return id;
}
}
Loading