【Cocos Creator 3.8】ChatLogコンポーネントの実装:アタッチするだけでチャット履歴をRichTextに追記・自動削除する汎用スクリプト
本記事では、ノードにアタッチするだけで「チャットログ(履歴)」を管理できる汎用コンポーネントを実装します。
メッセージを追加すると、指定行数・文字数・時間に応じて古いログを自動で削除し、RichTextに整形して表示してくれます。
UIチャット、システムログ、バトルログ、デバッグ表示など、「テキストの履歴を一定量だけ残したい」場面でそのまま使い回せるよう、外部スクリプトに一切依存しない設計にします。
コンポーネントの設計方針
1. 機能要件の整理
- アタッチ先ノードに RichText コンポーネントが付いていることを前提とする。
- スクリプト単体で完結し、他の GameManager やシングルトンに依存しない。
- コードから
addMessage()を呼ぶと、内部キューにメッセージを追加し、RichText 表示を更新する。 - 以下の条件で古いログを自動削除できるようにする:
- 最大行数(メッセージ数)
- 最大総文字数(全メッセージ合計)
- 保持時間(秒)(指定秒数を過ぎたメッセージを削除)
- ログの表示フォーマット(タイムスタンプ、区切り文字、改行の有無など)をある程度カスタマイズ可能にする。
- RichText の色タグや太字タグなど、リッチテキストタグをそのまま通す設計にする。
2. 外部依存をなくすためのアプローチ
- Inspector の @property から全ての設定を行う。
- 最大行数、最大文字数、保持時間、タイムスタンプ表示の有無など。
- アタッチ先ノードの RichText を自動取得し、存在しない場合はエラーログを出して処理を止める(防御的実装)。
- ログデータはコンポーネント内部の配列で完結管理し、他スクリプトからは
addMessage()/clear()だけを使えばよい API にする。
3. Inspector で設定可能なプロパティ設計
以下のようなプロパティを用意します。
enabledInEditorPreview: boolean
・エディタのプレビュー(Editor 上での再生)でもログを更新するかどうか。
・通常はtrueで問題ありません。maxLines: number
・保持する最大メッセージ数。
・0 以下の場合は「上限なし」とみなす。maxCharacters: number
・全メッセージを連結したときの総文字数上限。
・0 以下の場合は「上限なし」とみなす。maxLifetimeSeconds: number
・各メッセージの最大保持時間(秒)。
・0 以下の場合は「時間による自動削除は行わない」。autoScrollToBottom: boolean
・RichText を ScrollView 内で使っている場合などに、更新時に一番下までスクロールするかどうか。
・このコンポーネントでは RichText の内容だけを更新し、スクロール位置は オプションで ScrollView を指定したときのみ制御します。scrollViewNode: Node | null
・任意:RichText を内包している ScrollView ノード を指定すると、
autoScrollToBottomがtrueのときに自動で最下部へスクロールします。
・未指定(null)の場合は何もしません。showTimestamp: boolean
・各メッセージの先頭にタイムスタンプ(時刻)を付与するかどうか。timestampFormat: string
・タイムスタンプのフォーマット。
・簡易的に"HH:mm:ss"/"mm:ss"/"HH:mm"程度のパターンをサポートします。messageSeparator: string
・各メッセージの区切りに使う文字列。
・通常は"\n"(改行)を指定し、1メッセージ = 1 行のログにします。trimEmptyMessage: boolean
・メッセージ追加前にtext.trim()した結果が空文字の場合、追加をスキップするかどうか。maxMessagesPerSecond: number
・スパム防止用の簡易レート制御。
・1秒あたりに許可するaddMessage()呼び出し回数。
・0 以下の場合はレート制限なし。
これらをすべて @property 経由で Inspector から設定できるようにします。
TypeScriptコードの実装
import { _decorator, Component, RichText, Node, ScrollView, Label, log, error, sys } from 'cc';
const { ccclass, property, tooltip } = _decorator;
/**
* ChatLog
* - RichText にチャット / ログメッセージを追記し、古いログを自動で削除するコンポーネント。
* - 他のスクリプトに依存せず、このコンポーネント単体で完結します。
*/
@ccclass('ChatLog')
export class ChatLog extends Component {
@property({
tooltip: 'エディタ上のプレビュー再生中にもログを更新するかどうか。(通常は ON 推奨)'
})
enabledInEditorPreview: boolean = true;
@property({
tooltip: '保持する最大メッセージ数。0 以下の場合は行数制限なし。'
})
maxLines: number = 100;
@property({
tooltip: '全メッセージ合計の最大文字数。0 以下の場合は文字数制限なし。'
})
maxCharacters: number = 0;
@property({
tooltip: '各メッセージの最大保持時間(秒)。0 以下の場合は時間による自動削除なし。'
})
maxLifetimeSeconds: number = 0;
@property({
tooltip: 'ScrollView 内で使用する場合、更新時に自動で最下部までスクロールするかどうか。'
})
autoScrollToBottom: boolean = true;
@property({
type: Node,
tooltip: '任意:この RichText を内包している ScrollView ノード(Content ではなく ScrollView のノード)を指定します。'
})
scrollViewNode: Node | null = null;
@property({
tooltip: '各メッセージの先頭にタイムスタンプ(時刻)を付与するかどうか。'
})
showTimestamp: boolean = false;
@property({
tooltip: 'タイムスタンプのフォーマット(例: "HH:mm:ss", "mm:ss" など)。\n簡易的なパターンのみサポートします。'
})
timestampFormat: string = 'HH:mm:ss';
@property({
tooltip: '各メッセージの区切り文字。通常は "\\n"(改行)を指定します。'
})
messageSeparator: string = '\n';
@property({
tooltip: 'メッセージ追加前に trim() した結果が空文字の場合、追加をスキップするかどうか。'
})
trimEmptyMessage: boolean = true;
@property({
tooltip: '1秒あたりに許可する addMessage 呼び出し回数。0 以下で制限なし。'
})
maxMessagesPerSecond: number = 0;
// 内部で扱う 1メッセージ分の情報
private _messages: {
text: string;
time: number; // 追加された時刻(秒)
rawLength: number; // text の文字数(タグ含む)
}[] = [];
private _richText: RichText | null = null;
private _scrollView: ScrollView | null = null;
// レート制限用
private _lastSecond: number = 0;
private _messageCountThisSecond: number = 0;
onLoad() {
// RichText を取得
this._richText = this.getComponent(RichText);
if (!this._richText) {
error('[ChatLog] このコンポーネントを使用するノードには RichText コンポーネントが必要です。');
}
// ScrollView が指定されていれば取得
if (this.scrollViewNode) {
this._scrollView = this.scrollViewNode.getComponent(ScrollView);
if (!this._scrollView) {
error('[ChatLog] scrollViewNode に ScrollView コンポーネントが見つかりません。自動スクロールは無効になります。');
}
}
// 初期化
this._messages = [];
this._lastSecond = 0;
this._messageCountThisSecond = 0;
// 初期描画クリア
if (this._richText) {
this._richText.string = '';
}
}
start() {
// 特別な処理は不要だが、ここでエディタプレビュー無効化などをチェックしてもよい
}
update(deltaTime: number) {
// Editor 上でのプレビュー再生を許可しない場合は、Editor 実行中は何もしない
if (!this.enabledInEditorPreview && !sys.isMobile && !sys.isNative) {
// Web / Editor 上での挙動をざっくり判定しているだけなので、
// 必要に応じて Application.isPlaying などに差し替えてください。
}
// 時間による自動削除
if (this.maxLifetimeSeconds > 0) {
this._removeExpiredMessages();
}
}
/**
* 外部スクリプトから呼び出してログを追加するための公開メソッド。
* @param text 表示したいメッセージ(RichText のタグを含んでも OK)
*/
public addMessage(text: string): void {
if (!this._richText) {
error('[ChatLog] RichText コンポーネントが見つからないため、メッセージを追加できません。');
return;
}
if (this.trimEmptyMessage) {
const trimmed = text.trim();
if (trimmed.length === 0) {
// 空メッセージは無視
return;
}
text = trimmed;
}
// レート制限チェック
if (this.maxMessagesPerSecond > 0) {
const nowSec = Math.floor(Date.now() / 1000);
if (nowSec !== this._lastSecond) {
this._lastSecond = nowSec;
this._messageCountThisSecond = 0;
}
this._messageCountThisSecond++;
if (this._messageCountThisSecond > this.maxMessagesPerSecond) {
log('[ChatLog] メッセージレート制限により、メッセージが破棄されました。');
return;
}
}
const now = Date.now() / 1000;
// タイムスタンプ付与
if (this.showTimestamp) {
const ts = this._formatTimestamp(now * 1000);
text = `[${ts}] ${text}`;
}
const entry = {
text,
time: now,
rawLength: text.length,
};
this._messages.push(entry);
// 制限に応じて古いメッセージを削除
this._applyConstraints();
// RichText を再描画
this._refreshRichText();
}
/**
* ログをすべてクリアします。
*/
public clear(): void {
this._messages.length = 0;
if (this._richText) {
this._richText.string = '';
}
this._scrollToBottomIfNeeded();
}
/**
* 内部メッセージ配列に対して、行数・文字数・時間の制限を適用して古いメッセージを削除する。
*/
private _applyConstraints(): void {
// 1. 行数制限
if (this.maxLines > 0 && this._messages.length > this.maxLines) {
const overflow = this._messages.length - this.maxLines;
this._messages.splice(0, overflow);
}
// 2. 文字数制限
if (this.maxCharacters > 0) {
let totalLength = 0;
for (let i = this._messages.length - 1; i >= 0; i--) {
totalLength += this._messages[i].rawLength + this.messageSeparator.length;
if (totalLength > this.maxCharacters) {
// i より前(古いメッセージ)を削除
this._messages.splice(0, i + 1);
break;
}
}
}
// 3. 時間制限(update 内の _removeExpiredMessages でも実行されるが、
// 追加直後に削除すべきものがあればここでも削除する)
if (this.maxLifetimeSeconds > 0) {
this._removeExpiredMessages();
}
}
/**
* maxLifetimeSeconds に基づいて、古くなったメッセージを削除する。
*/
private _removeExpiredMessages(): void {
if (this.maxLifetimeSeconds 0) {
this._messages.splice(0, removeCount);
this._refreshRichText();
}
}
/**
* 内部メッセージ配列から RichText.string を再構築する。
*/
private _refreshRichText(): void {
if (!this._richText) {
return;
}
if (this._messages.length === 0) {
this._richText.string = '';
this._scrollToBottomIfNeeded();
return;
}
const sep = this.messageSeparator;
let result = '';
for (let i = 0; i < this._messages.length; i++) {
result += this._messages[i].text;
if (i < this._messages.length - 1) {
result += sep;
}
}
this._richText.string = result;
this._scrollToBottomIfNeeded();
}
/**
* ScrollView が指定されていれば、最下部までスクロールする。
*/
private _scrollToBottomIfNeeded(): void {
if (!this.autoScrollToBottom || !this._scrollView) {
return;
}
// ScrollView の vertical = true 前提で、一番下にスクロール
// normalizedPosition.y = 0 が一番下
this._scrollView.scrollToBottom(0.05); // 0.05秒でスムーズにスクロール
}
/**
* タイムスタンプを簡易フォーマットに従って文字列化する。
* @param ms ミリ秒単位の時刻(Date.now() の値)
*/
private _formatTimestamp(ms: number): string {
const d = new Date(ms);
const pad2 = (n: number) => (n < 10 ? '0' + n : '' + n);
const h = pad2(d.getHours());
const m = pad2(d.getMinutes());
const s = pad2(d.getSeconds());
switch (this.timestampFormat) {
case 'HH:mm':
return `${h}:${m}`;
case 'mm:ss':
return `${m}:${s}`;
case 'HH:mm:ss':
default:
return `${h}:${m}:${s}`;
}
}
}
主要メソッドの役割解説
onLoad()- アタッチ先ノードから
RichTextを取得し、存在しなければerror()で警告。 scrollViewNodeが指定されていればScrollViewを取得。- 内部メッセージ配列を初期化し、表示をクリア。
- アタッチ先ノードから
update()maxLifetimeSecondsが設定されている場合、一定時間を過ぎたメッセージを削除。
addMessage(text: string)- 外部から呼び出してログを追加するための公開 API。
- 空メッセージのスキップ、レート制限、タイムスタンプ付与を行う。
- 内部配列に保存し、
_applyConstraints()で行数・文字数・時間制限を適用。 - 最後に
_refreshRichText()で RichText 表示を更新。
clear()- 内部配列を空にし、RichText を空文字にしてログを全削除。
_applyConstraints()maxLines・maxCharacters・maxLifetimeSecondsに基づき、古いメッセージを前から削る。
_refreshRichText()- 内部配列を
messageSeparatorで連結し、RichText.string に反映。 - 必要に応じて ScrollView を最下部へスクロール。
- 内部配列を
使用手順と動作確認
1. スクリプトファイルの作成
- Assets パネルで右クリック → Create → TypeScript を選択します。
- ファイル名を
ChatLog.tsにします。 - 自動生成されたコードをすべて削除し、本記事の 「TypeScriptコードの実装」 セクションのコードを丸ごと貼り付けて保存します。
2. テスト用 UI ノードの作成
- Hierarchy パネルで右クリック → Create → UI → Canvas を作成(既にある場合は再利用)。
- Canvas を選択し、右クリック → Create → UI → ScrollView を作成します。
- この ScrollView の Content に RichText を配置する形にします。
- ScrollView ノードの子階層にある View → Content ノードを選択します。
- Content ノードに RichText コンポーネントを追加します。
- Inspector の Add Component → UI → RichText を選択。
- RichText の
Stringは空にしておきます。
3. ChatLog コンポーネントをアタッチ
- RichText を追加した Content ノード を選択します。
- Inspector で Add Component → Custom → ChatLog を選択してアタッチします。
- Inspector の ChatLog プロパティを設定します(例):
Enabled In Editor Preview:trueMax Lines:50(ログを 50 行まで保持)Max Characters:0(文字数制限なし)Max Lifetime Seconds:0(時間による削除なし)Auto Scroll To Bottom:trueScroll View Node: 先ほど作成した ScrollView ノード をドラッグ&ドロップShow Timestamp:trueTimestamp Format:HH:mm:ssMessage Separator:\nTrim Empty Message:trueMax Messages Per Second:0(制限なし)
4. 簡単なテストコードからメッセージを追加する
動作確認のため、同じシーン内の任意のノードにテスト用スクリプトを付けて、addMessage() を呼び出してみます。
- Assets パネルで右クリック → Create → TypeScript → ファイル名を
ChatLogTester.tsにします。 - 以下のような簡単なテストコードを書きます(ChatLog 自体は他スクリプトに依存していないので、これはあくまでサンプルです)。
import { _decorator, Component, Node } from 'cc';
import { ChatLog } from './ChatLog';
const { ccclass, property } = _decorator;
@ccclass('ChatLogTester')
export class ChatLogTester extends Component {
@property({ type: ChatLog, tooltip: 'テスト対象の ChatLog コンポーネントを指定します。' })
chatLog: ChatLog | null = null;
private _counter = 0;
start() {
// 1秒ごとにメッセージを追加
this.schedule(() => {
if (this.chatLog) {
this._counter++;
this.chatLog.addMessage(`テストメッセージ ${this._counter}`);
}
}, 1);
}
}
- Hierarchy で任意のノード(例:Canvas)を選択し、Add Component → Custom → ChatLogTester を追加します。
- Inspector の ChatLogTester.chatLog に、先ほど Content ノードにアタッチした ChatLog コンポーネント をドラッグ&ドロップします。
- シーンを保存して再生ボタンを押すと、1秒ごとに RichText にログが追記され、50行を超えると古い行が削除されていくのが確認できます。
5. RichText のタグを使ったカラフルなログ
addMessage() に渡す文字列に RichText のタグを含めることで、色付き・太字などのログを簡単に実現できます。
// 例: システムメッセージを黄色に
chatLog.addMessage('<color=#FFFF00>[SYSTEM]</color> ゲームを開始しました。');
// 例: エラーメッセージを赤太字に
chatLog.addMessage('<color=#FF0000><b>[ERROR]</b> 接続に失敗しました。</color>');
ChatLog コンポーネントは文字列をそのまま RichText に流し込むだけなので、RichText の仕様に沿ったタグはそのまま利用可能です。
まとめ
- ChatLog コンポーネントは、RichText にチャット/ログメッセージを追記しつつ、
- 最大行数
- 最大総文字数
- 保持時間(秒)
の 3 つの観点で古いログを自動削除する、汎用ログ表示コンポーネントです。
- 外部スクリプトやシングルトンに依存せず、このスクリプト単体で完結しているため、
- どのプロジェクトにも簡単にコピペして再利用できる
- Inspector の設定だけで挙動を調整できる
- ScrollView と組み合わせれば、チャットウィンドウ・バトルログ・デバッグログなど、様々な用途にそのまま流用できます。
- RichText のタグを活用することで、色分け・太字・サイズ変更なども自在に行えるため、視認性の高いログ UI を素早く構築できます。
プロジェクトごとに違うのは「どこからどんなメッセージを流すか」だけなので、
本記事の ChatLog をベースに、入力欄やネットワークチャット、システムイベント連携などを自由に拡張してみてください。




