feat(multiplayer): an option for auto allowing all join requests. Closes #1093.

This commit is contained in:
huanghongxun
2021-10-07 22:39:02 +08:00
parent 60865163db
commit eedcff785e
9 changed files with 122 additions and 19 deletions

View File

@@ -23,6 +23,8 @@ import com.jfoenix.controls.JFXTextField;
import com.jfoenix.validation.base.ValidatorBase; import com.jfoenix.validation.base.ValidatorBase;
import javafx.beans.binding.Bindings; import javafx.beans.binding.Bindings;
import javafx.beans.binding.BooleanBinding; import javafx.beans.binding.BooleanBinding;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.geometry.Insets; import javafx.geometry.Insets;
import javafx.scene.control.Label; import javafx.scene.control.Label;
import javafx.scene.layout.ColumnConstraints; import javafx.scene.layout.ColumnConstraints;
@@ -67,8 +69,8 @@ public class PromptDialogPane extends DialogPane {
} }
bindings.add(Bindings.createBooleanBinding(textField::validate, textField.textProperty())); bindings.add(Bindings.createBooleanBinding(textField::validate, textField.textProperty()));
if (StringUtils.isNotBlank(question.question)) { if (StringUtils.isNotBlank(question.question.get())) {
body.addRow(rowIndex++, new Label(question.question), textField); body.addRow(rowIndex++, new Label(question.question.get()), textField);
} else { } else {
GridPane.setColumnSpan(textField, 2); GridPane.setColumnSpan(textField, 2);
body.addRow(rowIndex++, textField); body.addRow(rowIndex++, textField);
@@ -82,7 +84,7 @@ public class PromptDialogPane extends DialogPane {
HBox.setMargin(checkBox, new Insets(0, 0, 0, -10)); HBox.setMargin(checkBox, new Insets(0, 0, 0, -10));
checkBox.setSelected(((Builder.BooleanQuestion) question).value); checkBox.setSelected(((Builder.BooleanQuestion) question).value);
checkBox.selectedProperty().addListener((a, b, newValue) -> ((Builder.BooleanQuestion) question).value = newValue); checkBox.selectedProperty().addListener((a, b, newValue) -> ((Builder.BooleanQuestion) question).value = newValue);
checkBox.setText(question.question); checkBox.setText(question.question.get());
body.addRow(rowIndex++, hBox); body.addRow(rowIndex++, hBox);
} else if (question instanceof Builder.CandidatesQuestion) { } else if (question instanceof Builder.CandidatesQuestion) {
JFXComboBox<String> comboBox = new JFXComboBox<>(); JFXComboBox<String> comboBox = new JFXComboBox<>();
@@ -90,8 +92,8 @@ public class PromptDialogPane extends DialogPane {
comboBox.getSelectionModel().selectedIndexProperty().addListener((a, b, newValue) -> comboBox.getSelectionModel().selectedIndexProperty().addListener((a, b, newValue) ->
((Builder.CandidatesQuestion) question).value = newValue.intValue()); ((Builder.CandidatesQuestion) question).value = newValue.intValue());
comboBox.getSelectionModel().select(0); comboBox.getSelectionModel().select(0);
if (StringUtils.isNotBlank(question.question)) { if (StringUtils.isNotBlank(question.question.get())) {
body.addRow(rowIndex++, new Label(question.question), comboBox); body.addRow(rowIndex++, new Label(question.question.get()), comboBox);
} else { } else {
GridPane.setColumnSpan(comboBox, 2); GridPane.setColumnSpan(comboBox, 2);
body.addRow(rowIndex++, comboBox); body.addRow(rowIndex++, comboBox);
@@ -99,7 +101,7 @@ public class PromptDialogPane extends DialogPane {
} else if (question instanceof Builder.HintQuestion) { } else if (question instanceof Builder.HintQuestion) {
HintPane pane = new HintPane(); HintPane pane = new HintPane();
GridPane.setColumnSpan(pane, 2); GridPane.setColumnSpan(pane, 2);
pane.setText(question.question); pane.textProperty().bind(question.question);
body.addRow(rowIndex++, pane); body.addRow(rowIndex++, pane);
} }
} }
@@ -142,16 +144,28 @@ public class PromptDialogPane extends DialogPane {
} }
public static class Question<T> { public static class Question<T> {
public final String question; public final StringProperty question = new SimpleStringProperty();
protected T value; protected T value;
public Question(String question) { public Question(String question) {
this.question = question; this.question.set(question);
} }
public T getValue() { public T getValue() {
return value; return value;
} }
public String getQuestion() {
return question.get();
}
public StringProperty questionProperty() {
return question;
}
public void setQuestion(String question) {
this.question.set(question);
}
} }
public static class HintQuestion extends Question<Void> { public static class HintQuestion extends Question<Void> {

View File

@@ -17,6 +17,9 @@
*/ */
package org.jackhuang.hmcl.ui.multiplayer; package org.jackhuang.hmcl.ui.multiplayer;
import com.jfoenix.controls.JFXCheckBox;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.scene.control.Label; import javafx.scene.control.Label;
import javafx.scene.control.ProgressIndicator; import javafx.scene.control.ProgressIndicator;
import javafx.scene.layout.ColumnConstraints; import javafx.scene.layout.ColumnConstraints;
@@ -35,12 +38,13 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public class CreateMultiplayerRoomDialog extends DialogPane implements DialogAware { public class CreateMultiplayerRoomDialog extends DialogPane implements DialogAware {
private final FutureCallback<LocalServerDetector.PingResponse> callback; private final FutureCallback<CreationRequest> callback;
private final LocalServerDetector lanServerDetectorThread; private final LocalServerDetector lanServerDetectorThread;
private final BooleanProperty allowAllJoinRequests = new SimpleBooleanProperty(true);
private LocalServerDetector.PingResponse server; private LocalServerDetector.PingResponse server;
CreateMultiplayerRoomDialog(FutureCallback<LocalServerDetector.PingResponse> callback) { CreateMultiplayerRoomDialog(FutureCallback<CreationRequest> callback) {
this.callback = callback; this.callback = callback;
setTitle(i18n("multiplayer.session.create")); setTitle(i18n("multiplayer.session.create"));
@@ -70,6 +74,11 @@ public class CreateMultiplayerRoomDialog extends DialogPane implements DialogAwa
portLabel.setText(i18n("multiplayer.nat.testing")); portLabel.setText(i18n("multiplayer.nat.testing"));
body.addRow(2, new Label(i18n("multiplayer.session.create.port")), portLabel); body.addRow(2, new Label(i18n("multiplayer.session.create.port")), portLabel);
JFXCheckBox allowAllJoinRequestsCheckBox = new JFXCheckBox(i18n("multiplayer.session.create.join.allow"));
allowAllJoinRequestsCheckBox.selectedProperty().bindBidirectional(allowAllJoinRequests);
GridPane.setColumnSpan(allowAllJoinRequestsCheckBox, 2);
body.addRow(3, allowAllJoinRequestsCheckBox);
setValid(false); setValid(false);
JFXHyperlink noinLink = new JFXHyperlink(); JFXHyperlink noinLink = new JFXHyperlink();
@@ -102,7 +111,10 @@ public class CreateMultiplayerRoomDialog extends DialogPane implements DialogAwa
protected void onAccept() { protected void onAccept() {
setLoading(); setLoading();
callback.call(Objects.requireNonNull(server), () -> { callback.call(new CreationRequest(
Objects.requireNonNull(server),
allowAllJoinRequests.get()
), () -> {
runInFX(this::onSuccess); runInFX(this::onSuccess);
}, msg -> { }, msg -> {
runInFX(() -> onFailure(msg)); runInFX(() -> onFailure(msg));
@@ -121,4 +133,21 @@ public class CreateMultiplayerRoomDialog extends DialogPane implements DialogAwa
lanServerDetectorThread.interrupt(); lanServerDetectorThread.interrupt();
} }
public static class CreationRequest {
private final LocalServerDetector.PingResponse server;
private final boolean allowAllJoinRequests;
public CreationRequest(LocalServerDetector.PingResponse server, boolean allowAllJoinRequests) {
this.server = server;
this.allowAllJoinRequests = allowAllJoinRequests;
}
public LocalServerDetector.PingResponse getServer() {
return server;
}
public boolean isAllowAllJoinRequests() {
return allowAllJoinRequests;
}
}
} }

View File

@@ -85,7 +85,7 @@ public final class MultiplayerManager {
return Metadata.HMCL_DIRECTORY.resolve("libraries").resolve(CATO_PATH); return Metadata.HMCL_DIRECTORY.resolve("libraries").resolve(CATO_PATH);
} }
public static CompletableFuture<CatoSession> joinSession(String token, String version, String sessionName, String peer, Mode mode, int remotePort, int localPort) throws IncompatibleCatoVersionException { public static CompletableFuture<CatoSession> joinSession(String token, String version, String sessionName, String peer, Mode mode, int remotePort, int localPort, JoinSessionHandler handler) throws IncompatibleCatoVersionException {
if (!CATO_VERSION.equals(version)) { if (!CATO_VERSION.equals(version)) {
throw new IncompatibleCatoVersionException(version, CATO_VERSION); throw new IncompatibleCatoVersionException(version, CATO_VERSION);
} }
@@ -133,6 +133,16 @@ public final class MultiplayerManager {
MultiplayerClient client = new MultiplayerClient(session.getId(), localPort); MultiplayerClient client = new MultiplayerClient(session.getId(), localPort);
session.addRelatedThread(client); session.addRelatedThread(client);
session.setClient(client); session.setClient(client);
if (handler != null) {
handler.onWaitingForJoinResponse();
}
TimerTask task = Lang.setTimeout(() -> {
future.completeExceptionally(new JoinRequestTimeoutException());
session.stop();
}, 30 * 1000);
client.onConnected().register(connectedEvent -> { client.onConnected().register(connectedEvent -> {
try { try {
int port = findAvailablePort(); int port = findAvailablePort();
@@ -148,10 +158,12 @@ public final class MultiplayerManager {
future.completeExceptionally(e); future.completeExceptionally(e);
session.stop(); session.stop();
} }
task.cancel();
}); });
client.onKicked().register(kickedEvent -> { client.onKicked().register(kickedEvent -> {
future.completeExceptionally(new CancellationException()); future.completeExceptionally(new CancellationException());
session.stop(); session.stop();
task.cancel();
}); });
client.start(); client.start();
}); });
@@ -160,7 +172,7 @@ public final class MultiplayerManager {
}); });
} }
public static CatoSession createSession(String token, String sessionName, int gamePort) throws IOException { public static CatoSession createSession(String token, String sessionName, int gamePort, boolean allowAllJoinRequests) throws IOException {
Path exe = getCatoExecutable(); Path exe = getCatoExecutable();
if (!Files.isRegularFile(exe)) { if (!Files.isRegularFile(exe)) {
throw new IllegalStateException("Cato file not found"); throw new IllegalStateException("Cato file not found");
@@ -170,7 +182,7 @@ public final class MultiplayerManager {
throw new CatoAlreadyStartedException(); throw new CatoAlreadyStartedException();
} }
MultiplayerServer server = new MultiplayerServer(gamePort); MultiplayerServer server = new MultiplayerServer(gamePort, allowAllJoinRequests);
server.startServer(); server.startServer();
String[] commands = new String[]{exe.toString(), String[] commands = new String[]{exe.toString(),
@@ -419,6 +431,10 @@ public final class MultiplayerManager {
} }
} }
public interface JoinSessionHandler {
void onWaitingForJoinResponse();
}
public static class IncompatibleCatoVersionException extends Exception { public static class IncompatibleCatoVersionException extends Exception {
private final String expected; private final String expected;
private final String actual; private final String actual;
@@ -448,4 +464,7 @@ public final class MultiplayerManager {
public static class CatoAlreadyStartedException extends RuntimeException { public static class CatoAlreadyStartedException extends RuntimeException {
} }
public static class JoinRequestTimeoutException extends RuntimeException {
}
} }

View File

@@ -210,9 +210,9 @@ public class MultiplayerPage extends DecoratorAnimatedPage implements DecoratorP
} }
Controllers.dialog(new CreateMultiplayerRoomDialog((result, resolve, reject) -> { Controllers.dialog(new CreateMultiplayerRoomDialog((result, resolve, reject) -> {
int gamePort = result.getAd(); int gamePort = result.getServer().getAd();
try { try {
MultiplayerManager.CatoSession session = MultiplayerManager.createSession(globalConfig().getMultiplayerToken(), result.getMotd(), gamePort); MultiplayerManager.CatoSession session = MultiplayerManager.createSession(globalConfig().getMultiplayerToken(), result.getServer().getMotd(), gamePort, result.isAllowAllJoinRequests());
session.getServer().setOnClientAdding((client, resolveClient, rejectClient) -> { session.getServer().setOnClientAdding((client, resolveClient, rejectClient) -> {
runInFX(() -> { runInFX(() -> {
Controllers.confirm(i18n("multiplayer.session.create.join.prompt", client.getUsername()), i18n("multiplayer.session.create.join"), MessageDialogPane.MessageType.INFO, Controllers.confirm(i18n("multiplayer.session.create.join.prompt", client.getUsername()), i18n("multiplayer.session.create.join"), MessageDialogPane.MessageType.INFO,
@@ -251,6 +251,8 @@ public class MultiplayerPage extends DecoratorAnimatedPage implements DecoratorP
} }
Controllers.prompt(new PromptDialogPane.Builder(i18n("multiplayer.session.join"), (result, resolve, reject) -> { Controllers.prompt(new PromptDialogPane.Builder(i18n("multiplayer.session.join"), (result, resolve, reject) -> {
PromptDialogPane.Builder.HintQuestion hintQuestion = (PromptDialogPane.Builder.HintQuestion) result.get(0);
String invitationCode = ((PromptDialogPane.Builder.StringQuestion) result.get(1)).getValue(); String invitationCode = ((PromptDialogPane.Builder.StringQuestion) result.get(1)).getValue();
MultiplayerManager.Invitation invitation; MultiplayerManager.Invitation invitation;
try { try {
@@ -279,7 +281,12 @@ public class MultiplayerPage extends DecoratorAnimatedPage implements DecoratorP
? MultiplayerManager.Mode.RELAY ? MultiplayerManager.Mode.RELAY
: MultiplayerManager.Mode.P2P, : MultiplayerManager.Mode.P2P,
invitation.getChannelPort(), invitation.getChannelPort(),
localPort) localPort, new MultiplayerManager.JoinSessionHandler() {
@Override
public void onWaitingForJoinResponse() {
hintQuestion.setQuestion(i18n("multiplayer.session.join.wait"));
}
})
.thenAcceptAsync(session -> { .thenAcceptAsync(session -> {
initCatoSession(session); initCatoSession(session);
@@ -314,6 +321,10 @@ public class MultiplayerPage extends DecoratorAnimatedPage implements DecoratorP
LOG.info("Cato already started"); LOG.info("Cato already started");
reject.accept(i18n("multiplayer.session.error.already_started")); reject.accept(i18n("multiplayer.session.error.already_started"));
return null; return null;
} else if (throwable instanceof MultiplayerManager.JoinRequestTimeoutException) {
LOG.info("Cato already started");
reject.accept(i18n("multiplayer.session.join.wait_timeout"));
return null;
} else { } else {
LOG.log(Level.WARNING, "Failed to join sessoin"); LOG.log(Level.WARNING, "Failed to join sessoin");
reject.accept(i18n("multiplayer.session.join.error")); reject.accept(i18n("multiplayer.session.join.error"));

View File

@@ -37,6 +37,7 @@ import static org.jackhuang.hmcl.util.Logging.LOG;
public class MultiplayerServer extends Thread { public class MultiplayerServer extends Thread {
private ServerSocket socket; private ServerSocket socket;
private final int gamePort; private final int gamePort;
private final boolean allowAllJoinRequests;
private FutureCallback<CatoClient> onClientAdding; private FutureCallback<CatoClient> onClientAdding;
private final EventManager<MultiplayerChannel.CatoClient> onClientAdded = new EventManager<>(); private final EventManager<MultiplayerChannel.CatoClient> onClientAdded = new EventManager<>();
@@ -46,8 +47,9 @@ public class MultiplayerServer extends Thread {
private final Map<String, Endpoint> clients = new ConcurrentHashMap<>(); private final Map<String, Endpoint> clients = new ConcurrentHashMap<>();
private final Map<String, Endpoint> nameClientMap = new ConcurrentHashMap<>(); private final Map<String, Endpoint> nameClientMap = new ConcurrentHashMap<>();
public MultiplayerServer(int gamePort) { public MultiplayerServer(int gamePort, boolean allowAllJoinRequests) {
this.gamePort = gamePort; this.gamePort = gamePort;
this.allowAllJoinRequests = allowAllJoinRequests;
setName("MultiplayerServer"); setName("MultiplayerServer");
setDaemon(true); setDaemon(true);
@@ -146,7 +148,7 @@ public class MultiplayerServer extends Thread {
nameClientMap.put(clientName, endpoint); nameClientMap.put(clientName, endpoint);
onClientAdded.fireEvent(catoClient); onClientAdded.fireEvent(catoClient);
if (onClientAdding != null) { if (onClientAdding != null && !allowAllJoinRequests) {
onClientAdding.call(catoClient, () -> { onClientAdding.call(catoClient, () -> {
try { try {
endpoint.write(new JoinResponse(gamePort)); endpoint.write(new JoinResponse(gamePort));
@@ -168,6 +170,9 @@ public class MultiplayerServer extends Thread {
LOG.log(Level.WARNING, "Failed to send kick response.", e); LOG.log(Level.WARNING, "Failed to send kick response.", e);
} }
}); });
} else {
// Allow all join requests.
endpoint.write(new JoinResponse(gamePort));
} }
} else if (request instanceof KeepAliveRequest) { } else if (request instanceof KeepAliveRequest) {
endpoint.write(new KeepAliveResponse(System.currentTimeMillis())); endpoint.write(new KeepAliveResponse(System.currentTimeMillis()));

View File

@@ -652,6 +652,7 @@ multiplayer.session.create=Create Session
multiplayer.session.create.error=Failed to create multiplayer session. multiplayer.session.create.error=Failed to create multiplayer session.
multiplayer.session.create.hint=Before creating multiplayer session, you must click "Open LAN Server" in running game, and type the port displayed in game in the blank below. multiplayer.session.create.hint=Before creating multiplayer session, you must click "Open LAN Server" in running game, and type the port displayed in game in the blank below.
multiplayer.session.create.join=Connection request multiplayer.session.create.join=Connection request
multiplayer.session.create.join.allow=Accepts all join requests (When disabled, you need to accept join requests manually, so you can prevent from attacks.)
multiplayer.session.create.join.prompt=Player %s wants to join the multiplayer session. Accept? multiplayer.session.create.join.prompt=Player %s wants to join the multiplayer session. Accept?
multiplayer.session.create.members=Members multiplayer.session.create.members=Members
multiplayer.session.create.members.kick=Kick multiplayer.session.create.members.kick=Kick
@@ -675,6 +676,8 @@ multiplayer.session.join.kicked=You have been kicked by the session holder. You
multiplayer.session.join.lost_connection=Lost connection with the multiplayer session. Maybe the session is destroyed by the creator, or you cannot establish connection with this session. multiplayer.session.join.lost_connection=Lost connection with the multiplayer session. Maybe the session is destroyed by the creator, or you cannot establish connection with this session.
multiplayer.session.join.port.error=Cannot find available local network port for listening. Please ensure that HMCL has the permission to listen on a port. multiplayer.session.join.port.error=Cannot find available local network port for listening. Please ensure that HMCL has the permission to listen on a port.
multiplayer.session.join.rejected=Your connection is rejected by the session holder. multiplayer.session.join.rejected=Your connection is rejected by the session holder.
multiplayer.session.join.wait=Waiting for acceptation.
multiplayer.session.join.wait_timeout=The session holder does not accept your join request in time.
multiplayer.session.members=Room Members multiplayer.session.members=Room Members
multiplayer.session.quit=Quit Room multiplayer.session.quit=Quit Room
multiplayer.session.quit.warning=After quiting room, you will lost the connection with the server. Continue? multiplayer.session.quit.warning=After quiting room, you will lost the connection with the server. Continue?

View File

@@ -652,6 +652,7 @@ multiplayer.session.create=創建房間
multiplayer.session.create.error=創建聯機房間失敗。 multiplayer.session.create.error=創建聯機房間失敗。
multiplayer.session.create.hint=創建聯機房間前,你需要先在正在運行的遊戲內的遊戲菜單中選擇 對區域網路開放 選項,然後在下方的輸入框中輸入遊戲內提示的埠號(通常是 5 位的數字) multiplayer.session.create.hint=創建聯機房間前,你需要先在正在運行的遊戲內的遊戲菜單中選擇 對區域網路開放 選項,然後在下方的輸入框中輸入遊戲內提示的埠號(通常是 5 位的數字)
multiplayer.session.create.join=連接申請 multiplayer.session.create.join=連接申請
multiplayer.session.create.join.allow=自動接受所有連接申請(不啟用此選項時,你需要手動同意申請,以避免不相關人士誤連你的伺服器)
multiplayer.session.create.join.prompt=玩家 %s 申請加入多人聯機房間,是否接受? multiplayer.session.create.join.prompt=玩家 %s 申請加入多人聯機房間,是否接受?
multiplayer.session.create.members=成員 multiplayer.session.create.members=成員
multiplayer.session.create.members.kick=踢出房間 multiplayer.session.create.members.kick=踢出房間
@@ -674,6 +675,8 @@ multiplayer.session.join.kicked=你已經被房主踢出房間,你將與房間
multiplayer.session.join.lost_connection=你已與房間失去連接。這可能意味著房主已經解散房間,或者你無法連接至房間。 multiplayer.session.join.lost_connection=你已與房間失去連接。這可能意味著房主已經解散房間,或者你無法連接至房間。
multiplayer.session.join.port.error=無法找到可用的本地網路埠,請確保 HMCL 擁有綁定本地埠的權限。 multiplayer.session.join.port.error=無法找到可用的本地網路埠,請確保 HMCL 擁有綁定本地埠的權限。
multiplayer.session.join.rejected=你被房主拒絕連接。 multiplayer.session.join.rejected=你被房主拒絕連接。
multiplayer.session.join.wait=等待對方同意加入申請。
multiplayer.session.join.wait_timeout=對方未能即時同意你的加入申請
multiplayer.session.members=房間成員 multiplayer.session.members=房間成員
multiplayer.session.quit=退出房間 multiplayer.session.quit=退出房間
multiplayer.session.quit.warning=退出房間後,你將會與伺服器斷開連接,是否繼續? multiplayer.session.quit.warning=退出房間後,你將會與伺服器斷開連接,是否繼續?

View File

@@ -652,6 +652,7 @@ multiplayer.session.create=创建房间
multiplayer.session.create.error=创建联机房间失败。 multiplayer.session.create.error=创建联机房间失败。
multiplayer.session.create.hint=创建联机房间前,你需要先在正在运行的游戏内的游戏菜单中选择 对局域网开放 选项,然后在下方的输入框中确认游戏内提示的端口号(通常是 5 位的数字) multiplayer.session.create.hint=创建联机房间前,你需要先在正在运行的游戏内的游戏菜单中选择 对局域网开放 选项,然后在下方的输入框中确认游戏内提示的端口号(通常是 5 位的数字)
multiplayer.session.create.join=连接申请 multiplayer.session.create.join=连接申请
multiplayer.session.create.join.allow=自动接受所有连接申请(不启用此选项时,你需要手动同意申请,以避免不相关人士误连你的服务器)
multiplayer.session.create.join.prompt=玩家 %s 申请加入多人联机房间,是否接受? multiplayer.session.create.join.prompt=玩家 %s 申请加入多人联机房间,是否接受?
multiplayer.session.create.members=成员 multiplayer.session.create.members=成员
multiplayer.session.create.members.kick=踢出房间 multiplayer.session.create.members.kick=踢出房间
@@ -674,6 +675,8 @@ multiplayer.session.join.kicked=你已经被房主踢出房间,你将与房间
multiplayer.session.join.lost_connection=你已与房间失去连接。这可能意味着房主已经解散房间,或者你无法连接至房间。 multiplayer.session.join.lost_connection=你已与房间失去连接。这可能意味着房主已经解散房间,或者你无法连接至房间。
multiplayer.session.join.port.error=无法找到可用的本地网络端口,请确保 HMCL 拥有绑定本地端口的权限。 multiplayer.session.join.port.error=无法找到可用的本地网络端口,请确保 HMCL 拥有绑定本地端口的权限。
multiplayer.session.join.rejected=你被房主拒绝连接。 multiplayer.session.join.rejected=你被房主拒绝连接。
multiplayer.session.join.wait=等待对方同意加入申请。
multiplayer.session.join.wait_timeout=对方未能即时同意你的加入申请
multiplayer.session.members=房间成员 multiplayer.session.members=房间成员
multiplayer.session.quit=退出房间 multiplayer.session.quit=退出房间
multiplayer.session.quit.warning=退出房间后,你将会与服务器断开连接,是否继续? multiplayer.session.quit.warning=退出房间后,你将会与服务器断开连接,是否继续?

View File

@@ -357,6 +357,22 @@ public final class Lang {
return () -> iterator; return () -> iterator;
} }
private static Timer timer;
public static synchronized TimerTask setTimeout(Runnable runnable, long delayMs) {
if (timer == null) {
timer = new Timer();
}
TimerTask task = new TimerTask() {
@Override
public void run() {
runnable.run();
}
};
timer.schedule(task, delayMs);
return task;
}
/** /**
* This is a useful function to prevent exceptions being eaten when using CompletableFuture. * This is a useful function to prevent exceptions being eaten when using CompletableFuture.
* You can write: * You can write: