Cloudinary に画像処理を任せて Next.js 製ノベルゲーム UI の実装をシンプルにする

Cloudinary に画像処理を任せて Next.js 製ノベルゲーム UI の実装をシンプルにする

Cloudinary を使ってノベルゲーム風 UI の画像処理を外部化し、 Next.js 側では Public ID とレイアウト指定だけで画面を構成するデモを紹介します。 Web ゲームにおけるアセット管理の負担軽減と、ゲームロジックと画像処理の責務分離という観点から構成と実装のポイントを整理しました。
2025.12.14

はじめに

本記事は SaaSで加速するゲーム開発 - Advent Calendar 2025 - の 14 日目のブログです。

本記事では Cloudinary を使って、 Next.js 製のノベルゲーム風 UI の画像処理やアセット管理を、アプリケーションコードからどこまで切り離せるか検証します。背景、立ち絵、テキストウィンドウ、文字送りアイコンといった画像のリサイズや形式変換を Cloudinary に任せることで、 Web ゲーム側の実装や運用の複雑さを下げる試みです。

cloudinary-novel-demo

Cloudinary とは

Cloudinary は、画像や動画をクラウド上に保存し、 URL ベースで変換や最適化を行ったうえで配信できるサービスです。リサイズやトリミング、画質自動調整、フォーマット自動選択などを、 URL のパスやクエリで指定できる点が特徴です。

対象読者

  • ブラウザ向けのノベルゲームや ADV を Next.js などで構築したい方
  • Web ゲームの画像処理やアセット管理の責務を、アプリケーションから切り離したい方
  • Cloudinary をゲーム寄りのユースケースで試してみたい方

参考情報

ノベルゲーム UI と Cloudinary の役割分担

今回のデモでは、次のようなノベルゲーム風の一画面を Web 上で描画します。

  • ベース解像度 1280 x 720 のキャンバス
  • 全面に敷かれた背景画像
  • 画面中央下に配置されたヒロイン立ち絵
  • 画面下部のテキストウィンドウと話者名、セリフ
  • 右下に配置された文字送りアイコン

これらを、Cloudinary 上の画像の Public ID だけを前提に組み立てます。

Next.js 側は、どの Public ID をどこに配置するかだけ決め、リサイズやトリミング、画質調整は Cloudinary に任せます。

Web ゲームの文脈でどこがうれしいのか

ブラウザ向けのゲーム制作では、PC とスマートフォンで画面解像度やアスペクト比が違う、UI 調整のたびに画像サイズやトリミング位置を変えたくなる、などといった悩みが付きまといます。これに対処するため、一般的にはたとえば次のような対策が行われます。

  • 解像度違いの画像を書き出して複数持つ
  • CSS 側でトリミングや拡大縮小を細かく調整する

今回の構成では、画像の変換と最適化の部分 をほぼ Cloudinary 側に任せています。

  • 背景画像やウィンドウ枠は、 URL で c_fill,w_1280,h_720 のような変換を指定してサーバ側でリサイズ
  • 画質やフォーマットは q_auto,f_auto のような指定でサービス側に一任
  • ゲーム側では Public ID と transform の組み合わせを文字列で指定するだけ

その結果として、ゲームロジックとアセット変換ロジックがきれいに分離されます。 デザイナ側では Cloudinary 上のアセットだけを気にすればよく、またエンジニア側では Public ID と UI レイアウトだけを気にすればよいという形で、責務を分担しやすくなります。

実装の概要

Cloudinary 側での準備

Cloudinary のアセットとして画像をアップロードし、 Cloud name と Public ID を控えておきます。

cloud name and public id

アップロードした画像は以下の通りです。

uploaded_images

この段階では、 text_next_arrow, window_frame には透明化処理を加えていません。この後の手順で alpha 値を設定して使用します。

Cloudinary プロキシ API /api/image

外部の画像 API を直接ブラウザから叩かず、 Next.js の Route Handler を経由させるために、 /api/image を用意しました。

app/api/image/route.ts
import { NextRequest, NextResponse } from "next/server";

export async function GET(request: NextRequest) {
  const searchParams = request.nextUrl.searchParams;
  const publicId = searchParams.get("publicId");
  const transform = searchParams.get("transform") ?? "";

  if (!publicId) {
    return NextResponse.json(
      { error: "publicId query parameter is required" },
      { status: 400 }
    );
  }

  const cloudName = process.env.CLOUDINARY_CLOUD_NAME;
  if (!cloudName) {
    return NextResponse.json(
      { error: "CLOUDINARY_CLOUD_NAME is not configured" },
      { status: 500 }
    );
  }

  const transformPath = transform ? `${transform}/` : "";
  const cloudinaryUrl =
    `https://res.cloudinary.com/${cloudName}/image/upload/` +
    `${transformPath}${publicId}`;

  try {
    const response = await fetch(cloudinaryUrl);

    if (!response.ok) {
      return NextResponse.json(
        { error: "Failed to fetch image from Cloudinary" },
        { status: 502 }
      );
    }

    const buffer = await response.arrayBuffer();
    const contentType =
      response.headers.get("content-type") ?? "image/png";

    return new NextResponse(buffer, {
      status: 200,
      headers: {
        "Content-Type": contentType,
        "Cache-Control": "public, max-age=86400, s-maxage=86400",
      },
    });
  } catch (error) {
    console.error("Error fetching image from Cloudinary:", error);
    return NextResponse.json(
      { error: "Unexpected error while fetching image" },
      { status: 500 }
    );
  }
}

やっていることは次のとおりです。

  • クエリパラメータから publicIdtransform を受け取る
  • Cloudinary の URL を組み立てて fetch する
  • 得られたバイナリをそのままレスポンスとして返す

Cloudinary 固有の処理はこのファイルだけに閉じ込めておき、フロントエンドは常に /api/image を叩く構造にしています。

ノベルゲーム風 UI コンポーネント

ノベルゲーム風の画面を描画するのが NovelScene コンポーネントです。コンポーネントは話者名、セリフ、および各画像の Public ID を受け取り、内部で /api/image 向けの URL を組み立てます。

components/NovelScene.tsx
"use client";

type NovelSceneProps = {
  speakerName: string;
  dialogue: string;
  backgroundId: string;
  characterId: string;
  windowFrameId: string;
  nextArrowId?: string;
};

/** 画像プロキシ API の URL を生成 */
function getImageUrl(publicId: string, transform?: string): string {
  const params = new URLSearchParams({ publicId });
  if (transform) {
    params.set("transform", transform);
  }
  return `/api/image?${params.toString()}`;
}

export default function NovelScene(props: NovelSceneProps) {
  const {
    speakerName,
    dialogue,
    backgroundId,
    characterId,
    windowFrameId,
    nextArrowId,
  } = props;

  return (
    <div className="novel-scene">
      {/* 背景画像 - 1280x720 にフィットさせる */}
      <img
        src={getImageUrl(
          backgroundId,
          "c_fill,w_1280,h_720,q_auto,f_auto"
        )}
        alt="背景"
        className="novel-background"
        draggable={false}
      />

      {/* ヒロイン立ち絵 */}
      <img
        src={getImageUrl(characterId, "q_auto,f_auto")}
        alt="キャラクター"
        className="novel-character"
        draggable={false}
      />

      {/* テキストウィンドウエリア */}
      <div className="novel-text-window">
        <img
          src={getImageUrl(
            windowFrameId,
            "c_fill,w_1280,h_240,q_auto,f_auto"
          )}
          alt=""
          className="novel-window-frame"
          draggable={false}
          aria-hidden="true"
        />

        <div className="novel-text-content">
          <p className="novel-speaker-name">{speakerName}</p>
          <p className="novel-dialogue">{dialogue}</p>
        </div>

        {nextArrowId && (
          <img
            src={getImageUrl(nextArrowId, "q_auto,f_auto")}
            alt=""
            className="novel-next-arrow"
            draggable={false}
            aria-hidden="true"
          />
        )}
      </div>
    </div>
  );
}

このように、 Cloudinary の変換指定は getImageUrl の引数として扱うだけです。次のような分担になっています。

  • 背景やウィンドウ枠は c_fill,w_1280,h_... でサイズとトリミングをサービス側に任せる
  • 立ち絵やアイコンは q_auto,f_auto だけ指定し、サイズは CSS で制御する

レイアウトとスタイル

レイアウトは 1280 x 720 のキャンバスを基準に CSS で定義しました。

globals.css
:root {
  --novel-width: 1280px;
  --novel-aspect: 16 / 9;
  --text-window-height: 240px;
  --text-padding-x: 48px;
  --text-padding-y: 24px;
}

body {
  margin: 0;
  padding: 0;
  background: #0a0a0a;
}

/* 画面中央にノベルシーンを表示 */
.novel-container {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  height: 100dvh;
  background: #000;
}

/* 本体 1280x720 相当 */
.novel-scene {
  position: relative;
  width: 100%;
  max-width: var(--novel-width);
  aspect-ratio: var(--novel-aspect);
  overflow: hidden;
  background: #101010;
}

/* 背景画像 */
.novel-background {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* 立ち絵 */
.novel-character {
  position: absolute;
  bottom: 0;
  left: 50%;
  transform: translateX(-50%);
  width: 25%;
  height: auto;
}

/* テキストウィンドウ */
.novel-text-window {
  position: absolute;
  bottom: 0;
  left: 0;
  right: 0;
  height: var(--text-window-height);
}

/* フレーム画像 - 半透明 */
.novel-window-frame {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  opacity: 0.5;
}

/* テキスト本体 */
.novel-text-content {
  position: relative;
  z-index: 10;
  display: flex;
  flex-direction: column;
  gap: 4px;
  height: 100%;
  padding: var(--text-padding-y) var(--text-padding-x);
}

/* 話者名とセリフ */
.novel-speaker-name {
  margin: 0;
  font-weight: 700;
  color: #ffd700;
}

.novel-dialogue {
  margin: 0;
  color: #fff;
}

/* 文字送りアイコン */
.novel-next-arrow {
  position: absolute;
  right: 32px;
  bottom: 16px;
  width: 48px;
  height: auto;
}

レイアウトはあくまで CSS の責務であり、画像の解像度やフォーマットは Cloudinary 側で処理されます。これにより、 UI 調整のたびに画像をエクスポートし直す必要がなくなります。画面構成と画像処理を別々に考えられる 点がメリットです。

検証結果と考察

cloudinary-novel-demo

実装した「ノベルゲーム風画面」の GIF アニメーションです。背景画像、立ち絵、テキストウィンドウ、右下の文字送りアイコンはすべて Cloudinary から取得したものです。

実装面での変化

今回の構成にしたことで、ノベルゲーム側のコードから次のような処理がなくなりました。

  • ビルド時に画像サイズや形式を変換するスクリプト
  • 画像の解像度違いを複数用意し、メディアクエリや JavaScript で出し分ける処理
  • 背景や UI フレームのトリミング位置を、 CSS だけで頑張って調整する作業

代わりに増えたのは、次の程度です。

  • Cloudinary の Public ID を把握しておく必要がある
  • getImageUrl に渡す transform 文字列を設計する必要がある

コードの行数だけを見ると増減はそこまで大きくありませんが、ノベルゲームのロジックから画像処理の責務が消えている ことがポイントです。「画面に対してどの画像をどう配置するか」と「画像そのものをどう最適化するか」を、別々のレイヤーで考えられるようになります。

パフォーマンス面の考察

Cloudinary を使うことでクライアント側での画像変換処理は発生しなくなりますが、全体として常に高速になるとは言えません。

  • 画像取得は クライアント → 自前の Next.js → Cloudinary という経路になるため、 Cloudinary に直アクセスする場合よりネットワークの往復は一段階増えます。
  • 初回アクセス時は transform 結果の生成や CDN キャッシュのミスもあり得るため、最初の 1 回は一定の待ち時間が発生します。
  • 一方で、 transform 結果は CDN にキャッシュされ、以降はキャッシュヒットする前提で設計できます。

今回の構成は、処理速度だけを最大化する構成ではありません。 代わりに、画像加工プロセスの切り離しや、アセット増加・差し替えに強い構成などを優先した設計になっています。このトレードオフを理解したうえで採用を検討するのが良さそうです。

まとめ

本記事では、 Cloudinary を使って Next.js 製ノベルゲーム UI の画像処理とアセット管理を外出しする構成を紹介しました。背景や UI フレームのリサイズ、画質やフォーマットの最適化を Cloudinary に任せることで、ゲーム側は Public ID とレイアウトだけに集中できるようになります。

ネットワークを経由するぶん、すべてのケースでパフォーマンスが向上するわけではありません。しかし、ブラウザ向けノベルゲームのように画像アセットが増えやすいジャンルにおいては、開発と運用の負担を下げる選択肢として検討する価値があると感じました。

この記事をシェアする

FacebookHatena blogX

関連記事