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
This commit is contained in:
Michael Bayne
2010-10-14 16:49:41 +00:00
parent 9321b6159b
commit d0d4b1a28f
4 changed files with 2638 additions and 0 deletions
+309
View File
@@ -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;
}
@@ -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<Shape> high, List<Shape> 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;
}
@@ -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<BubbleGlyph> 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<BubbleGlyph> 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<BubbleGlyph> 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<BubbleGlyph> 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<BubbleGlyph> getAndExpireBubbles (Name speaker)
{
int num = _bubbles.size();
// first, get all the old bubbles belonging to the user
List<BubbleGlyph> 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<Shape> getAvoidList (Name speaker)
{
List<Shape> 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<BubbleGlyph> _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);
}
}
}
File diff suppressed because it is too large Load Diff