All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.threerings.chat.ComicChatOverlay Maven / Gradle / Ivy

The newest version!
//
// Nenya library - tools for developing networked games
// Copyright (C) 2002-2012 Three Rings Design, Inc., All Rights Reserved
// https://github.com/threerings/nenya
//
// 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, ChatLogic logic, JScrollBar historyBar,
                             int subtitleHeight)
    {
        super(ctx, logic, 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 (ChatLogic.placeOf(type)) == ChatLogic.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 (ChatLogic.placeOf(type)) {
        case ChatLogic.INFO:
        case ChatLogic.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 ChatLogic.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 (ChatLogic.modeOf(type) == ChatLogic.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, ChatLogic.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, _logic.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;
        }

        // 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, _logic.getOutlineColor(type));
        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 (ChatLogic.modeOf(type)) {
        case ChatLogic.SHOUT:
        case ChatLogic.THINK:
        case ChatLogic.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 (ChatLogic.modeOf(type)) {
        case ChatLogic.SHOUT:
        case ChatLogic.EMOTE:
        case ChatLogic.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 (ChatLogic.placeOf(type)) {
        case ChatLogic.INFO:
        case ChatLogic.ATTENTION:
            // upper left
            r.setLocation(vbounds.x + BUBBLE_SPACING,
                          vbounds.y + BUBBLE_SPACING);
            return;

        case ChatLogic.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 (ChatLogic.placeOf(type)) {
        case ChatLogic.INFO:
        case ChatLogic.ATTENTION:
            // boring rectangle wrapped in an Area for translation
            return new Area(r);
        }

        switch (ChatLogic.modeOf(type)) {
        case ChatLogic.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 ChatLogic.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 ChatLogic.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 ChatLogic.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 _logic.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 (ChatLogic.modeOf(type) == ChatLogic.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 (ChatLogic.modeOf(type) == ChatLogic.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 = _logic.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 getDisplayDurationOffset ()
    {
        return 0; // we don't do any funny hackery, unlike our super class
    }

    /**
     * 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 == ChatLogic.INFO) || (_type == ChatLogic.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);
        }
    }
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy