こちらのページからコードをコピーしてツールをご利用ください。
my-novel-game)を作成します。⚠それぞれのツールはファイル名で連携しているため、別の名前で保存するときちんと動作しなくなります。
⚠サンプルゲーム内で使用している画像や音楽のデータは同梱されていません。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>ノベルゲーム制作ツール</title>
<style>
:root {
/* ベース背景色 */
--bg-outer: #050a14; /* 画面外側の背景色 */
--bg-game: #0d1b2a; /* ゲーム画面・モーダル背景色 */
--border-game: #1b263b; /* ゲーム画面枠線 */
/* メインテーマカラー(ボタン・アクセント) */
--color-primary: #ffd166; /* 主ボタン背景・強調表示 */
--color-primary-hover: #ffeea9; /* 主ボタンホバー時 */
--color-primary-text: #1d3557; /* 主ボタン・好感度枠のテキスト色 */
/* サブボタン・システムUI */
--color-secondary: #ffffff; /* システムボタン背景 */
--color-secondary-border: #1d3557; /* システムボタン枠線 */
--color-secondary-text: #1d3557; /* システムボタンテキスト色 */
--color-active: #e63946; /* AUTO/SKIP有効時ハイライト色 */
/* メッセージウィンドウ */
--msg-box-bg: rgba(255, 255, 255, 0.92); /* メッセージ枠背景 */
--msg-box-border: #1d3557; /* メッセージ枠線 */
--msg-speaker-text: #1d3557; /* 話者名テキスト色 */
--msg-body-text: #2b2d42; /* 本文テキスト色 */
/* セーブ・ロード・バックログ(モーダル画面) */
--modal-bg: rgba(13, 27, 42, 0.95); /* モーダル全体の背景 */
--modal-card-bg: #ffffff; /* セーブスロット背景色 */
--modal-card-border: #457b9d; /* セーブスロット枠線 */
--modal-btn-bg: #457b9d; /* 閉じるボタン背景色 */
--modal-btn-hover: #1d3557; /* 閉じるボタンホバー時 */
--text-light: #ffffff; /* モーダル用基本文字色 */
}
html, body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
background-color: var(--bg-outer);
overflow: hidden;
font-family: "Helvetica Neue", Arial, "Hiragino Kaku Gothic ProN", "Hiragino Sans", MeiRyo, sans-serif;
}
#game-wrapper {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
z-index: 1;
}
#game-container {
width: 800px;
height: 450px;
position: absolute;
background-color: var(--bg-game);
background-size: cover;
background-position: center;
color: #333;
overflow: hidden;
border: 4px solid var(--border-game);
border-radius: 12px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
transform-origin: center center;
box-sizing: border-box;
}
#fade-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: var(--bg-game);
opacity: 0;
pointer-events: none;
transition: opacity 0.5s ease;
z-index: 50;
}
#fade-overlay.is-active {
opacity: 1;
pointer-events: auto;
}
/* タイトル画面レイヤー */
#title-screen {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-image: url('images/bg01.webp'); /* ←ここを変更してください */
background-size: cover;
background-position: center;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 15px;
z-index: 100;
}
#title-screen::before {
content: "";
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(13, 27, 42, 0.55);
z-index: -1;
}
#title-screen h1 {
font-size: 38px;
margin-bottom: 20px;
color: var(--text-light);
letter-spacing: 2px;
text-shadow: 0 4px 8px rgba(0, 0, 0, 0.5);
}
.menu-btn {
padding: 12px 28px;
font-size: 16px;
font-weight: bold;
background-color: var(--color-primary);
color: var(--color-primary-text);
border: 3px solid var(--text-light);
border-radius: 25px;
cursor: pointer;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.25);
transition: all 0.2s ease;
width: 200px;
}
.menu-btn:hover {
background-color: var(--color-primary-hover);
transform: translateY(-3px);
box-shadow: 0 6px 14px rgba(0, 0, 0, 0.3);
}
#system-menu {
position: absolute;
top: 12px;
right: 12px;
display: none;
gap: 6px;
z-index: 10;
}
.sys-btn {
padding: 6px 12px;
font-size: 12px;
font-weight: bold;
background: var(--color-secondary);
color: var(--color-secondary-text);
border: 2px solid var(--color-secondary-border);
border-radius: 15px;
cursor: pointer;
box-shadow: 0 2px 6px rgba(0,0,0,0.15);
transition: all 0.2s ease;
}
.sys-btn:hover {
background: var(--color-primary);
transform: translateY(-2px);
}
.sys-btn.is-active {
background: var(--color-active);
color: var(--text-light);
border-color: var(--text-light);
box-shadow: 0 0 8px rgba(230, 57, 70, 0.8);
}
#favor-display {
position: absolute;
top: 12px;
left: 12px;
padding: 8px 14px;
font-size: 12px;
font-weight: bold;
background: var(--color-primary);
color: var(--color-primary-text);
border: 2px solid var(--text-light);
border-radius: 12px;
display: none;
flex-direction: column;
gap: 3px;
z-index: 10;
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.2);
text-align: left;
min-width: 90px;
}
.favor-item {
display: flex;
justify-content: space-between;
gap: 12px;
}
#log-modal {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: var(--modal-bg);
display: none;
flex-direction: column;
align-items: center;
padding: 20px;
box-sizing: border-box;
z-index: 200;
}
#log-modal h2 {
margin: 0 0 15px 0;
color: var(--color-primary);
font-size: 22px;
}
#log-container {
width: 90%;
height: 300px;
overflow-y: auto;
background: rgba(255, 255, 255, 0.05);
border: 2px solid var(--modal-card-border);
border-radius: 8px;
padding: 15px;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 12px;
}
.log-item {
display: flex;
flex-direction: column;
border-bottom: 1px dashed rgba(255, 255, 255, 0.2);
padding-bottom: 8px;
text-align: left;
}
.log-speaker {
font-weight: bold;
color: var(--color-primary);
font-size: 13px;
margin-bottom: 2px;
}
.log-text {
color: var(--text-light);
font-size: 14px;
line-height: 1.4;
}
#staff-credits {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: var(--bg-game);
display: none;
z-index: 80;
}
#credits-container {
display: flex;
width: 100%;
height: 100%;
position: relative;
}
#credits-image-area {
width: 50%;
height: 100%;
background-size: cover;
background-position: center;
transition: width 0.5s ease;
}
#credits-text-area {
width: 50%;
height: 100%;
background-color: var(--modal-bg);
color: var(--text-light);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 30px;
box-sizing: border-box;
text-align: center;
}
.credits-role {
font-size: 14px;
color: var(--color-primary);
font-weight: bold;
margin-bottom: 8px;
letter-spacing: 1px;
}
.credits-name {
font-size: 20px;
font-weight: bold;
margin-bottom: 24px;
line-height: 1.4;
}
#thanks-text {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 42px;
font-weight: bold;
color: var(--text-light);
text-shadow: 0 4px 12px rgba(0, 0, 0, 0.8), 0 0 8px rgba(255, 209, 102, 0.8);
display: none;
z-index: 90;
white-space: nowrap;
pointer-events: none;
}
#save-modal {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: var(--modal-bg);
display: none;
flex-direction: column;
justify-content: center;
align-items: center;
z-index: 200;
}
#save-modal h2 {
margin-bottom: 20px;
color: var(--color-primary);
font-size: 24px;
}
.slot-list {
display: flex;
flex-direction: column;
gap: 12px;
width: 80%;
max-width: 500px;
}
.slot-item {
display: flex;
justify-content: space-between;
align-items: center;
background: var(--modal-card-bg);
color: var(--color-secondary-text);
border: 2px solid var(--modal-card-border);
padding: 12px 18px;
border-radius: 12px;
cursor: pointer;
box-shadow: 0 3px 6px rgba(0,0,0,0.15);
transition: all 0.2s ease;
}
.slot-item:hover {
background: #e0f4ff;
border-color: var(--color-primary);
transform: translateY(-2px);
}
.slot-info {
display: flex;
flex-direction: column;
gap: 4px;
text-align: left;
}
.slot-title {
font-weight: bold;
color: var(--color-secondary-text);
}
.slot-preview {
font-size: 12px;
color: var(--modal-card-border);
}
.slot-date {
font-size: 10px;
color: #6c757d;
}
.modal-close-btn {
margin-top: 20px;
padding: 8px 24px;
background: var(--modal-btn-bg);
color: var(--text-light);
font-weight: bold;
border: none;
border-radius: 18px;
cursor: pointer;
transition: background 0.2s;
}
.modal-close-btn:hover {
background: var(--modal-btn-hover);
}
/* 立ち絵レイヤー(3人対応) */
#character-layer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 1;
}
.character-img {
position: absolute;
bottom: 0;
max-height: 380px;
opacity: 0;
transform: translateX(-50%);
transition: left 0.35s ease, opacity 0.3s ease;
display: none;
}
.character-img.pos-left {
left: 20%;
}
.character-img.pos-center {
left: 50%;
}
.character-img.pos-right {
left: 80%;
}
.character-img.is-visible {
opacity: 1;
display: block;
}
#message-box {
position: absolute;
bottom: 12px;
left: 12px;
right: 12px;
height: 105px;
background: var(--msg-box-bg);
border: 3px solid var(--msg-box-border);
border-radius: 12px;
padding: 12px 18px;
cursor: pointer;
box-sizing: border-box;
z-index: 2;
display: none;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
#speaker-name {
font-weight: bold;
color: var(--msg-speaker-text);
margin-bottom: 6px;
font-size: 15px;
}
#message-text {
color: var(--msg-body-text);
line-height: 1.5;
font-size: 14px;
}
#choice-container {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
flex-direction: column;
gap: 12px;
z-index: 10;
}
.choice-btn {
padding: 12px 24px;
font-size: 14px;
font-weight: bold;
background: var(--color-primary);
color: var(--color-primary-text);
border: 2px solid var(--text-light);
border-radius: 20px;
cursor: pointer;
box-shadow: 0 4px 8px rgba(0,0,0,0.2);
transition: all 0.2s ease;
}
.choice-btn:hover {
background: var(--color-primary-hover);
color: var(--color-primary-text);
transform: scale(1.05);
}
</style>
</head>
<body>
<div id="game-wrapper">
<div id="game-container">
<div id="fade-overlay"></div>
<!-- タイトル画面 -->
<div id="title-screen">
<h1>ノベルゲームタイトル</h1>
<button id="start-btn" class="menu-btn">NEW GAME</button>
<button id="title-load-btn" class="menu-btn">LOAD GAME</button>
</div>
<div id="staff-credits">
<div id="credits-container">
<div id="credits-image-area"></div>
<div id="credits-text-area"></div>
</div>
<div id="thanks-text">Thanks for playing!</div>
</div>
<!-- 好感度・ステータス表示 -->
<div id="favor-display"></div>
<div id="system-menu">
<button id="auto-btn" class="sys-btn">AUTO</button>
<button id="skip-btn" class="sys-btn">SKIP</button>
<button id="log-btn" class="sys-btn">LOG</button>
<button id="save-btn" class="sys-btn">SAVE</button>
<button id="load-btn" class="sys-btn">LOAD</button>
<button id="to-title-btn" class="sys-btn">TITLE</button>
</div>
<div id="log-modal">
<h2>BACKLOG</h2>
<div id="log-container"></div>
<button id="log-close-btn" class="modal-close-btn">閉じる</button>
</div>
<div id="save-modal">
<h2 id="modal-title">SAVE DATA</h2>
<div class="slot-list">
<div class="slot-item" data-slot="1">
<div class="slot-info">
<div class="slot-title">SLOT 1</div>
<div class="slot-preview" id="slot-preview-1">-- NO DATA --</div>
<div class="slot-date" id="slot-date-1"></div>
</div>
</div>
<div class="slot-item" data-slot="2">
<div class="slot-info">
<div class="slot-title">SLOT 2</div>
<div class="slot-preview" id="slot-preview-2">-- NO DATA --</div>
<div class="slot-date" id="slot-date-2"></div>
</div>
</div>
<div class="slot-item" data-slot="3">
<div class="slot-info">
<div class="slot-title">SLOT 3</div>
<div class="slot-preview" id="slot-preview-3">-- NO DATA --</div>
<div class="slot-date" id="slot-date-3"></div>
</div>
</div>
</div>
<button id="modal-close-btn" class="modal-close-btn">閉じる</button>
</div>
<!-- 立ち絵レイヤー(3人対応) -->
<div id="character-layer">
<img id="chara-left" class="character-img pos-left" src="" alt="立ち絵左">
<img id="chara-center" class="character-img pos-center" src="" alt="立ち絵中央">
<img id="chara-right" class="character-img pos-right" src="" alt="立ち絵右">
</div>
<div id="choice-container"></div>
<div id="message-box">
<div id="speaker-name"></div>
<div id="message-text"></div>
</div>
</div>
</div>
<script>
function resizeGame() {
const container = document.getElementById("game-container");
const baseWidth = 800;
const baseHeight = 450;
const windowWidth = window.innerWidth;
const windowHeight = window.innerHeight;
const scaleX = windowWidth / baseWidth;
const scaleY = windowHeight / baseHeight;
const scale = Math.min(scaleX, scaleY) * 0.96;
container.style.transform = `scale(${scale})`;
}
window.addEventListener("resize", resizeGame);
window.addEventListener("orientationchange", resizeGame);
// 共通定数設定
const TITLE_BGM_URL = "sound/sound01.mp3";
const ENDING_BGM_URL = "sound/sound01.mp3";
const SOUND_EFFECTS = {
click: "sound/click.mp3",
type: "sound/type.mp3",
select: "sound/select.mp3"
};
// 画面表示用ラベルのマッピング設定
const FAVOR_NAME_MAP = {
charaA: "ほのか",
charaB: "裕也",
charaC: "三月"
};
// スタッフロールデータ
const creditsData = [
{
img: "images/bg01.webp",
roles: [
{ role: "制作・シナリオ", name: "okojo" },
]
},
{
img: "images/bg02.webp",
roles: [
{ role: "イラスト", name: "ゴリラの素材屋さん" },
]
},
{
img: "images/bg03.webp",
roles: [
{ role: "音楽・BGM", name: "魔王魂" },
]
},
{
img: "images/bg04.webp",
roles: [
{ role: "効果音(SE)", name: "魔王魂" },
]
},
{
img: "images/bg05.webp",
roles: [
{ role: "スペシャルサンクス", name: "Gemini" },
]
},
{
img: "images/bg01.webp",
isFinal: true
}
];
let scenario = [];
let labelMap = {};
let favorability = createInitialFavorability();
let currentSceneId = 0;
let typeTimer = null;
let autoTimer = null;
let creditsTimer = null;
let isTyping = false;
let isFading = false;
let fullText = "";
const typingSpeed = 50;
const fadeDuration = 500;
let isAutoMode = false;
let isSkipMode = false;
const autoDelay = 1500;
const skipDelay = 100;
let readSceneIds = new Set();
let backlog = [];
let currentCreditIndex = 0;
let isCreditsActive = false;
let isFinalCreditScreen = false;
let currentModalMode = "save";
// 現在のキャラ配置管理用
let activeCharacters = { left: "", center: "", right: "" };
const bgmAudio = new Audio();
bgmAudio.loop = true;
bgmAudio.volume = 0.3;
let currentBgmSrc = "";
function parseScenarioText(text) {
const lines = text.split(/\r?\n/).filter(line => line.trim() !== "");
const parsedScenario = [];
labelMap = {};
let pendingCommands = [];
let currentScene = { commands: [] };
let sceneIdCounter = 0;
lines.forEach(line => {
line = line.trim();
if (line.startsWith("#") || line.startsWith("//")) return;
if (line.startsWith("@")) {
const firstColonIdx = line.indexOf(":");
let command = "", param = "";
if (firstColonIdx !== -1) {
command = line.slice(1, firstColonIdx).trim();
param = line.slice(firstColonIdx + 1).trim();
} else {
command = line.slice(1).trim();
}
const cmdObj = { command, param };
if (command === "label") {
labelMap[param] = sceneIdCounter;
cmdObj.label = param;
} else if (command === "branch") {
cmdObj.isBranch = true;
const parts = param.split("|");
parts.forEach(p => {
if (p.startsWith("type:")) cmdObj.branchType = p.replace("type:", "").trim();
if (p.startsWith("chara:")) cmdObj.branchChara = p.replace("chara:", "").trim();
if (p.startsWith("border:")) cmdObj.branchBorder = parseInt(p.replace("border:", "").trim());
if (p.startsWith("target:")) cmdObj.branchTarget = p.replace("target:", "").trim();
if (p.startsWith("else:")) cmdObj.elseTarget = p.replace("else:", "").trim();
if (p.startsWith("cond:")) cmdObj.branchCond = p.replace("cond:", "").trim();
if (p.startsWith("happy:")) cmdObj.happyTarget = p.replace("happy:", "").trim();
if (p.startsWith("normal:")) cmdObj.normalTarget = p.replace("normal:", "").trim();
});
} else if (command === "choice") {
const choiceItems = param.split("/");
cmdObj.choices = choiceItems.map(item => {
const parts = item.split("|").map(s => s.trim());
const text = parts[0];
let nextTarget = null, favorTarget = "charaA", favorChange = 0;
parts.slice(1).forEach(p => {
if (p.startsWith("next:")) nextTarget = p.replace("next:", "").trim();
if (p.startsWith("favor:")) {
const val = p.replace("favor:", "").trim();
if (val.includes(":")) {
const [target, amount] = val.split(":");
favorTarget = target.trim();
favorChange = parseInt(amount);
} else {
favorChange = parseInt(val);
}
}
});
return { text, nextTarget, favorTarget, favorChange };
});
}
pendingCommands.push(cmdObj);
return;
}
const speakerMatch = line.match(/^([^:@<>\n\s]+):(.*)$/);
if (speakerMatch) {
currentScene.speaker = speakerMatch[1].trim();
currentScene.text = speakerMatch[2].trim();
} else {
currentScene.speaker = "";
currentScene.text = line;
}
currentScene.commands = [...pendingCommands];
pendingCommands = [];
currentScene.id = sceneIdCounter;
currentScene.next = sceneIdCounter + 1;
parsedScenario.push(currentScene);
sceneIdCounter++;
currentScene = { commands: [] };
});
parsedScenario.forEach(scene => {
scene.commands.forEach(cmd => {
if (cmd.choices) {
cmd.choices.forEach(choice => {
if (choice.nextTarget) {
choice.next = (labelMap[choice.nextTarget] !== undefined) ? labelMap[choice.nextTarget] : parseInt(choice.nextTarget);
}
});
}
});
});
return parsedScenario;
}
async function loadScenarioFromTxt(filePath) {
try {
const response = await fetch(filePath);
const text = await response.text();
return parseScenarioText(text);
} catch (error) {
console.error("scenario.txt の読み込みエラー:", error);
return [];
}
}
function createInitialFavorability() {
return { charaA: 0, charaB: 0, charaC: 0 };
}
const fadeOverlayEl = document.getElementById("fade-overlay");
const titleScreenEl = document.getElementById("title-screen");
const systemMenuEl = document.getElementById("system-menu");
const favorDisplayEl = document.getElementById("favor-display");
const startBtnEl = document.getElementById("start-btn");
const titleLoadBtnEl = document.getElementById("title-load-btn");
const saveBtnEl = document.getElementById("save-btn");
const loadBtnEl = document.getElementById("load-btn");
const toTitleBtnEl = document.getElementById("to-title-btn");
const saveModalEl = document.getElementById("save-modal");
const modalTitleEl = document.getElementById("modal-title");
const modalCloseBtnEl = document.getElementById("modal-close-btn");
const gameContainerEl = document.getElementById("game-container");
const charaImgEls = {
left: document.getElementById("chara-left"),
center: document.getElementById("chara-center"),
right: document.getElementById("chara-right")
};
const speakerNameEl = document.getElementById("speaker-name");
const messageTextEl = document.getElementById("message-text");
const messageBoxEl = document.getElementById("message-box");
const choiceContainerEl = document.getElementById("choice-container");
const autoBtnEl = document.getElementById("auto-btn");
const skipBtnEl = document.getElementById("skip-btn");
const logBtnEl = document.getElementById("log-btn");
const logModalEl = document.getElementById("log-modal");
const logContainerEl = document.getElementById("log-container");
const logCloseBtnEl = document.getElementById("log-close-btn");
const staffCreditsEl = document.getElementById("staff-credits");
const creditsImgAreaEl = document.getElementById("credits-image-area");
const creditsTextAreaEl = document.getElementById("credits-text-area");
const thanksTextEl = document.getElementById("thanks-text");
function playSe(src, volume = 0.4) {
if (!src) return;
const se = new Audio(src);
se.volume = volume;
se.play().catch(err => console.log("SE再生エラー:", err));
}
function playBgm(src) {
if (!src) {
bgmAudio.pause();
bgmAudio.currentTime = 0;
currentBgmSrc = "";
return;
}
if (currentBgmSrc === src) return;
bgmAudio.pause();
bgmAudio.src = src;
bgmAudio.currentTime = 0;
bgmAudio.play().then(() => {
currentBgmSrc = src;
}).catch(error => {
console.log("BGM再生エラー:", error);
});
}
function loadReadScenes() {
const data = localStorage.getItem("novel_game_read_scenes");
if (data) {
try {
readSceneIds = new Set(JSON.parse(data));
} catch (e) {
console.error("既読データの読み込みエラー", e);
}
}
}
function saveReadScenes() {
localStorage.setItem("novel_game_read_scenes", JSON.stringify(Array.from(readSceneIds)));
}
function markSceneAsRead(sceneId) {
if (!readSceneIds.has(sceneId)) {
readSceneIds.add(sceneId);
saveReadScenes();
}
}
function isSceneRead(sceneId) {
return readSceneIds.has(sceneId);
}
function updateFavorDisplay() {
if (typeof favorability === "object" && favorability !== null) {
let html = "";
for (const [key, val] of Object.entries(favorability)) {
const labelName = FAVOR_NAME_MAP[key] || key;
html += `<div class="favor-item"><span>${labelName}:</span><span>${val}</span></div>`;
}
favorDisplayEl.innerHTML = html;
} else {
favorDisplayEl.textContent = favorability;
}
}
function updateAutoSkipUI() {
autoBtnEl.classList.toggle("is-active", isAutoMode);
skipBtnEl.classList.toggle("is-active", isSkipMode);
}
function stopAutoSkip() {
isAutoMode = false;
isSkipMode = false;
clearTimeout(autoTimer);
updateAutoSkipUI();
}
function scheduleNextStep() {
clearTimeout(autoTimer);
if (choiceContainerEl.children.length > 0) return;
if (isSkipMode) {
autoTimer = setTimeout(() => {
advanceStory();
}, skipDelay);
} else if (isAutoMode) {
autoTimer = setTimeout(() => {
advanceStory();
}, autoDelay);
}
}
function parseColorTags(str) {
if (!str) return "";
return str.replace(/<color:(.*?)>(.*?)<\/color>/g, (match, color, p2) => {
return `<span style="color:${color.trim()};">${p2}</span>`;
});
}
function typeWriter(speaker, text) {
clearInterval(typeTimer);
clearTimeout(autoTimer);
fullText = text;
messageTextEl.innerHTML = "";
isTyping = true;
const lastLog = backlog[backlog.length - 1];
if (!lastLog || lastLog.speaker !== speaker || lastLog.text !== text) {
backlog.push({ speaker, text });
}
const parsedHtml = parseColorTags(text);
if (isSkipMode) {
messageTextEl.innerHTML = parsedHtml;
isTyping = false;
scheduleNextStep();
return;
}
let currentHtml = "";
let i = 0;
typeTimer = setInterval(() => {
if (i < parsedHtml.length) {
if (parsedHtml[i] === '<') {
const closingIdx = parsedHtml.indexOf('>', i);
if (closingIdx !== -1) {
currentHtml += parsedHtml.slice(i, closingIdx + 1);
i = closingIdx + 1;
} else {
currentHtml += parsedHtml[i];
i++;
}
} else {
currentHtml += parsedHtml[i];
if (parsedHtml[i].trim() !== "") {
playSe(SOUND_EFFECTS.type, 0.2);
}
i++;
}
messageTextEl.innerHTML = currentHtml;
} else {
clearInterval(typeTimer);
messageTextEl.innerHTML = parsedHtml;
isTyping = false;
scheduleNextStep();
}
}, typingSpeed);
}
function openLogModal() {
logContainerEl.innerHTML = "";
if (backlog.length === 0) {
logContainerEl.innerHTML = '<div style="color:#aaa; padding:20px;">履歴がありません。</div>';
} else {
backlog.forEach(item => {
const itemEl = document.createElement("div");
itemEl.className = "log-item";
const speakerHtml = item.speaker ? `<div class="log-speaker">${item.speaker}</div>` : "";
const parsedLogText = parseColorTags(item.text);
itemEl.innerHTML = `${speakerHtml}<div class="log-text">${parsedLogText}</div>`;
logContainerEl.appendChild(itemEl);
});
}
logModalEl.style.display = "flex";
logContainerEl.scrollTop = logContainerEl.scrollHeight;
}
function closeLogModal() {
logModalEl.style.display = "none";
}
function showGameUI() {
titleScreenEl.style.display = "none";
staffCreditsEl.style.display = "none";
messageBoxEl.style.display = "block";
systemMenuEl.style.display = "flex";
favorDisplayEl.style.display = "flex";
isCreditsActive = false;
isFinalCreditScreen = false;
}
function clearAllCharacters() {
activeCharacters = { left: "", center: "", right: "" };
["left", "center", "right"].forEach(pos => {
const img = charaImgEls[pos];
img.classList.remove("is-visible");
img.src = "";
});
}
function showTitleUI() {
stopAutoSkip();
clearTimeout(creditsTimer);
titleScreenEl.style.display = "flex";
staffCreditsEl.style.display = "none";
messageBoxEl.style.display = "none";
systemMenuEl.style.display = "none";
favorDisplayEl.style.display = "none";
clearAllCharacters();
choiceContainerEl.innerHTML = "";
isCreditsActive = false;
isFinalCreditScreen = false;
if (TITLE_BGM_URL) {
playBgm(TITLE_BGM_URL);
}
}
function renderCreditStep(index) {
clearTimeout(creditsTimer);
const data = creditsData[index];
if (!data) return;
if (data.isFinal) {
creditsImgAreaEl.style.width = "100%";
creditsImgAreaEl.style.backgroundImage = `url('${data.img}')`;
creditsTextAreaEl.style.display = "none";
thanksTextEl.style.display = "block";
isFinalCreditScreen = true;
} else {
creditsImgAreaEl.style.width = "50%";
creditsImgAreaEl.style.backgroundImage = `url('${data.img}')`;
creditsTextAreaEl.style.display = "flex";
thanksTextEl.style.display = "none";
let html = "";
data.roles.forEach(item => {
html += `<div class="credits-role">${item.role}</div>`;
html += `<div class="credits-name">${item.name}</div>`;
});
creditsTextAreaEl.innerHTML = html;
isFinalCreditScreen = false;
creditsTimer = setTimeout(() => {
nextCreditStep();
}, 3000);
}
}
function nextCreditStep() {
if (isFading) return;
if (isFinalCreditScreen) {
playSe(SOUND_EFFECTS.click);
fadeOverlayEl.classList.add("is-active");
setTimeout(() => {
showTitleUI();
fadeOverlayEl.classList.remove("is-active");
}, fadeDuration);
return;
}
currentCreditIndex++;
if (currentCreditIndex >= creditsData.length) return;
isFading = true;
fadeOverlayEl.classList.add("is-active");
setTimeout(() => {
renderCreditStep(currentCreditIndex);
fadeOverlayEl.classList.remove("is-active");
setTimeout(() => {
isFading = false;
}, fadeDuration);
}, fadeDuration);
}
function startCredits() {
stopAutoSkip();
isCreditsActive = true;
currentCreditIndex = 0;
messageBoxEl.style.display = "none";
systemMenuEl.style.display = "none";
favorDisplayEl.style.display = "none";
clearAllCharacters();
if (ENDING_BGM_URL) {
playBgm(ENDING_BGM_URL);
}
fadeOverlayEl.classList.add("is-active");
setTimeout(() => {
staffCreditsEl.style.display = "block";
renderCreditStep(0);
fadeOverlayEl.classList.remove("is-active");
}, fadeDuration);
}
function updateCharacterDisplay(param) {
if (param === undefined) return;
if (!param) {
clearAllCharacters();
return;
}
const parts = param.split("|").map(s => s.trim());
let imgSrc = parts[0];
let pos = "center";
let clearTarget = null;
parts.slice(1).forEach(p => {
if (p.startsWith("pos:")) pos = p.replace("pos:", "").trim();
if (p.startsWith("clear:")) clearTarget = p.replace("clear:", "").trim();
});
if (clearTarget) {
if (charaImgEls[clearTarget]) {
activeCharacters[clearTarget] = "";
charaImgEls[clearTarget].classList.remove("is-visible");
charaImgEls[clearTarget].src = "";
}
return;
}
if (!["left", "center", "right"].includes(pos)) pos = "center";
["left", "center", "right"].forEach(p => {
if (p !== pos && activeCharacters[p] === imgSrc) {
activeCharacters[p] = "";
charaImgEls[p].classList.remove("is-visible");
charaImgEls[p].src = "";
}
});
activeCharacters[pos] = imgSrc;
const targetImg = charaImgEls[pos];
targetImg.src = imgSrc;
targetImg.classList.add("is-visible");
}
function updateSceneElements(scene) {
currentSceneId = scene.id;
speakerNameEl.textContent = scene.speaker;
choiceContainerEl.innerHTML = "";
if (scene.commands && scene.commands.length > 0) {
scene.commands.forEach(cmd => {
const command = cmd.command;
const param = cmd.param;
if (command === "bg") {
if (param) {
gameContainerEl.style.backgroundImage = `url('${param}')`;
} else {
gameContainerEl.style.backgroundImage = "none";
}
}
else if (command === "character") {
updateCharacterDisplay(param);
}
else if (command === "bgm") {
playBgm(param);
}
else if (command === "se") {
playSe(param, 0.5);
}
else if (cmd.choices) {
stopAutoSkip();
cmd.choices.forEach(choice => {
const button = document.createElement("button");
button.className = "choice-btn";
button.textContent = choice.text;
button.onclick = (e) => {
e.stopPropagation();
playSe(SOUND_EFFECTS.select);
if (choice.favorChange !== undefined) {
const target = choice.favorTarget || "charaA";
if (typeof favorability !== "object" || favorability === null) {
favorability = createInitialFavorability();
}
if (favorability[target] === undefined) {
favorability[target] = 0;
}
favorability[target] += choice.favorChange;
updateFavorDisplay();
}
renderScene(choice.next);
};
choiceContainerEl.appendChild(button);
});
}
});
}
typeWriter(scene.speaker, scene.text);
}
function renderScene(sceneId) {
clearTimeout(autoTimer);
if (isFading) return;
const scene = scenario.find(s => s.id === sceneId);
if (!scene) return;
if (isSkipMode && !isSceneRead(scene.id)) {
stopAutoSkip();
}
markSceneAsRead(scene.id);
const hasFade = scene.commands && scene.commands.some(c => c.command === "fade" && c.param === "true");
const useFade = hasFade && !isSkipMode;
if (useFade) {
isFading = true;
fadeOverlayEl.classList.add("is-active");
setTimeout(() => {
updateSceneElements(scene);
fadeOverlayEl.classList.remove("is-active");
setTimeout(() => {
isFading = false;
}, fadeDuration);
}, fadeDuration);
} else {
updateSceneElements(scene);
}
}
function getSlotKey(slotNum) {
return `novel_game_slot_${slotNum}`;
}
function updateModalSlots() {
for (let i = 1; i <= 3; i++) {
const dataStr = localStorage.getItem(getSlotKey(i));
const previewEl = document.getElementById(`slot-preview-${i}`);
const dateEl = document.getElementById(`slot-date-${i}`);
if (dataStr) {
const data = JSON.parse(dataStr);
const cleanText = data.text.replace(/<color:(.*?)>(.*?)<\/color>/g, "$2");
const shortText = cleanText.length > 20 ? cleanText.substring(0, 20) + "..." : cleanText;
let favorText = "";
if (typeof data.favorability === "object" && data.favorability !== null) {
favorText = Object.entries(data.favorability)
.map(([k, v]) => `${FAVOR_NAME_MAP[k] || k}:${v}`)
.join(' ');
} else {
favorText = data.favorability ?? 0;
}
<!-- セーブ・ロード画面に文字を表示する -->
previewEl.textContent = `${data.speaker}: ${shortText} (${favorText})`;
dateEl.textContent = data.savedAt;
} else {
previewEl.textContent = "-- NO DATA --";
dateEl.textContent = "";
}
}
}
function openModal(mode) {
stopAutoSkip();
currentModalMode = mode;
modalTitleEl.textContent = mode === "save" ? "SAVE DATA" : "LOAD DATA";
updateModalSlots();
saveModalEl.style.display = "flex";
}
function closeModal() {
saveModalEl.style.display = "none";
}
function saveToSlot(slotNum) {
const scene = scenario.find(s => s.id === currentSceneId);
const saveDataObj = {
sceneId: currentSceneId,
speaker: scene ? scene.speaker : "",
text: scene ? scene.text : "",
favorability: favorability,
activeCharacters: activeCharacters,
backlog: backlog,
savedAt: new Date().toLocaleString()
};
localStorage.setItem(getSlotKey(slotNum), JSON.stringify(saveDataObj));
updateModalSlots();
playSe(SOUND_EFFECTS.click);
alert(`スロット ${slotNum} にセーブしました。`);
}
function loadFromSlot(slotNum) {
const dataStr = localStorage.getItem(getSlotKey(slotNum));
if (!dataStr) {
alert("このスロットにはデータがありません。");
return;
}
const data = JSON.parse(dataStr);
if (typeof data.favorability === "number") {
favorability = createInitialFavorability();
favorability.charaA = data.favorability;
} else {
favorability = data.favorability || createInitialFavorability();
}
backlog = data.backlog || [];
updateFavorDisplay();
clearAllCharacters();
if (data.activeCharacters) {
activeCharacters = { ...data.activeCharacters };
["left", "center", "right"].forEach(pos => {
if (activeCharacters[pos]) {
charaImgEls[pos].src = activeCharacters[pos];
charaImgEls[pos].classList.add("is-visible");
}
});
}
playSe(SOUND_EFFECTS.click);
closeModal();
showGameUI();
renderScene(data.sceneId);
}
function checkConditionString(condStr) {
if (!condStr) return false;
const conditions = condStr.split(",").map(c => c.trim());
return conditions.every(cond => {
const match = cond.match(/^([a-zA-Z0-9_]+)\s*(>=|<=|>|<|==|!=)\s*(-?\d+)$/);
if (!match) return false;
const [, varName, op, valStr] = match;
const targetVal = Number(valStr);
const currentVal = (favorability && favorability[varName] !== undefined) ? favorability[varName] : 0;
switch (op) {
case ">=": return currentVal >= targetVal;
case "<=": return currentVal <= targetVal;
case ">": return currentVal > targetVal;
case "<": return currentVal < targetVal;
case "==": return currentVal === targetVal;
case "!=": return currentVal !== targetVal;
default: return false;
}
});
}
function advanceStory() {
clearTimeout(autoTimer);
const currentScene = scenario.find(s => s.id === currentSceneId);
if (!currentScene) return;
if (currentScene.commands) {
for (const cmd of currentScene.commands) {
if (cmd.command === "startCredits" && cmd.param === "true") {
playSe(SOUND_EFFECTS.click);
startCredits();
return;
}
if (cmd.command === "jump" && cmd.param) {
if (labelMap[cmd.param] !== undefined) {
renderScene(labelMap[cmd.param]);
return;
}
}
if (cmd.isBranch) {
playSe(SOUND_EFFECTS.click);
const branchType = cmd.branchType || "single";
const a = (typeof favorability === "object" && favorability !== null) ? (favorability.charaA || 0) : 0;
const b = (typeof favorability === "object" && favorability !== null) ? (favorability.charaB || 0) : 0;
const c = (typeof favorability === "object" && favorability !== null) ? (favorability.charaC || 0) : 0;
if (branchType === "single") {
const targetChara = cmd.branchChara || "charaA";
const border = (cmd.branchBorder !== undefined) ? cmd.branchBorder : 10;
const currentVal = (typeof favorability === "object" && favorability !== null) ? (favorability[targetChara] || 0) : 0;
if (currentVal >= border) {
const targetLabel = cmd.branchTarget || cmd.happyTarget;
if (targetLabel && labelMap[targetLabel] !== undefined) {
renderScene(labelMap[targetLabel]);
return;
}
}
const elseLabel = cmd.elseTarget || cmd.normalTarget;
if (elseLabel && labelMap[elseLabel] !== undefined) {
renderScene(labelMap[elseLabel]);
} else {
renderScene(currentScene.next !== undefined ? currentScene.next : 0);
}
return;
}
else if (branchType === "max") {
const maxFavor = Math.max(a, b, c);
if (maxFavor <= 0) {
const elseLabel = cmd.elseTarget || cmd.normalTarget;
renderScene(elseLabel && labelMap[elseLabel] !== undefined ? labelMap[elseLabel] : currentScene.next);
return;
}
if (maxFavor === a && labelMap["A_route"] !== undefined) {
renderScene(labelMap["A_route"]);
} else if (maxFavor === b && labelMap["B_route"] !== undefined) {
renderScene(labelMap["B_route"]);
} else if (maxFavor === c && labelMap["C_route"] !== undefined) {
renderScene(labelMap["C_route"]);
} else {
const elseLabel = cmd.elseTarget || cmd.normalTarget;
renderScene(elseLabel && labelMap[elseLabel] !== undefined ? labelMap[elseLabel] : currentScene.next);
}
return;
}
else if (branchType === "multi") {
const isPassed = checkConditionString(cmd.branchCond);
if (isPassed && cmd.branchTarget && labelMap[cmd.branchTarget] !== undefined) {
renderScene(labelMap[cmd.branchTarget]);
} else {
const elseLabel = cmd.elseTarget || cmd.normalTarget;
renderScene(elseLabel && labelMap[elseLabel] !== undefined ? labelMap[elseLabel] : currentScene.next);
}
return;
}
}
}
}
if (currentScene.next !== undefined) {
playSe(SOUND_EFFECTS.click);
renderScene(currentScene.next);
}
}
autoBtnEl.addEventListener("click", (e) => {
e.stopPropagation();
playSe(SOUND_EFFECTS.click);
isSkipMode = false;
isAutoMode = !isAutoMode;
updateAutoSkipUI();
if (isAutoMode && !isTyping) {
scheduleNextStep();
}
});
skipBtnEl.addEventListener("click", (e) => {
e.stopPropagation();
playSe(SOUND_EFFECTS.click);
isAutoMode = false;
isSkipMode = !isSkipMode;
updateAutoSkipUI();
if (isSkipMode) {
if (isTyping) {
clearInterval(typeTimer);
messageTextEl.innerHTML = parseColorTags(fullText);
isTyping = false;
}
scheduleNextStep();
}
});
logBtnEl.addEventListener("click", (e) => {
e.stopPropagation();
playSe(SOUND_EFFECTS.click);
stopAutoSkip();
openLogModal();
});
logCloseBtnEl.addEventListener("click", (e) => {
e.stopPropagation();
playSe(SOUND_EFFECTS.click);
closeLogModal();
});
gameContainerEl.addEventListener("wheel", (e) => {
if (messageBoxEl.style.display === "block" && logModalEl.style.display !== "flex") {
if (e.deltaY < 0) {
stopAutoSkip();
openLogModal();
}
}
});
let touchStartY = 0;
gameContainerEl.addEventListener("touchstart", (e) => {
touchStartY = e.touches[0].clientY;
}, { passive: true });
gameContainerEl.addEventListener("touchend", (e) => {
const touchEndY = e.changedTouches[0].clientY;
if (touchStartY - touchEndY > 50 && messageBoxEl.style.display === "block" && logModalEl.style.display !== "flex") {
stopAutoSkip();
openLogModal();
}
}, { passive: true });
staffCreditsEl.addEventListener("click", () => {
if (isFinalCreditScreen) {
nextCreditStep();
}
});
document.addEventListener("keydown", (e) => {
if (e.key === "a" || e.key === "A") {
autoBtnEl.click();
} else if (e.key === "s" || e.key === "S") {
skipBtnEl.click();
} else if (e.key === "Enter") {
if (isCreditsActive) {
if (isFinalCreditScreen) {
nextCreditStep();
}
} else if (messageBoxEl.style.display === "block" && logModalEl.style.display !== "flex") {
messageBoxEl.click();
}
}
});
document.querySelectorAll(".slot-item").forEach(item => {
item.addEventListener("click", (e) => {
e.stopPropagation();
const slotNum = item.getAttribute("data-slot");
if (currentModalMode === "save") {
saveToSlot(slotNum);
} else {
loadFromSlot(slotNum);
}
});
});
startBtnEl.addEventListener("click", () => {
favorability = createInitialFavorability();
backlog = [];
stopAutoSkip();
updateFavorDisplay();
clearAllCharacters();
playSe(SOUND_EFFECTS.click);
showGameUI();
renderScene(0);
});
titleLoadBtnEl.addEventListener("click", () => {
playSe(SOUND_EFFECTS.click);
openModal("load");
});
saveBtnEl.addEventListener("click", (e) => {
e.stopPropagation();
playSe(SOUND_EFFECTS.click);
openModal("save");
});
loadBtnEl.addEventListener("click", (e) => {
e.stopPropagation();
playSe(SOUND_EFFECTS.click);
openModal("load");
});
modalCloseBtnEl.addEventListener("click", (e) => {
e.stopPropagation();
playSe(SOUND_EFFECTS.click);
closeModal();
});
toTitleBtnEl.addEventListener("click", (e) => {
e.stopPropagation();
playSe(SOUND_EFFECTS.click);
if (confirm("タイトル画面に戻りますか?")) {
playBgm("");
showTitleUI();
}
});
messageBoxEl.addEventListener("click", () => {
if (isFading) return;
if (choiceContainerEl.children.length > 0) return;
if (isAutoMode || isSkipMode) {
stopAutoSkip();
}
if (isTyping) {
playSe(SOUND_EFFECTS.click);
clearInterval(typeTimer);
messageTextEl.innerHTML = parseColorTags(fullText);
isTyping = false;
return;
}
advanceStory();
});
async function initApp() {
resizeGame();
loadReadScenes();
scenario = await loadScenarioFromTxt("scenario.txt");
updateModalSlots();
showTitleUI();
const enableAudioOnFirstTouch = () => {
if (titleScreenEl.style.display !== "none" && TITLE_BGM_URL) {
playBgm(TITLE_BGM_URL);
}
document.removeEventListener("click", enableAudioOnFirstTouch);
document.removeEventListener("touchstart", enableAudioOnFirstTouch);
};
document.addEventListener("click", enableAudioOnFirstTouch);
document.addEventListener("touchstart", enableAudioOnFirstTouch);
}
initApp();
const debugChannel = new BroadcastChannel('novel_game_debug');
function sendDebugStatus() {
debugChannel.postMessage({
type: 'STATE_UPDATE',
favorability: favorability,
favorNameMap: FAVOR_NAME_MAP,
currentSceneId: currentSceneId,
currentSpeaker: speakerNameEl.textContent,
currentText: fullText
});
}
debugChannel.onmessage = (event) => {
const data = event.data;
if (data.type === 'SET_FAVORABILITY') {
if (data.target && typeof data.target === "string") {
if (typeof favorability !== "object" || favorability === null) {
favorability = createInitialFavorability();
}
favorability[data.target] = Number(data.value);
} else if (typeof data.value === "object" && data.value !== null) {
favorability = { ...data.value };
}
updateFavorDisplay();
sendDebugStatus();
}
else if (data.type === 'JUMP_SCENE') {
const targetId = Number(data.value);
showGameUI();
renderScene(targetId);
sendDebugStatus();
}
else if (data.type === 'REQUEST_INITIAL_STATE') {
sendDebugStatus();
}
};
const originalRenderScene = renderScene;
renderScene = function(sceneId) {
originalRenderScene(sceneId);
sendDebugStatus();
};
const originalUpdateFavorDisplay = updateFavorDisplay;
updateFavorDisplay = function() {
originalUpdateFavorDisplay();
sendDebugStatus();
};
window.updateScenarioFromText = function(txtContent) {
scenario = parseScenarioText(txtContent);
favorability = createInitialFavorability();
backlog = [];
stopAutoSkip();
clearAllCharacters();
showGameUI();
renderScene(0);
sendDebugStatus();
};
</script>
</body>
</html>
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>scenario.txt プレビューエディタ - ノベルゲーム制作ツール</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.13/codemirror.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.13/theme/dracula.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.13/codemirror.min.js"></script>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: "Helvetica Neue", Arial, sans-serif;
background-color: #181825;
color: #cdd6f4;
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
header {
background-color: #11111b;
padding: 10px 20px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 2px solid #313244;
height: 50px;
}
h1 { font-size: 16px; color: #f9e2af; font-weight: bold; }
.btn-group { display: flex; gap: 10px; align-items: center; }
button, label.file-label {
padding: 6px 14px;
font-size: 12px;
font-weight: bold;
border-radius: 6px;
border: none;
cursor: pointer;
transition: all 0.2s;
}
.btn-primary { background-color: #89b4fa; color: #11111b; }
.btn-primary:hover { background-color: #b4befe; }
.btn-secondary { background-color: #45475a; color: #cdd6f4; }
.btn-secondary:hover { background-color: #585b70; }
.btn-preview { background-color: #a6e3a1; color: #11111b; }
.btn-preview:hover { background-color: #b3f0af; }
input[type="file"] { display: none; }
main {
position: absolute;
top: 50px;
bottom: 0;
left: 0;
right: 0;
display: flex;
width: 100%;
}
.editor-pane {
width: 50%;
height: 100%;
border-right: 2px solid #313244;
display: flex;
flex-direction: column;
}
.snippet-bar {
background-color: #181825;
border-bottom: 1px solid #313244;
padding: 6px 10px;
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.snippet-group {
display: flex;
gap: 4px;
align-items: center;
border-right: 1px solid #313244;
padding-right: 8px;
margin-right: 4px;
}
.snippet-group:last-child {
border-right: none;
}
.snippet-btn {
padding: 3px 8px;
font-size: 11px;
font-weight: bold;
border-radius: 4px;
border: 1px solid #45475a;
background-color: #313244;
color: #cdd6f4;
cursor: pointer;
transition: all 0.15s;
}
.snippet-btn:hover {
background-color: #585b70;
border-color: #89b4fa;
color: #ffffff;
}
.snippet-btn-cmd { color: #f38ba8; }
.snippet-btn-val { color: #89dceb; }
.snippet-btn-red { color: #f38ba8; border-color: #f38ba8; }
.snippet-btn-blue { color: #89b4fa; border-color: #89b4fa; }
.snippet-btn-green { color: #a6e3a1; border-color: #a6e3a1; }
.CodeMirror {
flex: 1;
height: auto;
font-family: "Consolas", "Courier New", monospace;
font-size: 14px;
line-height: 1.6;
}
.preview-pane {
width: 50%;
height: 100%;
background-color: #050a14;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
position: relative;
}
.preview-header {
position: absolute;
top: 10px;
left: 15px;
font-size: 12px;
color: #a6adc8;
background: rgba(0, 0, 0, 0.6);
padding: 4px 10px;
border-radius: 4px;
pointer-events: none;
z-index: 10;
}
iframe {
width: 100%;
height: 100%;
border: none;
}
.cm-scenario-command { color: #f38ba8 !important; font-weight: bold; }
.cm-scenario-speaker { color: #f9e2af !important; font-weight: bold; }
.cm-scenario-label { color: #a6e3a1 !important; font-weight: bold; }
.cm-scenario-comment { color: #6c7086 !important; font-style: italic; }
.cm-scenario-param { color: #89dceb !important; }
.cm-scenario-tag { color: #f5c2e7 !important; font-weight: bold; }
</style>
</head>
<body>
<header>
<h1>scenario.txt プレビューエディタ</h1>
<div class="btn-group">
<button id="preview-btn" class="btn-preview">▶ プレビュー再生</button>
<label class="file-label btn-secondary">
ファイルを開く
<input type="file" id="file-input" accept=".txt">
</label>
<button id="download-btn" class="btn-primary">scenario.txt を保存</button>
</div>
</header>
<main>
<div class="editor-pane">
<div class="snippet-bar">
<div class="snippet-group">
<button class="snippet-btn snippet-btn-cmd" data-snippet="@bg: images/">@bg</button>
<button class="snippet-btn snippet-btn-cmd" data-snippet="@bgm: sound/">@bgm</button>
<button class="snippet-btn snippet-btn-cmd" data-snippet="@se: sound/">@se</button>
<button class="snippet-btn snippet-btn-cmd" data-snippet="@fade: true">@fade</button>
<button class="snippet-btn snippet-btn-cmd" data-snippet="@label: ">@label</button>
<button class="snippet-btn snippet-btn-cmd" data-snippet="@jump: ">@jump</button>
</div>
<div class="snippet-group">
<button class="snippet-btn snippet-btn-val" data-snippet="@character: images/|pos:left">立ち絵:左</button>
<button class="snippet-btn snippet-btn-val" data-snippet="@character: images/|pos:center">立ち絵:中</button>
<button class="snippet-btn snippet-btn-val" data-snippet="@character: images/|pos:right">立ち絵:右</button>
<button class="snippet-btn snippet-btn-val" data-snippet="@character: |clear:left">消去:左</button>
<button class="snippet-btn snippet-btn-val" data-snippet="@character: |clear:right">消去:右</button>
<button class="snippet-btn snippet-btn-val" data-snippet="@character: ">全消去</button>
</div>
<div class="snippet-group">
<button class="snippet-btn snippet-btn-cmd" data-snippet="@choice: 選択肢1|next:label1|favor:charaA:10 / 選択肢2|next:label2|favor:charaB:10">@choice</button>
<button class="snippet-btn snippet-btn-cmd" data-snippet="@branch: type:single|chara:charaA|border:10|target:happy_end|else:normal_end">@branch(個別)</button>
<button class="snippet-btn snippet-btn-cmd" data-snippet="@branch: type:max|else:normal_end">@branch(最多)</button>
<button class="snippet-btn snippet-btn-cmd" data-snippet="@branch: type:multi|cond:charaA>=10,charaB>=10|target:true_end|else:normal_end">@branch(複合)</button>
</div>
<div class="snippet-group">
<button class="snippet-btn snippet-btn-red" data-tag-color="red">文字:赤</button>
<button class="snippet-btn snippet-btn-blue" data-tag-color="blue">文字:青</button>
<button class="snippet-btn snippet-btn-green" data-tag-color="green">文字:緑</button>
</div>
</div>
<textarea id="editor"></textarea>
</div>
<div class="preview-pane">
<div class="preview-header">PREVIEW</div>
<iframe id="preview-frame" src="index.html"></iframe>
</div>
</main>
<script>
// 複数行コマンド対応の新しい色分けルール
CodeMirror.defineMode("novel-scenario", function() {
return {
token: function(stream) {
if (stream.sol() && (stream.match("#") || stream.match("//"))) {
stream.skipToEnd();
return "scenario-comment";
}
if (stream.sol() && stream.match("@")) {
if (stream.match("label") || stream.match("jump")) {
stream.skipToEnd();
return "scenario-label";
}
stream.eatWhile(/[a-zA-Z]/);
return "scenario-command";
}
if (stream.sol() && stream.match(/^[^:@<>\n\s]+(?=:)/)) {
return "scenario-speaker";
}
if (stream.match(/<\/color>/i) || stream.match(/<color:[^>]+>/i)) {
return "scenario-tag";
}
if (stream.eat("|") || stream.eat("/")) {
return "scenario-param";
}
stream.next();
return null;
}
};
});
const initialText = `@bg: images/bg03.webp
@bgm: sound/sound02.mp3
@character: images/honoka.webp|pos:center
ほのか:このエディタを使うとツール内の独自コードを記述しやすくなるよ!
ほのか:例えば、上側のツールバーにあるボタンをクリックすると、よく使うコマンドをぱっとエディタに入力できるんだ。
@character: images/ryo.webp|pos:left
亮:<color:green>「立ち絵」</color>のコマンドを挿入すると、こんなふうにキャラクターが表示できるぞ。
@character: images/azusa.webp|pos:right
梓:テキストの色を変えたいときは、文字を選択した状態で<color:red>「文字:赤」</color>とかを選ぶと<color:red>こんな感じで</color>文字の色を変えられちゃうよん!
ほのか:このツールでは、<color:green>「左」「中」「右」</color>に表示できるキャラは1人だけなんだ。だからこうやって「中」にいるキャラを「右」に移動させると……
@character: images/honoka.webp|pos:right
ほのか:右のキャラが消えちゃうんだ。
@character: images/azusa.webp|pos:right
梓:で、また右にキャラを表示させると、いま右にいたキャラが消えるってわけ。
@character: images/honoka.webp|pos:center
ほのか:同じキャラを2か所に表示することもできないから気を付けてね!
亮:そうそう、このチュートリアルでは<color:blue>好感度(変数)</color>を使用していないから、左上の好感度ウインドウがちょっと邪魔だよな?
亮:そういうときは<color:green>「index.html」</color>内のコードを少し変更することで非表示にできるぞ。
梓:「UIをもっとシンプルなカラーリングにしたい!」ってときも、<color:green>「index.html」</color>内のコードをいじればいいよん!
ほのか:もっと詳しく知りたい人はWebマニュアルを読んでみてね!`;
const editor = CodeMirror.fromTextArea(document.getElementById("editor"), {
mode: "novel-scenario",
theme: "dracula",
lineNumbers: true,
lineWrapping: true
});
editor.setValue(initialText);
function insertSnippet(text) {
const doc = editor.getDoc();
const cursor = doc.getCursor();
doc.replaceRange(text, cursor);
editor.focus();
}
function insertColorTag(color) {
const doc = editor.getDoc();
const selection = doc.getSelection();
if (selection) {
const wrappedText = `<color:${color}>${selection}</color>`;
doc.replaceSelection(wrappedText);
} else {
const template = `<color:${color}>テキスト</color>`;
insertSnippet(template);
}
editor.focus();
}
document.querySelectorAll('.snippet-btn').forEach(button => {
button.addEventListener('click', function() {
const snippet = this.getAttribute('data-snippet');
const tagColor = this.getAttribute('data-tag-color');
if (tagColor) {
insertColorTag(tagColor);
} else if (snippet) {
insertSnippet(snippet);
}
});
});
const previewFrame = document.getElementById("preview-frame");
const previewBtn = document.getElementById("preview-btn");
function updatePreview() {
const scenarioText = editor.getValue();
if (previewFrame.contentWindow && typeof previewFrame.contentWindow.updateScenarioFromText === 'function') {
previewFrame.contentWindow.updateScenarioFromText(scenarioText);
}
}
previewBtn.addEventListener("click", updatePreview);
document.getElementById("file-input").addEventListener("change", function(e) {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = function(e) {
editor.setValue(e.target.result);
};
reader.readAsText(file);
});
document.getElementById("download-btn").addEventListener("click", function() {
const text = editor.getValue();
const blob = new Blob([text], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "scenario.txt";
a.click();
URL.revokeObjectURL(url);
});
</script>
</body>
</html>
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>変数管理・デバッグツール - ノベルゲーム制作ツール</title>
<style>
body {
font-family: "Helvetica Neue", Arial, sans-serif;
background: #111827;
color: #f3f4f6;
margin: 0;
padding: 20px;
}
h1 {
font-size: 18px;
border-bottom: 2px solid #374151;
padding-bottom: 8px;
color: #ffd166;
margin-top: 0;
}
.card {
background: #1f2937;
padding: 15px;
border-radius: 8px;
margin-bottom: 15px;
border: 1px solid #374151;
}
label {
display: block;
font-size: 13px;
color: #9ca3af;
margin-bottom: 6px;
}
.chara-row {
display: flex;
align-items: center;
justify-content: space-between;
background: #111827;
padding: 8px 12px;
border-radius: 6px;
margin-bottom: 8px;
border: 1px solid #374151;
}
.chara-info {
display: flex;
flex-direction: column;
}
.chara-name {
font-weight: bold;
color: #ffffff;
font-size: 14px;
}
.chara-key {
font-size: 11px;
color: #6b7280;
}
.input-group {
display: flex;
gap: 6px;
}
input[type="number"] {
width: 70px;
padding: 6px;
border-radius: 4px;
border: 1px solid #4b5563;
background: #374151;
color: #fff;
text-align: right;
font-weight: bold;
}
button {
padding: 6px 12px;
border: none;
border-radius: 4px;
background: #2563eb;
color: #fff;
font-weight: bold;
cursor: pointer;
font-size: 12px;
transition: background 0.2s;
}
button:hover {
background: #1d4ed8;
}
.btn-apply-all {
width: 100%;
margin-top: 6px;
background: #059669;
padding: 8px;
}
.btn-apply-all:hover {
background: #047857;
}
.info-val {
font-size: 20px;
font-weight: bold;
color: #60a5fa;
}
.scene-preview {
font-size: 13px;
color: #d1d5db;
background: #111827;
padding: 10px;
border-radius: 4px;
margin-top: 5px;
line-height: 1.4;
}
</style>
</head>
<body>
<h1>ノベルゲーム 変数管理ツール</h1>
<!-- キャラクター別 好感度・ステータス管理 -->
<div class="card">
<label>好感度・ステータスパラメータ (favorability)</label>
<div id="favor-container">
<!-- JSで動的に入力欄を生成 -->
</div>
<button class="btn-apply-all" onclick="setAllFavorability()">全パラメータを一括変更</button>
</div>
<!-- シーン・進行管理 -->
<div class="card">
<label>現在のシーンID (currentSceneId)</label>
<div class="info-val" id="scene-id-disp">-</div>
<label style="margin-top: 15px;">指定シーンへジャンプ (テスト用)</label>
<div class="input-group">
<input type="number" id="jump-scene-input" value="0" placeholder="ID">
<button onclick="jumpToScene()">ジャンプ</button>
</div>
<label style="margin-top: 12px;">現在のテキスト</label>
<div class="scene-preview">
<b id="speaker-disp" style="color: #ffd166;"></b><br>
<span id="text-disp">---</span>
</div>
</div>
<script>
const channel = new BroadcastChannel('novel_game_debug');
// 初期表示名マッピング(本体から受信するまでの仮のデータ)
let FAVOR_NAME_MAP = {
charaA: "ほのか",
charaB: "裕也",
charaC: "三月"
};
// 初期状態の取得要求
channel.postMessage({ type: 'REQUEST_INITIAL_STATE' });
// ゲームからの通知を受信してUIを更新
channel.onmessage = (event) => {
const data = event.data;
if (data.type === 'STATE_UPDATE') {
// 本体から名称マッピングが送られてきた場合は更新
if (data.favorNameMap) {
FAVOR_NAME_MAP = data.favorNameMap;
}
renderFavorInputs(data.favorability);
document.getElementById('scene-id-disp').textContent = data.currentSceneId;
document.getElementById('speaker-disp').textContent = data.currentSpeaker ? `【${data.currentSpeaker}】` : '(話者なし)';
// <color:...> タグを取り除いた純粋なテキストをプレビュー表示
const cleanText = (data.currentText || '').replace(/<color:(.*?)>(.*?)<\/color>/g, '$2');
document.getElementById('text-disp').textContent = cleanText;
}
};
// キャラごとの入力UIを動的に生成・更新
function renderFavorInputs(favorability) {
const container = document.getElementById('favor-container');
if (typeof favorability !== "object" || favorability === null) {
favorability = { charaA: Number(favorability) || 0, charaB: 0, charaC: 0 };
}
for (const [key, val] of Object.entries(favorability)) {
let row = document.getElementById(`favor-row-${key}`);
const displayName = FAVOR_NAME_MAP[key] || key;
if (!row) {
row = document.createElement('div');
row.id = `favor-row-${key}`;
row.className = 'chara-row';
row.innerHTML = `
<div class="chara-info">
<span class="chara-name" id="favor-label-${key}">${displayName}</span>
<span class="chara-key">${key}</span>
</div>
<div class="input-group">
<input type="number" id="favor-input-${key}" value="${val}">
<button onclick="setSingleFavorability('${key}')">反映</button>
</div>
`;
container.appendChild(row);
} else {
// すでに要素が存在する場合でも表示名を最新に上書きする
const labelEl = document.getElementById(`favor-label-${key}`);
if (labelEl) {
labelEl.textContent = displayName;
}
const input = document.getElementById(`favor-input-${key}`);
if (document.activeElement !== input) {
input.value = val;
}
}
}
}
// (以下、setSingleFavorability や jumpToScene などは既存のまま変更なし)
// 特定パラメータのみ変更
function setSingleFavorability(targetKey) {
const val = document.getElementById(`favor-input-${targetKey}`).value;
channel.postMessage({
type: 'SET_FAVORABILITY',
target: targetKey,
value: Number(val)
});
}
// 全パラメータを一括変更
function setAllFavorability() {
const updatedFavor = {};
const inputs = document.querySelectorAll('[id^="favor-input-"]');
inputs.forEach(input => {
const key = input.id.replace('favor-input-', '');
updatedFavor[key] = Number(input.value);
});
channel.postMessage({
type: 'SET_FAVORABILITY',
value: updatedFavor
});
}
// 指定シーンへ直接ジャンプ
function jumpToScene() {
const val = document.getElementById('jump-scene-input').value;
channel.postMessage({ type: 'JUMP_SCENE', value: val });
}
</script>
</body>
</html>
@bg: images/bg01.webp
@bgm: sound/sound01.mp3
@character: images/honoka.webp|pos:center
ほのか:こんにちは!<color:green>『ノベルゲーム制作ツール』</color>へようこそ!
#実行したいコマンドを「@」で入力したあとセリフを入力してね!
ほのか:このツールでは、プログラミングができなくてもノベルゲームが作れちゃうんだよ。
ほのか:これから私と一緒に、このツールで「どんなことができるか」を体験してみよう!
@choice: ぜひ教えて!|next:explain1|favor:charaA:10 / 簡単にできるの?|next:explain2|favor:charaA:0
ほのか:心の準備はいいかな?
#「@choice」で選択肢を表示してから「選択肢に表示される文字|ジャンプ先のラベル|変数の増減値」を指定してるんだよ。
@label: explain1
#「@label」は「ジャンプ先の目印」だよ。
@se: sound/ok.mp3
<color:blue>ほのかの好感度が10あがった!</color>
@label: explain2
#「簡単にできるの?」を選択した場合は、「@label: explain1」は無視してここにジャンプするよ!
ほのか:まずは <color:green>【画面演出と立ち絵の位置移動】</color> から紹介するね!
@character: images/honoka.webp|pos:left
ほのか:こんなふうに『<color:green>|pos:left</color>』と書くと立ち絵を左側に表示できるよ。
@character: images/honoka.webp|pos:right
ほのか:そして『<color:green>|pos:right</color>』と書けば右側にも移動できちゃうんだ!
@character: images/honoka.webp|pos:center
@bg: images/bg02.webp
@bgm: sound/sound02.mp3
ほのか:背景(<color:green>@bg</color>)やBGM(<color:green>@bgm</color>)を変えるだけで、一気に場面転換の雰囲気を変えられるよ。
ほのか:次は<color:green>【快適なシステム機能】</color>の紹介だよ!画面右上のボタンを見てみてね。
ほのか:<color:green>『AUTO』</color>で文章を自動進行、<color:green>『SKIP』</color>で一度読んだ文章(既読)だけを高速スキップできるんだ。
ほのか:<color:green>『LOG』</color>を押すか上ホイール・上スワイプをすると、いつでもバックログ(既読履歴)を確認できるよ。
ほのか:もちろん<color:green>『SAVE/LOAD』</color>で最大3つのスロットにゲームデータを保存・読み込みもできるから安心だね!
ほのか:それじゃあ次は<color:green>【選択肢とルート分岐】</color>を試してみようか!
ほのか:あっ、ちょうどいいところに!裕也くーん!三月ちゃーん!
@character: images/yuya.webp|pos:left
裕也:ん?なんだよ、なんか用か?
@character: images/mitsuki.webp|pos:right
三月:なに?そんなにヒマじゃないんだけど。
@character: images/honoka.webp|pos:center
ほのか:じゃあ、さっそく選択肢を出してみるね。
@choice: ほのかと話す|next:talk_honoka|favor:charaA:10 / 裕也と話す|next:talk_yuya|favor:charaB:10 / 三月と話す|next:talk_mitsuki|favor:charaC:10
ほのか:誰と一緒にチュートリアルの後半を進めたい?
@label: talk_honoka
@se: sound/ok.mp3
ほのか:私を選んでくれたんだね!ありがとう!
@jump: branch_check
ほのか:選択肢の『<color:green>favor:charaA:10</color>』パラメータで、左上の画面に表示されている好感度が加算されたよ!
#「このセリフのあとこのラベルにジャンプさせたい」というときは、セリフの上側に「@jump」を入力してね!
@label: talk_yuya
@se: sound/ok.mp3
裕也:お?オレを選ぶなんてお前、お目が高いな!
@jump: branch_check
裕也:選択肢の『<color:green>favor:charaB:10</color>』でオレの好感度が上がったぞ!画面左上をチェックしてみてくれ!
@label: talk_mitsuki
@se: sound/ok.mp3
三月:……私?ふうん。変わってるね。
@jump: branch_check
三月:『<color:green>favor:charaC:10</color>』のコマンドで、私の数値が10加算されたよ。
@label: branch_check
@character: images/honoka.webp|pos:center
ほのか:ここからは『<color:green>@branch</color>』コマンドを使った、自動ルート分岐のテストだよ!
@branch: type:max
ほのか:今回は一番好感度が高いキャラクターの個別ルート(<color:green>type:max</color>)へ自動でジャンプしてみるね!
#セリフの上に「@branch: type:max」を記述しておくことで、このセリフのあとに「好感度によるルート分岐」が発生するよ!
@label: A_route
@fade: true
#セリフの上側に「@fade: true」を入れておけば、そのシーンに移動するときフェードインの演出が入るんだ。
@bg: images/bg03.webp
@character: |clear:left
@character: |clear:right
@character: images/honoka.webp|pos:center
#「裕也」と「三月」の立ち絵を消して「ほのか」だけを残しているよ!
ほのか:ここは【ほのかルート】だよ!私の好感度が一番高かったからここに来たんだね!
@jump: ending_tutorial
ほのか:『<color:green>type:max</color>』を使うと、一番値が高いキャラのルート(A_route, B_route, C_route)へ自動分岐できるんだよ。
@label: B_route
@fade: true
@bg: images/bg04.webp
@character: |clear:right
@character: images/yuya.webp|pos:center
#「三月」の立ち絵を消して「裕也」の立ち絵を中央に移動させているよ。
#それぞれの位置に配置できるキャラは一人だけだから、中央にいた「ほのか」は自動的に非表示になるんだ。
裕也:ここは【裕也ルート】だ!オレの数値を一番高くしてくれたんだな、サンキュな!
@jump: ending_tutorial
裕也:ルート分岐を使えばこんなふうに「一番仲良くなったキャラの個別エンディングに行く」みたいな演出が作れるぞ!
@label: C_route
@fade: true
@bg: images/bg05.webp
@character: |clear:left
@character: images/mitsuki.webp|pos:center
三月:ここは【三月ルート】……私の数値が最高だった証拠。
@jump: ending_tutorial
三月:複雑なフラグ管理をしなくても、好感度の比較だけで分岐できるのは……とても合理的。
@label: ending_tutorial
@character: images/honoka.webp|pos:center
ほのか:チュートリアルはこれでバッチリ!
@character: images/yuya.webp|pos:left
裕也:あとはシナリオテキストを書き換えるだけで、お前だけのオリジナルノベルゲームが作れるぜ!
@character: images/mitsuki.webp|pos:right
三月:最後に『<color:green>@startCredits:true</color>』を呼び出すと、スタッフロールが再生されるよ。
@startCredits: true
ほのか:それじゃあ、素敵なゲームを作ってね!バイバイー!
#「@startCredits」を入力するのもセリフの上側だから気を付けてね!