【Java】会話イベント用メッセージアニメーション

2018年3月11日

本稿はJavaでメッセージを順番に描画するアニメーション処理を作成してみたいと思います。

RPGなどでNPCキャラと、はなすときに流れる会話アニメーションを実装する時に使用することができるはずです。

メッセージアニメーションクラスの作成

メッセージアニメーションをするには毎フレーム描画する文字を増やすだけで実装できそうですが、途中で色を変更したり、文字のスタイルを変更する機能も欲しいです。

今回はメッセージスピード、文字列の自動折り返し、改行、途中でフォントの変更、文字色の変更ができることを目標として実装してみます。

これらの色とフォント実装には、個々の文字列に何かしらの属性を追加しないといけないのですが、ちょうどいい感じに実装できそうなAttributedStringクラスを利用することにしました。

しかも、うまく実装すればFont変更や文字色変更以外の機能も実装できてしまいそうです。

テキストの折り返しには、いい感じにテキストの改行を実装してくれるLineBreakMeasureクラスを利用します。

ソースが長くなるのでメッセージアニメーションを実装するためのクラスをMessageDrawAnimation.javaとし、ウィンドウやMessageDrawAnimationの利用側をTest.javaとしてソース分けします。

それではサンプルをご覧ください。

MessageDrawAnimation.java
import java.awt.Color;
import java.awt.Font;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.Rectangle;
import java.awt.font.FontRenderContext;
import java.awt.font.LineBreakMeasurer;
import java.awt.font.TextAttribute;
import java.awt.font.TextLayout;
import java.text.AttributedCharacterIterator;
import java.text.AttributedString;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Map;

public class MessageDrawAnimation{
	private ArrayList<MDAAttributedString> dataList = new ArrayList<>();
	private StringBuilder currentText = new StringBuilder();
	private AttributedString ensureCache;
	private Rectangle rect;
	private Position[] posList;
	private int nextIndex;
	private int nextTextIndex;
	private int speed;
	private int frameCount;
	private int nextFrameCount;
	private int outLinePadding=0;
	private boolean isCompile;
	private boolean endOfAnimation;
	public static final int MAX_MESSAGE_SPEED=100;
	public static final int MIN_MESSAGE_SPEED=1;

	/**
	 * 引数の描画領域と設定を利用してメッセージ描画オブジェクトを構築します。
	 * @param rect 描画領域
	 * @param speed メッセージスピード
	 * @param outLinePadding 矩形描画する際の外側に広げる量
	 */
	public MessageDrawAnimation(Rectangle rect, int speed, int outLinePadding){
		if ( speed > 100 || speed < 1 ) throw new IllegalArgumentException("Please set the message speed between 1 and 100");
		this.rect = rect;
		this.speed = speed;
		this.nextFrameCount = Math.abs(speed - 100);
		this.outLinePadding = outLinePadding;
	}

	/**
	 * 引数の描画領域と設定を利用してメッセージ描画オブジェクトを構築します。
	 * @param rect 描画領域
	 * @param speed メッセージスピード
	 */
	public MessageDrawAnimation(Rectangle rect, int speed){
		this(rect, speed, 0);
	}

	/**
	 * デフォルトの設定で描画を実行する文字列を追加します。
	 * @param str
	 */
	public void add(String text) {
		add(text, new Font(Font.MONOSPACED, Font.PLAIN, 12), Color.BLACK);
	}

	/**
	 * 指定されたフォントと色設定で描画を実行する文字列を追加します。
	 * @param str
	 */
	public void add(String text, Font font, Color color) {
		MDAAttributedString as = new MDAAttributedString(text);
		as.setAttribute(TextAttribute.FONT, font);
		as.setAttribute(TextAttribute.FOREGROUND, color);
		add(as);
	}

	/**
	 * 任意属性付で描画を実行する文字列を追加します。
	 * @param str
	 */
	public void add(MDAAttributedString as) {
		if ( as == null ) return ;
		if ( isCompile ) throw new RuntimeException("Already started");
		dataList.add(as);
	}

	/**
	 * このオブジェクトを1フレーム進行させます。
	 */
	public void update() {
		frameCount++;
		while ( frameCount > nextFrameCount && nextIndex < dataList.size() ) {
			MDAAttributedString as = dataList.get(nextIndex);
			String text = as.getText();
			if ( nextTextIndex < text.length() ) {
				currentText.append(text.charAt(nextTextIndex));
				ensureCache = null;
				nextTextIndex++;
				frameCount = 0;
			} else {
				nextIndex++;
				nextTextIndex = 0;
				if ( nextIndex >= dataList.size() ) endOfAnimation = true;
			}
		}
	}

	/**
	 * 現在の設定で確定し、必要な情報を計算します。
	 * @param g
	 */
	public void compile(Graphics g) {

		final StringBuilder sb = new StringBuilder();
		int len = dataList.size();
		int startIndex[] = new int[len];
		int endIndex[] = new int[len];
		ArrayList<Map<? extends AttributedCharacterIterator.Attribute, ?>> attrs = new ArrayList<>(len);
		for ( int i = 0;i < dataList.size();i++ ) {
			MDAAttributedString as = dataList.get(i);
			if ( i == 0 ) {
				startIndex[i] = 0;
				endIndex[i] = as.text.length();
			} else {
				startIndex[i] = endIndex[i-1];
				endIndex[i] = endIndex[i-1] + as.text.length();
			}
			attrs.add(as.getAttributes());
			sb.append(as.text);
		}

		String text = sb.toString();
		AttributedString as2 = new AttributedString(text);
		for ( int i = 0;i < len;i++ ) {
			as2.addAttributes(attrs.get(i), startIndex[i], endIndex[i]);
		}
		Graphics2D g2 = (Graphics2D)g;

		FontRenderContext context = g2.getFontRenderContext();
		LineBreakMeasurer measurer = new LineBreakMeasurer(as2.getIterator(), context);

		int position;
		int wrappingWidth = rect.width;
		float dy=0;
		float dx=0;
		float y = rect.y;
		float x = rect.x;

		ArrayList<Position> arr = new ArrayList<>();

		// 文字列の最後まで
		while ((position = measurer.getPosition()) < text.length()) {

			TextLayout layout;

			// 改行検索
			int indexOf = text.indexOf("\n", position);

			if (position <= indexOf && indexOf < measurer.nextOffset(wrappingWidth)) {
				// 改行位置の手前の分まで取得
				layout = measurer.nextLayout(wrappingWidth, ++indexOf, false);
			} else {
				// 自動で折り返してるとこまで取得
				layout = measurer.nextLayout(wrappingWidth);
				indexOf = measurer.getPosition();
			}

			if (layout == null) {
				break;
			}

			dy += layout.getAscent();

			dx = layout.isLeftToRight() ? 0 : (wrappingWidth - layout
					.getAdvance());

			// 文字列を書きだす位置情報
			Position pos = new Position();

			//描画位置の算出
			pos.x = x + dx;
			pos.y = y + dy;
			pos.idx = indexOf;
			arr.add(pos);

			dy += layout.getDescent() + layout.getLeading();
		}
		this.posList = arr.toArray(new Position[arr.size()]);

		isCompile = true;
	}

	/**
	 * 文字列を描画します。
	 * @param g
	 */
	public void drawString(Graphics g) {
		final Graphics2D g2 = (Graphics2D)g;
		String text = currentText.toString();
		if ( text.length() > 0 ) {
			if ( ensureCache == null ) {
				AttributedString result = new AttributedString(text);
				for ( int i = 0,index=0;i < nextIndex+1;i++ ) {
					MDAAttributedString as = dataList.get(i);
					int len = as.text.length() + index;
					if ( len > text.length() ) {
						len = text.length();
					}
					result.addAttributes(as.getAttributes(), index, len);
					index = len;
				}
				ensureCache = result;
			}
			_drawString(g2, ensureCache, text);
		}
	}

	private void _drawString(Graphics2D g2, AttributedString as, String text) {

		if ( text == null || text.length() <= 0 ) return;

		if ( !isCompile ) {
			compile(g2);
		}

		int wrappingWidth = rect.width;

		FontRenderContext context = g2.getFontRenderContext();

		LineBreakMeasurer measurer = new LineBreakMeasurer(as.getIterator(), context);

		for (int i = 0;(measurer.getPosition()) < text.length();i++) {
			TextLayout layout;

			Position pos = posList[i];

			int indexOf = pos.idx;

			//次のindexが現在の文字列より長かった場合は文字の長さにする
			if (text.length() < indexOf) {
				layout = measurer.nextLayout(wrappingWidth, text.length(), false);
			}else {
				layout = measurer.nextLayout(wrappingWidth, indexOf, false);
			}

			//取得不能か、矩形領域からはみ出たら処理を終了
			if (layout == null || pos.y + layout.getDescent()
			 + layout.getLeading() > rect.y + rect.height) {
				break;
			}

			// 文字列の描画
			layout.draw(g2, pos.x, pos.y);
		}
	}

	/**
	 * このメッセージアニメーションの枠を描画します。
	 * @param g
	 */
	public void drawRect(Graphics g) {
		g.drawRect(
				rect.x-outLinePadding
				,rect.y-outLinePadding
				,rect.width+outLinePadding+outLinePadding
				,rect.height+outLinePadding+outLinePadding
		);
	}

	/**
	 * このメッセージアニメーションの枠を塗りつぶします。
	 * @param g
	 */
	public void fillRect(Graphics g) {
		g.fillRect(
				rect.x-outLinePadding
				,rect.y-outLinePadding
				,rect.width+outLinePadding+outLinePadding
				,rect.height+outLinePadding+outLinePadding
		);
	}

	/**
	 * このメッセージアニメーションの枠を角を丸めて描画します。
	 * @param g
	 */
	public void drawRoundRect(Graphics g) {
		g.drawRoundRect(
				rect.x-outLinePadding
				,rect.y-outLinePadding
				,rect.width+outLinePadding+outLinePadding
				,rect.height+outLinePadding+outLinePadding
				, 10
				, 10
		);
	}

	/**
	 * このメッセージアニメーションの枠を角を丸めて塗りつぶします。
	 * @param g
	 */
	public void fillRoundRect(Graphics g) {
		g.fillRoundRect(
				rect.x-outLinePadding
				,rect.y-outLinePadding
				,rect.width+outLinePadding+outLinePadding
				,rect.height+outLinePadding+outLinePadding
				, 10
				, 10
		);
	}

	/**
	 * メッセージスピードを返します。
	 * @return
	 */
	public int getSpeed() {
		return speed;
	}


	/**
	 * メッセージアニメーションが終了したかを返します。
	 * @return
	 */
	public boolean isEndOfAnimation() {
		return endOfAnimation;
	}

	/**
	 * 書式付文字列クラスです。
	 * 文字列に任意の属性を付加したい場合に利用できます。
	 */
	public static class MDAAttributedString{
		private String text;
		private Map<AttributedCharacterIterator.Attribute,Object> attributes = new HashMap<>();
		/**
		 * 引数の文字を描画するためのAttributedStringオブジェクトを構築します。
		 * @param text 文字列
		 */
		public MDAAttributedString(String text){
			this.text = text.replaceAll("\\r\\n|\\r","\n");
		}
		/**
		 * 属性を付加します
		 * @param attr
		 * @param value
		 */
		public void setAttribute(AttributedCharacterIterator.Attribute attr, Object value) {
			attributes.put(attr, value);
		}
		public Object getAttribute(AttributedCharacterIterator.Attribute attr) {
			return attributes.get(attr);
		}
		public Object removeAttribute(AttributedCharacterIterator.Attribute attr) {
			return attributes.remove(attr);
		}
		/**
		 * 文字列を取得します。
		 * @return
		 */
		public String getText() {
			return text;
		}
		public Map<? extends AttributedCharacterIterator.Attribute,?> getAttributes(){
			return attributes;
		}
	}
	private class Position{
		float x;
		float y;
		int idx;
	}
}

下記はウィンドウと利用側のソースです。

Test.java
import java.awt.Color;
import java.awt.Font;
import java.awt.Graphics;
import java.awt.Insets;
import java.awt.Rectangle;
import java.awt.font.TextAttribute;

import javax.swing.JFrame;
import javax.swing.JPanel;

public class Test{
	public static void main(String[] args) {
		GameWindow gw = new GameWindow("テストウィンドウ",400,300);
		gw.setVisible(true);
		gw.change(new DrawCanvas());
		gw.startGameLoop();
	}
}
class GameWindow extends JFrame implements Runnable{
	private Thread th = null;
	private double sleepAddTime;
	private int fps=60;
	public GameWindow(String title, int width, int height) {
		super(title);
		setDefaultCloseOperation(EXIT_ON_CLOSE);
		setSize(width,height);
		setLocationRelativeTo(null);
		setLayout(null);
		setResizable(false);
		setFps(fps);
	}
	public synchronized void change(JPanel p) {
		getContentPane().removeAll();
		Insets inset = getInsets();
		p.setBounds(-inset.left,-inset.top,getWidth(),getHeight());
		add(p);
		validate();
		repaint();
	}
	public synchronized void startGameLoop(){
		if ( th == null ) {
			th = new Thread(this);
			th.start();
		}
	}
	public synchronized void stopGameLoop(){
		if ( th != null ) {
			th = null;
		}
	}
	public void run(){
		double nextTime = System.currentTimeMillis() + sleepAddTime;
		while(th != null){
			try{
				long res = (long)nextTime - System.currentTimeMillis();
				if ( res < 0 ) res = 0;
				Thread.sleep(res);
				repaint();
				nextTime += sleepAddTime;
			}catch(InterruptedException e){
				e.printStackTrace();
			}
		}
	}
	public void setFps(int fps){
		if ( fps < 10 || fps > 60 ) {
			throw new IllegalArgumentException("fpsの設定は10~60の間で指定してください。");
		}
		this.fps = fps;
		sleepAddTime = 1000.0 / fps;
	}
}
class DrawCanvas extends JPanel{
	//矩形内に文字列アニメーションを実装
	Rectangle rect1 = new Rectangle(50,50,300,50);
	MessageDrawAnimation md1 = new MessageDrawAnimation(rect1, 95, 10);

	Rectangle rect2 = new Rectangle(50,120,300,80);
	MessageDrawAnimation md2 = new MessageDrawAnimation(rect2, 95, 10);

	Rectangle rect3 = new Rectangle(50,220,300,50);
	MessageDrawAnimation md3 = new MessageDrawAnimation(rect3, 100, 10);
	public DrawCanvas() {
		md1.add("こんにちわ。今日はいい天気ですね。\n一緒にお出かけしませんか?");

		//任意の属性、フォント、色、アンダーラインを設定
		MessageDrawAnimation.MDAAttributedString as = new MessageDrawAnimation
				.MDAAttributedString("雨");
		as.setAttribute(TextAttribute.FONT, new Font(Font.MONOSPACED, Font.PLAIN, 12));
		as.setAttribute(TextAttribute.FOREGROUND, Color.RED);
		as.setAttribute(TextAttribute.UNDERLINE, TextAttribute.UNDERLINE_LOW_TWO_PIXEL);

		md2.add("え!?\n",new Font(Font.MONOSPACED, Font.PLAIN, 25), Color.BLACK);
		md2.add(as);
		md2.add("降ってますよ。やめておきませんか?", new Font(Font.MONOSPACED, Font.PLAIN, 12), Color.BLACK);

		md3.add("雨だから行くんじゃないですか。はやく傘を持ってきてください。");
	}
	public void paintComponent(Graphics g) {
		super.paintComponent(g);
		g.setColor(Color.PINK);
		md1.update();
		md1.fillRoundRect(g);
		md1.drawString(g);

		//メッセージ1が終了したら開始
		if ( md1.isEndOfAnimation() ) {
			g.setColor(Color.CYAN);
			md2.update();
			md2.fillRoundRect(g);
			md2.drawString(g);
		}

		//メッセージ2が終了したら開始
		if ( md2.isEndOfAnimation() ) {
			g.setColor(Color.PINK);
			md3.update();
			md3.fillRoundRect(g);
			md3.drawString(g);
		}
	}
}
実行結果

テストがあまりできていないのでバグがあるかもしれません(^^;)

大まかな処理の流れですが、属性付文字列をArrayListで管理し、updateメソッドが走ると、1フレーム進行させ、必要なら描画文字列バッファのStringBuilderに1文字ずつ追加していきます。

次に描画位置の情報は始めて描画する時か、compileメソッドを呼び出した時に一度だけ全計算し、描画位置を管理するArrayListに保持しておきます。

drawStringメソッドを呼び出すと描画文字列バッファ内の文字列を描画位置情報の示す位置に属性付で描画していきます。

必要であれば自分好みに改造してもいいかもしれませんね。

追加できる属性一覧はこちらを参考にしてください。

※すべてがちゃんと動く保証はありません。

MessageDrawAnimationの利用はnew MessageDrawAnimation(描画領域,メッセージスピード,[外枠の描画をしたい場合はその大きさ])で利用可能です。

MessageDrawAnimationの利用方法のみであれば下記部分のみを参考にしてください。

class DrawCanvas extends JPanel{
	//矩形内に文字列アニメーションを実装
	Rectangle rect1 = new Rectangle(50,50,300,50);
	MessageDrawAnimation md1 = new MessageDrawAnimation(rect1, 95, 10);

	Rectangle rect2 = new Rectangle(50,120,300,80);
	MessageDrawAnimation md2 = new MessageDrawAnimation(rect2, 95, 10);

	Rectangle rect3 = new Rectangle(50,220,300,50);
	MessageDrawAnimation md3 = new MessageDrawAnimation(rect3, 100, 10);
	public DrawCanvas() {
		md1.add("こんにちわ。今日はいい天気ですね。\n一緒にお出かけしませんか?");

		//任意の属性、フォント、色、アンダーラインを設定
		MessageDrawAnimation.MDAAttributedString as = new MessageDrawAnimation
				.MDAAttributedString("雨");
		as.setAttribute(TextAttribute.FONT, new Font(Font.MONOSPACED, Font.PLAIN, 12));
		as.setAttribute(TextAttribute.FOREGROUND, Color.RED);
		as.setAttribute(TextAttribute.UNDERLINE, TextAttribute.UNDERLINE_LOW_TWO_PIXEL);

		md2.add("え!?\n",new Font(Font.MONOSPACED, Font.PLAIN, 25), Color.BLACK);
		md2.add(as);
		md2.add("降ってますよ。やめておきませんか?", new Font(Font.MONOSPACED, Font.PLAIN, 12), Color.BLACK);

		md3.add("雨だから行くんじゃないですか。はやく傘を持ってきてください。");
	}
	public void paintComponent(Graphics g) {
		super.paintComponent(g);
		g.setColor(Color.PINK);
		md1.update();
		md1.fillRoundRect(g);
		md1.drawString(g);

		//メッセージ1が終了したら開始
		if ( md1.isEndOfAnimation() ) {
			g.setColor(Color.CYAN);
			md2.update();
			md2.fillRoundRect(g);
			md2.drawString(g);
		}

		//メッセージ2が終了したら開始
		if ( md2.isEndOfAnimation() ) {
			g.setColor(Color.PINK);
			md3.update();
			md3.fillRoundRect(g);
			md3.drawString(g);
		}
	}
}

参考サイト
System Engineerの戯言
PASTA SOURCE
LineBreakMeasurer java公式ドキュメント
TextLayout java公式ドキュメント
AttributedString java公式ドキュメント

Java

Posted by nompor