the NTM のキャラクター吹き出しシステム — Speech.astro の設計と実装

  • #Astro
  • #キャラクター
  • #コンポーネント設計
  • #MDX
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 で正規化するため、satansa-tan どちらの表記でも同一キャラとして扱われる。

const charMap: Record<string, string> = {
  aiko:    'aiko',
  zash:    'Zash',
  'sa-tan': 'sa-tan',
  satan:   'sa-tan',
  mix:     'Mix'
};

なお、charMap の値にある大文字(ZashMix)はネームプレート(displayName)の表示用に使われる。実際のアイコン画像のアセットパス生成には小文字化された nameKey が使用されるため、大文字小文字を区別するLinuxサーバー環境であってもパスの不整合(404エラー)は発生しない設計だ。


レイアウト構造

キャラクター列とバルーンの配置

吹き出しブロックは、キャラクター列を position: absolute で配置し、バルーン部分が幅全体を使う構成になっている。

padding-left: iconW(88px) + gap(16px) = 104px
┌─────────┬─────────────────────────────┐
│ [icon]  │  NamePlate                  │
│         │ ┌─────────────────────────┐ │
│         │ │  バルーン本文           │ │
│         │ └─────────────────────────┘ │
└─────────┴─────────────────────────────┘
  absolute                flex
  bottom: 0 ← 常にバルーン下辺に揃う

この構造の核心は「キャラクターが常にバルーン下辺に揃う」点だ。台詞が 1 行であっても 10 行であっても、アイコンはバルーンの底から立っている形になる。

定数として ICON_W = 88pxGAP = 16pxNAME_H = 26px を持ち、CSS カスタムプロパティ(define:vars)でスタイルに渡している。

右寄せキャラクター(Zash 等)は isRight={true} プロパティで制御する。padding-leftpadding-right を入れ替え、left: 0right: 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 プロパティで吹き出しの見た目を切り替える。

typeaccentbgtext用途
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_ALIASEScharMapthemes を変更するだけで対応できる。ラッパー側は変更不要。


モバイル対応

@media (max-width: 640px) でアイコン幅を 88px → 72px に縮小し、padding を同量削減する。バルーン内部の padding と フォントサイズも縮小方向に調整している。


更新履歴

  • 2026-07-18: 初稿