the NTM のキャラクター吹き出しシステム — Speech.astro の設計と実装
この記事について
the NTM(thentm.com)の記事で使われているキャラクター吹き出しシステムの実装を記録する。Astro + MDX の構成で、記事本文から <Aiko> や <Zash> を呼び出すと、表情差分アイコン・吹き出しバルーン・ネームプレートが一体になって描画される。
コアコンポーネントは sites/the_ntm/src/components/Speech.astro。各キャラクター専用のラッパー(Aiko.astro / Satan.astro / Zash.astro / Mix.astro)が MDX 上での記述を簡略化する。
感情パラメータとアイコン解決
6 種類の感情バリアント
アイコンは 6 つの感情状態に対応する。
| emotion 値 | 用途 |
|---|---|
normal | デフォルト・通常の会話 |
shout | 怒り・強い強調 |
sad | 悲しみ・落胆 |
smart | 思慮・真剣・分析 |
confused | 困惑・疑問 |
joy | 喜び・興奮 |
MDX を書く側がコード上の定数を暗記しなくてよいよう、エイリアス解決を実装している。
const EMOTION_ALIASES: Record<string, string> = {
happy: 'joy', excited: 'joy',
angry: 'shout',
cry: 'sad', crying: 'sad',
think: 'smart', serious: 'smart',
};
emotion="happy" と書けば joy に変換される。未知の値は normal にフォールバックするため、タイポがあっても破綻しない設計になっている。
アイコン URL の解決パターン
アイコンファイルのパスは次の形式で組み立てる。
/assets/icons/{nameKey}/{nameKey}_{emotion}_sd.webp
例えば aiko キャラクターの joy 表情は /assets/icons/aiko/aiko_joy_sd.webp になる。キャラクター識別は name.toLowerCase() に対して charMap で正規化するため、satan と sa-tan どちらの表記でも同一キャラとして扱われる。
const charMap: Record<string, string> = {
aiko: 'aiko',
zash: 'Zash',
'sa-tan': 'sa-tan',
satan: 'sa-tan',
mix: 'Mix'
};
なお、charMap の値にある大文字(Zash や Mix)はネームプレート(displayName)の表示用に使われる。実際のアイコン画像のアセットパス生成には小文字化された nameKey が使用されるため、大文字小文字を区別するLinuxサーバー環境であってもパスの不整合(404エラー)は発生しない設計だ。
レイアウト構造
キャラクター列とバルーンの配置
吹き出しブロックは、キャラクター列を position: absolute で配置し、バルーン部分が幅全体を使う構成になっている。
padding-left: iconW(88px) + gap(16px) = 104px
┌─────────┬─────────────────────────────┐
│ [icon] │ NamePlate │
│ │ ┌─────────────────────────┐ │
│ │ │ バルーン本文 │ │
│ │ └─────────────────────────┘ │
└─────────┴─────────────────────────────┘
absolute flex
bottom: 0 ← 常にバルーン下辺に揃う
この構造の核心は「キャラクターが常にバルーン下辺に揃う」点だ。台詞が 1 行であっても 10 行であっても、アイコンはバルーンの底から立っている形になる。
定数として ICON_W = 88px、GAP = 16px、NAME_H = 26px を持ち、CSS カスタムプロパティ(define:vars)でスタイルに渡している。
右寄せキャラクター(Zash 等)は isRight={true} プロパティで制御する。padding-left と padding-right を入れ替え、left: 0 を right: 0 に変えるだけで対称形ができる設計になっている。
連続台詞のマージン制御
記事内で複数の台詞が連続するとき、デフォルトの margin: 2.5rem 0 だと間隔が空きすぎる。会話シーンとして読めるよう間を詰めている。
.ntm-speech + .ntm-speech {
margin-top: 0.75rem;
}
.ntm-speech:has(+ .ntm-speech) {
margin-bottom: 0;
}
:has() セレクターで「後ろに吹き出しが続く要素」の margin-bottom も潰す。margin-top の打ち消しだけでは CSS のマージン相殺が干渉する場合があるため、前後から詰める実装にした。
テーマとタイプバリアント
type プロパティで吹き出しの見た目を切り替える。
| type | accent | bg | text | 用途 |
|---|---|---|---|---|
standard | #8b9cac | #f7f9fc | #28323a | 通常会話 |
shout | #cf5448 | #fff4f2 | #92281f | 強い主張・怒り |
thought | #5d8fb8 | #f2f7fc | #1f425d | 思考・内省 |
system | #8d98a4 | #f4f6f8 | #505b66 | システムコメント |
shout タイプはバルーン枠を 2px に太くし、テキストサイズを 1.15rem に拡大して視覚的な強度を加える。
MDX からの呼び出し
各キャラクターに薄いラッパーコンポーネントがあり、MDX 上で name プロパティを省略できる。
<Aiko emotion="confused">
それって、どういう意味じゃ?
</Aiko>
<Satan emotion="smart" type="thought">
実はここに構造的な問題がある。
</Satan>
<Zash type="shout">
どうせそうなる。
</Zash>
Zash は isRight={true} 固定、Aiko は isRight={false} 固定でラッパー内に書かれており、MDX を書く側は左右を意識しなくてよい。ラッパーの実体は数行だ。
---
import Speech from './Speech.astro';
const { emotion = 'normal', type = 'standard' } = Astro.props;
---
<Speech name="aiko" emotion={emotion} type={type} isRight={false}>
<slot />
</Speech>
コンポーネントの拡張(新感情の追加、新キャラクターの追加)は Speech.astro の EMOTION_ALIASES・charMap・themes を変更するだけで対応できる。ラッパー側は変更不要。
モバイル対応
@media (max-width: 640px) でアイコン幅を 88px → 72px に縮小し、padding を同量削減する。バルーン内部の padding と フォントサイズも縮小方向に調整している。
更新履歴
- 2026-07-18: 初稿