From d0d4b1a28f928f7bf282dff85d20807eb7298913 Mon Sep 17 00:00:00 2001 From: Michael Bayne Date: Thu, 14 Oct 2010 16:49:41 +0000 Subject: [PATCH] Factored the subtitle and comic chat stuff out of Yohoho. All completely untested. Whee! I thought I had gotten around to making a standalone avatar chat room based on the Vilya Stage code, and was hoping to add this there, but I guess I never did that. git-svn-id: svn+ssh://src.earth.threerings.net/nenya/trunk@1021 ed5b42cb-e716-0410-a449-f6a68f950b19 --- src/java/com/threerings/chat/ChatGlyph.java | 309 +++++ src/java/com/threerings/chat/ChatOverlay.java | 208 +++ .../com/threerings/chat/ComicChatOverlay.java | 944 +++++++++++++ .../threerings/chat/SubtitleChatOverlay.java | 1177 +++++++++++++++++ 4 files changed, 2638 insertions(+) create mode 100644 src/java/com/threerings/chat/ChatGlyph.java create mode 100644 src/java/com/threerings/chat/ChatOverlay.java create mode 100644 src/java/com/threerings/chat/ComicChatOverlay.java create mode 100644 src/java/com/threerings/chat/SubtitleChatOverlay.java diff --git a/src/java/com/threerings/chat/ChatGlyph.java b/src/java/com/threerings/chat/ChatGlyph.java new file mode 100644 index 00000000..530d5fa8 --- /dev/null +++ b/src/java/com/threerings/chat/ChatGlyph.java @@ -0,0 +1,309 @@ +// +// $Id$ +// +// Vilya library - tools for developing networked games +// Copyright (C) 2002-2010 Three Rings Design, Inc., All Rights Reserved +// http://www.threerings.net/code/vilya/ +// +// This library is free software; you can redistribute it and/or modify it +// under the terms of the GNU Lesser General Public License as published +// by the Free Software Foundation; either version 2.1 of the License, or +// (at your option) any later version. +// +// This library 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 +// Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public +// License along with this library; if not, write to the Free Software +// Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +package com.threerings.chat; + +import java.awt.AlphaComposite; +import java.awt.Color; +import java.awt.Composite; +import java.awt.Graphics2D; +import java.awt.Point; +import java.awt.Polygon; +import java.awt.Rectangle; +import java.awt.Shape; +import java.awt.geom.AffineTransform; +import java.awt.geom.Area; + +import javax.swing.Icon; + +import com.samskivert.swing.Label; +import com.samskivert.swing.util.SwingUtil; + +import com.threerings.media.MediaPanel; +import com.threerings.media.animation.Animation; +import com.threerings.media.image.ColorUtil; + +import static com.threerings.NenyaLog.log; + +/** + * Responsible for rendering a "unit" of chat on a {@link MediaPanel}. + */ +public class ChatGlyph extends Animation +{ + /** Can be used by the Overlay to mark our position in the history. */ + public int histIndex; + + /** + * Construct a chat glyph. + * + * @param owner the subtitle overlay that ownz us. + * @param bounds the bounds of the glyph + * @param shape the shape to draw/outline. + * @param icon the Icon to draw inside the bubble, or null for none + * @param iconpos the virtual coordinate at which to draw the icon (can be null if no icon) + * @param label the Label to draw inside the bubble. + * @param labelpos the virtual coordinate at which to draw the label + */ + public ChatGlyph (SubtitleChatOverlay owner, int type, long lifetime, Rectangle bounds, + Shape shape, Icon icon, Point iconpos, Label label, Point labelpos, Color outline) + { + super(bounds); + jiggleBounds(); + + _owner = owner; + _shape = shape; + _type = type; + _icon = icon; + _ipos = iconpos; + _label = label; + _lpos = labelpos; + _lifetime = lifetime; + _outline = outline; + + if (Color.BLACK.equals(_outline)) { + _background = Color.WHITE; + } else { + _background = ColorUtil.blend(Color.WHITE, _outline, .8f); + } + } + + /** + * Sets whether or not this glyph is in dimmed mode. + */ + public void setDim (boolean dim) + { + if (_dim != dim) { + _dim = dim; + invalidate(); + } + } + + /** + * Render the chat glyph with no thought to dirty rectangles or + * restoring composites. + */ + public void render (Graphics2D gfx) + { + Object oalias = SwingUtil.activateAntiAliasing(gfx); + gfx.setColor(getBackground()); + gfx.fill(_shape); + + gfx.setColor(_outline); + gfx.draw(_shape); + SwingUtil.restoreAntiAliasing(gfx, oalias); + + if (_icon != null) { + _icon.paintIcon(_owner.getTarget(), gfx, _ipos.x, _ipos.y); + } + + gfx.setColor(Color.BLACK); + _label.render(gfx, _lpos.x, _lpos.y); + } + + /** + * Get the internal chat type of this bubble. + */ + public int getType () + { + return _type; + } + + /** + * Returns the shape of this chat bubble. + */ + public Shape getShape () + { + return _shape; + } + + @Override + public void setLocation (int x, int y) + { + // we'll need these so that we can translate our complex shapes + int dx = x - _bounds.x, dy = y - _bounds.y; + super.setLocation(x, y); + + if (dx != 0 || dy != 0) { + // update our icon position, if any + if (_ipos != null) { + _ipos.translate(dx, dy); + } + + // update our label position + _lpos.translate(dx, dy); + + if (_shape instanceof Area) { + ((Area) _shape).transform( + AffineTransform.getTranslateInstance(dx, dy)); + + } else if (_shape instanceof Polygon) { + ((Polygon) _shape).translate(dx, dy); + + } else { + log.warning("Unable to translate shape", "glyph", this, "shape", _shape + "]!"); + } + } + } + + /** + * Called when the view has scrolled. The default implementation adjusts the glyph to remain + * in the same position relative to the view rather than allowing it to scroll with the view. + */ + public void viewDidScroll (int dx, int dy) + { + translate(dx, dy); + } + + /** + * Attempt to translate this glyph. + */ + public void translate (int dx, int dy) + { + setLocation(_bounds.x + dx, _bounds.y + dy); + } + + @Override + public void tick (long tickStamp) + { + // set up our born stamp if we've got one + if (_bornStamp == 0L) { + _bornStamp = tickStamp; + invalidate(); // make sure we're painted the first time + } + + // if we're not yet ready to die, do nothing + long deathTime = _bornStamp + _lifetime; + if (tickStamp < deathTime) { + /* TEMPORARILY disabled blinking until we sort it out + if (_type == SubtitleChatOverlay.ATTENTION) { + float alphaWas = _alpha; + long val = tickStamp - _bornStamp; + if ((val < 3000) && (val % 1000 < 500)) { + _alpha = 0f; + } else { + _alpha = ALPHA; + } + if (_alpha != alphaWas) { + invalidate(); + } + } + */ + return; + } + + long msecs = tickStamp - deathTime; + if (msecs >= ANIM_TIME) { + _alpha = 0f; + // stick a fork in ourselves + _finished = true; + _owner.glyphExpired(this); + + } else { + _alpha = ALPHA * (ANIM_TIME - msecs) / ANIM_TIME; + } + + invalidate(); + } + + @Override + public void fastForward (long timeDelta) + { + if (_bornStamp > 0L) { + _bornStamp += timeDelta; + } + } + + @Override + public void paint (Graphics2D gfx) + { + float alpha = _dim ? _alpha / 3 : _alpha; + if (alpha == 0f) { + return; + } + if (alpha != 1f) { + Composite ocomp = gfx.getComposite(); + gfx.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, alpha)); + render(gfx); + gfx.setComposite(ocomp); + } else { + render(gfx); + } + } + + protected Color getBackground () + { + return _background; + } + + /** + * The damn repaint manager expects 1 more pixel than the shape gives, so we manually resize. + */ + protected void jiggleBounds () + { + _bounds.setSize(_bounds.width + 1, _bounds.height + 1); + } + + /** The subtitle chat overlay that ownz us. */ + protected SubtitleChatOverlay _owner; + + /** The type of chat represented by this glyph. */ + protected int _type; + + /** The shape of this glyph. */ + protected Shape _shape; + + /** The visual icon, or null for none. */ + protected Icon _icon; + + /** The position at which we render our icon. */ + protected Point _ipos; + + /** The textual label. */ + protected Label _label; + + /** The position at which we render our text. */ + protected Point _lpos; + + /** The alpha level that we'll render at when fading out. */ + protected float _alpha = ALPHA; + + /** Whether we're in dimmed mode. */ + protected boolean _dim = false; + + /** The length of the fade animation. */ + protected static final long ANIM_TIME = 800L; + + /** The number of milliseconds to wait before this bubble will expire + * and should begin to fade out. */ + protected long _lifetime; + + /** The time at which we came into being on the screen. */ + protected long _bornStamp; + + /** Our outline color. */ + protected Color _outline; + + /** Our background color. */ + protected Color _background; + + /** The initial alpha of all chat glyphs. */ + protected static final float ALPHA = .9f; +} diff --git a/src/java/com/threerings/chat/ChatOverlay.java b/src/java/com/threerings/chat/ChatOverlay.java new file mode 100644 index 00000000..fba47d63 --- /dev/null +++ b/src/java/com/threerings/chat/ChatOverlay.java @@ -0,0 +1,208 @@ +// +// $Id$ +// +// Vilya library - tools for developing networked games +// Copyright (C) 2002-2010 Three Rings Design, Inc., All Rights Reserved +// http://www.threerings.net/code/vilya/ +// +// This library is free software; you can redistribute it and/or modify it +// under the terms of the GNU Lesser General Public License as published +// by the Free Software Foundation; either version 2.1 of the License, or +// (at your option) any later version. +// +// This library 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 +// Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public +// License along with this library; if not, write to the Free Software +// Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +package com.threerings.chat; + +import java.awt.Point; +import java.awt.Shape; +import java.util.List; + +import com.threerings.util.MessageBundle; +import com.threerings.util.Name; + +import com.threerings.crowd.chat.client.ChatDisplay; +import com.threerings.crowd.util.CrowdContext; +import com.threerings.media.VirtualMediaPanel; + +import static com.threerings.NenyaLog.log; + +/** + * An abstract class that represents a chat display that can be overlayed upon another component. + */ +public abstract class ChatOverlay + implements ChatDisplay +{ + /** + * An interface for providing information about what is under the overlay. + */ + public interface InfoProvider + { + /** + * Get a list of Shape objects that we should attempt to avoid when laying out the chat. + * The ChatOverlay will not modify these shape objects. The easy thing to do would be to + * just return java.awt.Rectangle objects. + * + * @param speaker The username of the speaking player, or null. + * @param high Add to this list shapes that should never be drawn on. + * + * @param low If non-null, add to this list shapes that can be drawn on if needed. + */ + public void getAvoidables (Name speaker, List high, List low); + + /** + * Get a point which is approximately the origin of the speaker, or null if unknown. + */ + public Point getSpeaker (Name speaker); + } + + /** + * Construct a chat overlay. + */ + public ChatOverlay (CrowdContext ctx) + { + _ctx = ctx; + } + + /** + * Causes the chat overlay to make itself visible or invisible. + */ + public void setVisible (boolean visible) + { + // derived classes will want to do the right thing here + } + + /** + * Set the dimmed mode of the currently displaying glyphs. + */ + public void setDimmed (boolean dimmed) + { + _dimmed = dimmed; + } + + /** + * Indicates that the target component was added to the widget hier. Should be called when we + * wish to start displaying chat. + */ + public void added (VirtualMediaPanel target) + { + _target = target; + } + + /** + * Layout the chat overlay inside the previously configured target component. Should be called + * if our component changes size. + */ + public abstract void layout (); + + /** + * Indicates that the target component was removed from the widget hier. Should be called when + * we no longer wish to paint chat. + */ + public void removed () + { + _target = null; + } + + /** + * Callback from the target that the place has changed and we are to now talk to the new info + * provider. + */ + public void newPlaceEntered (InfoProvider provider) + { + _provider = provider; + } + + /** + * A callback indicating that we've left the place and should stop talking to a particular + * infoprovider. + */ + public void placeExited () + { + _provider = null; + } + + /** + * Returns the media panel on which this chat overlay is operating. + */ + public VirtualMediaPanel getTarget () + { + return _target; + } + + /** + * Should be called when a speaker departs the chat area to allow the overlay to clean up. + */ + public void speakerDeparted (Name speaker) + { + // The regular overlay doesn't care about speakers leaving. + } + + /** + * Called if our containing media panel scrolled its view. + */ + public void viewDidScroll (int dx, int dy) + { + } + + /** + * Returns true if this chat overlay is showing and should therefore update its display + * accordingly. + */ + protected boolean isShowing () + { + return (_target != null) && _target.isShowing(); + } + + /** + * Translates a string using the general client message bundle. + */ + protected String xlate (String message) + { + return xlate(getDefaultMessageBundle(), message); + } + + /** + * Translates a string using the specified bundle. + */ + protected String xlate (String bundle, String message) + { + if (bundle != null) { + MessageBundle msgb = _ctx.getMessageManager().getBundle(bundle); + if (msgb == null) { + log.warning("No message bundle available to translate message", + "bundle", bundle, "message", message); + } else { + message = msgb.xlate(message); + } + } + return message; + } + + /** + * Returns the message bundle used to translate default messages. + */ + protected String getDefaultMessageBundle () + { + return null; + } + + /** The light of our life. */ + protected CrowdContext _ctx; + + /** The component in which we are being displayed. */ + protected VirtualMediaPanel _target; + + /** The source of hints to how we layout the overlay. */ + protected InfoProvider _provider; + + /** Whether the chat glyphs are dimmed or not. */ + protected boolean _dimmed; +} diff --git a/src/java/com/threerings/chat/ComicChatOverlay.java b/src/java/com/threerings/chat/ComicChatOverlay.java new file mode 100644 index 00000000..f1dc664f --- /dev/null +++ b/src/java/com/threerings/chat/ComicChatOverlay.java @@ -0,0 +1,944 @@ +// +// $Id$ +// +// Vilya library - tools for developing networked games +// Copyright (C) 2002-2010 Three Rings Design, Inc., All Rights Reserved +// http://www.threerings.net/code/vilya/ +// +// This library is free software; you can redistribute it and/or modify it +// under the terms of the GNU Lesser General Public License as published +// by the Free Software Foundation; either version 2.1 of the License, or +// (at your option) any later version. +// +// This library 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 +// Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public +// License along with this library; if not, write to the Free Software +// Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +package com.threerings.chat; + +import java.util.List; +import java.util.Iterator; + +import java.awt.Color; +import java.awt.Dimension; +import java.awt.Font; +import java.awt.Graphics2D; +import java.awt.Point; +import java.awt.Polygon; +import java.awt.Rectangle; +import java.awt.Shape; +import java.awt.geom.Area; +import java.awt.geom.Ellipse2D; +import java.awt.geom.RoundRectangle2D; + +import javax.swing.JScrollBar; + +import com.google.common.collect.Lists; + +import com.samskivert.swing.Label; +import com.samskivert.swing.util.SwingUtil; + +import com.threerings.util.MessageBundle; +import com.threerings.util.Name; + +import com.threerings.media.image.ColorUtil; + +import com.threerings.crowd.chat.data.ChatCodes; +import com.threerings.crowd.chat.data.ChatMessage; +import com.threerings.crowd.chat.data.UserMessage; +import com.threerings.crowd.util.CrowdContext; + +import static com.threerings.NenyaLog.log; + +/** + * Implements comic chat in the yohoho client. + */ +public class ComicChatOverlay extends SubtitleChatOverlay +{ + /** + * Construct a comic chat overlay. + * + * @param subtitleHeight the amount of vertical space to use for subtitles. + */ + public ComicChatOverlay (CrowdContext ctx, JScrollBar historyBar, int subtitleHeight) + { + super(ctx, historyBar, subtitleHeight); + } + + @Override + public void newPlaceEntered (InfoProvider provider) + { + _newPlacePoint = _ctx.getChatDirector().getHistory().size(); + super.newPlaceEntered(provider); + + // and clear place-oriented bubbles + clearBubbles(false); + } + + @Override + public void layout () + { + clearBubbles(true); // these will get repopulated from the history + super.layout(); + } + + @Override + public void removed () + { + // we do this before calling super because we want our target to + // be around for the bubble clearing + clearBubbles(true); + + super.removed(); + } + + @Override + public void clear () + { + super.clear(); + + clearBubbles(true); + } + + @Override + public void viewDidScroll (int dx, int dy) + { + super.viewDidScroll(dx, dy); + viewDidScroll(_bubbles, dx, dy); + } + + @Override + public void setDimmed (boolean dimmed) + { + super.setDimmed(dimmed); + updateDimmed(_bubbles); + } + + @Override + public void speakerDeparted (Name speaker) + { + for (Iterator iter = _bubbles.iterator(); iter.hasNext();) { + BubbleGlyph rec = iter.next(); + if (rec.isSpeaker(speaker)) { + _target.abortAnimation(rec); + iter.remove(); + } + } + } + + @Override + public void historyUpdated (int adjustment) + { + _newPlacePoint -= adjustment; + super.historyUpdated(adjustment); + } + + /** + * Clear chat bubbles, either all of them or just the place-oriented ones. + */ + protected void clearBubbles (boolean all) + { + for (Iterator iter = _bubbles.iterator(); iter.hasNext();) { + ChatGlyph rec = iter.next(); + if (all || isPlaceOrientedType(rec.getType())) { + _target.abortAnimation(rec); + iter.remove(); + } + } + } + + @Override + protected boolean shouldShowFromHistory (ChatMessage msg, int index) + { + // only show if the message was received since we last entered + // a new place, or if it's place-less chat. + return ((index >= _newPlacePoint) || + (! isPlaceOrientedType(getType(msg, false)))); + } + + @Override + protected boolean isApprovedLocalType (String localtype) + { + if (ChatCodes.PLACE_CHAT_TYPE.equals(localtype) || + ChatCodes.USER_CHAT_TYPE.equals(localtype)) { + return true; + } + log.debug("Ignoring non-standard system/feedback chat", "localtype", localtype); + return false; + } + + /** + * Is the type of chat place-oriented. + */ + protected boolean isPlaceOrientedType (int type) + { + return (placeOf(type)) == PLACE; + } + + @Override + protected void displayMessage (ChatMessage message, int type, Graphics2D layoutGfx) + { + // we might need to modify the textual part with translations, + // but we can't do that to the message object, since other chatdisplays also get it. + String text = message.message; + switch (placeOf(type)) { + case INFO: + case ATTENTION: + if (createBubble(layoutGfx, type, message.timestamp, text, null, null)) { + return; // EXIT; + } + // if the bubble didn't fit (unlikely), make it a subtitle + break; + + case PLACE: { + UserMessage msg = (UserMessage) message; + Point speakerloc = _provider.getSpeaker(msg.speaker); + if (speakerloc == null) { + log.warning("ChatOverlay.InfoProvider doesn't know the speaker!", + "speaker", msg.speaker, "type", type); + return; + } + + // emotes won't actually have tails, but we do want them to appear near the pirate + if (modeOf(type) == EMOTE) { + text = xlate( + MessageBundle.tcompose("m.emote_format", msg.getSpeakerDisplayName())) + + " " + text; + } + + // try to add all the text as a bubble, but if it doesn't + // fit, add some of it and 'continue' the rest in a subtitle. + String leftover = text; + for (int ii = 1; ii < 7; ii++) { + String bubtext = splitNear(text, text.length() / ii); + if (createBubble(layoutGfx, type, msg.timestamp, + bubtext + ((ii > 1) ? "..." : ""), msg.speaker, speakerloc)) { + + leftover = text.substring(bubtext.length()); + break; + } + } + + if (leftover.length() > 0 && !isHistoryMode()) { + String ltext = MessageBundle.tcompose("m.continue_format", msg.speaker); + ltext = xlate(ltext) + " \"" + leftover + "\""; + addSubtitle(createSubtitle(layoutGfx, CONTINUATION, + message.timestamp, null, 0, ltext, true)); + } + return; // EXIT + } + } + + super.displayMessage(message, type, layoutGfx); + } + + /** + * Split the text at the space nearest the specified location. + */ + protected String splitNear (String text, int pos) + { + if (pos >= text.length()) { + return text; + } + + int forward = text.indexOf(' ', pos); + int backward = text.lastIndexOf(' ', pos); + + int newpos = (Math.abs(pos - forward) < Math.abs(pos - backward)) ? forward : backward; + + // if we couldn't find a decent place to split, just do it wherever + if (newpos == -1) { + newpos = pos; + + } else { + // actually split the space onto the first part + newpos++; + } + return text.substring(0, newpos); + } + + /** + * Create a chat bubble with the specified type and text. + * + * @param speakerloc if non-null, specifies that a tail should be added which points to that + * location. + * @return true if we successfully laid out the bubble + */ + protected boolean createBubble ( + Graphics2D gfx, int type, long timestamp, String text, Name speaker, Point speakerloc) + { + Label label = layoutText(gfx, getFont(type), text); + label.setAlignment(Label.CENTER); + gfx.dispose(); + + // get the size of the new bubble + Rectangle r = getBubbleSize(type, label.getSize()); + + // get the user's old bubbles. + List oldbubs = getAndExpireBubbles(speaker); + int numold = oldbubs.size(); + + Rectangle placer, bigR = null; + if (numold == 0) { + placer = new Rectangle(r); + positionRectIdeally(placer, type, speakerloc); + + } else { + // get a big rectangle encompassing the old and new + bigR = getRectWithOlds(r, oldbubs); + placer = new Rectangle(bigR); + + positionRectIdeally(placer, type, speakerloc); + // we actually try to place midway between ideal and old + // and adjust up half the height of the new boy + placer.setLocation((placer.x + bigR.x) / 2, + (placer.y + (bigR.y - (r.height / 2))) / 2); + } + + // then look for a place nearby where it will fit + // (making sure we only put it in the area above the subtitles) + Rectangle vbounds = new Rectangle(_target.getViewBounds()); + vbounds.height -= _subtitleHeight; + if (!SwingUtil.positionRect(placer, vbounds, getAvoidList(speaker))) { + // we couldn't fit the bubble! + return false; + } + + // now 'placer' is positioned reasonably. + if (0 == numold) { + r.setLocation(placer.x, placer.y); + + } else { + int dx = placer.x - bigR.x; + int dy = placer.y - bigR.y; + for (int ii=0; ii < numold; ii++) { + BubbleGlyph bub = oldbubs.get(ii); + bub.removeTail(); + Rectangle ob = bub.getBubbleBounds(); + // recenter the translated bub within placer's width.. + int xadjust = dx - (ob.x - bigR.x) + + (placer.width - ob.width) / 2; + bub.translate(xadjust, dy); + } + + // and position 'r' in the right place relative to 'placer' + r.setLocation(placer.x + (placer.width - r.width) / 2, + placer.y + placer.height - r.height); + } + + Shape shape = getBubbleShape(type, r); + Shape full = shape; + + // if we have a tail, the full area should include that. + if (speakerloc != null) { + Area area = new Area(getTail(type, r, speakerloc)); + area.add(new Area(shape)); + full = area; + } + + Color color; + switch (type) { + case INFO: color = INFO_COLOR; break; + case ATTENTION: color = ATTENTION_COLOR; break; + default: color = Color.BLACK; break; + } + + // finally, add the bubble + long lifetime = getChatExpire(timestamp, label.getText())-timestamp; + BubbleGlyph newbub = new BubbleGlyph( + this, type, lifetime, full, label, + adjustLabel(type, r.getLocation()), shape, speaker, color); + newbub.setDim(_dimmed); + _bubbles.add(newbub); + _target.addAnimation(newbub); + + // and we need to dirty all the bubbles because they'll all + // be painted in slightly different colors + int numbubs = _bubbles.size(); + for (int ii=0; ii < numbubs; ii++) { + _bubbles.get(ii).setAgeLevel(numbubs - ii - 1); + } + + return true; // success! + } + + /** + * Calculate the size of the chat bubble based on the dimensions of the label and the type of + * chat. It will be turned into a shape later, but we manipulate it for a while as just a + * rectangle (which are easier to move about and do intersection tests with, and besides the + * Shape interface has no way to translate). + */ + protected Rectangle getBubbleSize (int type, Dimension d) + { + switch (modeOf(type)) { + case SHOUT: + case THINK: + case EMOTE: + // extra room for these two monsters + return new Rectangle(d.width + (PAD * 4), d.height + (PAD * 4)); + + default: + return new Rectangle(d.width + (PAD * 2), d.height + (PAD * 2)); + } + } + + /** + * Position the label based on the type. + */ + protected Point adjustLabel (int type, Point labelpos) + { + switch (modeOf(type)) { + case SHOUT: + case EMOTE: + case THINK: + labelpos.translate(PAD * 2, PAD * 2); + break; + + default: + labelpos.translate(PAD, PAD); + break; + } + + return labelpos; + } + + /** + * Position the rectangle in its ideal location given the type and speaker positon (which may + * be null). + */ + protected void positionRectIdeally (Rectangle r, int type, Point speaker) + { + if (speaker != null) { + // center it on top of the speaker (it'll be moved..) + r.setLocation(speaker.x - (r.width / 2), + speaker.y - (r.height / 2)); + return; + } + + // otherwise we have different areas for different types + Rectangle vbounds = _target.getViewBounds(); + switch (placeOf(type)) { + case INFO: + case ATTENTION: + // upper left + r.setLocation(vbounds.x + BUBBLE_SPACING, + vbounds.y + BUBBLE_SPACING); + return; + + case PLACE: + log.warning("Got to a place where I shouldn't get!"); + break; // fall through + } + + // put it in the center.. + log.debug("Unhandled chat type in getLocation()", "type", type); + r.setLocation((vbounds.width - r.width) / 2, + (vbounds.height - r.height) / 2); + } + + /** + * Get a rectangle based on the old bubbles, but with room for the new one. + */ + protected Rectangle getRectWithOlds (Rectangle r, List oldbubs) + { + int n = oldbubs.size(); + // if no old bubs, just return the new one. + if (n == 0) { + return r; + } + + // otherwise, encompass all the oldies + Rectangle bigR = null; + for (int ii=0; ii < n; ii++) { + BubbleGlyph bub = oldbubs.get(ii); + if (ii == 0) { + bigR = bub.getBubbleBounds(); + } else { + bigR = bigR.union(bub.getBubbleBounds()); + } + } + + // and add space for the new boy + bigR.width = Math.max(bigR.width, r.width); + bigR.height += r.height; + + return bigR; + } + + /** + * Get the appropriate shape for the specified type of chat. + */ + protected Shape getBubbleShape (int type, Rectangle r) + { + switch (placeOf(type)) { + case INFO: + case ATTENTION: + // boring rectangle wrapped in an Area for translation + return new Area(r); + } + + switch (modeOf(type)) { + case SPEAK: + // a rounded rectangle balloon, put in an Area so that it's + // translatable + return new Area(new RoundRectangle2D.Float( + r.x, r.y, r.width, r.height, PAD * 4, PAD * 4)); + + case SHOUT: { + // spikey balloon + Polygon left = new Polygon(), right = new Polygon(); + Polygon top = new Polygon(), bot = new Polygon(); + + int x = r.x + PAD; + int y = r.y + PAD; + int wid = r.width - PAD * 2; + int hei = r.height - PAD * 2; + Area a = new Area(new Rectangle(x, y, wid, hei)); + int spikebase = 10; + int cornbase = spikebase*3/4; + + // configure spikes to the left and right sides + left.addPoint(x, y); + left.addPoint(x - PAD, y + spikebase/2); + left.addPoint(x, y + spikebase); + right.addPoint(x + wid, y); + right.addPoint(x + wid + PAD, y + spikebase/2); + right.addPoint(x + wid, y + spikebase); + + // add the left and right side spikes + int ypos = 0; + int ahei = hei - cornbase; + int maxpos = ahei - spikebase + 1; + int numvert = (int) Math.ceil(ahei / ((float) spikebase)); + for (int ii=0; ii < numvert; ii++) { + int newpos = cornbase/2 + + Math.min((ahei * ii) / numvert, maxpos); + left.translate(0, newpos - ypos); + right.translate(0, newpos - ypos); + a.add(new Area(left)); + a.add(new Area(right)); + ypos = newpos; + } + + // configure spikes for the top and bottom + top.addPoint(x, y); + top.addPoint(x + spikebase/2, y - PAD); + top.addPoint(x + spikebase, y); + bot.addPoint(x, y + hei); + bot.addPoint(x + spikebase/2, y + hei + PAD); + bot.addPoint(x + spikebase, y + hei); + + // add top and bottom spikes + int xpos = 0; + int awid = wid - cornbase; + maxpos = awid - spikebase + 1; + int numhorz = (int) Math.ceil(awid / ((float) spikebase)); + for (int ii=0; ii < numhorz; ii++) { + int newpos = cornbase/2 + + Math.min((awid * ii) / numhorz, maxpos); + top.translate(newpos - xpos, 0); + bot.translate(newpos - xpos, 0); + a.add(new Area(top)); + a.add(new Area(bot)); + xpos = newpos; + } + + // and lets also add corner spikes + Polygon corner = new Polygon(); + corner.addPoint(x, y + cornbase); + corner.addPoint(x - PAD + 2, y - PAD + 2); + corner.addPoint(x + cornbase, y); + a.add(new Area(corner)); + + corner.reset(); + corner.addPoint(x + wid - cornbase, y); + corner.addPoint(x + wid + PAD - 2, y - PAD + 2); + corner.addPoint(x + wid, y + cornbase); + a.add(new Area(corner)); + + corner.reset(); + corner.addPoint(x + wid, y + hei - cornbase); + corner.addPoint(x + wid + PAD - 2, y + hei + PAD - 2); + corner.addPoint(x + wid - cornbase, y + hei); + a.add(new Area(corner)); + + corner.reset(); + corner.addPoint(x + cornbase, y + hei); + corner.addPoint(x - PAD + 2, y + hei + PAD - 2); + corner.addPoint(x, y + hei - cornbase); + a.add(new Area(corner)); + // grunt work! + + return a; + } + + case EMOTE: { + // a box that curves inward on all sides + Area a = new Area(r); + a.subtract(new Area(new Ellipse2D.Float(r.x, r.y - PAD, r.width, PAD * 2))); + a.subtract(new Area(new Ellipse2D.Float(r.x, r.y + r.height - PAD, r.width, PAD * 2))); + a.subtract(new Area(new Ellipse2D.Float(r.x - PAD, r.y, PAD * 2, r.height))); + a.subtract(new Area(new Ellipse2D.Float(r.x + r.width - PAD, r.y, PAD * 2, r.height))); + return a; + } + + case THINK: { + // cloudy balloon! + int x = r.x + PAD; + int y = r.y + PAD; + int wid = r.width - PAD * 2; + int hei = r.height - PAD * 2; + Area a = new Area(new Rectangle(x, y, wid, hei)); + + // small circles on the left and right + int dia = 12; + int numvert = (int) Math.ceil(hei / ((float) dia)); + int leftside = x - dia/2; + int rightside = x + wid - (dia/2) - 1; + int maxh = hei - dia; + for (int ii=0; ii < numvert; ii++) { + int ypos = y + Math.min((hei * ii) / numvert, maxh); + a.add(new Area(new Ellipse2D.Float(leftside, ypos, dia, dia))); + a.add(new Area(new Ellipse2D.Float(rightside, ypos, dia, dia))); + } + + // larger ovals on the top and bottom + dia = 16; + int numhorz = (int) Math.ceil(wid / ((float) dia)); + int topside = y - dia/3; + int botside = y + hei - (dia/3) - 1; + int maxw = wid - dia; + for (int ii=0; ii < numhorz; ii++) { + int xpos = x + Math.min((wid * ii) / numhorz, maxw); + a.add(new Area(new Ellipse2D.Float(xpos, topside, dia, dia*2/3))); + a.add(new Area(new Ellipse2D.Float(xpos, botside, dia, dia*2/3))); + } + + return a; + } + } + + // fall back to subtitle shape + return getSubtitleShape(type, r, r); + } + + /** + * Create a tail to the specified rectangular area from the speaker point. + */ + protected Shape getTail (int type, Rectangle r, Point speaker) + { + // emotes don't actually have tails + if (modeOf(type) == EMOTE) { + return new Area(); // empty shape + } + + int midx = r.x + (r.width / 2); + int midy = r.y + (r.height / 2); + + // we actually want to start about SPEAKER_DISTANCE away from the + // speaker + int xx = speaker.x - midx; + int yy = speaker.y - midy; + float dist = (float) Math.sqrt(xx * xx + yy * yy); + float perc = (dist - SPEAKER_DISTANCE) / dist; + + if (modeOf(type) == THINK) { + int steps = Math.max((int) (dist / SPEAKER_DISTANCE), 2); + float step = perc / steps; + Area a = new Area(); + for (int ii=0; ii < steps; ii++, perc -= step) { + int radius = Math.min(SPEAKER_DISTANCE / 2 - 1, ii + 2); + a.add(new Area(new Ellipse2D.Float( + (int) ((1 - perc) * midx + perc * speaker.x) + perc * radius, + (int) ((1 - perc) * midy + perc * speaker.y) + perc * radius, + radius * 2, radius * 2))); + } + + return a; + } + + // ELSE draw a triangular tail shape + Polygon p = new Polygon(); + p.addPoint((int) ((1 - perc) * midx + perc * speaker.x), + (int) ((1 - perc) * midy + perc * speaker.y)); + + if (Math.abs(speaker.x - midx) > Math.abs(speaker.y - midy)) { + int x; + if (midx > speaker.x) { + x = r.x + PAD; + } else { + x = r.x + r.width - PAD; + } + p.addPoint(x, midy - (TAIL_WIDTH / 2)); + p.addPoint(x, midy + (TAIL_WIDTH / 2)); + + } else { + int y; + if (midy > speaker.y) { + y = r.y + PAD; + } else { + y = r.y + r.height - PAD; + } + p.addPoint(midx - (TAIL_WIDTH / 2), y); + p.addPoint(midx + (TAIL_WIDTH / 2), y); + } + + return p; + } + + /** + * Expire a bubble, if necessary, and return the old bubbles for the specified speaker. + */ + protected List getAndExpireBubbles (Name speaker) + { + int num = _bubbles.size(); + + // first, get all the old bubbles belonging to the user + List oldbubs = Lists.newArrayList(); + if (speaker != null) { + for (int ii=0; ii < num; ii++) { + BubbleGlyph bub = _bubbles.get(ii); + if (bub.isSpeaker(speaker)) { + oldbubs.add(bub); + } + } + } + + // see if we need to expire this user's oldest bubble + if (oldbubs.size() >= MAX_BUBBLES_PER_USER) { + BubbleGlyph bub = oldbubs.remove(0); + _bubbles.remove(bub); + _target.abortAnimation(bub); + + // or some other old bubble + } else if (num >= MAX_BUBBLES) { + _target.abortAnimation(_bubbles.remove(0)); + } + + // return the speaker's old bubbles + return oldbubs; + } + + @Override + protected void glyphExpired (ChatGlyph glyph) + { + super.glyphExpired(glyph); + _bubbles.remove(glyph); + } + + /** + * Get a label formatted as close to the golden ratio as possible for the specified text and + * given the standard padding we use on all bubbles. + */ + protected Label layoutText (Graphics2D gfx, Font font, String text) + { + Label label = createLabel(text); + label.setFont(font); + + // layout in one line + Rectangle vbounds = _target.getViewBounds(); + label.setTargetWidth(vbounds.width - PAD * 2); + label.layout(gfx); + Dimension d = label.getSize(); + + // if the label is wide enough, try to split the text into multiple + // lines + if (d.width > MINIMUM_SPLIT_WIDTH) { + int targetheight = getGoldenLabelHeight(d); + if (targetheight > 1) { + label.setTargetHeight(targetheight * d.height); + label.layout(gfx); + } + } + return label; + } + + /** + * Given the specified label dimensions, attempt to find the height that will give us the + * width/height ratio that is closest to the golden ratio. + */ + protected int getGoldenLabelHeight (Dimension d) + { + // compute the ratio of the one line (addin' the paddin') + double lastratio = ((double) d.width + (PAD * 2)) / + ((double) d.height + (PAD * 2)); + + // now try increasing the # of lines and seeing if we get closer to the golden ratio + for (int lines=2; true; lines++) { + double ratio = ((double) (d.width / lines) + (PAD * 2)) / + ((double) (d.height * lines) + (PAD * 2)); + if (Math.abs(ratio - GOLDEN) < Math.abs(lastratio - GOLDEN)) { + // we're getting closer + lastratio = ratio; + } else { + // we're getting further away, the last one was the one we want + return lines - 1; + } + } + } + + /** + * Return a list of rectangular areas that we should avoid while laying out a bubble for the + * specified speaker. + */ + protected List getAvoidList (Name speaker) + { + List avoid = Lists.newArrayList(); + if (_provider == null) { + return avoid; + } + + // for now we don't accept low-priority avoids + _provider.getAvoidables(speaker, avoid, null); + + // add the existing chatbub non-tail areas from other speakers + for (BubbleGlyph bub : _bubbles) { + if (!bub.isSpeaker(speaker)) { + avoid.add(bub.getBubbleTerritory()); + } + } + + return avoid; + } + + @Override + protected int getDisplayDurationIndex () + { + // normalize the duration returned by super. Annoying. + return super.getDisplayDurationIndex() - 1; + } + + /** + * A glyph of a particlar chat bubble + */ + protected static class BubbleGlyph extends ChatGlyph + { + /** + * Construct a chat bubble glyph. + * + * @param sansTail the chat bubble shape without the tail. + */ + public BubbleGlyph ( + SubtitleChatOverlay owner, int type, long lifetime, Shape shape, Label label, + Point labelpos, Shape sansTail, Name speaker, Color outline) { + super(owner, type, lifetime, shape.getBounds(), shape, null, null, + label, labelpos, outline); + + _sansTail = sansTail; + _speaker = speaker; + } + + public void setAgeLevel (int agelevel) { + _agelevel = agelevel; + invalidate(); + } + + @Override + public void viewDidScroll (int dx, int dy) { + // only system info and attention messages remain fixed, all others scroll + if ((_type == INFO) || (_type == ATTENTION)) { + translate(dx, dy); + } + } + + @Override + protected Color getBackground () { + if (_background == Color.WHITE) { + return BACKGROUNDS[_agelevel]; + } else { + return _background; + } + } + + /** + * Get the screen real estate that this bubble has reserved and doesn't want to let any + * other bubbles take. + */ + public Shape getBubbleTerritory () { + Rectangle bounds = getBubbleBounds(); + bounds.grow(BUBBLE_SPACING, BUBBLE_SPACING); + return bounds; + } + + /** + * Get the bounds of this bubble, sans tail space. + */ + public Rectangle getBubbleBounds () { + return _sansTail.getBounds(); + } + + /** + * Is the specified player the speaker of this bubble? + */ + public boolean isSpeaker (Name player) { + return (_speaker != null) && _speaker.equals(player); + } + + /** + * Remove the tail on this bubble, if any. + */ + public void removeTail () { + invalidate(); + _shape = _sansTail; + _bounds = _shape.getBounds(); + jiggleBounds(); + invalidate(); + } + + /** The shape of this chat bubble, without the tail. */ + protected Shape _sansTail; + + /** The name of the speaker. */ + protected Name _speaker; + + /** The age level of the bubble, used to pick the background color. */ + protected int _agelevel = 0; + } + + /** The place in our history at which we last entered a new place. */ + protected int _newPlacePoint = 0; + + /** The currently displayed bubble areas. */ + protected List _bubbles = Lists.newArrayList(); + + /** The minimum width of a bubble's label before we consider splitting lines. */ + protected static final int MINIMUM_SPLIT_WIDTH = 90; + + /** The golden ratio. */ + protected static final double GOLDEN = (1.0d + Math.sqrt(5.0d)) / 2.0d; + + /** The space we force between adjacent bubbles. */ + protected static final int BUBBLE_SPACING = 15; + + /** The distance to stay from the speaker. */ + protected static final int SPEAKER_DISTANCE = 20; + + /** The width of the end of the tail. */ + protected static final int TAIL_WIDTH = 12; + + /** The maximum number of bubbles to show. */ + protected static final int MAX_BUBBLES = 8; + + /** The maximum number of bubbles to show per user. */ + protected static final int MAX_BUBBLES_PER_USER = 3; + + /** The background colors to use when drawing bubbles. */ + protected static final Color[] BACKGROUNDS = new Color[MAX_BUBBLES]; + static { + Color yellowy = new Color(0xdd, 0xdd, 0x6a); + Color blackish = new Color(0xcccccc); + + float steps = (MAX_BUBBLES - 1) / 2; + for (int ii=0; ii < MAX_BUBBLES / 2; ii++) { + BACKGROUNDS[ii] = ColorUtil.blend(Color.white, yellowy, (steps - ii) / steps); + } + for (int ii= MAX_BUBBLES / 2; ii < MAX_BUBBLES; ii++) { + BACKGROUNDS[ii] = ColorUtil.blend(blackish, yellowy, (ii - steps) / steps); + } + } +} diff --git a/src/java/com/threerings/chat/SubtitleChatOverlay.java b/src/java/com/threerings/chat/SubtitleChatOverlay.java new file mode 100644 index 00000000..c4474354 --- /dev/null +++ b/src/java/com/threerings/chat/SubtitleChatOverlay.java @@ -0,0 +1,1177 @@ +// +// $Id$ +// +// Vilya library - tools for developing networked games +// Copyright (C) 2002-2010 Three Rings Design, Inc., All Rights Reserved +// http://www.threerings.net/code/vilya/ +// +// This library is free software; you can redistribute it and/or modify it +// under the terms of the GNU Lesser General Public License as published +// by the Free Software Foundation; either version 2.1 of the License, or +// (at your option) any later version. +// +// This library 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 +// Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public +// License along with this library; if not, write to the Free Software +// Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +package com.threerings.chat; + +import java.util.Iterator; +import java.util.List; + +import java.awt.Color; +import java.awt.Dimension; +import java.awt.Font; +import java.awt.Graphics2D; +import java.awt.Point; +import java.awt.Polygon; +import java.awt.Rectangle; +import java.awt.Shape; +import java.awt.geom.Arc2D; +import java.awt.geom.Area; +import java.awt.geom.Ellipse2D; + +import javax.swing.BoundedRangeModel; +import javax.swing.Icon; +import javax.swing.JScrollBar; +import javax.swing.UIManager; +import javax.swing.event.ChangeEvent; +import javax.swing.event.ChangeListener; + +import com.google.common.collect.Lists; + +import com.samskivert.swing.Label; +import com.samskivert.util.Tuple; + +import com.threerings.media.VirtualMediaPanel; +import com.threerings.util.MessageBundle; + +import com.threerings.crowd.chat.client.HistoryList; +import com.threerings.crowd.chat.data.ChatCodes; +import com.threerings.crowd.chat.data.ChatMessage; +import com.threerings.crowd.chat.data.SystemMessage; +import com.threerings.crowd.chat.data.TellFeedbackMessage; +import com.threerings.crowd.chat.data.UserMessage; +import com.threerings.crowd.util.CrowdContext; + +import static com.threerings.NenyaLog.log; + +/** + * Implements subtitle chat. + */ +public class SubtitleChatOverlay extends ChatOverlay + implements ChangeListener, HistoryList.Observer +{ + /** The default chat decay parameter. See {@link #getDisplayDurationIndex}. */ + public static final int DEFAULT_CHAT_DECAY = 1; + + /** + * Construct a subtitle chat overlay. + * + * @param subtitleHeight the height of the subtitle area. + */ + public SubtitleChatOverlay (CrowdContext ctx, JScrollBar bar, int subtitleHeight) + { + this(ctx, bar, subtitleHeight, false, 8, 8); + } + + /** + * Construct a comic chat overlay using all the available space for subtitling. + */ + public SubtitleChatOverlay (CrowdContext ctx, JScrollBar bar) + { + this(ctx, bar, false); + } + + public SubtitleChatOverlay (CrowdContext ctx, JScrollBar bar, boolean full) + { + this(ctx, bar, 0, true, full ? 8 : 3, full ? 8 : 4); + } + + public SubtitleChatOverlay (CrowdContext ctx, JScrollBar bar, boolean full, + boolean overrideHistory) + { + this(ctx, bar, full); + _overrideHistory = overrideHistory; + } + + // from interface HistoryList.Observer + public void historyUpdated (int adjustment) + { + if (adjustment != 0) { + for (int ii = 0, nn = _showingHistory.size(); ii < nn; ii++) { + ChatGlyph cg = _showingHistory.get(ii); + cg.histIndex -= adjustment; + } + // some history entries were deleted, we need to re-figure the history scrollbar action + resetHistoryOffset(); + } + + if (isLaidOut() && isHistoryMode()) { + int val = _historyModel.getValue(); + updateHistBar(val - adjustment); + + // only repaint if we need to + if ((val != _historyModel.getValue()) || (adjustment != 0) || !_histOffsetFinal) { + figureCurrentHistory(); + } + } + } + + // documentation inherited from interface ChangeListener + public void stateChanged (ChangeEvent e) + { + // the scrollbar has changed. + if (!_settingBar) { + figureCurrentHistory(); + } + } + + // documentation inherited from superinterface ChatDisplay + public void clear () + { + clearGlyphs(_subtitles); + } + + // documentation inherited from superinterface ChatDisplay + public boolean displayMessage (ChatMessage message, boolean alreadyDisplayed) + { + // nothing doing if we've not been laid out + if (!isLaidOut()) { + return false; + } + + // possibly display it now + Graphics2D gfx = getTargetGraphics(); + if (gfx != null) { + displayMessage(message, gfx); // display it + gfx.dispose(); // clean up + return true; + } + return false; + } + + @Override + public void viewDidScroll (int dx, int dy) + { + super.viewDidScroll(dx, dy); + viewDidScroll(_subtitles, dx, dy); + viewDidScroll(_showingHistory, dx, dy); + } + + @Override + public void added (VirtualMediaPanel target) + { + super.added(target); + + _history.addObserver(this); + + if (_overrideHistory) { + setHistoryEnabled(true); + return; + } + + // derived classes may want to override this method and set up chat history mode based on + // whatever preference storage mechanism they use + } + + @Override + public void layout () + { + // sanity check + if (_target == null) { + log.warning(this + " laid out without target?", new Exception()); + return; + } + + Rectangle vbounds = _target.getViewBounds(); + if ((vbounds.height < 1) || (vbounds.width < 1)) { + // fuck that! + return; + } + + clearGlyphs(_subtitles); // we'll re-populate from the history + + if (_subtitlesFill) { + _subtitleHeight = vbounds.height; + } + + // make a guess as to the extent of the history (how many avg sized subtitles will fit in + // the subtitle area) + _historyExtent = ((_subtitleHeight - _subtitleYSpacing) / SUBTITLE_HEIGHT_GUESS); + _scrollbar.setBlockIncrement(_historyExtent); + + // show messages that were born recently enough to be shown. + long now = System.currentTimeMillis(); + // find the first message to display + int histSize = _history.size(); + int index = histSize - 1; + for ( ; index >= 0; index--) { + ChatMessage msg = _history.get(index); + _lastExpire = 0L; + if (now > getChatExpire(msg.timestamp, msg.message)) { + break; + } + } + // now that we've found the message that's one too old, increment the index so that it + // points to the first message we should display + index++; + _lastExpire = 0L; + + // now dispatch from that point + Graphics2D gfx = getTargetGraphics(); + for ( ; index < histSize; index++) { + ChatMessage msg = _history.get(index); + if (shouldShowFromHistory(msg, index)) { + displayMessage(msg, gfx); + } + } + + // and clean up + gfx.dispose(); + + // make a note that we're laid out + _laidout = true; + + // reset the history offset.. + resetHistoryOffset(); + + // finally, if we're in history mode, we should figure that out too + if (isHistoryMode()) { + updateHistBar(histSize - 1); + figureCurrentHistory(); + } + } + + @Override + public void setDimmed (boolean dimmed) + { + super.setDimmed(dimmed); + updateDimmed(_subtitles); + updateDimmed(_showingHistory); + } + + @Override + public void removed () + { + // we need to do this before super so that our target is still around + clearGlyphs(_subtitles); + clearGlyphs(_showingHistory); + + super.removed(); + + _history.removeObserver(this); + + // clear out our history so that when we are once again added, we activate it and go + // through the motions of refiguring everything + setHistoryEnabled(false); + + // make a note that we'll need to lay ourselves out before we do anything fun next time + _laidout = false; + } + + /** + * Shared chained constructor. + */ + protected SubtitleChatOverlay (CrowdContext ctx, JScrollBar bar, int height, + boolean fill, int xspace, int yspace) + { + super(ctx); + + _scrollbar = bar; + _subtitleHeight = height; + _subtitlesFill = fill; + _subtitleXSpacing = xspace; + _subtitleYSpacing = yspace; + _history = ctx.getChatDirector().getHistory(); + } + + /** + * Update the chat glyphs in the specified list to be set to the current dimmed setting. + */ + protected void updateDimmed (List glyphs) + { + for (ChatGlyph glyph : glyphs) { + glyph.setDim(_dimmed); + } + } + + /** + * Are we currently in history mode? + */ + protected boolean isHistoryMode () + { + return (_historyModel != null); + } + + /** + * Return the current Graphics context of our target, or null if not applicable. + */ + protected Graphics2D getTargetGraphics () + { + // this may return null even if target is not null. + return (_target == null) ? null : (Graphics2D)_target.getGraphics(); + } + + /** + * Configures us for display of chat history or not. + */ + protected void setHistoryEnabled (boolean historyEnabled) + { + if (historyEnabled && _historyModel == null) { + _historyModel = _scrollbar.getModel(); + _historyModel.addChangeListener(this); + resetHistoryOffset(); + + // out with the subtitles, we'll be displaying history + clearGlyphs(_subtitles); + + // "scroll" down to the latest history entry + updateHistBar(_history.size() - 1); + + // refigure our history + figureCurrentHistory(); + + } else if (!historyEnabled && _historyModel != null) { + _historyModel.removeChangeListener(this); + _historyModel = null; + + // out with the history, we'll be displaying subtitles + clearGlyphs(_showingHistory); + } + } + + /** + * Helper function for informing glyphs of the scrolled view. + */ + protected void viewDidScroll (List glyphs, int dx, int dy) + { + for (ChatGlyph glyph : glyphs) { + glyph.viewDidScroll(dx, dy); + } + } + + /** + * Update the history scrollbar with the specified value. + */ + protected void updateHistBar (int val) + { + // we may need to figure out the new history offset amount.. + if (!_histOffsetFinal && _history.size() > _histOffset) { + Graphics2D gfx = getTargetGraphics(); + if (gfx != null) { + figureHistoryOffset(gfx); + gfx.dispose(); + } + } + + // then figure out the new value and range + int oldval = Math.max(_histOffset, val); + int newmaxval = Math.max(0, _history.size() - 1); + int newval = (oldval >= newmaxval - 1) ? newmaxval : oldval; + + // and set it, which MAY generate a change event, but we want to ignore it so we use the + // _settingBar flag + _settingBar = true; + _historyModel.setRangeProperties(newval, _historyExtent, _histOffset, + newmaxval + _historyExtent, + _historyModel.getValueIsAdjusting()); + _settingBar = false; + } + + /** + * Reset the history offset so that it will be recalculated next time it is needed. + */ + protected void resetHistoryOffset () + { + _histOffsetFinal = false; + _histOffset = 0; + } + + /** + * Figure out how many of the first history elements fit in our bounds such that we can set the + * bounds on the scrollbar correctly such that the scrolling to the smallest value just barely + * puts the first element onscreen. + */ + protected void figureHistoryOffset (Graphics2D gfx) + { + if (!isLaidOut()) { + return; + } + + int hei = _subtitleYSpacing; + int hsize = _history.size(); + for (int ii = 0; ii < hsize; ii++) { + ChatGlyph rec = getHistorySubtitle(ii, gfx); + Rectangle r = rec.getBounds(); + hei += r.height; + + // oop, we passed it, it was the last one + if (hei >= _subtitleHeight) { + _histOffset = Math.max(0, ii - 1); + _histOffsetFinal = true; + return; + } + + hei += getHistorySubtitleSpacing(ii); + } + + // basically, this means there isn't yet enough history to fill the first 'page' of the + // history scrollback, so we set the offset to the max value, but we do not set + // _histOffsetFinal to be true so that this will be recalculated + _histOffset = hsize - 1; + } + + /** + * Figure out which ChatMessages in the history should currently appear in the showing history. + */ + protected void figureCurrentHistory () + { + int first = _historyModel.getValue(); + int count = 0; + Graphics2D gfx = null; + + if (isLaidOut() && !_history.isEmpty()) { + gfx = getTargetGraphics(); + if (gfx == null) { + log.warning("Can't figure current history, no graphics."); + return; + } + + // start from the bottom.. + Rectangle vbounds = _target.getViewBounds(); + int ypos = vbounds.height - _subtitleYSpacing; + + for (int ii = first; ii >= 0; ii--, count++) { + ChatGlyph rec = getHistorySubtitle(ii, gfx); + + // see if it will fit + Rectangle r = rec.getBounds(); + ypos -= r.height; + if ((count != 0) && ypos <= (vbounds.height - _subtitleHeight)) { + break; // don't add that one.. + } + + // position it + rec.setLocation(vbounds.x + _subtitleXSpacing, vbounds.y + ypos); + // add space for the next + ypos -= getHistorySubtitleSpacing(ii); + } + } + + // finally, because we've been adding to the _showingHistory here (via getHistorySubtitle) + // and in figureHistoryOffset (possibly called prior to this method) we now need to prune + // out the ChatGlyphs that aren't actually needed and make sure the ones that are are + // positioned on the screen correctly + for (Iterator itr = _showingHistory.iterator(); itr.hasNext(); ) { + ChatGlyph cg = itr.next(); + boolean managed = (_target != null) && _target.isManaged(cg); + if (cg.histIndex <= first && cg.histIndex > (first - count)) { + // it should be showing + if (!managed) { + _target.addAnimation(cg); + } + + } else { + // it shouldn't be showing + if (managed) { + _target.abortAnimation(cg); + } + itr.remove(); + } + } + + if (gfx != null) { + gfx.dispose(); + } + } + + /** + * Get the glyph for the specified history index, creating if necessary. + */ + protected ChatGlyph getHistorySubtitle (int index, Graphics2D layoutGfx) + { + // do a brute search (over a small set) for an already-created subtitle + for (int ii = 0, nn = _showingHistory.size(); ii < nn; ii++) { + ChatGlyph cg = _showingHistory.get(ii); + if (cg.histIndex == index) { + return cg; + } + } + + // it looks like we have to create a new one: expensive! + ChatGlyph cg = createHistorySubtitle(index, layoutGfx); + cg.histIndex = index; + cg.setDim(_dimmed); + _showingHistory.add(cg); + return cg; + } + + /** + * Creates a subtitle for display in the history panel. + * + * @param index the index of the message in the history list + */ + protected ChatGlyph createHistorySubtitle (int index, Graphics2D layoutGfx) + { + ChatMessage message = _history.get(index); + int type = getType(message, true); + return createSubtitle(message, type, layoutGfx, false); + } + + /** + * Determines the amount of spacing to put after a history subtitle. + * + * @param index the index of the message in the history list + */ + protected int getHistorySubtitleSpacing (int index) + { + ChatMessage message = _history.get(index); + return getSubtitleSpacing(getType(message, true)); + } + + protected boolean isLaidOut () + { + return isShowing() && _laidout; + } + + /** + * We're looking through history to figure out what messages we should be showing, should we + * show the following? + */ + protected boolean shouldShowFromHistory (ChatMessage msg, int index) + { + return true; // yes by default. + } + + /** + * Clears out the supplied list of chat glyphs. + */ + protected void clearGlyphs (List glyphs) + { + if (_target != null) { + for (int ii = 0, nn = glyphs.size(); ii < nn; ii++) { + ChatGlyph rec = glyphs.get(ii); + _target.abortAnimation(rec); + } + + } else if (!glyphs.isEmpty()) { + log.warning("No target to abort chat animations"); + } + glyphs.clear(); + } + + /** + * Display the specified message now, unless we are to ignore it. + */ + protected void displayMessage (ChatMessage message, Graphics2D gfx) + { + // get the non-history message type... + int type = getType(message, false); + + if (type != IGNORECHAT) { + // display it now + displayMessage(message, type, gfx); + } + } + + /** + * Display the message after we've decided which type it is. + */ + protected void displayMessage ( + ChatMessage message, int type, Graphics2D layoutGfx) + { + // if we're in history mode, this will show up in the history and we'll rebuild our + // subtitle list if and when history goes away + if (isHistoryMode()) { + return; + } + + addSubtitle(createSubtitle(message, type, layoutGfx, true)); + } + + /** + * Add a subtitle for display now. + */ + protected void addSubtitle (ChatGlyph rec) + { + // scroll up the old subtitles + Rectangle r = rec.getBounds(); + scrollUpSubtitles(-r.height - getSubtitleSpacing(rec.getType())); + + // put this one in place + Rectangle vbounds = _target.getViewBounds(); + rec.setLocation(vbounds.x + _subtitleXSpacing, + vbounds.y + vbounds.height - _subtitleYSpacing - r.height); + + // add it to our list and to our media panel + rec.setDim(_dimmed); + _subtitles.add(rec); + _target.addAnimation(rec); + } + + /** + * Create a subtitle, but don't do anything funny with it. + */ + protected ChatGlyph createSubtitle (ChatMessage message, int type, Graphics2D layoutGfx, + boolean expires) + { + // we might need to modify the textual part with translations, but we can't do that to the + // message object, since other chatdisplays also get it. + String text = message.message; + Tuple finfo = decodeFormat(type, message.getFormat()); + String format = finfo.left; + boolean quotes = finfo.right; + + // now format the text + if (format != null) { + if (quotes) { + text = "\"" + text + "\""; + } + text = " " + text; + text = xlate(MessageBundle.tcompose( + format, ((UserMessage) message).getSpeakerDisplayName())) + text; + } + + return createSubtitle(layoutGfx, type, message.timestamp, null, 0, text, expires); + } + + /** + * Determines the format string and whether to use quotes based on the chat type. + */ + protected Tuple decodeFormat (int type, String format) + { + boolean quotes = true; + switch (placeOf(type)) { + // derived classes may wish to change the format here based on the place + case PLACE: + switch (modeOf(type)) { + case EMOTE: + quotes = false; + break; + } + break; + } + return Tuple.newTuple(format, quotes); + } + + /** + * Create a subtitle- a line of text that goes on the bottom. + */ + protected ChatGlyph createSubtitle (Graphics2D gfx, int type, long timestamp, Icon icon, + int indent, String text, boolean expires) + { + Dimension is = new Dimension(); + if (icon != null) { + is.setSize(icon.getIconWidth(), icon.getIconHeight()); + } + + Rectangle vbounds = _target.getViewBounds(); + Label label = createLabel(text); + label.setFont(getFont(type)); + int paddedIconWidth = (icon == null) ? 0 : is.width + ICON_PADDING; + label.setTargetWidth( + vbounds.width - indent - paddedIconWidth - + 2 * (_subtitleXSpacing + Math.max(UIManager.getInt("ScrollBar.width"), PAD))); + label.layout(gfx); + gfx.dispose(); + + Dimension ls = label.getSize(); + Rectangle r = new Rectangle(0, 0, ls.width + indent + paddedIconWidth, + Math.max(is.height, ls.height)); + r.grow(0, 1); + Point iconpos = r.getLocation(); + iconpos.translate(indent, r.height - is.height - 1); + Point labelpos = r.getLocation(); + labelpos.translate(indent + paddedIconWidth, 1); + Rectangle lr = new Rectangle(r.x + indent + paddedIconWidth, r.y, + r.width - indent - paddedIconWidth, ls.height + 2); + + // last a really long time if we're not supposed to expire + long lifetime = Integer.MAX_VALUE; + if (expires) { + lifetime = getChatExpire(timestamp, label.getText()) - timestamp; + } + Shape shape = getSubtitleShape(type, lr, r); + return new ChatGlyph(this, type, lifetime, r.union(shape.getBounds()), shape, icon, + iconpos, label, labelpos, getOutlineColor(type)); + } + + /** + * Get the expire time for the specified chat. + */ + protected long getChatExpire (long timestamp, String text) + { + long[] durations = DISPLAY_DURATION_PARAMS[getDisplayDurationIndex()]; + // start the computation from the maximum of the timestamp or our last expire time + long start = Math.max(timestamp, _lastExpire); + // set the next expire to a time proportional to the text length + _lastExpire = start + Math.min(text.length() * durations[0], durations[2]); + // but don't let it be longer than the maximum display time + _lastExpire = Math.min(timestamp + durations[2], _lastExpire); + // and be sure to pop up the returned time so that it is above the min + return Math.max(timestamp + durations[1], _lastExpire); + } + + /** + * Called by a chat glyph when it has determined that it is expired. + */ + protected void glyphExpired (ChatGlyph glyph) + { + _subtitles.remove(glyph); + } + + /** + * Convert the Message class/localtype/mode into our internal type code. + */ + protected int getType (ChatMessage message, boolean history) + { + String localtype = message.localtype; + + if (message instanceof TellFeedbackMessage) { + if (((TellFeedbackMessage)message).isFailure()) { + return FEEDBACK; + } + return (history || isApprovedLocalType(localtype)) ? TELLFEEDBACK : IGNORECHAT; + + } else if (message instanceof UserMessage) { + int type = decodeType(localtype); + // factor in the mode + if (type != 0) { + type = adjustTypeByMode(((UserMessage) message).mode, type); + } + // if we're showing from history, include specialized chat messages + if (history) { + return SPECIALIZED; + } + + } else if (message instanceof SystemMessage) { + if (history || isApprovedLocalType(localtype)) { + switch (((SystemMessage) message).attentionLevel) { + case SystemMessage.INFO: + return INFO; + case SystemMessage.FEEDBACK: + return FEEDBACK; + case SystemMessage.ATTENTION: + return ATTENTION; + default: + log.warning("Unknown attention level for system message", "msg", message); + } + } + return IGNORECHAT; + } + + log.warning("Skipping received message of unknown type", "msg", message); + return IGNORECHAT; + } + + /** + * Decoes the main chat type given the supplied localtype provided by the chat system. + */ + protected int decodeType (String localtype) + { + if (ChatCodes.USER_CHAT_TYPE.equals(localtype)) { + return TELL; + } else if (ChatCodes.PLACE_CHAT_TYPE.equals(localtype)) { + return PLACE; + } else { + return 0; + } + } + + /** + * Adjust the chat type based on the mode of the chat message. + */ + protected int adjustTypeByMode (int mode, int type) + { + switch (mode) { + case ChatCodes.DEFAULT_MODE: + return type | SPEAK; + case ChatCodes.EMOTE_MODE: + return type | EMOTE; + case ChatCodes.THINK_MODE: + return type | THINK; + case ChatCodes.SHOUT_MODE: + return type | SHOUT; + case ChatCodes.BROADCAST_MODE: + return BROADCAST; // broadcast always looks like broadcast + default: + return type; + } + } + + /** + * Check to see if we want to display the specified localtype. + */ + protected boolean isApprovedLocalType (String localtype) + { + return true; // we show everything, the ComicChat is a little more picky + } + + /** + * Get the spacing, in pixels, between the latest subtitle of the specified type and the + * previous subtitle. + */ + protected int getSubtitleSpacing (int type) + { + switch (placeOf(type)) { + // derived classes may wish to adjust subtitle spacing here based on chat type + default: + return 1; + } + } + + /** + * Scroll all the subtitles up by the specified amount. + */ + protected void scrollUpSubtitles (int dy) + { + // dirty and move all the old glyphs + Rectangle vbounds = _target.getViewBounds(); + int miny = vbounds.y + vbounds.height - _subtitleHeight; + for (Iterator iter = _subtitles.iterator(); iter.hasNext();) { + ChatGlyph sub = iter.next(); + sub.translate(0, dy); + if (sub.getBounds().y <= miny) { + iter.remove(); + _target.abortAnimation(sub); + } + } + } + + /** + * Get the font to use for the given bubble type. + */ + protected Font getFont (int type) + { + return DEFAULT_FONT; + } + + /** + * Creates a label for the specified text. Derived classes may wish to use specialized labels. + */ + protected Label createLabel (String text) + { + return new Label(text); + } + + /** + * Get the appropriate shape for the specified type of chat. + * + * @param r the rectangle bounding the chat label. + * @param b the rectangle bounding the chat label and icon. + */ + protected Shape getSubtitleShape (int type, Rectangle r, Rectangle b) + { + int placeType = placeOf(type); + switch (placeType) { + case SPECIALIZED: + case PLACE: + return getPlaceSubtitleShape(type, r); + + case TELL: { + // lightning box! + int halfy = r.y + r.height/2; + Polygon p = new Polygon(); + p.addPoint(r.x - PAD - 2, r.y); + p.addPoint(r.x - PAD / 2 - 2, halfy); + p.addPoint(r.x - PAD, halfy); + p.addPoint(r.x - PAD / 2, r.y + r.height); + p.addPoint(r.x + r.width + PAD + 2, r.y + r.height); + p.addPoint(r.x + r.width + PAD / 2 + 2, halfy); + p.addPoint(r.x + r.width + PAD, halfy); + p.addPoint(r.x + r.width + PAD / 2, r.y); + return p; + } + + case TELLFEEDBACK: { + // reverse-lightning box! + int halfy = r.y + r.height/2; + Polygon p = new Polygon(); + p.addPoint(r.x - PAD - 2, r.y + r.height); + p.addPoint(r.x - PAD / 2 - 2, halfy); + p.addPoint(r.x - PAD, halfy); + p.addPoint(r.x - PAD / 2, r.y); + p.addPoint(r.x + r.width + PAD + 2, r.y); + p.addPoint(r.x + r.width + PAD / 2 + 2, halfy); + p.addPoint(r.x + r.width + PAD, halfy); + p.addPoint(r.x + r.width + PAD / 2, r.y + r.height); + return p; + } + + case FEEDBACK: { + // slanted box subtitle + Polygon p = new Polygon(); + p.addPoint(r.x - PAD / 2, r.y); + p.addPoint(r.x + r.width + PAD, r.y); + p.addPoint(r.x + r.width + PAD / 2, r.y + r.height); + p.addPoint(r.x - PAD, r.y + r.height); + return p; + } + + case BROADCAST: + case CONTINUATION: + case INFO: + case ATTENTION: + default: { + Rectangle grown = new Rectangle(r); + grown.grow(PAD, 0); + return new Area(grown); + } + } + } + + /** + * Computes the chat glyph outline color from the chat message type. + */ + protected Color getOutlineColor (int type) + { + switch (type) { + case BROADCAST: + return BROADCAST_COLOR; + case TELL: + return TELL_COLOR; + case TELLFEEDBACK: + return TELLFEEDBACK_COLOR; + case INFO: + return INFO_COLOR; + case FEEDBACK: + return FEEDBACK_COLOR; + case ATTENTION: + return ATTENTION_COLOR; + default: + return Color.black; + } + } + + /** + * A helper function for {@link #getSubtitleShape}. + */ + protected Shape getPlaceSubtitleShape (int type, Rectangle r) + { + switch (modeOf(type)) { + default: + case SPEAK: { + // rounded rectangle subtitle + Area a = new Area(r); + a.add(new Area(new Ellipse2D.Float(r.x - PAD, r.y, PAD * 2, r.height))); + a.add(new Area(new Ellipse2D.Float(r.x + r.width - PAD, r.y, PAD * 2, r.height))); + return a; + } + + case THINK: { + r.grow(PAD / 2, 0); + Area a = new Area(r); + int dia = 8; + int num = (int) Math.ceil(r.height / ((float) dia)); + int leftside = r.x - dia/2; + int rightside = r.x + r.width - dia/2 - 1; + + int maxh = r.height - dia; + for (int ii=0; ii < num; ii++) { + int ypos = r.y + Math.min((r.height * ii) / num, maxh); + a.add(new Area(new Ellipse2D.Float(leftside, ypos, dia, dia))); + a.add(new Area(new Ellipse2D.Float(rightside, ypos, dia, dia))); + } + return a; + } + + case SHOUT: { + r.grow(PAD / 2, 0); + Area a = new Area(r); + Polygon left = new Polygon(); + Polygon right = new Polygon(); + + int spikehei = 8; + int num = (int) Math.ceil(r.height / ((float) spikehei)); + left.addPoint(r.x, r.y); + left.addPoint(r.x - PAD, r.y + spikehei / 2); + left.addPoint(r.x, r.y + spikehei); + right.addPoint(r.x + r.width , r.y); + right.addPoint(r.x + r.width + PAD, r.y + spikehei / 2); + right.addPoint(r.x + r.width, r.y + spikehei); + + int ypos = 0; + int maxpos = r.y + r.height - spikehei + 1; + for (int ii=0; ii < num; ii++) { + int newpos = Math.min((r.height * ii) / num, maxpos); + left.translate(0, newpos - ypos); + right.translate(0, newpos - ypos); + a.add(new Area(left)); + a.add(new Area(right)); + ypos = newpos; + } + + return a; + } + + case EMOTE: { + // a box that curves inward on the left and right + r.grow(PAD, 0); + Area a = new Area(r); + a.subtract(new Area(new Ellipse2D.Float(r.x - PAD / 2, r.y, PAD, r.height))); + a.subtract(new Area(new Ellipse2D.Float(r.x + r.width - PAD / 2, r.y, PAD, r.height))); + return a; + } + } + } + + /** + * Extract the mode constant from the type value. + */ + protected int modeOf (int type) + { + return type & 0xF; + } + + /** + * Extract the place constant from the type value. + */ + protected int placeOf (int type) + { + return type & ~0xF; + } + + /** + * Get the current display duration parameters: an integer: 0 = fast, 1 = medium, 2 = long. + * See {@link #DISPLAY_DURATION_PARAMS}. + */ + protected int getDisplayDurationIndex () + { + // by default we add one, because it's assumed that we're in subtitle-only view + return DEFAULT_CHAT_DECAY + 1; + } + + /** List of existing messages from our chat director. */ + protected HistoryList _history; + + /** If set, show history no matter what the client prefs say. */ + protected boolean _overrideHistory; + + /** The currently displayed subtitles O' history. */ + protected List _showingHistory = Lists.newArrayList(); + + /** Our history scrollbar. */ + protected JScrollBar _scrollbar; + + /** Tracks whether or not we've been laid out. */ + protected boolean _laidout; + + /** If we're in history mode, this will be non-null and will notify + * us of our historical positioning. */ + protected BoundedRangeModel _historyModel = null; + + /** The currently displayed subtitle areas. */ + protected List _subtitles = Lists.newArrayList(); + + /** The amount of vertical space to use for subtitles. */ + protected int _subtitleHeight; + + /** If true, subtitles should fill all available height. */ + protected boolean _subtitlesFill; + + /** The amount of space we want around the subtitles. */ + protected int _subtitleXSpacing; + protected int _subtitleYSpacing; + + /** The unbounded expire time of the last chat glyph displayed. */ + protected long _lastExpire; + + /** If the history offset we've figured is all figured out or needs to be refigured. */ + protected boolean _histOffsetFinal = false; + + /** If true, we're the ones updating the history scrollbar and change + * events should be ignored. */ + boolean _settingBar = false; + + /** The history offset (from 0) such that the history lines (0, _histOffset - 1) will all fit + * onscreen if the lowest scrollbar position is _histOffset. */ + protected int _histOffset = 0; + + /** A guess of how many history lines fit onscreen at a time. */ + protected int _historyExtent; + + /** Our default chat font. */ + protected static final Font DEFAULT_FONT = new Font("SansSerif", Font.PLAIN, 12); + + /** A guess as to the height of a subtitle (plus spacing). */ + protected static final int SUBTITLE_HEIGHT_GUESS = 16; + + /** The padding in each direction around the text to the edges of a chat 'bubble'. */ + protected static final int PAD = 10; + + /** The amount of space to insert between the icon and the text. */ + protected static final int ICON_PADDING = 4; + + /** + * Times to display chat. + * { (time per character), (min time), (max time) } + * + * Groups 0/1/2 are short/medium/long for chat bubbles, + * and groups 1/2/3 are short/medium/long for subtitles. + */ + protected static final long[][] DISPLAY_DURATION_PARAMS = { + { 125L, 10000L, 30000L }, // fastest durations + { 200L, 15000L, 40000L }, // medium (default) durations + { 275L, 20000L, 50000L }, // longest regular duration.. + { 350L, 25000L, 60000L } // grampatime! + }; + + /** Type mode code for default chat type (speaking). */ + protected static final int SPEAK = 0; + + /** Type mode code for shout chat type. */ + protected static final int SHOUT = 1; + + /** Type mode code for emote chat type. */ + protected static final int EMOTE = 2; + + /** Type mode code for think chat type. */ + protected static final int THINK = 3; + + /** Type place code for default place chat (cluster, scene). */ + protected static final int PLACE = 1 << 4; + + // 2 and 3 are skipped for legacy migration reasons + + /** Type code for a chat type that was used in some special context, + * like in a negotiation. */ + protected static final int SPECIALIZED = 4 << 4; + + /** Our internal code for tell chat. */ + protected static final int TELL = 5 << 4; + + /** Our internal code for tell feedback chat. */ + protected static final int TELLFEEDBACK = 6 << 4; + + /** Our internal code for info system messges. */ + protected static final int INFO = 7 << 4; + + /** Our internal code for feedback system messages. */ + protected static final int FEEDBACK = 8 << 4; + + /** Our internal code for attention system messages. */ + protected static final int ATTENTION = 9 << 4; + + /** Our internal code for any type of chat that is continued in a subtitle. */ + protected static final int CONTINUATION = 10 << 4; + + /** Type place code for broadcast chat type. */ + protected static final int BROADCAST = 11 << 4; + + /** Our internal code for a chat type we will ignore. */ + protected static final int IGNORECHAT = -1; + + // used to color chat bubbles + protected static final Color BROADCAST_COLOR = new Color(0x990000); + protected static final Color FEEDBACK_COLOR = new Color(0x00AA00); + protected static final Color TELL_COLOR = new Color(0x0000AA); + protected static final Color TELLFEEDBACK_COLOR = new Color(0x00AAAA); + protected static final Color INFO_COLOR = new Color(0xAAAA00); + protected static final Color ATTENTION_COLOR = new Color(0xFF5000); +}