diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/PromptDialogPane.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/PromptDialogPane.java index f452cdb3f..1a3758d89 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/PromptDialogPane.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/PromptDialogPane.java @@ -35,6 +35,7 @@ import org.jackhuang.hmcl.util.FutureCallback; import org.jackhuang.hmcl.util.StringUtils; import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed; +import static org.jackhuang.hmcl.ui.FXUtils.runInFX; import java.util.ArrayList; import java.util.Arrays; @@ -64,10 +65,14 @@ public class PromptDialogPane extends StackPane { List bindings = new ArrayList<>(); for (Builder.Question question : builder.questions) { if (question instanceof Builder.StringQuestion) { + Builder.StringQuestion stringQuestion = (Builder.StringQuestion) question; JFXTextField textField = new JFXTextField(); - textField.textProperty().addListener((a, b, newValue) -> ((Builder.StringQuestion) question).value = textField.getText()); - textField.setText(((Builder.StringQuestion) question).value); + textField.textProperty().addListener((a, b, newValue) -> stringQuestion.value = textField.getText()); + textField.setText(stringQuestion.value); textField.setValidators(((Builder.StringQuestion) question).validators.toArray(new ValidatorBase[0])); + if (stringQuestion.promptText != null) { + textField.setPromptText(stringQuestion.promptText); + } bindings.add(Bindings.createBooleanBinding(textField::validate, textField.textProperty())); if (StringUtils.isNotBlank(question.question)) { @@ -96,6 +101,10 @@ public class PromptDialogPane extends StackPane { vbox.getChildren().add(new Label(question.question)); } vbox.getChildren().add(hBox); + } else if (question instanceof Builder.HintQuestion) { + HintPane pane = new HintPane(); + pane.setText(question.question); + vbox.getChildren().add(pane); } } @@ -109,12 +118,16 @@ public class PromptDialogPane extends StackPane { acceptPane.showSpinner(); builder.callback.call(builder.questions, () -> { - acceptPane.hideSpinner(); future.complete(builder.questions); - fireEvent(new DialogCloseEvent()); + runInFX(() -> { + acceptPane.hideSpinner(); + fireEvent(new DialogCloseEvent()); + }); }, msg -> { - acceptPane.hideSpinner(); - lblCreationWarning.setText(msg); + runInFX(() -> { + acceptPane.hideSpinner(); + lblCreationWarning.setText(msg); + }); }); }); @@ -153,14 +166,26 @@ public class PromptDialogPane extends StackPane { } } + public static class HintQuestion extends Question { + public HintQuestion(String hint) { + super(hint); + } + } + public static class StringQuestion extends Question { protected final List validators; + protected String promptText; public StringQuestion(String question, String defaultValue, ValidatorBase... validators) { super(question); this.value = defaultValue; this.validators = Arrays.asList(validators); } + + public StringQuestion setPromptText(String promptText) { + this.promptText = promptText; + return this; + } } public static class CandidatesQuestion extends Question { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/multiplayer/MultiplayerManager.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/multiplayer/MultiplayerManager.java index c31799adf..c2965f577 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/multiplayer/MultiplayerManager.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/multiplayer/MultiplayerManager.java @@ -19,19 +19,29 @@ package org.jackhuang.hmcl.ui.multiplayer; import com.google.gson.JsonParseException; import org.jackhuang.hmcl.Metadata; +import org.jackhuang.hmcl.event.Event; +import org.jackhuang.hmcl.event.EventManager; import org.jackhuang.hmcl.game.Artifact; +import org.jackhuang.hmcl.launch.StreamPump; import org.jackhuang.hmcl.task.FileDownloadTask; import org.jackhuang.hmcl.task.Task; +import org.jackhuang.hmcl.util.Lang; import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.io.NetworkUtils; import org.jackhuang.hmcl.util.platform.ManagedProcess; +import org.jetbrains.annotations.Nullable; import java.io.IOException; +import java.net.ServerSocket; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; import java.util.Arrays; import java.util.Base64; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; /** * Cato Management. @@ -44,10 +54,6 @@ public final class MultiplayerManager { private MultiplayerManager() { } - public static void fetchIdAndToken() { - // TODO - } - public static Task downloadCato() { return new FileDownloadTask( NetworkUtils.toURL(CATO_DOWNLOAD_URL + CATO_ARTIFACT.getPath()), @@ -59,22 +65,22 @@ public final class MultiplayerManager { return CATO_ARTIFACT.getPath(Metadata.HMCL_DIRECTORY); } - public static ManagedProcess joinRoom(String token, String peer, String localAddress, String remoteAddress) throws IOException { + public static CatoSession joinSession(String sessionName, String peer, int remotePort, int localPort) throws IOException { Path exe = getCatoExecutable(); if (!Files.isRegularFile(exe)) { throw new IllegalStateException("Cato file not found"); } - String[] commands = new String[]{exe.toString(), "--peer", peer, "--from", localAddress, "--to", remoteAddress}; + String[] commands = new String[]{exe.toString(), "--token", "new", "--id", peer, "--from", String.format("127.0.0.1:%d", remotePort), "--to", String.format("127.0.0.1:%d", localPort)}; Process process = new ProcessBuilder() .command(commands) .inheritIO() .start(); - ManagedProcess managedProcess = new ManagedProcess(process, Arrays.asList(commands)); - managedProcess.getProcess().getOutputStream().write(token.getBytes(StandardCharsets.UTF_8)); - return managedProcess; + CatoSession catoSession = new CatoSession(sessionName, process, Arrays.asList(commands)); + + return catoSession; } - public static ManagedProcess createRoom() throws IOException { + public static CatoSession createSession(String sessionName) throws IOException { Path exe = getCatoExecutable(); if (!Files.isRegularFile(exe)) { throw new IllegalStateException("Cato file not found"); @@ -84,28 +90,9 @@ public final class MultiplayerManager { .command(commands) .inheritIO() .start(); - ManagedProcess managedProcess = new ManagedProcess(process, Arrays.asList(commands)); -// -// Thread stdout = Lang.thread(new StreamPump(managedProcess.getProcess().getInputStream()), it -> { -// -// }); -// -// Thread exitWaiter = Lang.thread(() -> { -// int exitCode = process.waitFor(); -// if (exitCode != 0) { -// -// } -// }) + CatoSession catoSession = new CatoSession(sessionName, process, Arrays.asList(commands)); -// managedProcess.addRelatedThread(stdout); -// managedProcess.addRelatedThread(exitWaiter); - - return managedProcess; - } - - public static String generateInvitationCode(String id, int port) { - String json = JsonUtils.GSON.toJson(new Invitation(id, port)); - return new String(Base64.getEncoder().encode(json.getBytes(StandardCharsets.UTF_8)), StandardCharsets.UTF_8); + return catoSession; } public static Invitation parseInvitationCode(String invitationCode) throws JsonParseException { @@ -113,6 +100,101 @@ public final class MultiplayerManager { return JsonUtils.fromNonNullJson(json, Invitation.class); } + public static int findAvailablePort() throws IOException { + try (ServerSocket socket = new ServerSocket(0)) { + return socket.getLocalPort(); + } + } + + public static class CatoSession extends ManagedProcess { + private final EventManager onExit = new EventManager<>(); + private final EventManager onIdGenerated = new EventManager<>(); + + private final String name; + private String id; + + CatoSession(String name, Process process, List commands) { + super(process, commands); + + this.name = name; + addRelatedThread(Lang.thread(this::waitFor, "CatoExitWaiter", true)); + addRelatedThread(Lang.thread(new StreamPump(process.getInputStream(), it -> { + if (id == null) { + Matcher matcher = TEMP_TOKEN_PATTERN.matcher(it); + if (matcher.find()) { + id = matcher.group("id"); + onIdGenerated.fireEvent(new CatoIdEvent(this, id)); + } + } + }), "CatoStreamPump", true)); + } + + private void waitFor() { + try { + int exitCode = getProcess().waitFor(); + onExit.fireEvent(new CatoExitEvent(this, exitCode)); + } catch (InterruptedException e) { + onExit.fireEvent(new CatoExitEvent(this, CatoExitEvent.EXIT_CODE_INTERRUPTED)); + } + } + + public boolean isReady() { + return id != null; + } + + public String getName() { + return name; + } + + @Nullable + public String getId() { + return id; + } + + public String generateInvitationCode(int port) { + if (id == null) { + throw new IllegalStateException("id not generated"); + } + String json = JsonUtils.GSON.toJson(new Invitation(id, name, port)); + return new String(Base64.getEncoder().encode(json.getBytes(StandardCharsets.UTF_8)), StandardCharsets.UTF_8); + } + + public EventManager onExit() { + return onExit; + } + + private static final Pattern TEMP_TOKEN_PATTERN = Pattern.compile("id\\(mix(?\\w+)\\)"); + } + + public static class CatoExitEvent extends Event { + private final int exitCode; + + public CatoExitEvent(Object source, int exitCode) { + super(source); + this.exitCode = exitCode; + } + + public int getExitCode() { + return exitCode; + } + + public static final int EXIT_CODE_INTERRUPTED = -1; + public static final int EXIT_CODE_SESSION_EXPIRED = 10; + } + + public static class CatoIdEvent extends Event { + private final String id; + + public CatoIdEvent(Object source, String id) { + super(source); + this.id = id; + } + + public String getId() { + return id; + } + } + enum State { DISCONNECTED, MASTER, @@ -121,10 +203,12 @@ public final class MultiplayerManager { public static class Invitation { private final String id; + private final String sessionName; private final int port; - public Invitation(String id, int port) { + public Invitation(String id, String sessionName, int port) { this.id = id; + this.sessionName = sessionName; this.port = port; } @@ -132,6 +216,10 @@ public final class MultiplayerManager { return id; } + public String getSessionName() { + return sessionName; + } + public int getPort() { return port; } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/multiplayer/MultiplayerPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/multiplayer/MultiplayerPage.java index c8c0a1b73..4734d0d35 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/multiplayer/MultiplayerPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/multiplayer/MultiplayerPage.java @@ -26,8 +26,16 @@ import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.task.TaskExecutor; import org.jackhuang.hmcl.ui.Controllers; +import org.jackhuang.hmcl.ui.FXUtils; +import org.jackhuang.hmcl.ui.construct.MessageDialogPane; +import org.jackhuang.hmcl.ui.construct.NumberValidator; +import org.jackhuang.hmcl.ui.construct.PromptDialogPane; +import org.jackhuang.hmcl.ui.construct.RequiredValidator; import org.jackhuang.hmcl.ui.decorator.DecoratorPage; +import java.util.function.Consumer; + +import static org.jackhuang.hmcl.ui.FXUtils.runInFX; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; public class MultiplayerPage extends Control implements DecoratorPage { @@ -35,6 +43,10 @@ public class MultiplayerPage extends Control implements DecoratorPage { private final ObjectProperty multiplayerState = new SimpleObjectProperty<>(); private final ReadOnlyObjectWrapper natState = new ReadOnlyObjectWrapper<>(); + private final ReadOnlyIntegerWrapper port = new ReadOnlyIntegerWrapper(-1); + private final ReadOnlyObjectWrapper session = new ReadOnlyObjectWrapper<>(); + + private Consumer onExit; public MultiplayerPage() { testNAT(); @@ -66,6 +78,22 @@ public class MultiplayerPage extends Control implements DecoratorPage { return natState.getReadOnlyProperty(); } + public int getPort() { + return port.get(); + } + + public ReadOnlyIntegerProperty portProperty() { + return port.getReadOnlyProperty(); + } + + public MultiplayerManager.CatoSession getSession() { + return session.get(); + } + + public ReadOnlyObjectProperty sessionProperty() { + return session.getReadOnlyProperty(); + } + private void testNAT() { Task.supplyAsync(() -> { DiscoveryTest tester = new DiscoveryTest(null, 0, "stun.qq.com", 3478); @@ -92,6 +120,110 @@ public class MultiplayerPage extends Control implements DecoratorPage { } } + public void copyInvitationCode() { + if (getSession() == null || !getSession().isReady() || port.get() < 0 || getMultiplayerState() != MultiplayerManager.State.MASTER) { + throw new IllegalStateException("CatoSession not ready"); + } + + FXUtils.copyText(getSession().generateInvitationCode(port.get())); + } + + public void createRoom() { + if (getSession() != null || getMultiplayerState() != MultiplayerManager.State.DISCONNECTED) { + throw new IllegalStateException("CatoSession already ready"); + } + + Controllers.prompt(new PromptDialogPane.Builder(i18n("multiplayer.session.create"), (result, resolve, reject) -> { + try { + initCatoSession(MultiplayerManager.createSession(((PromptDialogPane.Builder.StringQuestion) result.get(1)).getValue())); + } catch (Exception e) { + reject.accept(i18n("multiplayer.session.create.error")); + return; + } + + port.set(Integer.parseInt(((PromptDialogPane.Builder.StringQuestion) result.get(2)).getValue())); + setMultiplayerState(MultiplayerManager.State.MASTER); + resolve.run(); + }) + .addQuestion(new PromptDialogPane.Builder.HintQuestion(i18n("multiplayer.session.create.hint"))) + .addQuestion(new PromptDialogPane.Builder.StringQuestion(i18n("multiplayer.session.create.name"), "", new RequiredValidator())) + .addQuestion(new PromptDialogPane.Builder.StringQuestion(i18n("multiplayer.session.create.port"), "", new NumberValidator()))); + } + + public void joinRoom() { + if (getSession() != null || getMultiplayerState() != MultiplayerManager.State.DISCONNECTED) { + throw new IllegalStateException("CatoSession already ready"); + } + + Controllers.prompt(new PromptDialogPane.Builder(i18n("multiplayer.session.join.prompt"), (result, resolve, reject) -> { + String invitationCode = ((PromptDialogPane.Builder.StringQuestion) result.get(1)).getValue(); + MultiplayerManager.Invitation invitation; + try { + invitation = MultiplayerManager.parseInvitationCode(invitationCode); + } catch (Exception e) { + reject.accept(i18n("multiplayer.session.join.invitation_code.error")); + return; + } + + int localPort; + try { + localPort = MultiplayerManager.findAvailablePort(); + } catch (Exception e) { + reject.accept(i18n("multiplayer.session.join.port.error")); + return; + } + + try { + initCatoSession(MultiplayerManager.joinSession(invitation.getSessionName(), invitation.getId(), invitation.getPort(), localPort)); + } catch (Exception e) { + reject.accept(i18n("multiplayer.session.error")); + return; + } + + setMultiplayerState(MultiplayerManager.State.SLAVE); + resolve.run(); + }) + .addQuestion(new PromptDialogPane.Builder.HintQuestion("multiplayer.session.join.hint")) + .addQuestion(new PromptDialogPane.Builder.StringQuestion(i18n("multiplayer.session.join.invitation_code"), "", new RequiredValidator()))); + } + + public void closeRoom() { + if (getSession() == null || !getSession().isReady() || getMultiplayerState() != MultiplayerManager.State.MASTER) { + throw new IllegalStateException("CatoSession not ready"); + } + + Controllers.confirm(i18n("multiplayer.session.close.warning"), i18n("message.warning"), MessageDialogPane.MessageType.WARNING, + () -> { + getSession().stop(); + session.set(null); + setMultiplayerState(MultiplayerManager.State.DISCONNECTED); + }, null); + } + + public void quitRoom() { + if (getSession() == null || !getSession().isReady() || getMultiplayerState() != MultiplayerManager.State.SLAVE) { + throw new IllegalStateException("CatoSession not ready"); + } + + getSession().stop(); + session.set(null); + setMultiplayerState(MultiplayerManager.State.DISCONNECTED); + } + + private void initCatoSession(MultiplayerManager.CatoSession session) { + runInFX(() -> { + session.onExit().registerWeak(this::onCatoExit); + + this.session.set(session); + }); + } + + private void onCatoExit(MultiplayerManager.CatoExitEvent event) { + if (event.getExitCode() == MultiplayerManager.CatoExitEvent.EXIT_CODE_SESSION_EXPIRED) { + Controllers.dialog(i18n("multiplayer.session.expired")); + } + } + @Override public ReadOnlyObjectProperty stateProperty() { return state; diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/multiplayer/MultiplayerPageSkin.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/multiplayer/MultiplayerPageSkin.java index 26bbb725d..202439d7b 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/multiplayer/MultiplayerPageSkin.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/multiplayer/MultiplayerPageSkin.java @@ -18,6 +18,7 @@ package org.jackhuang.hmcl.ui.multiplayer; import de.javawi.jstun.test.DiscoveryInfo; +import javafx.beans.binding.Bindings; import javafx.geometry.Insets; import javafx.scene.control.Label; import javafx.scene.control.ScrollPane; @@ -25,6 +26,8 @@ import javafx.scene.control.SkinBase; import javafx.scene.layout.*; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.SVG; +import org.jackhuang.hmcl.ui.animation.ContainerAnimations; +import org.jackhuang.hmcl.ui.animation.TransitionPane; import org.jackhuang.hmcl.ui.construct.*; import org.jackhuang.hmcl.util.javafx.BindingMapping; @@ -48,29 +51,29 @@ public class MultiplayerPageSkin extends SkinBase { VBox roomPane = new VBox(); { AdvancedListItem createRoomItem = new AdvancedListItem(); - createRoomItem.setTitle(i18n("multiplayer.room.create")); + createRoomItem.setTitle(i18n("multiplayer.session.create")); createRoomItem.setLeftGraphic(wrap(SVG::plusCircleOutline)); - createRoomItem.setOnAction(e -> FXUtils.openLink("")); + createRoomItem.setOnAction(e -> control.createRoom()); AdvancedListItem joinRoomItem = new AdvancedListItem(); - joinRoomItem.setTitle(i18n("multiplayer.room.join")); + joinRoomItem.setTitle(i18n("multiplayer.session.join")); joinRoomItem.setLeftGraphic(wrap(SVG::accountArrowRightOutline)); - joinRoomItem.setOnAction(e -> FXUtils.openLink("")); + joinRoomItem.setOnAction(e -> control.joinRoom()); AdvancedListItem copyLinkItem = new AdvancedListItem(); - copyLinkItem.setTitle(i18n("multiplayer.room.copy_room_code")); + copyLinkItem.setTitle(i18n("multiplayer.session.copy_room_code")); copyLinkItem.setLeftGraphic(wrap(SVG::accountArrowRightOutline)); - copyLinkItem.setOnAction(e -> FXUtils.openLink("")); + copyLinkItem.setOnAction(e -> control.copyInvitationCode()); AdvancedListItem quitItem = new AdvancedListItem(); - quitItem.setTitle(i18n("multiplayer.room.quit")); + quitItem.setTitle(i18n("multiplayer.session.quit")); quitItem.setLeftGraphic(wrap(SVG::closeCircle)); - quitItem.setOnAction(e -> FXUtils.openLink("")); + quitItem.setOnAction(e -> control.quitRoom()); AdvancedListItem closeRoomItem = new AdvancedListItem(); - closeRoomItem.setTitle(i18n("multiplayer.room.quit")); + closeRoomItem.setTitle(i18n("multiplayer.session.close")); closeRoomItem.setLeftGraphic(wrap(SVG::closeCircle)); - closeRoomItem.setOnAction(e -> FXUtils.openLink("")); + closeRoomItem.setOnAction(e -> control.closeRoom()); FXUtils.onChangeAndOperate(getSkinnable().multiplayerStateProperty(), state -> { if (state == MultiplayerManager.State.DISCONNECTED) { @@ -86,13 +89,13 @@ public class MultiplayerPageSkin extends SkinBase { } AdvancedListBox sideBar = new AdvancedListBox() - .startCategory("multiplayer.room") + .startCategory("multiplayer.session") .add(roomPane) .startCategory("help") .addNavigationDrawerItem(settingsItem -> { settingsItem.setTitle(i18n("help")); - settingsItem.setLeftGraphic(wrap(SVG.gamepad(null, 20, 20))); - settingsItem.setOnAction(e -> FXUtils.openLink("")); + settingsItem.setLeftGraphic(wrap(SVG::gamepad)); + settingsItem.setOnAction(e -> FXUtils.openLink("https://hmcl.huangyuhui.net/help/launcher/multiplayer.html")); }); FXUtils.setLimitWidth(sideBar, 200); root.setLeft(sideBar); @@ -108,8 +111,45 @@ public class MultiplayerPageSkin extends SkinBase { ComponentList roomPane = new ComponentList(); { - VBox pane = new VBox(); + TransitionPane transitionPane = new TransitionPane(); + VBox disconnectedPane = new VBox(8); + { + HintPane hintPane = new HintPane(MessageDialogPane.MessageType.INFORMATION); + hintPane.setText(i18n("multiplayer.state.disconnected.hint")); + + Label label = new Label(i18n("multiplayer.state.disconnected")); + + disconnectedPane.getChildren().setAll(hintPane, label); + } + + VBox masterPane = new VBox(); + { + Label label = new Label(i18n("multiplayer.state.master")); + label.textProperty().bind(Bindings.createStringBinding(() -> + i18n("multiplayer.state.master", control.getSession().getName(), control.getPort()), + control.portProperty(), control.sessionProperty())); + masterPane.getChildren().setAll(label); + } + + StackPane slavePane = new StackPane(); + { + Label label = new Label(); + label.textProperty().bind(Bindings.createStringBinding(() -> + i18n("multiplayer.state.slave", control.getSession().getName()), + control.sessionProperty())); + slavePane.getChildren().setAll(label); + } + + FXUtils.onChangeAndOperate(getSkinnable().multiplayerStateProperty(), state -> { + if (state == MultiplayerManager.State.DISCONNECTED) { + transitionPane.setContent(disconnectedPane, ContainerAnimations.NONE.getAnimationProducer()); + } else if (state == MultiplayerManager.State.MASTER) { + transitionPane.setContent(masterPane, ContainerAnimations.NONE.getAnimationProducer()); + } else if (state == MultiplayerManager.State.SLAVE) { + transitionPane.setContent(slavePane, ContainerAnimations.NONE.getAnimationProducer()); + } + }); } ComponentList natDetectionPane = new ComponentList(); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/multiplayer/MultiplayerServer.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/multiplayer/MultiplayerServer.java new file mode 100644 index 000000000..68b31a1f5 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/multiplayer/MultiplayerServer.java @@ -0,0 +1,137 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2021 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.ui.multiplayer; + +import com.google.gson.JsonParseException; +import org.jackhuang.hmcl.util.Lang; +import org.jackhuang.hmcl.util.gson.JsonSubtype; +import org.jackhuang.hmcl.util.gson.JsonType; +import org.jackhuang.hmcl.util.gson.JsonUtils; + +import java.io.*; +import java.net.ServerSocket; +import java.net.Socket; + +public class MultiplayerServer { + private ServerSocket socket; + private Thread thread; + private final int gamePort; + + public MultiplayerServer(int gamePort) { + this.gamePort = gamePort; + } + + public void start() throws IOException { + if (socket != null) { + throw new IllegalStateException("MultiplayerServer already started"); + } + socket = new ServerSocket(0); + + Lang.thread(this::run, "MultiplayerServer", true); + } + + public int getPort() { + if (socket == null) { + throw new IllegalStateException("MultiplayerServer not started"); + } + + return socket.getLocalPort(); + } + + private void run() { + try { + while (true) { + Socket clientSocket = socket.accept(); + Lang.thread(() -> handleClient(clientSocket), "MultiplayerServerClientThread", true); + } + } catch (IOException e) { + + } + } + + private void handleClient(Socket targetSocket) { + try (Socket clientSocket = targetSocket; + BufferedReader reader = new BufferedReader(new InputStreamReader(clientSocket.getInputStream())); + BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(clientSocket.getOutputStream()))) { + String line; + while ((line = reader.readLine()) != null) { + Request request = JsonUtils.fromNonNullJson(line, Request.class); + request.process(this, writer); + } + } catch (IOException | JsonParseException e) { + + } + } + + @JsonType( + property = "type", + subtypes = { + @JsonSubtype(clazz = JoinRequest.class, name = "join") + } + ) + public static class Request { + + public void process(MultiplayerServer server, BufferedWriter writer) throws IOException, JsonParseException { + } + } + + public static class JoinRequest extends Request { + private final String clientLauncherVersion; + private final String username; + + public JoinRequest(String clientLauncherVersion, String username) { + this.clientLauncherVersion = clientLauncherVersion; + this.username = username; + } + + public String getClientLauncherVersion() { + return clientLauncherVersion; + } + + public String getUsername() { + return username; + } + + @Override + public void process(MultiplayerServer server, BufferedWriter writer) throws IOException, JsonParseException { + writer.write(JsonUtils.GSON.toJson(new JoinResponse(server.gamePort))); + } + } + + @JsonType( + property = "type", + subtypes = { + @JsonSubtype(clazz = JoinResponse.class, name = "join") + } + ) + public static class Response { + + } + + public static class JoinResponse extends Response { + private final int port; + + public JoinResponse(int port) { + this.port = port; + } + + public int getPort() { + return port; + } + } +} diff --git a/HMCL/src/main/resources/assets/lang/I18N.properties b/HMCL/src/main/resources/assets/lang/I18N.properties index fd35e5724..393ab277c 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -555,18 +555,29 @@ multiplayer.nat.type.symmetric=Bad (Symmetric) multiplayer.nat.type.symmetric_udp_firewall=Bad (Symmetric with UDP Firewall) multiplayer.nat.type.unknown=Unknown multiplayer.powered_by=Powered by cato -multiplayer.room=Room -multiplayer.room.name.format=%1$s's Room -multiplayer.room.close=Close Room -multiplayer.room.copy_room_code=Copy Invitation Code -multiplayer.room.create=Create Room -multiplayer.room.error.port=Cannot detect game port, you must click "Open LAN Server" in game to enable multiplayer functionality. -multiplayer.room.hint=You must click "Open LAN Server" in game in order to enable multiplayer functionality. -multiplayer.room.join=Join Room -multiplayer.room.members=Room Members -multiplayer.room.port.prompt=Enter game port -multiplayer.room.quit=Quit Room -multiplayer.room.username=Username +multiplayer.session=Room +multiplayer.session.name.format=%1$s's Room +multiplayer.session.close=Close Room +multiplayer.session.close.warning=After closing room, all players joined the room will lost connection. Continue? +multiplayer.session.copy_room_code=Copy Invitation Code +multiplayer.session.create=Create Room +multiplayer.session.create.error=Failed to create multiplayer room. +multiplayer.session.create.hint=Before creating multiplayer room, you must click "Open LAN Server" in running game, and type the port displayed in game in the blank below. +multiplayer.session.create.port=Port +multiplayer.session.create.port.error=Cannot detect game port, you must click "Open LAN Server" in game to enable multiplayer functionality. +multiplayer.session.expired=Multiplayer session has expired. You should re-create or re-join a room to continue. +multiplayer.session.hint=You must click "Open LAN Server" in game in order to enable multiplayer functionality. +multiplayer.session.join=Join Room +multiplayer.session.join.invitation_code=Invitation code +multiplayer.session.join.invitation_code.error=Incorrect invitation code. Please obtain invitation code from the player who creates the multiplayer room. +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.members=Room Members +multiplayer.session.quit=Quit Room +multiplayer.session.username=Username +multiplayer.state.disconnected=Not created/entered a multiplayer session +multiplayer.state.disconnected.hint=Someone should create a multiplayer session, and others join the session to play the game together. +multiplayer.state.master=Created room: %1$s, port: %2$d +multiplayer.state.slave=Joined room: %s datapack=Datapacks datapack.add=Install datapack diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh.properties b/HMCL/src/main/resources/assets/lang/I18N_zh.properties index ac902a06f..bd1162166 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh.properties @@ -555,18 +555,28 @@ multiplayer.nat.type.symmetric=差(對稱型) multiplayer.nat.type.symmetric_udp_firewall=差(對稱型+防火牆) multiplayer.nat.type.unknown=未知 multiplayer.powered_by=由 cato 提供技術支援 -multiplayer.room=房間 -multiplayer.room.name.format=%1$s 的房間 -multiplayer.room.close=關閉房間 -multiplayer.room.copy_room_code=複製邀請碼 -multiplayer.room.create=創建房間 -multiplayer.room.error.port=無法檢測遊戲埠號,你必須先啟動遊戲並在遊戲內打開對區域網路開放選項後才能啟動聯機。 -multiplayer.room.hint=你必須先啟動遊戲並在遊戲內打開對區域網路開放選項後才能創建房間 -multiplayer.room.join=加入房間 -multiplayer.room.members=房間成員 -multiplayer.room.port.prompt=輸入埠號 -multiplayer.room.quit=退出房間 -multiplayer.room.username=使用者名稱 +multiplayer.session=房間 +multiplayer.session.name.format=%1$s 的房間 +multiplayer.session.close=關閉房間 +multiplayer.session.close.warning=關閉房間後,已經加入聯機房間的玩家將會斷開連接,是否繼續? +multiplayer.session.copy_room_code=複製邀請碼 +multiplayer.session.create=創建房間 +multiplayer.session.create.error=創建聯機房間失敗。 +multiplayer.session.create.hint=創建聯機房間前,你需要先在正在運行的遊戲內的遊戲菜單中選擇 對區域網路開放 選項,然後在下方的輸入框中輸入遊戲內提示的埠號(通常是 5 位的數字) +multiplayer.session.create.port=埠號 +multiplayer.session.create.port.error=無法檢測遊戲埠號,你必須先啟動遊戲並在遊戲內打開對區域網路開放選項後才能啟動聯機。 +multiplayer.session.expired=聯機會話連續使用時間超過了 3 小時,你需要重新創建/加入房間以繼續聯機。 +multiplayer.session.join=加入房間 +multiplayer.session.join.invitation_code=邀請碼 +multiplayer.session.join.invitation_code.error=邀請碼不正確,請向開服玩家獲取邀請碼 +multiplayer.session.join.port.error=無法找到可用的本地網路埠,請確保 HMCL 擁有綁定本地埠的權限。 +multiplayer.session.members=房間成員 +multiplayer.session.quit=退出房間 +multiplayer.session.username=使用者名稱 +multiplayer.state.disconnected=未創建/加入房間 +multiplayer.state.disconnected.hint=多人聯機功能需要先有一位玩家創建房間後,其他玩家加入房間後繼續遊戲。 +multiplayer.state.master=你已創建房間:%1$s,埠號 %2$d +multiplayer.state.slave=你已加入房間: %s datapack=資料包 datapack.add=加入資料包 diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties index f2f72a04f..e493450e9 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties @@ -555,18 +555,29 @@ multiplayer.nat.type.symmetric=差(对称型) multiplayer.nat.type.symmetric_udp_firewall=差(对称型+防火墙) multiplayer.nat.type.unknown=未知 multiplayer.powered_by=由 cato 提供技术支持 -multiplayer.room=房间 -multiplayer.room.name.format=%1$s 的房间 -multiplayer.room.close=关闭房间 -multiplayer.room.copy_room_code=复制邀请码 -multiplayer.room.create=创建房间 -multiplayer.room.error.port=无法检测游戏端口号,你必须先启动游戏并在游戏内打开对局域网开放选项后才能启动联机。 -multiplayer.room.hint=你必须先启动游戏并在游戏内打开对局域网开放选项后才能创建房间 -multiplayer.room.join=加入房间 -multiplayer.room.members=房间成员 -multiplayer.room.port.prompt=输入端口号 -multiplayer.room.quit=退出房间 -multiplayer.room.username=用户名 +multiplayer.session=房间 +multiplayer.session.name.format=%1$s 的房间 +multiplayer.session.close=关闭房间 +multiplayer.session.close.warning=关闭房间后,已经加入联机房间的玩家将会断开连接,是否继续? +multiplayer.session.copy_room_code=复制邀请码 +multiplayer.session.create=创建房间 +multiplayer.session.create.error=创建联机房间失败。 +multiplayer.session.create.hint=创建联机房间前,你需要先在正在运行的游戏内的游戏菜单中选择 对局域网开放 选项,然后在下方的输入框中输入游戏内提示的端口号(通常是 5 位的数字) +multiplayer.session.create.name=房间名称 +multiplayer.session.create.port=端口号 +multiplayer.session.create.port.error=无法检测游戏端口号,你必须先启动游戏并在游戏内打开对局域网开放选项后才能启动联机。 +multiplayer.session.expired=联机会话连续使用时间超过了 3 小时,你需要重新创建/加入房间以继续联机。 +multiplayer.session.join=加入房间 +multiplayer.session.join.invitation_code=邀请码 +multiplayer.session.join.invitation_code.error=邀请码不正确,请向开服玩家获取邀请码 +multiplayer.session.join.port.error=无法找到可用的本地网络端口,请确保 HMCL 拥有绑定本地端口的权限。 +multiplayer.session.members=房间成员 +multiplayer.session.quit=退出房间 +multiplayer.session.username=用户名 +multiplayer.state.disconnected=未创建/加入房间 +multiplayer.state.disconnected.hint=多人联机功能需要先有一位玩家创建房间后,其他玩家加入房间后继续游戏。 +multiplayer.state.master=你已创建房间:%1$s,端口号 %2$d +multiplayer.state.slave=你已加入房间: %s datapack=数据包 datapack.add=添加数据包