分岐の無いサウンドノベルの雛形を作成してみました。
ファイル構成
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
ソースファイル
ファイル名:sounds/opening.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… 自動で再生を開始するかautoNexttrue… 表示直後にすぐ次のスライドへ(背景アニメ用途)- 未指定/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 ファイルさえ書き換えれば、プログラム側を触らなくてもシナリオを差し替えられます。
分岐のないサウンドノベルであれば、この程度のルールで十分運用可能だとお思われます。

コメント