/*
* This file is part of the Illarion project.
*
* Copyright © 2015 - Illarion e.V.
*
* Illarion is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Illarion 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.
*/
package illarion.client.gui.controller.game;
import de.lessvoid.nifty.Nifty;
import de.lessvoid.nifty.NiftyEventSubscriber;
import de.lessvoid.nifty.builder.EffectBuilder;
import de.lessvoid.nifty.builder.ElementBuilder.Align;
import de.lessvoid.nifty.controls.ButtonClickedEvent;
import de.lessvoid.nifty.controls.Label;
import de.lessvoid.nifty.controls.ScrollPanel;
import de.lessvoid.nifty.controls.ScrollPanel.AutoScroll;
import de.lessvoid.nifty.controls.TextField;
import de.lessvoid.nifty.controls.label.builder.LabelBuilder;
import de.lessvoid.nifty.elements.Element;
import de.lessvoid.nifty.elements.events.NiftyMousePrimaryMultiClickedEvent;
import de.lessvoid.nifty.elements.render.TextRenderer;
import de.lessvoid.nifty.input.NiftyInputEvent;
import de.lessvoid.nifty.input.NiftyStandardInputEvent;
import de.lessvoid.nifty.screen.KeyInputHandler;
import de.lessvoid.nifty.screen.Screen;
import de.lessvoid.nifty.screen.ScreenController;
import de.lessvoid.nifty.tools.Color;
import de.lessvoid.nifty.tools.SizeValue;
import illarion.client.IllaClient;
import illarion.client.graphics.Avatar;
import illarion.client.graphics.Camera;
import illarion.client.graphics.FontLoader;
import illarion.client.gui.ChatGui;
import illarion.client.net.client.IntroduceCmd;
import illarion.client.net.client.SayCmd;
import illarion.client.util.ChatHandler.SpeechMode;
import illarion.client.util.Lang;
import illarion.client.util.UpdateTask;
import illarion.client.util.translation.Translator;
import illarion.client.world.Char;
import illarion.client.world.World;
import illarion.common.types.Rectangle;
import illarion.common.util.FastMath;
import org.bushe.swing.event.annotation.AnnotationProcessor;
import org.illarion.engine.GameContainer;
import org.illarion.engine.graphic.Font;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.Map;
import java.util.Map.Entry;
import java.util.concurrent.atomic.AtomicLong;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* This class takes care to receive Chat input from the GUI and sends it to the server. Also it receives Chat from the
* server and takes care for displaying it on the GUI.
*
* @author Martin Karing <nitram@illarion.org>
*/
public final class GUIChatHandler implements ChatGui, KeyInputHandler, ScreenController, UpdatableHandler {
@Override
public void activateChatBox() {
World.getUpdateTaskManager().addTask((container, delta) -> {
if (chatMsg != null) {
chatMsg.setFocus();
}
});
}
@Override
public void deactivateChatBox(boolean clear) {
World.getUpdateTaskManager().addTask((container, delta) -> {
if (chatMsg != null) {
if (chatMsg.hasFocus()) {
assert screen != null;
screen.getFocusHandler().setKeyFocus(null);
}
if (clear) {
chatMsg.setText("");
}
}
});
}
@Override
public boolean isChatBoxActive() {
return (chatMsg != null) && chatMsg.hasFocus();
}
/**
* Add a entry to the chat box.
*
* @param message the message to add to the chat box
* @param color the color of the message
*/
@Override
public void addChatMessage(@Nonnull String message, @Nonnull Color color) {
World.getUpdateTaskManager().addTask(new ChatBoxEntry(message, color));
}
/**
* Display a chat bubble on the screen.
*
* @param character the character this chat message is assigned to
* @param message the message that is displayed
* @param color the color of the message
*/
@Override
public void showChatBubble(
@Nullable Char character, @Nonnull String message, @Nonnull Color color) {
World.getUpdateTaskManager().addTask(new CharTalkEntry(character, message, color));
}
/**
* This utility class is used to store texts that get shown in the Chat log.
*/
private class ChatBoxEntry implements UpdateTask {
/**
* The text of the entry.
*/
@Nonnull
private final String text;
/**
* The color of the entry.
*/
@Nonnull
private final Color color;
/**
* Constructor for the entry.
*
* @param msgText the text stored in the entry
* @param msgColor the color of the entry
*/
ChatBoxEntry(@Nonnull String msgText, @Nonnull Color msgColor) {
text = msgText;
color = msgColor;
}
@Nonnull
protected Color getColor() {
return color;
}
@Override
public void onUpdateGame(@Nonnull GameContainer container, int delta) {
addChatLogText(text, color);
}
}
private class CharTalkEntry implements UpdateTask {
@Nullable
private final Char targetChar;
/**
* The text of the entry.
*/
@Nonnull
private final String text;
/**
* The color of the entry.
*/
private final Color color;
/**
* Constructor for the entry.
*
* @param character the character that spoke this text
* @param message the text stored in the entry
* @param msgColor the color of the entry
*/
CharTalkEntry(@Nullable Char character, @Nonnull String message, Color msgColor) {
text = message;
color = msgColor;
targetChar = character;
}
@Override
public void onUpdateGame(@Nonnull GameContainer container, int delta) {
addMessageBubble(targetChar, text, color);
}
}
/**
* This pattern is used to clean the text before its send to the server.
*/
@Nonnull
private static final Pattern REPEATED_SPACE_PATTERN = Pattern.compile("\\s+");
/**
* The expanded height of the Chat.
*/
@Nonnull
private static final SizeValue CHAT_EXPANDED_HEIGHT = SizeValue.px(500);
/**
* The collapsed size of the Chat.
*/
@Nonnull
private static final SizeValue CHAT_COLLAPSED_HEIGHT = SizeValue.px(170);
/**
* The log that is used to display the text.
*/
@Nullable
private ScrollPanel chatLog;
/**
* The input field that holds the text that is yet to be send.
*/
@Nullable
private TextField chatMsg;
/**
* The layer used to show the Chat bubbles.
*/
@Nullable
private Element chatLayer;
/**
* The screen that displays the GUI.
*/
@Nullable
private Screen screen;
/**
* The nifty instance of this Chat handler.
*/
@Nullable
private Nifty nifty;
/**
* This flag shows of the Chat log is dirty and needs to be cleaned up.
*/
private boolean dirty;
/**
* The pattern used to detect the introduce command.
*/
private final Pattern introducePattern = Pattern.compile("^\\s*[/#]i(ntroduce)?\\s*$", Pattern.CASE_INSENSITIVE);
/**
* The pattern to detect a whispered message.
*/
private final Pattern whisperPattern = Pattern
.compile("^\\s*[/#]w(hisper)?\\s*(.*)\\s*$", Pattern.CASE_INSENSITIVE);
/**
* The pattern to detect a shouted message.
*/
private final Pattern shoutPattern = Pattern.compile("^\\s*[/#]s(hout)?\\s*(.*)\\s*$", Pattern.CASE_INSENSITIVE);
/**
* The pattern to detect a emote.
*/
private final Pattern emotePattern = Pattern.compile("^\\s*[/#](me\\s*)(.*)\\s*$", Pattern.CASE_INSENSITIVE);
/**
* The pattern to detect a ooc.
*/
private final Pattern oocPattern = Pattern.compile("^\\s*[/#]o(oc)?\\s*(.*)\\s*$", Pattern.CASE_INSENSITIVE);
@NiftyEventSubscriber(id = "expandTextLogBtn")
public void onChatButtonClicked(String topic, ButtonClickedEvent data) {
toggleChatLog();
}
/**
* Change the expanded or collapsed state of the Chat.
*/
private void toggleChatLog() {
if (screen == null) {
return;
}
if (isChatLogExpanded()) {
setHeightOfChatLog(CHAT_COLLAPSED_HEIGHT);
} else {
setHeightOfChatLog(CHAT_EXPANDED_HEIGHT);
}
}
private boolean isChatLogExpanded() {
if (screen == null) {
return false;
}
Element chatScroll = screen.findElementById("chatPanel");
return (chatScroll != null) && chatScroll.getConstraintHeight().equals(CHAT_EXPANDED_HEIGHT);
}
private void setHeightOfChatLog(@Nonnull SizeValue value) {
if (screen == null) {
return;
}
Element chatScroll = screen.findElementById("chatPanel");
if (chatScroll == null) {
return;
}
chatScroll.setConstraintHeight(value);
chatScroll.getParent().setConstraintHeight(SizeValue.def());
chatScroll.getParent().getParent().setConstraintHeight(SizeValue.def());
chatScroll.getParent().getParent().getParent().layoutElements();
ScrollPanel scrollPanel = chatScroll.getNiftyControl(ScrollPanel.class);
if (scrollPanel != null) {
scrollPanel.setAutoScroll(AutoScroll.BOTTOM);
scrollPanel.setAutoScroll(AutoScroll.OFF);
}
}
@Override
public void bind(@Nonnull Nifty nifty, @Nonnull Screen screen) {
this.screen = screen;
this.nifty = nifty;
chatMsg = screen.findNiftyControl("chatMsg", TextField.class);
chatLog = screen.findNiftyControl("chatPanel", ScrollPanel.class);
chatMsg.getElement().addInputHandler(this);
chatLayer = screen.findElementById("chatLayer");
}
/**
* Receive a Input event from the GUI and send a text in case this event applies.
*/
@Override
public boolean keyEvent(@Nonnull NiftyInputEvent inputEvent) {
if (inputEvent == NiftyStandardInputEvent.SubmitText) {
assert chatMsg != null;
if (chatMsg.hasFocus()) {
if (chatMsg.getDisplayedText().isEmpty()) {
assert screen != null;
screen.getFocusHandler().setKeyFocus(null);
} else {
sendText(chatMsg.getDisplayedText());
chatMsg.setText("");
if (IllaClient.getCfg().getBoolean("disableChatAfterSending")) {
assert screen != null;
screen.getFocusHandler().setKeyFocus(null);
}
}
} else {
chatMsg.setFocus();
}
return true;
}
return false;
}
/**
* Send the text as talking text to the server.
*
* @param text the text to send
*/
private void sendText(@Nonnull String text) {
if (introducePattern.matcher(text).matches()) {
World.getNet().sendCommand(new IntroduceCmd());
return;
}
Matcher shoutMatcher = shoutPattern.matcher(text);
if (shoutMatcher.find()) {
cleanAndSendText("", shoutMatcher.group(2), SpeechMode.Shout);
return;
}
Matcher whisperMatcher = whisperPattern.matcher(text);
if (whisperMatcher.find()) {
cleanAndSendText("", whisperMatcher.group(2), SpeechMode.Whisper);
return;
}
Matcher emoteMatcher = emotePattern.matcher(text);
if (emoteMatcher.find()) {
String cleanMe = REPEATED_SPACE_PATTERN.matcher(emoteMatcher.group(1)).replaceAll(" ").toLowerCase();
cleanAndSendText('#' + cleanMe, emoteMatcher.group(2), SpeechMode.Normal);
return;
}
Matcher oocMatcher = oocPattern.matcher(text);
if (oocMatcher.find()) {
cleanAndSendText("#o ", oocMatcher.group(2), SpeechMode.Whisper);
return;
}
cleanAndSendText("", text, SpeechMode.Normal);
}
/**
* Cleanup the text and send it in case anything remains to send.
*
* @param prefix the prefix that is prepend to the text
* @param text the text to send
* @param mode the speech mode used to send the command
*/
private static void cleanAndSendText(
String prefix, @Nonnull String text, @Nonnull SpeechMode mode) {
String cleanText = REPEATED_SPACE_PATTERN.matcher(text.trim()).replaceAll(" ");
if (cleanText.isEmpty()) {
return;
}
World.getNet().sendCommand(new SayCmd(mode, prefix + text));
}
@Override
public void onEndScreen() {
assert nifty != null;
nifty.unsubscribeAnnotations(this);
AnnotationProcessor.unprocess(this);
clearChatLog();
clearChatBubbles();
}
@Override
public void onStartScreen() {
setHeightOfChatLog(CHAT_COLLAPSED_HEIGHT);
World.getUpdateTaskManager().addTask((container, delta) -> {
keyEvent(NiftyStandardInputEvent.SubmitText);
keyEvent(NiftyStandardInputEvent.SubmitText);
});
AnnotationProcessor.process(this);
assert nifty != null;
nifty.subscribeAnnotations(this);
}
@Override
public void update(GameContainer container, int delta) {
cleanupChatLog();
updateChatBubbleLocations();
}
private void clearChatLog() {
if (chatLog == null) {
return;
}
Element chatLogElement = chatLog.getElement();
if (chatLogElement != null) {
Element contentPane = chatLogElement.findElementById("chatLog");
if (contentPane != null) {
int entryCount = contentPane.getChildren().size();
for (int i = 0; i < entryCount; i++) {
contentPane.getChildren().get(i).markForRemoval();
}
}
}
}
private void clearChatBubbles() {
activeBubbles.values().forEach(Element::markForRemoval);
activeBubbles.clear();
}
/**
* Remove all entries that do not belong in the list anymore from it. Also update the layout and the scrolling
* position.
*/
private void cleanupChatLog() {
if (!dirty || (chatLog == null)) {
return;
}
dirty = false;
Element contentPane = chatLog.getElement().findElementById("chatLog");
int entryCount = contentPane.getChildren().size();
for (int i = 0; i < (entryCount - 400); i++) {
Element elementToRemove = contentPane.getChildren().get(i);
if (i == (entryCount - 401)) {
elementToRemove.markForRemoval(() -> chatLog.getElement().layoutElements());
} else {
elementToRemove.markForRemoval();
}
}
contentPane.setConstraintHeight(SizeValue.def());
chatLog.getElement().layoutElements();
chatLog.setAutoScroll(AutoScroll.BOTTOM);
chatLog.setAutoScroll(AutoScroll.OFF);
}
private void updateChatBubbleLocations() {
if (chatLayer == null) {
return;
}
boolean layoutRequired = false;
for (Entry<Char, Element> charBubbleEntry : activeBubbles.entrySet()) {
if (updateChatBubbleLocation(charBubbleEntry.getKey(), charBubbleEntry.getValue())) {
layoutRequired = true;
}
}
if (cleanOverlappingBubbles()) {
layoutRequired = true;
}
if (layoutRequired) {
chatLayer.layoutElements();
}
}
@Nonnull
private final AtomicLong chatLineCounter = new AtomicLong(0L);
private SizeValue emptyLineHeight;
/**
* Add a entry to the Chat log.
*
* @param text the text to add
* @param color the color of the text to add
*/
private void addChatLogText(@Nonnull String text, @Nonnull Color color) {
if (chatLog == null) {
return;
}
Element contentPane = chatLog.getElement().findElementById("chatLog");
long index = chatLineCounter.getAndIncrement();
LabelBuilder label = new LabelBuilder();
label.id("chatLog#chatLine-" + index);
label.font("chatFont");
label.text(text);
label.color(color);
label.textHAlign(Align.Left);
label.wrap(true);
label.width(contentPane.getConstraintWidth().toString());
label.visibleToMouse(true);
label.build(nifty, screen, contentPane);
LabelBuilder translationLabel = new LabelBuilder();
translationLabel.id("chatLog#transChatLine-" + index);
translationLabel.font("chatFont");
translationLabel.text("");
translationLabel.color(color);
translationLabel.textHAlign(Align.Left);
translationLabel.wrap(true);
translationLabel.visible(false);
if (emptyLineHeight != null) {
translationLabel.marginTop(emptyLineHeight.toString());
}
translationLabel.width(contentPane.getConstraintWidth().toString());
Element translationElement = translationLabel.build(nifty, screen, contentPane);
if (emptyLineHeight == null) {
TextRenderer renderer = translationElement.getRenderer(TextRenderer.class);
if (renderer != null) {
int height = renderer.getTextHeight();
emptyLineHeight = SizeValue.px(-height);
translationElement.setMarginTop(emptyLineHeight);
}
}
dirty = true;
}
@Nonnull
private final Translator translator = new Translator();
@NiftyEventSubscriber(pattern = "chatLog#chatLine-[0-9]+")
public void onChatLineDoubleClick(@Nonnull String id, @Nonnull NiftyMousePrimaryMultiClickedEvent event) {
if ((screen == null) || !translator.isServiceEnabled() || (chatLog == null) || (event.getClickCount() != 2)) {
return;
}
Element panel = chatLog.getElement().findElementById("chatLog");
if (panel == null) {
return;
}
String strId = id.substring("chatLog#chatLine-".length());
String translateId = "chatLog#transChatLine-" + strId;
Element sourceElement = panel.findElementById(id);
Label sourceLabel = (sourceElement != null) ? sourceElement.getNiftyControl(Label.class) : null;
Element translationElement = panel.findElementById(translateId);
Label translationLabel = (translationElement != null) ? translationElement.getNiftyControl(Label.class) :
null;
if ((sourceLabel == null) || (sourceLabel.getText() == null) || (translationLabel == null)) {
return;
}
if ((translationLabel.getText() == null) || translationLabel.getText().isEmpty()) {
translationElement.setMarginTop(SizeValue.def());
translationElement.setConstraintHeight(SizeValue.def());
translationLabel.setText(Lang.getMsg("chat.translating"));
translationElement.setVisible(true);
sourceElement.setVisibleToMouseEvents(false);
dirty = true;
translator.translate(sourceLabel.getText(),
translation -> World.getUpdateTaskManager().addTask((container, delta) -> {
if (translation == null) {
translationLabel.setText("");
translationElement.setVisible(false);
if (emptyLineHeight != null) {
translationElement.setMarginTop(emptyLineHeight);
}
} else {
translationElement.setMarginTop(SizeValue.def());
translationLabel.setText(Lang.getMsg("chat.translation.header") + ' ' + translation);
}
dirty = true;
}));
}
}
private final Map<Char, Element> activeBubbles = new HashMap<>();
/**
* The the Chat bubble of a character talking on the map.
*
* @param character the character who is talking
* @param message the message to display
* @param color the color to show the text in
*/
private void addMessageBubble(@Nullable Char character, @Nonnull String message, @Nonnull Color color) {
if ((character == null) || (chatLayer == null)) {
return;
}
@Nullable Element oldBubble = activeBubbles.remove(character);
if (oldBubble != null) {
nifty.removeElement(screen, oldBubble);
}
LabelBuilder labelBuilder = new LabelBuilder();
labelBuilder.style("nifty-label");
Font font = FontLoader.getInstance().getFont(FontLoader.BUBBLE_FONT);
int textWidth = font.getWidth(message);
if (textWidth > 300) {
labelBuilder.width("300px");
labelBuilder.wrap(true);
} else {
labelBuilder.width(SizeValue.px(textWidth).toString());
labelBuilder.wrap(false);
}
labelBuilder.font(FontLoader.BUBBLE_FONT);
labelBuilder.color(color);
labelBuilder.text(message);
EffectBuilder hideEffectBuilder = new EffectBuilder("fade");
hideEffectBuilder.startDelay(3500 + (message.length() * 50));
hideEffectBuilder.length(200);
hideEffectBuilder.effectParameter("start", "FF");
hideEffectBuilder.effectParameter("end", "00");
labelBuilder.onHideEffect(hideEffectBuilder);
Element bubble = labelBuilder.build(nifty, screen, chatLayer);
if (updateChatBubbleLocation(character, bubble)) {
chatLayer.layoutElements();
}
bubble.hide(() -> {
nifty.removeElement(screen, bubble);
activeBubbles.remove(character);
});
activeBubbles.put(character, bubble);
}
private boolean updateChatBubbleLocation(@Nonnull Char character, @Nonnull Element bubble) {
if (chatLayer == null) {
return false;
}
Avatar charAvatar = character.getAvatar();
if (charAvatar == null) {
return false;
}
Rectangle charDisplayRect = charAvatar.getDisplayRect();
if (charDisplayRect.isEmpty()) {
return false;
}
int charDisplayCenterX = charDisplayRect.getCenterX() - Camera.getInstance().getViewportOffsetX();
int charDisplayY = charDisplayRect.getBottom() - Camera.getInstance().getViewportOffsetY();
int bubblePosX = FastMath
.clamp(charDisplayCenterX - (bubble.getWidth() / 2), 0, chatLayer.getWidth() - bubble.getWidth());
int bubblePosY = FastMath
.clamp(charDisplayY - bubble.getHeight() - 5, 0, chatLayer.getHeight() - bubble.getHeight());
SizeValue newConstraintX = SizeValue.px(bubblePosX);
SizeValue newConstraintY = SizeValue.px(bubblePosY);
if (!bubble.getConstraintX().equals(newConstraintX) || !bubble.getConstraintY().equals(newConstraintY)) {
bubble.setConstraintX(newConstraintX);
bubble.setConstraintY(newConstraintY);
return true;
}
return false;
}
private boolean cleanOverlappingBubbles() {
if ((chatLayer == null) || (nifty == null) || (screen == null)) {
return false;
}
int elementCount = chatLayer.getChildrenCount();
if (elementCount <= 1) {
return false;
}
Collection<Element> elementsToRemove = new ArrayList<>();
Collection<Rectangle> coveredAreas = new ArrayList<>();
for (int i = elementCount - 1; i >= 0; i--) {
Element child = chatLayer.getChildren().get(i);
Rectangle elementArea = new Rectangle(child.getConstraintX().getValueAsInt(1.f),
child.getConstraintY().getValueAsInt(1.f), child.getWidth(),
child.getHeight());
boolean childWillBeRemoved = false;
for (@Nonnull Rectangle coveredArea : coveredAreas) {
if (coveredArea.intersects(elementArea)) {
elementsToRemove.add(child);
childWillBeRemoved = true;
break;
}
}
if (!childWillBeRemoved) {
coveredAreas.add(elementArea);
}
}
for (@Nonnull Element elementToRemove : elementsToRemove) {
nifty.removeElement(screen, elementToRemove);
}
return !elementsToRemove.isEmpty();
}
}