mirror of
https://github.com/HMCL-dev/HMCL.git
synced 2024-11-21 03:10:58 +08:00
feat(multiplayer): WIP: update cato 1.1.0
This commit is contained in:
parent
23a805d576
commit
7b5b2596b9
@ -52,17 +52,18 @@ import java.util.logging.Level;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static org.jackhuang.hmcl.util.Lang.wrap;
|
||||
import static org.jackhuang.hmcl.util.Logging.LOG;
|
||||
|
||||
/**
|
||||
* Cato Management.
|
||||
*/
|
||||
public final class MultiplayerManager {
|
||||
private static final String CATO_DOWNLOAD_URL = "https://files.huangyuhui.net/maven/";
|
||||
static final String CATO_VERSION = "1.0.c";
|
||||
static final String CATO_VERSION = "1.1.0";
|
||||
// private static final String CATO_DOWNLOAD_URL = "https://files.huangyuhui.net/maven/cato/cato/" + MultiplayerManager.CATO_VERSION;
|
||||
private static final String CATO_DOWNLOAD_URL = "https://codechina.csdn.net/to/ioi_bin/-/raw/e2d1b04805764e77f7c17c2e5a64d0ccb1831e16/client/";
|
||||
private static final String CATO_PATH = getCatoPath();
|
||||
public static final int CATO_AGREEMENT_VERSION = 2;
|
||||
|
||||
private static final String REMOTE_ADDRESS = "127.0.0.1";
|
||||
private static final String LOCAL_ADDRESS = "0.0.0.0";
|
||||
|
||||
@ -71,7 +72,7 @@ public final class MultiplayerManager {
|
||||
|
||||
public static Task<Void> downloadCato() {
|
||||
return new FileDownloadTask(
|
||||
NetworkUtils.toURL(CATO_DOWNLOAD_URL + CATO_PATH),
|
||||
NetworkUtils.toURL(CATO_DOWNLOAD_URL + getCatoFileName()),
|
||||
getCatoExecutable().toFile()
|
||||
).thenRunAsync(() -> {
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.LINUX || OperatingSystem.CURRENT_OS == OperatingSystem.OSX) {
|
||||
@ -86,14 +87,8 @@ public final class MultiplayerManager {
|
||||
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, JoinSessionHandler handler) throws IncompatibleCatoVersionException {
|
||||
if (!CATO_VERSION.equals(version)) {
|
||||
throw new IncompatibleCatoVersionException(version, CATO_VERSION);
|
||||
}
|
||||
|
||||
LOG.info(String.format("Joining session (token=%s,version=%s,sessionName=%s,peer=%s,mode=%s,remotePort=%d,localPort=%d)", token, version, sessionName, peer, mode, remotePort, localPort));
|
||||
|
||||
return CompletableFuture.completedFuture(null).thenComposeAsync(unused -> {
|
||||
private static CompletableFuture<CatoSession> startCato(String sessionName, String token, State state) {
|
||||
return CompletableFuture.completedFuture(null).thenApplyAsync(unused -> {
|
||||
Path exe = getCatoExecutable();
|
||||
if (!Files.isRegularFile(exe)) {
|
||||
throw new CatoNotExistsException(exe);
|
||||
@ -103,12 +98,7 @@ public final class MultiplayerManager {
|
||||
throw new CatoAlreadyStartedException();
|
||||
}
|
||||
|
||||
String[] commands = new String[]{exe.toString(),
|
||||
"--token", StringUtils.isBlank(token) ? "new" : token,
|
||||
"--id", peer,
|
||||
"--local", String.format("%s:%d", LOCAL_ADDRESS, localPort),
|
||||
"--remote", String.format("%s:%d", REMOTE_ADDRESS, remotePort),
|
||||
"--mode", mode.getName()};
|
||||
String[] commands = new String[]{exe.toString(), "--token", StringUtils.isBlank(token) ? "new" : token};
|
||||
Process process;
|
||||
try {
|
||||
process = new ProcessBuilder()
|
||||
@ -118,11 +108,21 @@ public final class MultiplayerManager {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
|
||||
CatoSession session = new CatoSession(sessionName, State.SLAVE, process, Arrays.asList(commands));
|
||||
return new CatoSession(sessionName, state, process, Arrays.asList(commands));
|
||||
});
|
||||
}
|
||||
|
||||
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)) {
|
||||
throw new IncompatibleCatoVersionException(version, CATO_VERSION);
|
||||
}
|
||||
|
||||
LOG.info(String.format("Joining session (token=%s,version=%s,sessionName=%s,peer=%s,mode=%s,remotePort=%d,localPort=%d)", token, version, sessionName, peer, mode, remotePort, localPort));
|
||||
|
||||
return startCato(sessionName, token, State.SLAVE).thenComposeAsync(wrap(session -> {
|
||||
CompletableFuture<CatoSession> future = new CompletableFuture<>();
|
||||
|
||||
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(process.getOutputStream(), StandardCharsets.UTF_8));
|
||||
session.forwardPort(peer, LOCAL_ADDRESS, localPort, REMOTE_ADDRESS, remotePort, mode);
|
||||
|
||||
Consumer<CatoExitEvent> onExit = event -> {
|
||||
boolean ready = session.isReady();
|
||||
@ -137,14 +137,6 @@ public final class MultiplayerManager {
|
||||
};
|
||||
session.onExit.register(onExit);
|
||||
|
||||
session.onExit().register(() -> {
|
||||
try {
|
||||
writer.close();
|
||||
} catch (IOException e) {
|
||||
LOG.log(Level.WARNING, "Failed to close cato session stdin writer", e);
|
||||
}
|
||||
});
|
||||
|
||||
TimerTask peerConnectionTimeoutTask = Lang.setTimeout(() -> {
|
||||
future.completeExceptionally(new PeerConnectionTimeoutException());
|
||||
session.stop();
|
||||
@ -165,13 +157,9 @@ public final class MultiplayerManager {
|
||||
client.onConnected().register(connectedEvent -> {
|
||||
try {
|
||||
int port = findAvailablePort();
|
||||
String command = String.format("net add %s %s:%d %s:%d %s", peer, LOCAL_ADDRESS, port, REMOTE_ADDRESS, connectedEvent.getPort(), mode.getName());
|
||||
LOG.info("Invoking cato: " + command);
|
||||
session.forwardPort(peer, LOCAL_ADDRESS, port, REMOTE_ADDRESS, connectedEvent.getPort(), mode);
|
||||
session.addRelatedThread(Lang.thread(new LocalServerBroadcaster(port, session), "LocalServerBroadcaster", true));
|
||||
client.setGamePort(port);
|
||||
writer.write(command);
|
||||
writer.newLine();
|
||||
writer.flush();
|
||||
session.onExit.unregister(onExit);
|
||||
future.complete(session);
|
||||
} catch (IOException e) {
|
||||
@ -200,45 +188,21 @@ public final class MultiplayerManager {
|
||||
});
|
||||
|
||||
return future;
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
public static CompletableFuture<CatoSession> createSession(String token, String sessionName, int gamePort, boolean allowAllJoinRequests) {
|
||||
return CompletableFuture.completedFuture(null).thenComposeAsync(unused -> {
|
||||
Path exe = getCatoExecutable();
|
||||
if (!Files.isRegularFile(exe)) {
|
||||
throw new CatoNotExistsException(exe);
|
||||
}
|
||||
|
||||
if (!isPortAvailable(3478)) {
|
||||
throw new CatoAlreadyStartedException();
|
||||
}
|
||||
|
||||
LOG.info(String.format("Creating session (token=%s,sessionName=%s,gamePort=%d)", token, sessionName, gamePort));
|
||||
|
||||
MultiplayerServer server;
|
||||
try {
|
||||
server = new MultiplayerServer(gamePort, allowAllJoinRequests);
|
||||
server.startServer();
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
|
||||
String[] commands = new String[]{exe.toString(),
|
||||
"--token", StringUtils.isBlank(token) ? "new" : token,
|
||||
"--allows", String.format("%s:%d/%s:%d", REMOTE_ADDRESS, server.getPort(), REMOTE_ADDRESS, gamePort)};
|
||||
Process process;
|
||||
try {
|
||||
process = new ProcessBuilder()
|
||||
.command(commands)
|
||||
.start();
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
LOG.info(String.format("Creating session (token=%s,sessionName=%s,gamePort=%d)", token, sessionName, gamePort));
|
||||
|
||||
return startCato(sessionName, token, State.MASTER).thenComposeAsync(wrap(session -> {
|
||||
CompletableFuture<CatoSession> future = new CompletableFuture<>();
|
||||
|
||||
CatoSession session = new CatoSession(sessionName, State.MASTER, process, Arrays.asList(commands));
|
||||
MultiplayerServer server = new MultiplayerServer(gamePort, allowAllJoinRequests);
|
||||
server.startServer();
|
||||
|
||||
session.allowForwardingAddress(REMOTE_ADDRESS, server.getPort());
|
||||
session.allowForwardingAddress(REMOTE_ADDRESS, gamePort);
|
||||
session.showAllowedAddress();
|
||||
|
||||
Consumer<CatoExitEvent> onExit = event -> {
|
||||
boolean ready = session.isReady();
|
||||
@ -268,7 +232,7 @@ public final class MultiplayerManager {
|
||||
});
|
||||
|
||||
return future;
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
public static Invitation parseInvitationCode(String invitationCode) throws JsonParseException {
|
||||
@ -290,28 +254,33 @@ public final class MultiplayerManager {
|
||||
}
|
||||
}
|
||||
|
||||
public static String getCatoPath() {
|
||||
private static String getCatoFileName() {
|
||||
switch (OperatingSystem.CURRENT_OS) {
|
||||
case WINDOWS:
|
||||
if (Architecture.SYSTEM_ARCH == Architecture.X86_64
|
||||
|| (Architecture.SYSTEM_ARCH == Architecture.ARM64 && OperatingSystem.SYSTEM_BUILD_NUMBER >= 21277)) {
|
||||
return "cato/cato/" + MultiplayerManager.CATO_VERSION + "/cato-windows-amd64.exe";
|
||||
if (Architecture.SYSTEM_ARCH == Architecture.X86_64) {
|
||||
return "cato-client-windows-amd64.exe";
|
||||
} else if (Architecture.SYSTEM_ARCH == Architecture.ARM64) {
|
||||
return "cato-client-windows-arm64.exe";
|
||||
} else if (Architecture.SYSTEM_ARCH == Architecture.X86) {
|
||||
return "cato-client-windows-i386.exe";
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
case OSX:
|
||||
if (Architecture.SYSTEM_ARCH == Architecture.X86_64) {
|
||||
return "cato/cato/" + MultiplayerManager.CATO_VERSION + "/cato-darwin-amd64";
|
||||
return "cato-client-darwin-amd64";
|
||||
} else if (Architecture.SYSTEM_ARCH == Architecture.ARM64) {
|
||||
return "cato/cato/" + MultiplayerManager.CATO_VERSION + "/cato-darwin-arm64";
|
||||
return "cato-client-darwin-arm64";
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
case LINUX:
|
||||
if (Architecture.SYSTEM_ARCH == Architecture.X86_64) {
|
||||
return "cato/cato/" + MultiplayerManager.CATO_VERSION + "/cato-linux-amd64";
|
||||
} else if (Architecture.SYSTEM_ARCH == Architecture.ARM32 || Architecture.SYSTEM_ARCH == Architecture.ARM64) {
|
||||
return "cato/cato/" + MultiplayerManager.CATO_VERSION + "/cato-linux-arm7";
|
||||
return "cato-client-linux-amd64";
|
||||
} else if (Architecture.SYSTEM_ARCH == Architecture.ARM32) {
|
||||
return "cato-client-linux-arm7";
|
||||
} else if (Architecture.SYSTEM_ARCH == Architecture.ARM64) {
|
||||
return "cato-client-linux-arm64";
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
@ -320,6 +289,12 @@ public final class MultiplayerManager {
|
||||
}
|
||||
}
|
||||
|
||||
public static String getCatoPath() {
|
||||
String name = getCatoFileName();
|
||||
if (StringUtils.isBlank(name)) return "";
|
||||
return "cato/cato/" + MultiplayerManager.CATO_VERSION + "/" + name;
|
||||
}
|
||||
|
||||
public static class CatoSession extends ManagedProcess {
|
||||
private final EventManager<CatoExitEvent> onExit = new EventManager<>();
|
||||
private final EventManager<CatoIdEvent> onIdGenerated = new EventManager<>();
|
||||
@ -331,6 +306,7 @@ public final class MultiplayerManager {
|
||||
private boolean peerConnected = false;
|
||||
private MultiplayerClient client;
|
||||
private MultiplayerServer server;
|
||||
private final BufferedWriter writer;
|
||||
|
||||
CatoSession(String name, State type, Process process, List<String> commands) {
|
||||
super(process, commands);
|
||||
@ -344,6 +320,8 @@ public final class MultiplayerManager {
|
||||
addRelatedThread(Lang.thread(this::waitFor, "CatoExitWaiter", true));
|
||||
addRelatedThread(Lang.thread(new StreamPump(process.getInputStream(), this::checkCatoLog), "CatoInputStreamPump", true));
|
||||
addRelatedThread(Lang.thread(new StreamPump(process.getErrorStream(), this::checkCatoLog), "CatoErrorStreamPump", true));
|
||||
|
||||
writer = new BufferedWriter(new OutputStreamWriter(process.getOutputStream(), StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
public MultiplayerClient getClient() {
|
||||
@ -390,6 +368,12 @@ public final class MultiplayerManager {
|
||||
onExit.fireEvent(new CatoExitEvent(this, exitCode));
|
||||
} catch (InterruptedException e) {
|
||||
onExit.fireEvent(new CatoExitEvent(this, CatoExitEvent.EXIT_CODE_INTERRUPTED));
|
||||
} finally {
|
||||
try {
|
||||
writer.close();
|
||||
} catch (IOException e) {
|
||||
LOG.log(Level.WARNING, "Failed to close cato stdin writer", e);
|
||||
}
|
||||
}
|
||||
destroyRelatedThreads();
|
||||
}
|
||||
@ -419,6 +403,25 @@ public final class MultiplayerManager {
|
||||
return Base64.getEncoder().encodeToString(json.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
public synchronized void invokeCommand(String command) throws IOException {
|
||||
LOG.info("Invoking cato: " + command);
|
||||
writer.write(command);
|
||||
writer.newLine();
|
||||
writer.flush();
|
||||
}
|
||||
|
||||
public void forwardPort(String peerId, String localAddress, int localPort, String remoteAddress, int remotePort, Mode mode) throws IOException {
|
||||
invokeCommand(String.format("net add %s %s:%d %s:%d %s", peerId, localAddress, localPort, remoteAddress, remotePort, mode.getName()));
|
||||
}
|
||||
|
||||
public void allowForwardingAddress(String address, int port) throws IOException {
|
||||
invokeCommand(String.format("ufw net open %s:%d", address, port));
|
||||
}
|
||||
|
||||
public void showAllowedAddress() throws IOException {
|
||||
invokeCommand("ufw net whitelist");
|
||||
}
|
||||
|
||||
public EventManager<CatoExitEvent> onExit() {
|
||||
return onExit;
|
||||
}
|
||||
@ -529,7 +532,7 @@ public final class MultiplayerManager {
|
||||
|
||||
public enum Mode {
|
||||
P2P,
|
||||
RELAY;
|
||||
BRIDGE;
|
||||
|
||||
String getName() {
|
||||
return name().toLowerCase(Locale.ROOT);
|
||||
|
@ -238,9 +238,9 @@ public class MultiplayerPage extends DecoratorAnimatedPage implements DecoratorP
|
||||
initCatoSession(session);
|
||||
|
||||
this.gamePort.set(gamePort);
|
||||
setMultiplayerState(MultiplayerManager.State.CONNECTING);
|
||||
setMultiplayerState(MultiplayerManager.State.MASTER);
|
||||
resolve.run();
|
||||
})
|
||||
}, Platform::runLater)
|
||||
.exceptionally(throwable -> {
|
||||
reject.accept(localizeCreateErrorMessage(throwable, isStaticToken));
|
||||
return null;
|
||||
@ -255,6 +255,7 @@ public class MultiplayerPage extends DecoratorAnimatedPage implements DecoratorP
|
||||
|
||||
Controllers.prompt(new PromptDialogPane.Builder(i18n("multiplayer.session.join"), (result, resolve, reject) -> {
|
||||
PromptDialogPane.Builder.HintQuestion hintQuestion = (PromptDialogPane.Builder.HintQuestion) result.get(0);
|
||||
boolean isStaticToken = StringUtils.isNotBlank(globalConfig().getMultiplayerToken());
|
||||
|
||||
String invitationCode = ((PromptDialogPane.Builder.StringQuestion) result.get(1)).getValue();
|
||||
MultiplayerManager.Invitation invitation;
|
||||
@ -280,8 +281,8 @@ public class MultiplayerPage extends DecoratorAnimatedPage implements DecoratorP
|
||||
invitation.getVersion(),
|
||||
invitation.getSessionName(),
|
||||
invitation.getId(),
|
||||
globalConfig().isMultiplayerRelay() && StringUtils.isNotBlank(globalConfig().getMultiplayerToken())
|
||||
? MultiplayerManager.Mode.RELAY
|
||||
globalConfig().isMultiplayerRelay() && (StringUtils.isNotBlank(globalConfig().getMultiplayerToken()) || StringUtils.isNotBlank(System.getProperty("hmcl.multiplayer.relay")))
|
||||
? MultiplayerManager.Mode.BRIDGE
|
||||
: MultiplayerManager.Mode.P2P,
|
||||
invitation.getChannelPort(),
|
||||
localPort, new MultiplayerManager.JoinSessionHandler() {
|
||||
@ -318,7 +319,7 @@ public class MultiplayerPage extends DecoratorAnimatedPage implements DecoratorP
|
||||
resolve.run();
|
||||
}, Platform::runLater)
|
||||
.exceptionally(throwable -> {
|
||||
reject.accept(localizeJoinErrorMessage(throwable));
|
||||
reject.accept(localizeJoinErrorMessage(throwable, isStaticToken));
|
||||
return null;
|
||||
});
|
||||
} catch (MultiplayerManager.IncompatibleCatoVersionException e) {
|
||||
@ -329,7 +330,7 @@ public class MultiplayerPage extends DecoratorAnimatedPage implements DecoratorP
|
||||
.addQuestion(new PromptDialogPane.Builder.StringQuestion(i18n("multiplayer.session.join.invitation_code"), "", new RequiredValidator())));
|
||||
}
|
||||
|
||||
private String localizeErrorMessage(Throwable t, Function<Throwable, String> fallback) {
|
||||
private String localizeErrorMessage(Throwable t, boolean isStaticToken, Function<Throwable, String> fallback) {
|
||||
Throwable e = resolveException(t);
|
||||
if (e instanceof CancellationException) {
|
||||
LOG.info("Connection rejected by the server");
|
||||
@ -348,7 +349,11 @@ public class MultiplayerPage extends DecoratorAnimatedPage implements DecoratorP
|
||||
return i18n("multiplayer.session.join.error.connection");
|
||||
} else if (e instanceof MultiplayerManager.CatoExitTimeoutException) {
|
||||
LOG.info("Cato failed to connect to main net");
|
||||
return i18n("multiplayer.exit.timeout");
|
||||
if (isStaticToken) {
|
||||
return i18n("multiplayer.exit.timeout.static_token");
|
||||
} else {
|
||||
return i18n("multiplayer.exit.timeout.dynamic_token");
|
||||
}
|
||||
} else if (e instanceof MultiplayerManager.CatoExitException) {
|
||||
LOG.info("Cato exited accidentally");
|
||||
if (!((MultiplayerManager.CatoExitException) e).isReady()) {
|
||||
@ -362,7 +367,7 @@ public class MultiplayerPage extends DecoratorAnimatedPage implements DecoratorP
|
||||
}
|
||||
|
||||
private String localizeCreateErrorMessage(Throwable t, boolean isStaticToken) {
|
||||
return localizeErrorMessage(t, e -> {
|
||||
return localizeErrorMessage(t, isStaticToken, e -> {
|
||||
LOG.log(Level.WARNING, "Failed to create session", e);
|
||||
if (isStaticToken) {
|
||||
return i18n("multiplayer.session.create.error.static_token") + e.getLocalizedMessage();
|
||||
@ -372,8 +377,8 @@ public class MultiplayerPage extends DecoratorAnimatedPage implements DecoratorP
|
||||
});
|
||||
}
|
||||
|
||||
private String localizeJoinErrorMessage(Throwable t) {
|
||||
return localizeErrorMessage(t, e -> {
|
||||
private String localizeJoinErrorMessage(Throwable t, boolean isStaticToken) {
|
||||
return localizeErrorMessage(t, isStaticToken, e -> {
|
||||
LOG.log(Level.WARNING, "Failed to join session", e);
|
||||
return i18n("multiplayer.session.join.error");
|
||||
});
|
||||
|
@ -647,7 +647,8 @@ multiplayer.download.success=Dependencies initialization succeeded
|
||||
multiplayer.download.unsupported=Current operating system or architecure is unsupported.
|
||||
multiplayer.exit.after_ready=Multiplayer session broken. cato exitcode %d
|
||||
multiplayer.exit.before_ready=Multiplayer session failed to create. cato exitcode %d
|
||||
multiplayer.exit.timeout=Failed to connect to multiplayer server.
|
||||
multiplayer.exit.timeout.static_token=Failed to connect to multiplayer server. Please switch to dynamic token and try again.
|
||||
multiplayer.exit.timeout.dynamic_token=Failed to connect to multiplayer server.
|
||||
multiplayer.hint=Multiplayer functionality is experimental. Please give feedback.
|
||||
multiplayer.nat=Network Type Detection
|
||||
multiplayer.nat.hint=Network type detection will make it clear whether your network fulfills our requirement for multiplayer mode.
|
||||
|
@ -647,7 +647,8 @@ multiplayer.download.success=多人聯機初始化完成
|
||||
multiplayer.download.unsupported=多人聯機依賴不支持當前系統或平台
|
||||
multiplayer.exit.after_ready=多人聯機會話意外退出,退出碼 %d
|
||||
multiplayer.exit.before_ready=多人聯機房間創建失敗,cato 退出碼 %d
|
||||
multiplayer.exit.timeout=無法連接多人聯機服務,你可以在多人聯機頁面的回饋中回饋問題。
|
||||
multiplayer.exit.timeout.static_token=無法連接多人聯機服務,請你切換動態 Token 重試。
|
||||
multiplayer.exit.timeout.dynamic_token=無法連接多人聯機服務,你可以在多人聯機頁面的回饋中回饋問題。
|
||||
multiplayer.hint=多人聯機功能處於實驗階段,如果有問題請回饋。
|
||||
multiplayer.nat=網路檢測
|
||||
multiplayer.nat.hint=執行網路檢測可以讓你更清楚你的網路狀況是否符合聯機功能的需求。不符合聯機功能運行條件的網路狀況將可能導致聯機失敗。
|
||||
|
@ -647,7 +647,8 @@ multiplayer.download.success=多人联机初始化完成
|
||||
multiplayer.download.unsupported=多人联机依赖不支持当前系统或平台
|
||||
multiplayer.exit.after_ready=多人联机会话意外退出,退出码 %d
|
||||
multiplayer.exit.before_ready=多人联机房间创建失败,cato 退出码 %d
|
||||
multiplayer.exit.timeout=无法连接多人联机服务,你可以在多人联机页面的反馈中反馈问题。
|
||||
multiplayer.exit.timeout.static_token=无法连接多人联机服务,请你切换动态 Token 重试。
|
||||
multiplayer.exit.timeout.dynamic_token=无法连接多人联机服务,你可以在多人联机页面的反馈中反馈问题。
|
||||
multiplayer.hint=多人联机功能处于实验阶段,如果有问题请反馈。
|
||||
multiplayer.nat=网络检测
|
||||
multiplayer.nat.hint=执行网络检测可以让你更清楚你的网络状况是否符合联机功能的需求。检测结果为差的网络可能导致联机失败。
|
||||
|
Loading…
Reference in New Issue
Block a user