HTML+CSS+JavaScriptで作るサウンドノベル

コンピュータ

分岐の無いサウンドノベルの雛形を作成してみました。

ファイル構成

J:\GIT\MY-DEMO-APP\SOUND_NOVEL
|   game.js
|   index.html
|   MakeVoice.ps1
|   style.css
|
+---images
|       scene1.png
|       scene2.png
|
+---sounds
|       bgm1.mp3
|       opening.mp3
|
+---videos
|       forest_loop.mp4
|
\---voices
        v001.mp3
        v002.mp3
        v003.mp3

ソースファイル

videosの動画 … AnimeEffectsで作成
imagesの画像 … GIMPで作成

ファイル名:MakeVoice.ps1

Add-Type -AssemblyName System.Speech

$voice = New-Object System.Speech.Synthesis.SpeechSynthesizer
$voices = $voice.GetInstalledVoices() | ForEach-Object { $_.VoiceInfo.Name }
$voiceName = $voices[0]
Write-Host $voiceName

$voice.SelectVoice($voiceName)

$voice.SetOutputToWaveFile("voice001.wav")
$voice.Speak("目を覚ますと、そこは森の中だった。")
$voice.SetOutputToDefaultAudioDevice()

Remove-Item "voices\v001.mp3" -ErrorAction SilentlyContinue
ffmpeg.exe -i voice001.wav voices\v001.mp3
Remove-Item voice001.wav

$voice.SetOutputToWaveFile("voice002.wav")
$voice.Speak("草むらの向こうから、何かが動いている気配がした。")
$voice.SetOutputToDefaultAudioDevice()

Remove-Item "voices\v002.mp3" -ErrorAction SilentlyContinue
ffmpeg.exe -i voice002.wav voices\v002.mp3
Remove-Item voice002.wav

$voice.SetOutputToWaveFile("voice003.wav")
$voice.Speak("僕は思わず、その場に立ち尽くした。")
$voice.SetOutputToDefaultAudioDevice()

Remove-Item "voices\v003.mp3" -ErrorAction SilentlyContinue
ffmpeg.exe -i voice003.wav voices\v003.mp3
Remove-Item voice003.wav

voicesの音声ファイル作成スクリプト


ファイル名:index.html

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>紙芝居ADVエンジン</title>
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <link rel="stylesheet" href="style.css">
  <script id="slides-data" type="application/json">
[
  {
    "type": "bgm",
    "src": "sounds/opening.mp3",
    "channel": 0,
    "volume": 0.8
  },
  {
    "type": "image",
    "src": "images/scene1.png",
    "autoNext": true,
    "wait": 800
  },
  {
    "type": "voice",
    "text": "目を覚ますと、そこは森の中だった。",
    "voice": "voices/v001.mp3"
  },
  {
    "type": "bgm",
    "src": "sounds/bgm1.mp3",
    "channel": 1,
    "volume": 0.8
  },
  {
    "type": "video",
    "src": "videos/forest_loop.mp4",
    "loop": true,
    "autoPlay": true,
    "autoNext": true
  },
  {
    "type": "voice",
    "text": "草むらの向こうから、何かが動いている気配がした。",
    "voice": "voices/v002.mp3"
  },
  {
    "type": "image",
    "src": "images/scene2.png",
    "autoNext": true,
    "wait": 800
  },
  {
    "type": "bgmStop",
    "channel": 0
  },
  {
    "type": "bgmStop",
    "channel": 1
  },
  {
    "type": "voice",
    "text": "僕は思わず、その場に立ち尽くした。",
    "voice": "voices/v003.mp3"
  }
]

  </script>
</head>
<body>

  <div id="screen">
    <img id="img" alt="">
    <video id="video" playsinline muted></video>
  </div>

  <!-- テキストウィンドウ -->
  <div id="text"></div>

  <!-- 音声レイヤー -->
  <audio id="bgm0"></audio>    <!-- BGM用:ループ再生 -->
  <audio id="bgm1"></audio>
  <audio id="bgm2"></audio>
  <audio id="voice"></audio>  <!-- セリフ音声用 -->

  <script src="game.js"></script>
</body>
</html>

シーンシナリオJSONを含む


ファイル名:style.css

/* 全体レイアウト */
body {
  margin: 0;
  background: #000;
  color: #fff;
  font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
  user-select: none;
  display: flex;
  flex-direction: column;
  height: 100vh;
}

/* 画像・動画エリア */
#screen {
  flex: 1;
  display: flex;
  justify-content: center;
  align-items: center;
  position: relative;
  background: #000;
}

#img,
#video {
  max-width: 100%;
  max-height: 100%;
  display: none;
}

/* テキストウィンドウ */
#text {
  padding: 12px 16px;
  background: rgba(0, 0, 0, 0.8);
  border-top: 1px solid #444;
  min-height: 3em;
  box-sizing: border-box;
  font-size: 16px;
  line-height: 1.6;
}

/* スマホなど横幅が狭いとき用 */
@media (max-width: 600px) {
  #text {
    font-size: 14px;
    padding: 10px 12px;
  }
}

ファイル名:game.js

"use strict";

const raw = document.getElementById("slides-data").textContent;
const slides = JSON.parse(raw);

let timerId = null;
let index = -1;
let started = false;

const img = document.getElementById("img");
const video = document.getElementById("video");
const text = document.getElementById("text");

const bgmChannels = [
  document.getElementById("bgm0"),
  document.getElementById("bgm1"),
  document.getElementById("bgm2")
];
const voiceAudio = document.getElementById("voice"); // セリフ音声用

function initScreen() {
  text.textContent = "クリックで開始";
}

// 共通の「次へ」関数
function nextSlide() {
  index++;
  showSlide(index);
}

initScreen();

function showSlide(i) {
  const s = slides[i];

  // 前の状態をクリア
  clearTimeout(timerId);
  video.onended = null;

  if (!s) {
    text.textContent = "おわり";
    img.style.display = "none";
    video.style.display = "none";
    voiceAudio.pause();
    return;
  }

  // --------------------------------------------------
  // BGM 開始スライド
  // --------------------------------------------------
  if (s.type === "bgm") {
      const ch = s.channel ?? 0;   // 指定がなければ0番
      const audio = bgmChannels[ch];

      audio.src = s.src;
      audio.loop = true;
      audio.volume = s.volume ?? 1.0;
      audio.play().catch(() => console.warn("BGM 再生失敗"));

      nextSlide();
      return;
  }

  // --------------------------------------------------
  // BGM 停止スライド
  // --------------------------------------------------
  if (s.type === "bgmStop") {
      const ch = s.channel ?? 0;
      const audio = bgmChannels[ch];
      audio.pause();
      audio.currentTime = 0;

      nextSlide();
      return;
  }

  // --------------------------------------------------
  // 背景:image
  // --------------------------------------------------
  if (s.type === "image") {
    if (s.src) {
      img.src = s.src;
    }
    img.style.display = "block";

    video.pause();
    video.style.display = "none";

    text.textContent = "";
    voiceAudio.pause();

    // 自動で次へ進めたい場合
    if (s.autoNext) {
      const wait = typeof s.wait === "number" ? s.wait : 500;
      timerId = setTimeout(nextSlide, wait);
    }
    return;
  }

  // --------------------------------------------------
  // 背景:video
  // --------------------------------------------------
  if (s.type === "video") {
    if (s.src) {
      video.src = s.src;
    }
    video.loop = s.loop ?? false;
    video.style.display = "block";
    img.style.display = "none";

    text.textContent = "";
    voiceAudio.pause();

    if (s.autoPlay) {
      video.play().catch(() => {
        console.warn("動画の自動再生に失敗しました");
      });
    }

    if (s.autoNext) {
      // 背景アニメとしてすぐ次へ
      nextSlide();
    } else {
      // 終了したら次へ
      video.onended = () => nextSlide();
    }
    return;
  }

  // --------------------------------------------------
  // セリフ:voice
  // --------------------------------------------------
  if (s.type === "voice") {
    text.textContent = s.text ?? "";

    if (s.voice) {
      voiceAudio.src = s.voice;
      voiceAudio.play().catch(() => {
        console.warn("voice 再生に失敗しました");
      });
    } else {
      voiceAudio.pause();
    }

    // voice スライドはここで止まる(クリック待ち)
    return;
  }

  // 想定外 type
  console.warn("Unknown slide type:", s.type);
}

// クリック → voice スライドのときだけ次へ
document.body.addEventListener("click", () => {
  if (!started) {
    // ★ 初回クリックでゲーム開始
    started = true;
    nextSlide();   // index が -1 → 0 になってスライド0からスタート
    return;
  }
  const s = slides[index];
  if (!s) return;

  if (s.type === "voice") {
    nextSlide();
  }
});

実行環境

上記ソースコードと動画・画像・音声ファイルをディレクトリに配置します。
ローカルストレージで動作を国んしました。

静的Webサイトに配置しても動作するはずですが、動画が再生されません。音が鳴りますのでボリューム注意。
実際動くGitHub Pagesへ

シナリオ JSON の使い方

このサウンドノベルエンジンでは、シナリオ(スライドの流れ)を JSON 形式 で記述します。
JSON は index.html 内の <script> タグに直接埋め込んでいます。

<script id="slides-data" type="application/json">
[
  {
    "type": "bgm",
    "src": "sounds/opening.mp3",
    "channel": 0,
    "volume": 0.8
  },
  {
    "type": "image",
    "src": "images/scene1.png",
    "autoNext": true,
    "wait": 800
  },
  {
    "type": "voice",
    "text": "目を覚ますと、そこは森の中だった。",
    "voice": "voices/v001.mp3"
  }
]
</script>

この JSON 配列の 1 要素が、1 枚のスライド に対応します。
スライドの種類は type プロパティで指定します。


スライドの基本構造

{
  "type": "スライドの種類",
  "src": "画像・動画・音声ファイルのパス",
  "text": "表示するテキスト(voice用)",
  "voice": "セリフ音声ファイル(mp3)",
  "channel": 0,
  "autoNext": true,
  "wait": 800
}

スライドの種類(type)によって、使用するプロパティが変わります。
この雛形では、以下の 5 種類の type を用意しています。

  • image … 背景画像
  • video … 背景動画
  • voice … テキスト+セリフ音声
  • bgm … BGM(または環境音)再生
  • bgmStop … BGM 停止

type: “image”(背景画像)

背景として画像を表示するスライドです。テキストは表示しません。

{
  "type": "image",
  "src": "images/scene1.png",
  "autoNext": true,
  "wait": 800
}
  • src … 表示する画像ファイル
  • autoNext … 指定時間後に自動で次のスライドへ進む
  • wait … 自動進行までの待ち時間(ミリ秒)

type: “video”(背景動画)

動画を背景として再生するスライドです。

{
  "type": "video",
  "src": "videos/forest_loop.mp4",
  "loop": true,
  "autoPlay": true,
  "autoNext": true
}
  • loop … 動画をループ再生するかどうか
  • autoPlay … 自動で再生を開始するか
  • autoNext
    • true … 表示直後にすぐ次のスライドへ(背景アニメ用途)
    • 未指定/false … 動画が終了したタイミングで次のスライドへ

テキストは表示しません。動画はあくまで背景扱いです。


type: “voice”(テキスト+セリフ音声)

テキストウィンドウに文字を表示し、同時に音声ファイルを再生するスライドです。
このスライドだけはクリック待ちで停止し、ユーザーのクリックで次へ進みます。

{
  "type": "voice",
  "text": "草むらの向こうから、何かが動いている気配がした。",
  "voice": "voices/v002.mp3"
}
  • text … テキストウィンドウに表示する文章
  • voice … セリフ音声ファイル(mp3)

画像・動画の背景は、直前のスライドの状態がそのまま残ります。


type: “bgm”(BGM・環境音の再生)

BGM や環境音を再生するためのスライドです。

{
  "type": "bgm",
  "src": "sounds/bgm1.mp3",
  "channel": 1,
  "volume": 0.8
}
  • src … 再生する BGM ファイル
  • channel … 再生するチャンネル番号(0, 1, 2…)
  • volume … 音量(0.0〜1.0)

スライド自体は即座にスルーされ、BGM だけが裏で再生され続けます。
同時再生したい場合は、channel を変えて複数の BGM を指定します。


{ "type": "bgm", "src": "sounds/wind.mp3",  "channel": 0, "volume": 0.5 },
{ "type": "bgm", "src": "sounds/birds.mp3", "channel": 1, "volume": 1.0 }

このように書くと、「風」と「小鳥の声」を同時に再生できます。


type: “bgmStop”(BGM停止)

指定したチャンネルの BGM を停止します。

{
  "type": "bgmStop",
  "channel": 0
}
  • channel … 停止するチャンネル番号(省略時は 0)

このスライドも即スルーされ、次のスライドへ進みます。


スライド進行のルール

この雛形では、スライドの進み方を次のように決めています。

  • 自動で進むスライド
    • image(autoNext が true の場合、wait 経過後に進む)
    • video(autoNext が true なら即、そうでない場合は動画終了時)
    • bgm(BGM を再生して即スルー)
    • bgmStop(BGM を停止して即スルー)
  • クリック待ちで止まるスライド
    • voice(テキスト+セリフ音声)

背景(image / video)は、必要なタイミングでだけ挟んでおき、
実際のセリフは voice スライドを並べていく、という構成にすると管理しやすくなります。


シナリオ JSON 記述のコツ

  • 背景を変えたい場面だけ image / video を挟む
  • 会話パートは voice を連続して並べる
  • BGM はシーンの頭で bgm、終わりで bgmStop を入れる
  • 同じ背景のままセリフだけ変えたい場合は、voice だけ増やす

JSON ファイルさえ書き換えれば、プログラム側を触らなくてもシナリオを差し替えられます。
分岐のないサウンドノベルであれば、この程度のルールで十分運用可能だとお思われます。

コメント