ゲームブックをちょっと更新


画像
画像

サンプルデータを少し作ってみました。

わりと普通なお題なんですが
だんだん頭が良くなる感じ。


・ダウンロードされる方はこちら。↓


・ソースコードはこちら。↓

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>ゲームブック</title>
    <style>
        body {
            margin: 0;
            padding: 0;
            background: linear-gradient(135deg, #1e3c72, #2a5298);
            color: white;
            font-family: 'MS Gothic', monospace;
            font-size: 32px;
            line-height: 1.6;
            overflow: hidden;
            background-image: 
                radial-gradient(circle at 20% 20%, rgba(255,255,255,0.1) 1px, transparent 1px),
                radial-gradient(circle at 80% 80%, rgba(255,255,255,0.1) 1px, transparent 1px);
            background-size: 50px 50px;
        }

        .game-container {
            width: 100vw;
            height: 100vh;
            display: flex;
            flex-direction: column;
            justify-content: center;
            align-items: center;
            position: relative;
        }

        .title-screen {
            text-align: center;
            opacity: 1;
            transition: opacity 0.8s ease-in-out;
        }

        .title-screen.fade-out {
            opacity: 0;
        }

        .title-text {
            font-size: 48px;
            font-weight: bold;
            margin-bottom: 50px;
            text-shadow: 3px 3px 6px rgba(0,0,0,0.7);
            background: linear-gradient(45deg, #ffd700, #ffed4e);
            -webkit-background-clip: text;
            -webkit-text-fill-color: transparent;
            background-clip: text;
        }

        .click-message {
            position: absolute;
            bottom: 20px;
            font-size: 18px;
            cursor: pointer;
            animation: pulse 2s infinite;
            text-shadow: 2px 2px 4px rgba(0,0,0,0.7);
        }

        @keyframes pulse {
            0% { opacity: 0.7; }
            50% { opacity: 1; }
            100% { opacity: 0.7; }
        }

        .game-screen {
            width: 100%;
            height: 100%;
            display: none;
            flex-direction: column;
            opacity: 0;
            transition: opacity 0.8s ease-in-out;
        }

        .game-screen.fade-in {
            opacity: 1;
        }

        .image-area {
            width: calc(100% - 40px);
            height: 400px;
            margin: 20px 20px 0 20px;
            background-color: #000;
            display: flex;
            justify-content: center;
            align-items: center;
            border: 8px solid #4a4a4a;
            border-radius: 12px;
            box-shadow: 
                inset -3px -3px 6px rgba(0,0,0,0.8),
                inset 3px 3px 6px rgba(255,255,255,0.1),
                0 8px 16px rgba(0,0,0,0.5);
            position: relative;
            overflow: hidden;
        }

        .image-area::before {
            content: '';
            position: absolute;
            top: 4px;
            left: 4px;
            right: 4px;
            bottom: 4px;
            border: 2px solid #666;
            border-radius: 8px;
            pointer-events: none;
        }

        .image-area img {
            max-width: 100%;
            max-height: 100%;
            object-fit: contain;
            border-radius: 4px;
        }

        .image-area video {
            max-width: 100%;
            max-height: 100%;
            object-fit: contain;
            border-radius: 4px;
        }

        .text-area {
            flex: 1;
            margin: 20px;
            padding: 20px;
            background: linear-gradient(145deg, #2a2a2a, #1a1a1a);
            border: 8px solid #4a4a4a;
            border-radius: 12px;
            box-shadow: 
                inset -3px -3px 6px rgba(0,0,0,0.8),
                inset 3px 3px 6px rgba(255,255,255,0.1),
                0 8px 16px rgba(0,0,0,0.5);
            cursor: pointer;
            display: flex;
            flex-direction: column;
            justify-content: space-between;
            position: relative;
        }

        .text-area::before {
            content: '';
            position: absolute;
            top: 4px;
            left: 4px;
            right: 4px;
            bottom: 4px;
            border: 2px solid #666;
            border-radius: 8px;
            pointer-events: none;
        }

        .text-content {
            min-height: 220px;
            position: relative;
            padding: 10px;
        }

        .text-line {
            margin-bottom: 8px;
            min-height: 1.6em;
            color: #f0f0f0;
            text-shadow: 1px 1px 2px rgba(0,0,0,0.8);
            word-break: break-word; /* テキストの折り返しを保証 */
        }

        .next-indicator {
            text-align: center;
            font-size: 24px;
            margin-top: 20px;
            opacity: 0;
            transition: opacity 0.3s ease;
            color: #ffd700;
            text-shadow: 2px 2px 4px rgba(0,0,0,0.8);
        }

        .next-indicator.visible {
            opacity: 1;
            animation: bounce 1.5s infinite;
        }

        @keyframes bounce {
            0%, 100% { transform: translateY(0); }
            50% { transform: translateY(-5px); }
        }

        .choices-area {
            margin: 0 20px 20px 20px;
            padding: 20px;
            background: linear-gradient(145deg, #2a2a2a, #1a1a1a);
            border: 8px solid #4a4a4a;
            border-radius: 12px;
            box-shadow: 
                inset -3px -3px 6px rgba(0,0,0,0.8),
                inset 3px 3px 6px rgba(255,255,255,0.1),
                0 8px 16px rgba(0,0,0,0.5);
            position: relative;
        }

        .choices-area::before {
            content: '';
            position: absolute;
            top: 4px;
            left: 4px;
            right: 4px;
            bottom: 4px;
            border: 2px solid #666;
            border-radius: 8px;
            pointer-events: none;
        }

        .choice-button {
            display: block;
            width: calc(100% - 20px);
            padding: 12px;
            margin: 8px 10px;
            background: linear-gradient(145deg, #3a3a3a, #2a2a2a);
            border: 4px solid #555;
            border-radius: 8px;
            cursor: pointer;
            font-family: inherit;
            font-size: 16px;
            color: #f0f0f0;
            text-shadow: 1px 1px 2px rgba(0,0,0,0.8);
            transition: all 0.2s ease;
            box-shadow: 
                inset -2px -2px 4px rgba(0,0,0,0.6),
                inset 2px 2px 4px rgba(255,255,255,0.1);
        }

        .choice-button:hover {
            background: linear-gradient(145deg, #4a4a4a, #3a3a3a);
            border-color: #777;
            transform: translateY(-2px);
            box-shadow: 
                inset -2px -2px 4px rgba(0,0,0,0.6),
                inset 2px 2px 4px rgba(255,255,255,0.15),
                0 4px 8px rgba(0,0,0,0.3);
        }

        .choice-button:active {
            transform: translateY(0);
            box-shadow: 
                inset 2px 2px 4px rgba(0,0,0,0.8),
                inset -2px -2px 4px rgba(255,255,255,0.1);
        }

        .choice-button.link-choice {
            border-color: #4a90e2;
            background: linear-gradient(145deg, #2a4a6a, #1a3a5a);
        }

        .choice-button.link-choice:hover {
            background: linear-gradient(145deg, #3a5a7a, #2a4a6a);
            border-color: #5aa0f2;
        }

        .ending-screen {
            width: 100vw;
            height: 100vh;
            display: none;
            justify-content: center;
            align-items: center;
            background: linear-gradient(135deg, #1e3c72, #2a5298);
            color: #ffd700;
            font-size: 48px;
            font-weight: bold;
            text-shadow: 3px 3px 6px rgba(0,0,0,0.7);
            opacity: 0;
            transition: opacity 1.2s ease-in-out;
        }

        .ending-screen.fade-in {
            opacity: 1;
        }

        .cursor-blink {
            animation: blink 1s infinite;
        }

        @keyframes blink {
            0%, 50% { opacity: 1; }
            51%, 100% { opacity: 0; }
        }

        .transition-overlay {
            position: fixed;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            background: radial-gradient(circle, rgba(255,255,255,0.1) 0%, rgba(0,0,0,1) 100%);
            opacity: 0;
            pointer-events: none;
            transition: opacity 0.5s ease-in-out;
            z-index: 1000;
        }

        .transition-overlay.active {
            opacity: 1;
        }

        .loading-message {
            position: absolute;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            color: white;
            font-size: 24px;
            text-shadow: 2px 2px 4px rgba(0,0,0,0.8);
        }

        /* レスポンシブ対応: スマートフォン向け調整 */
        @media (max-width: 768px) {
            body {
                font-size: 20px;
            }
            .title-text {
                font-size: 36px;
            }
            .image-area {
                height: 250px;
                margin: 10px 10px 0 10px;
                width: calc(100% - 20px);
            }
            .text-area {
                margin: 10px;
                padding: 15px;
            }
            .choices-area {
                margin: 0 10px 10px 10px;
                padding: 15px;
            }
            .text-content {
                min-height: 160px;
            }
        }
    </style>
</head>
<body>
    <div class="transition-overlay" id="transition_overlay">
        <div class="loading-message" id="loading_message">読み込み中...</div>
    </div>

    <div class="game-container">
        <div class="title-screen" id="title_screen">
            <div class="title-text" id="title_text">時の渦に堕ちる前に</div>
            <div class="click-message" id="click_message">クリックしてください</div>
        </div>

        <div class="game-screen" id="game_screen">
            <div class="image-area" id="image_area">
                <img id="current_image" style="display: none;" alt="Scene Image">
                <video id="current_video" style="display: none;" controls autoplay muted loop></video>
            </div>
            <div class="text-area" id="text_area">
                <div class="text-content" id="text_content">
                    <div class="text-line" id="line_0"></div>
                    <div class="text-line" id="line_1"></div>
                    <div class="text-line" id="line_2"></div>
                    <div class="text-line" id="line_3"></div>
                </div>
                <div class="next-indicator" id="next_indicator">▼</div>
            </div>
            <div class="choices-area" id="choices_area" style="display: none;"></div>
        </div>

        <div class="ending-screen" id="ending_screen">
            おしまい
        </div>
    </div>

    <script>
        // 定数定義
        const MAX_LINES_PER_PAGE = 4;
        const TEXT_SPEED_MS = 16;
        const XML_URL = 'game_data.xml';
        const CLICK_SOUND_URL = 'click.mp3';
        const BACKGROUND_IMAGE_URL = '';

        // ゲーム状態
        let game_xml = null;
        let current_element = null;
        let previous_element = null; // 外部ファイル読み込み失敗時のフォールバック用
        let current_text = '';
        let current_pages = [];
        let current_page_index = 0;
        let is_typing = false;
        let skip_typing = false; // タイピングスキップ用フラグ
        let typing_timeout = null; // タイマー解除用
        let current_audio = null;
        let click_sound = null;

        // DOM要素の取得
        const transition_overlay = document.getElementById('transition_overlay');
        const loading_message = document.getElementById('loading_message');
        const title_screen = document.getElementById('title_screen');
        const game_screen = document.getElementById('game_screen');
        const ending_screen = document.getElementById('ending_screen');
        const title_text = document.getElementById('title_text');
        const click_message = document.getElementById('click_message');
        const image_area = document.getElementById('image_area');
        const current_image = document.getElementById('current_image');
        const current_video = document.getElementById('current_video');
        const text_area = document.getElementById('text_area');
        const text_content = document.getElementById('text_content');
        const next_indicator = document.getElementById('next_indicator');
        const choices_area = document.getElementById('choices_area');

        // サンプルXMLデータ
        const sample_xml = `<?xml version="1.0" encoding="UTF-8"?>
<gamebook title="時の渦に堕ちる前に ―Chronostasis―">
    <start image="https://picsum.photos/800/400?random=10">
        四月。春のにおいがどこか甘くて、窓の外の桜並木はもう盛りを過ぎていた。
        桐島 奏(きりしまかなで)は、朝のホームルームが始まる五分前に教室へ滑り込んだ。
        「また遅刻ギリギリじゃん」と幼馴染の天野 悠斗(あまのゆうと)が言う。
        「走ってるよ、心の中でな」
        悠斗が呆れたように笑う。奏は自分の席に腰を下ろした。
        三十人あまりのざわめき。窓から差し込む春の光。温かい空気。なんでもない月曜日。
        このとき奏は、今日が自分の人生を永遠に変える日だとは、夢にも思っていなかった。
        「えー、今日から一緒に勉強する転校生を紹介します」
        担任の田中先生がドアを手で示した。
        ドアが開いて、一人の女子生徒が入ってきた。
        ……瞬間、奏は何かを感じた。うまく言葉にできない、でも確実な「何かが違う」という感覚。
        女子生徒は黒板の前に立ち、一度だけ教室全体を見渡した。
        その視線は、観光客が景色を確認するような目じゃなかった。
        戦場に降り立った兵士が、地形を把握するような――静かな、冷たい目だった。
        「椎名 凛です。よろしくお願いします」
        短い自己紹介。深いお辞儀。それだけ。
        教室のどこかで「かわいい」という声が上がった。
        でも奏が気になったのは、そういうことじゃなかった。
        凛は、クラス全体を眺めるのではなく――最初から奏の席を知っていたかのような確信で、こちらを見た。
        一瞬だけ。でもたしかに、目が合った。その瞳に、驚きは微塵もなかった。
        <path name="声をかけてみる" image="https://picsum.photos/800/400?random=11">
            一時間目の数学が終わり、十分間の休憩に入った。
            奏は気づいたら立ち上がり、斜め前の席へ歩いていた。
            なぜそうしたのかうまく説明できない。ただ、あの視線が頭から離れなかった。
            凛は窓の外を眺めていた。校庭の桜を見ているのか、それとも全く別の何かを見ているのか。
            「椎名さん」と奏は声をかけた。
            振り向いた凛の顔に、表情の変化はなかった。
            「桐島 奏。同じクラスになったし、困ってることがあったら言ってくれ」
            凛は三秒ほど、奏の顔を見つめた。品定めするような、確認するような目だった。
            「……桐島 奏」と凛は繰り返した。名前を音として確かめるように。
            「そう。なんか変な言い方だな」
            「ごめんなさい。少し考えごとをしていました」
            普通のやりとりのはずだった。でも「ありがとう」の後の沈黙が、なぜか重かった。
            「図書室って、放課後も使えますか?」と凛が唐突に聞いた。
            「水曜と金曜以外は六時まで。司書さんが本に詳しくて何でも教えてくれるぞ」
            「そうですか。……今日は、それだけです」
            「今日は」という言い方が引っかかった。まるで明日また別のことを聞くつもりのように。
            チャイムが鳴る。奏は自分の席に戻る。
            「どうした?椎名さんに話しかけてたじゃん」と悠斗が小声で言った。
            「なんか気になって」
            「一目惚れ?」
            「違う」と奏は即答した。でも――なぜ気になるのか、自分でもわからなかった。
            放課後。帰り支度をしていると、凛が近づいてきた。
            「桐島くん。少しだけ、話せますか?」
            凛の声は平坦で感情の起伏が少ない。でも、今はその奥に微かな緊張の色があった。
            「あなたのことを、少し知っています」と凛は言った。
            「え?」
            「正確には……あらかじめ知っていた、という方が近い」
            「どういう意味だ?」
            「今はまだうまく説明できません。でも――あなたのおじいさんのことは知っています」
            「桐島 義雄さん、ですよね?」
            奏の手が止まった。
            祖父の名前。転校してきたばかりの女子が、なぜそれを知っている?
            「なんで知ってるんだ?」
            「説明するにはもう少し時間が必要です。明日、また話しましょう。必ず説明します」
            凛は深くお辞儀をして、教室を出た。
            奏は取り残されたまま、しばらく扉を眺めていた。
            心臓が、変な速さで打っていた。
            翌朝。奏は早めに学校に着いた。なぜかはうまく説明できない。
            ただ昨日の話が気になって、夕食中も、風呂の中でも、ずっと頭に引っかかっていた。
            祖父の義雄は、六年前にアルツハイマーの診断を受け、今は老人ホームにいる。
            奏が子供のころはよく一緒に遊んだ。研究者だったという話は聞いたことがある。
            でも何の研究か、どんな功績があったのか、両親はいつも曖昧な答えしかくれなかった。
            凛はすでに席に座っていた。「おはよう」と声をかける。
            「おはようございます。……スマートフォンを見てください」
            言われるまま、ポケットからスマホを取り出した。
            ホーム画面の日付:2024年4月9日(火)。
            「何秒か待ってください」
            十秒後。画面の日付が変わった。
            2087年4月9日(火)。
            奏は目を疑った。三度確認した。目を細め、画面を近づけた。
            2087年。
            「……え?」
            「今の一瞬、あなたの端末が私の認証信号を受信しました」と凛が静かに言った。
            「私は2087年から来ています、桐島くん」
            チャイムが鳴り始めた。春の光の中で、奏はその言葉を何度も頭の中で繰り返した。
            <path name="信じてみる" image="https://picsum.photos/800/400?random=12">
                「……信じる」と奏は言った。
                自分でも驚くほど素直に、そのひとことが出た。
                凛が微かに目を見開く。「……意外ですね」
                「俺だって驚いてる。でもスマホの画面は2087年だった。それを見て疑うのは難しい」
                「多くの人は見ても信じません。現実を守るために」
                「俺はそういうの苦手なんだ、昔から。都合の悪い現実を無視するの」
                凛は短い間、奏を見つめた。その目が少しだけ和らいだような気がした。
                「ありがとうございます。では放課後、詳しく話しましょう」
                放課後、二人は図書室の隅のテーブルに座った。夕暮れが窓を橙色に染めていた。
                「まず、私の身分を証明します」
                凛は制服の内側から薄いカードを取り出した。
                ガラスのような素材に、光る文字が刻まれている。
                「時間修正局 第三課 調査員 椎名 凛」
                「時間修正局……」と奏は繰り返した。
                「2051年に設立された国際機関です。タイムトラベル技術の発展に伴い、時間軸の保護を目的として作られた」
                「2087年にはタイムトラベルが実現してるのか」
                「実現しています。ただし非常に厳しく管理されています。無許可での時間移動は重大な犯罪です」
                「それで……なぜ俺のところに?」
                凛は一瞬だけ息を吸った。
                「あなたのおじいさん――桐島 義雄さんが、歴史から消えようとしています」
                窓の外で風が吹き、桜の花びらが最後の一枚を散らした。
                「義雄さんは量子もつれ通信の基礎理論を確立した研究者です」と凛は続けた。
                「一言でいうなら、光速の壁を超えた通信の基盤技術です」
                「2031年に理論を発表、2039年に実用化されました」
                「そんな大発見が、なんで俺の耳に入ってなかったんだ?」
                「義雄さん自身が、功績を表に出すことを好まなかった。でも科学史における位置づけは明確です」
                「彼の量子通信技術が、地球規模の気候シミュレーションを可能にしました」
                「二十年後――2044年頃に、大規模な気候危機が迫っています」
                「食糧難と海面上昇が同時発生する可能性が高い」
                「それって……毎年ニュースで言ってる気候変動の話か?」
                「はい。あなたの時代にも兆候はすでにある。毎年更新される猛暑の記録、頻発する大型台風……」
                奏は黙った。気づいていた。ニュースで「また記録更新」と聞くたびに、どこか他人事のように聞いていた。
                「義雄さんの理論がなければ、気候対策のシミュレーションが間に合わなかった可能性が高い」
                「つまり……じいさんが研究を諦めれば、未来で大勢が死ぬかもしれない?」
                「そういうことです」と凛は静かに言った。
                「でも誰かが、その義雄を歴史から消そうとしている?」
                「はい。過去に干渉し、義雄さんが研究を諦めるような状況を作っている者がいます」
                「なぜそんなことを?」
                凛はわずかに間を置いた。その瞳に、珍しく迷いの色が浮かんだ。
                「……動機が、まだわかっていないんです」
                「組織は?時間修正局は何も知らないのか?」
                「上司は……私に全部を話してくれているわけではありません」
                「一つ聞いていいか?お前は……本当のことを話してくれてるのか?」
                凛は奏の目を、真正面から見た。
                「……完全には、話していません。でも嘘はついていません」
                「話していないのは、まだ私にも確信が持てないことがあるからです」
                「それは……正直だな」
                「あなたに嘘をつくことは、私の任務に反します。そして――嘘はつきたくない」
                凛の声が、ほんの少しだけ、揺れた。
                「わかった。信じる。で、俺に何をしてほしいんだ?」
                「義雄さんに会ってほしい」と凛は言った。「彼には、あなたに伝えるべき秘密がある」
                世界の歪みは、翌日から始まった。
                廊下の掲示板の文化祭の写真から、一人分の姿が消えていた。
                奏は立ち止まってまじまじと見た。去年の秋に撮った写真。でも、明らかに一人分の空白がある。
                教室に入ると、悠斗が奏に言った。「なあ、三組に転校生来たの知ってるか?」
                「一組の椎名さんのこと言ってるのか?」
                「誰それ?俺が言ってるのは三組の話だけど」
                「……一組に椎名 凛が来ただろ。同じクラスだぞ」
                「お前のクラスに転校生?俺は聞いてないけど」
                奏の背筋に、冷たいものが走った。
                なのに悠斗は知らないと言っている。
                「悠斗、昨日俺と一緒に椎名さんの自己紹介を聞いたよな?」
                「は?昨日って何があったっけ?」
                奏は凛を見た。凛は自分の席でその会話を聞いていた。表情を変えず、静かに頷く。
                「始まりましたね」と凛が小声で言った。「急がなければなりません」
                三時間目の授業中、担任が出席を取ると、一人の生徒の名前を飛ばした。
                「田中先生、宮本くんは?」と誰かが言う。
                「宮本?そんな生徒はいないぞ」
                クラスが揺れた。でも誰も強く主張しない。
                「加速しています」と凛がノートの端に書いた。
                放課後、図書室で凛がデバイスを取り出した。時間汚染の進行グラフが表示されている。
                「昨日より三十パーセント増加しています。そして発信源が、この学校の敷地内にある」
                「この学校に……誰かいるのか?」
                凛が画面を操作した。一人の名前が浮かんだ。
                ――黒田 司。
                「黒田がなんで?」
                「正確には、黒田くん自身ではありません。彼の父親が未来から来ています」
                <path name="老人ホームへ急ぐ" image="https://picsum.photos/800/400?random=20">
                    その日の放課後、二人は電車に乗った。
                    「聞いてもいいか?」と奏は車窓の外を見ながら言った。
                    「未来から来て、家族や友達と離れてここにいる。……辛くないか?」
                    凛は少し間を置いた。
                    「……慣れています」
                    「慣れてるって、なんか悲しい言い方だな」
                    「そうですか」
                    「未来で、家族はいるのか?」
                    また間があった。窓の外に春の街が流れていく。
                    「いました。父が、研究者でした。量子通信の分野で」
                    「義雄さんの仕事を継いだような人で――任務中に、消えました」
                    「消えた、って」
                    「存在が、歴史から抹消されました。私の中には記憶がある。でも、この世界には記録がない」
                    奏はしばらく何も言えなかった。
                    「……それで、同じことが起きないように守ろうとしてるのか?」
                    「そうです。でも、それだけじゃない」
                    「世界が壊れていくのを、ただ見ていたくないんです」
                    老人ホームは、駅から歩いて五分のところにあった。
                    清潔感のある白い建物。入り口の花壇に、チューリップが咲いている。
                    廊下を歩く。両側の部屋からテレビの音や、低い話し声が聞こえてくる。
                    祖父の部屋は奥から三番目。ドアをノックすると、穏やかな声が返ってきた。
                    「どうぞ」
                    小さな部屋。窓から夕方の光が差し込んでいる。
                    義雄は椅子に座って、古い写真を手に持っていた。
                    「じいちゃん。奏だよ」
                    最初は焦点が合っていない目。でも数秒後、その目に光が戻った。
                    「……奏か」
                    「うん。会いに来た」
                    義雄は凛を見た。ゆっくりと、でも鋭く。
                    「あんたは……」
                    「椎名 凛と申します。義雄さんにお話を聞きに来ました」
                    義雄の目が細くなった。老人らしい穏やかさが消え、代わりに鋭い眼光が戻る。
                    まるで三十年前の研究者が、戻ってきたような目だった。
                    「……未来から来たな?」と義雄は言った。
                    奏と凛は、同時に息を呑んだ。
                    「わかるのか?」と奏が聞く。
                    「若いころ、似たような目をした人間と会ったことがある。目が同じだから」
                    凛が一歩前に出た。「義雄さん。あなたの身に、危険が迫っています」
                    「知っておる」と義雄は静かに言った。「……ずっと待っておった」
                    老人の目に、静かな覚悟が宿っていた。
                    「奏、座りなさい。話しておかなければならないことがある」
                    奏は椅子を引いて座った。凛も隣に立った。
                    外では夕暮れが深まり、窓の向こうの空がゆっくりと紫色に変わっていく。
                    「わしが若い頃……三十五の年に、一人の男に会った」と義雄は始めた。
                    「男は言った。『あなたの研究は世界を救います。でも同時に、多くの人を苦しめることになる』と」
                    「どういう意味だ?」と奏が聞く。
                    「わしにもわからなかった。でも――その言葉が、ずっと引っかかっておった」
                    義雄は窓の外を見た。
                    「研究を続けるべきか、諦めるべきか。何年も悩んだ。でも結局、続けることにした」
                    「なぜ?」
                    義雄は奏を見た。その目が、優しかった。
                    「お前の父が生まれたからだ。家族ができると、違う見方になる」
                    「この子のためにできることをしよう、と思うようになった」
                    義雄は奏の手を、震える手で、ぎゅっと握った。
                    「奏、わしは今でも答えがわからん。研究を続けたのが正しかったのか」
                    「じいちゃん……」
                    「ただ一つだけ、確かなことがある」
                    「お前に選ばせたい。わしじゃなく、お前に」
                    その言葉が、奏の胸に深く刺さった。
                    老人ホームを出ると、夜の街灯が灯り始めていた。
                    「渡瀬という人物が今夜動きます」と凛が言った。「学校の設備室に向かいましょう」
                    月のない夜。懐中電灯の光が廊下を照らした。
                    理科棟の奥、鉄の扉。それを開けると、暗闇の中に複雑な機械が並んでいた。
                    そしてその中央に、一人の男が立っていた。
                    黒田 司に似た、鋭い目をした男。四十代くらい。
                    「来ると思っていた」と男は言った。
                    「渡瀬 一郎さん」と凛が言った。「黒田くんの父」
                    「そうだ。私は2087年の日本から来ている」
                    「なぜ義雄を消そうとするんですか?」
                    渡瀬は静かに、でも強い目で答えた。
                    「あの人の発明が、世界を壊したからだ」
                    「壊した?気候危機を救ったんじゃないのか?」と奏は言った。
                    「たしかに気候危機は乗り越えた。でも、その代わりに何が起きたか知っているか?」
                    「量子通信は、テクノロジーを持つ者と持たない者の格差を爆発的に拡大した」
                    「2087年の日本は豊かだ。でもその豊かさは、一部の人間だけのものだ」
                    「私が生まれた地区では、子供たちが教育を受けられない。医療にも満足にアクセスできない」
                    「それは義雄の責任なのか?技術を使う人間の問題じゃないのか?」と奏は言った。
                    「どちらも正しい」と渡瀬は言った。「でも私は、自分の子供たちが飢えるのを見ていた」
                    「だから、源を断とうとした」
                    凛が口を開いた。「渡瀬さん。義雄さんの発明を消せば、気候崩壊が起きます」
                    「わかっている。でも、それでも平等な世界の方が……」
                    「あなたの子供は、気候崩壊した世界でも生きていけますか?」
                    渡瀬が黙った。
                    三人の間に、重い沈黙が落ちた。
                    奏は、この沈黙の中に、答えのない問いが浮かんでいるのを感じていた。
                    どちらが正しいという話じゃない。どちらも間違っていない。どちらも間違っている。
                    でも――決断しなければならない。それが自分の役割だと、義雄は言った。
                    <path name="義雄の発明を守る" image="https://picsum.photos/800/400?random=30">
                        「守る」と奏は言った。
                        渡瀬が奏を見た。凛も見た。
                        「義雄じいちゃんの発明を守る。現在のタイムラインを元に戻す」
                        「理由を聞かせてくれ」と渡瀬が静かに言った。
                        「渡瀬さんの言ってることは間違ってない。格差は本当の問題だ。あなたの怒りは正しい」
                        「でも――気候崩壊した世界を作ることが、格差の答えにはならない」
                        「子供たちを飢えさせたくないなら、そんな世界にしちゃいけない」
                        渡瀬の目が揺れた。
                        「技術が格差を生むなら、技術の使い方を変えるべきだ」
                        「それは今から、俺たちの時代から始めるしかない」
                        「渡瀬さんがその問題に気づいているなら、もっと別の戦い方があるはずだ」
                        長い沈黙の後、渡瀬は静かに装置の電源を落とした。
                        「……お前の祖父は、こういう孫を持てて幸せだな」とだけ言った。
                        凛が装置の修正作業を始めた。
                        数十分後、数値が正常に戻った。
                        世界の歪みが、少しずつ修復されていく。
                        翌朝、悠斗が普通に学校に来ていた。
                        廊下の写真から消えていた顔が、すべて戻っていた。
                        「おはよう、奏」と悠斗が言う。
                        「……おはよう」と奏は答えた。
                        声が少しだけ震えたが、悠斗は気づかなかった。
                        放課後、凛が告げた。「任務が完了しました。明日、帰ります」
                        「また来れるか?」と奏は聞いた。
                        「わかりません。でも――もし来るときは、もっとましな未来でありたい」
                        「もっとましな未来にするのは、俺たちの仕事だな」
                        凛が微かに笑った。初めて見る、凛の笑顔だった。
                        「はい」と凛は言った。「頑張ってください、桐島くん」
                        次の日、凛の席は空になっていた。
                        でも奏には、やることができていた。
                        格差の問題、SNSいじめ、気候変動――どれも大きすぎる問題だ。
                        でも、誰かが始めなければ、何も変わらない。
                        それが自分たちの時代の仕事だと、奏は初めてはっきりと理解した。
                        ――物語は終わる。でも、現実の物語はここから始まる。
                    </path>
                    <path name="発明を消させる" image="https://picsum.photos/800/400?random=31">
                        「……発明を消す」と奏は言った。
                        凛の目が大きく見開かれた。「桐島くん!」
                        「渡瀬さんの言ってることには、間違いがない」
                        「格差で苦しむ人たちを放っておくのが正しいとは思えない」
                        「でも気候崩壊が――」と凛が言いかける。
                        「なんとかなるかもしれない。別の方法で」
                        渡瀬が静かに装置のスイッチを入れた。
                        コンソールが光り始める。歴史改変のプロセスが動き出した。
                        最初は何も変わらなかった。
                        でも翌日。学校に行くと、あちこちに違和感があった。
                        廊下の写真が違う。悠斗の顔つきが少しだけ違う。
                        そして――凛が教室にいなかった。
                        席はある。でも、そこに誰かがいた記憶が、奏の中でもぼんやりとし始めていた。
                        「椎名……さん?」と奏は呟く。
                        誰だっけ。その名前。
                        自分の記憶が薄れていく感覚。
                        スマホを見ると、凛との会話履歴が少しずつ消えていく。
                        一行、また一行。まるで最初から存在しなかったかのように。
                        「待て――」と奏は呟いた。「待ってくれ」
                        でも、止められない。歴史が書き換わっていく。
                        義雄の発明がなかった世界。気候対策が間に合わなかった世界。
                        時間修正局が存在しなかった世界。
                        凛が存在しなかった世界。
                        奏は廊下の真ん中で立ち尽くした。
                        頭の中に、誰かの声の残像がある。落ち着いた、静かな声。
                        でも誰の声だったか。どうしても思い出せない。
                        「……誰かが、いたような気がする」
                        でも、もう遅かった。
                        世界は変わった。そして何かが――永遠に失われた。
                        パラドックスは止まらない。奏自身の記憶も、少しずつ――
                        窓から見える空が、いつもより少し違う色をしていた。
                        でも誰も気にしなかった。もともとそういう空だったのだから。
                        ――物語は、ここで終わる。
                        何かを失ってから気づくのは、いつだって遅すぎる。
                    </path>
                    <path name="凛と未来へ行く" image="https://picsum.photos/800/400?random=32">
                        「未来を見てから決める」と奏は言った。「凛、連れて行ってくれ」
                        凛が奏を見た。迷うような間があった。
                        「……規則違反です」
                        「でも見もせずに選択するより、正直な方法だろ」
                        「俺は都合の悪い現実を無視するのが苦手なんだ」
                        渡瀬が装置の設定を変えた。「三人分、2087年に送ることはできる」
                        「時間修正局に捕まるかもしれません」と凛が言った。
                        「なら戻ってきたら一緒に謝ろう」
                        凛は短く息を吐いた。そして、初めて奏に笑いかけた。
                        「……仕方ないですね」
                        装置が光った。世界が白く輝いた。
                        次の瞬間、奏は見知らぬ街の路上に立っていた。
                        2087年の東京。空は青かった。でも、どこか違う青さだった。
                        高層ビルが立ち並び、自動運転の乗り物が静かに行き交う、清潔な街並み。
                        「こっちを見てください」と凛が言った。
                        細い路地に入ると、風景が一変した。
                        ビルの影の下に、テントが並んでいる。その前に子供たちが座っていた。
                        「格差が、目に見える形で存在しています」と凛は静かに言った。
                        渡瀬が歯を食いしばった。「これが、俺が変えたかった現実だ」
                        でも奏は、テントの前の子供たちをじっと見ていた。
                        薄汚れた服。でも、目が生きていた。
                        一人の子供が、古い端末で何かを読んでいた。
                        「学んでいる」と奏は言った。
                        「インターネットへのアクセスは、この時代ではほぼ無料です」
                        「義雄さんの技術がその基盤です」と凛が言った。
                        「格差はある。問題はある。でも、この世界は生きています」
                        「義雄さんの発明がなかった世界では――この路地には誰もいません」
                        「2044年の気候崩壊で、この地区自体が浸水しているから」
                        渡瀬の目から、涙が一筋流れ落ちた。
                        「問題があることと、存在することは、別の話だ」と奏は言った。
                        「格差は今から変えられる。でも、存在しない世界は変えられない」
                        渡瀬がゆっくりと頷いた。「……わかった。装置を壊す。タイムラインは元に戻る」
                        三人は現代に戻った。
                        義雄は守られ、歴史は元の道筋を歩み始めた。
                        「格差の問題は残ります」と凛が奏に言った。「未来で解決しなければならない」
                        「それは俺たちの仕事だ。今の、俺たちの」
                        翌日、凛は2087年に帰った。
                        でも奏の中には、確信が残った。
                        問題は山ほどある。でも、それを知った上で生きることが、始まりなのだと。
                        2087年の空は青かった。この時代の空もまだ青い。
                        その青さを守ることが、今日からの奏の仕事だった。
                        ――現実は続く。そして、未来は今ここで、少しずつ作られていく。
                    </path>
                </path>
            </path>
        </path>
        <path name="様子を見る" image="https://picsum.photos/800/400?random=40">
            奏は声をかけることができなかった。
            転校初日にいきなり話しかけるのは――なんとなく躊躇われた。
            「……まあ、いいか」と心の中で呟いて、授業が始まった。
            昼休み、いつも通り悠斗と購買に行き、屋上でパンをかじった。
            「なあ、椎名さんのこと何か知ってるか?」と奏は何気なく聞いた。
            「転校生の?いや全然。でも男子の間では早速話題らしいぞ」
            「そういうことじゃなくて――なんか、変な感じがしたんだよ」
            「俺のことを最初から知ってるみたいな目をしてた」
            「お前の勘違いじゃん?」
            悠斗がパンの袋を丸めた。その顔が、いつものへらへら顔じゃなかった。
            「……そういや奏、一つ頼みがあるんだけど」
            「どうした?」
            「放課後、時間あるか?」
            放課後、二人は誰もいない空き教室に入った。
            悠斗が打ち明けたのは、奏を重い気持ちにさせる話だった。
            クラスの黒田 司が、匿名SNSで悠斗についてのデマを流しているという。
            「どんな内容だ?」
            「……言いたくないやつ。でも知り合いのアカウントが反応し始めてて」
            悠斗の声には、普段のおちゃらけた感じが一切なかった。
            「通報したのか?」
            「した。でも匿名だからすぐには対応できないって言われた」
            「学校に言えばいいだろ」
            「それが……黒田の家が地元の有力者で、父親がPTAに入ってるから」
            「先生も腰が引けてるんだよ」
            奏は言葉に詰まった。
            正しいことをしようとしても、力関係がそれを阻む。
            理不尽とわかっていても、どこから手をつければいいのかわからない。
            「俺にできることがあったら言え」と奏は言った。
            でも「できること」が何なのか、自分でもわかっていなかった。
            翌朝、悠斗は学校に来なかった。
            スマホに連絡を入れる。既読がつかない。
            「天野って今日休みか?」と別のクラスメイトに聞いた。
            「え……天野って誰?」
            奏の思考が一瞬止まった。
            「天野 悠斗。同じクラスだろ」
            「ごめん、それ誰だっけ?」
            もう一人に聞いた。「天野 悠斗を知ってるか?」
            「知らない」
            廊下に出て、三人に聞いた。全員が「知らない」と答えた。
            奏は教室に戻った。悠斗の席が――空だった。
            出席番号の欄が空白。まるで最初から誰も座っていなかったかのように。
            スマホを見ると「送信エラー:存在しない番号です」という表示。
            足元が崩れていくような感覚。
            「桐島くん」
            振り向くと、椎名 凛が立っていた。
            いつも通り平静な顔。でも目の奥に緊迫した色があった。
            「気づきましたね」と凛は静かに言った。「時間の歪みに」
            「お前、何か知ってるのか?」と奏は声を低くして言った。
            「悠斗が消えた。クラスの誰も知らない。なんなんだこれは?」
            「タイムラインの汚染が進んでいます」と凛が答えた。
            「過去が改変されると、現在が変わります」
            「あなたの友達が『存在しない』歴史が、浸食しています」
            「意味がわからない。お前は何者だ?」
            「信じてもらえるかどうかわかりませんが――私は未来から来ています」
            「時間修正局の調査員です」
            奏は凛を見つめた。あり得ない。常識で判断すれば、あり得ない。
            でも目の前の現実は、常識では説明できない。
            「……悠斗は戻るのか?」
            凛は少し間を置いた。「必ず戻します」
            「わかった。続きを話してくれ」
            放課後、図書室の隅のテーブル。夕暮れが窓を橙色に染めている。
            「私は椎名 凛。2087年生まれ、時間修正局第三課の調査員です」
            制服の内側からカードを取り出す。光る文字。「時間修正局 第三課 調査員 椎名 凛」
            「なぜ俺の前に現れた?」
            「あなたのおじいさん――桐島 義雄さんが、歴史から消えようとしているからです」
            「じいちゃんが?」
            「義雄さんは量子もつれ通信の基礎理論を確立した研究者です」
            「彼の発明が、二十年後の気候危機を乗り越える鍵になる」
            「でも誰かが、若い頃の義雄さんに干渉して、研究を諦めさせようとしている」
            「誰が?」
            「発信源は、この学校にあります」
            「そして――悠斗くんの消滅と、その発信源には関係があります」
            「どういうことだ?」
            「黒田 司の父親が、未来からここに来ています」
            「黒田くんを使って状況を操作しようとしている」
            奏は目を細めた。
            悠斗へのSNSいじめ、その背後にいた黒田、そして未来人の父。
            「悠斗を追い詰めたのも、計算のうちだったのか?」
            「おそらく。タイムラインを乱すことで、修正局の動きを妨害しようとしたと見ています」
            「ふざけるな」と奏は言った。声が思ったより硬かった。
            「悠斗を道具に使ったのか。許せない」
            凛は静かに奏を見た。
            「その怒りは正当です。でも今は、冷静に動かなければなりません」
            「……わかってる」と奏は深く息を吸った。「どうすればいい?」
            「まず義雄さんに会ってください。それがすべての鍵を握っています」
            <path name="義雄に会いに行く" image="https://picsum.photos/800/400?random=20">
                その日の夜、奏と凛は電車に乗った。
                「お前はずっとこういう仕事をしてるのか?」と奏は聞いた。
                「入局して三年になります」
                「なんでそんな仕事をしようと思ったんだ?」
                凛は少し間を置いた。
                「父が時間修正局の研究者でした。任務中に……タイムラインの崩壊に巻き込まれて消えました」
                「消えた、って」
                「存在が、歴史から抹消されました。私の中には記憶がある。でも、この世界には記録がない」
                奏はしばらく何も言えなかった。
                「……それで、同じことが起きないように守ろうとしてるのか?」
                「そうです。でも、それだけじゃない」
                「世界が壊れていくのを、ただ見ていたくないんです」
                老人ホームに着いた。廊下を歩く。祖父の部屋をノックする。
                「じいちゃん。奏だよ」
                義雄の目が焦点を合わせるまで数秒。そして光が戻る。
                「……奏か」
                義雄は凛を見た。ゆっくりと、でも鋭く。
                「……未来から来たな?」と義雄は言った。
                凛が驚いた様子を見せた。「……なぜわかるんですか?」
                「若い頃、似たような目をした人間と会った。目が同じだから」
                「義雄さん。あなたの研究が、危険にさらされています」と凛が言った。
                「知っておる。でも――まず奏に話したいことがある」
                義雄は奏を見た。穏やかな、でも真剣な目で。
                「わしが三十五のとき、一人の男に会った。未来から来た、と言う男だった」
                「男は言った。『あなたの研究は世界を救います。でも同時に、格差を生み出します』と」
                「……格差?」
                「技術は便利だ。でも、持つ者と持たない者の差を広げることもある」
                「わしはそれを聞いて、何年も悩んだ」
                義雄は窓の外を見た。夕暮れの街。
                「でも研究を続けた。お前の父が生まれたからだ。家族ができると、違う見方になる」
                「この子のためにできることをしよう、と思うようになった」
                義雄は奏の手を握った。皺だらけの、でも温かい手。
                「奏、わしは今でも答えがわからん。研究を続けたのが正しかったのか」
                「じいちゃん……」
                「ただ一つだけ、確かなことがある」
                「お前に選ばせたい。わしじゃなく、お前に」
                その言葉が、奏の胸に深く刺さった。
                老人ホームを出ると、夜の街灯が灯り始めていた。
                「渡瀬という人物が今夜動きます」と凛が言った。
                月のない夜。理科棟の奥、鉄の扉。
                開けると、暗闇の中に複雑な機械が並んでいた。
                その中央に、男が立っていた。黒田 司に似た、鋭い目をした男。
                「来ると思っていた」と男は言った。
                「渡瀬 一郎さんですね。黒田くんの父親」と凛が言った。
                「そうだ。私は2087年の日本から来ている」
                「なぜ義雄を消そうとするんですか?」
                渡瀬は静かに、でも揺るぎない目で答えた。
                「あの人の発明が、世界を不平等にしたからだ」
                「量子通信は気候危機を救ったはずです」と凛が言う。
                「たしかに気候危機は乗り越えた。でも、テクノロジーを持つ者と持たない者の格差が爆発的に広がった」
                「私が生まれた地区では、子供たちが教育を受けられない。医療にもアクセスできない」
                「それは義雄の責任なのか?技術の使い方の問題じゃないのか?」と奏は言った。
                「どちらも正しい」と渡瀬は言った。「でも私は自分の子供が飢えるのを見ていた」
                「だから源を断とうとした」
                凛が静かに言った。「渡瀬さん。義雄さんの発明がなければ、気候崩壊で多くの命が失われます」
                「わかっている。でもそれでも――」
                沈黙が落ちた。
                奏は、この沈黙の中に、答えのない問いが浮かんでいるのを感じた。
                どちらが正しいという話じゃない。でも、決断しなければならない。
                <path name="義雄の発明を守る" image="https://picsum.photos/800/400?random=30">
                    「守る」と奏は言った。
                    渡瀬が奏を見た。凛も見た。
                    「義雄じいちゃんの発明を守る。現在のタイムラインを元に戻す」
                    「理由を聞かせてくれ」と渡瀬が静かに言った。
                    「渡瀬さんの言ってることは間違ってない。格差は本当の問題だ。あなたの怒りは正しい」
                    「でも――気候崩壊した世界を作ることが、格差の答えにはならない」
                    「子供たちを飢えさせたくないなら、そんな世界にしちゃいけない」
                    渡瀬の目が揺れた。
                    「技術が格差を生むなら、技術の使い方を変えるべきだ」
                    「それは今から、俺たちの時代から始めるしかない」
                    「渡瀬さんがその問題に気づいているなら、もっと別の戦い方があるはずだ」
                    長い沈黙の後、渡瀬は静かに装置の電源を落とした。
                    「……お前の祖父は、こういう孫を持てて幸せだな」とだけ言った。
                    凛が装置の修正作業を始めた。数十分後、数値が正常に戻った。
                    翌朝、悠斗が普通に学校に来ていた。
                    廊下の写真から消えていた顔が、すべて戻っていた。
                    「おはよう、奏」と悠斗が言う。
                    「……おはよう」と奏は答えた。
                    涙が出そうになったが、どうにかこらえた。
                    放課後、凛が告げた。「任務が完了しました。明日、帰ります」
                    「また来れるか?」と奏は聞いた。
                    「わかりません。でも――もし来るときは、もっとましな未来でありたい」
                    「もっとましな未来にするのは、俺たちの仕事だな」
                    凛が微かに笑った。その顔が、奏の記憶に深く刻まれた。
                    次の日、凛の席は空になっていた。
                    でも奏には、やることができていた。
                    格差の問題、SNSいじめ、気候変動――どれも大きすぎる問題だ。
                    でも、誰かが始めなければ、何も変わらない。
                    それが自分たちの時代の仕事だと、奏は初めてはっきりと理解した。
                    ――物語は終わる。でも、現実の物語はここから始まる。
                </path>
                <path name="発明を消させる" image="https://picsum.photos/800/400?random=31">
                    「……発明を消す」と奏は言った。
                    凛の目が大きく見開かれた。「桐島くん!」
                    「渡瀬さんの言ってることには、間違いがない」
                    「格差で苦しむ人たちを放っておくのが正しいとは思えない」
                    「でも気候崩壊が――」と凛が言いかける。
                    「なんとかなるかもしれない。別の方法で」
                    渡瀬が静かに装置のスイッチを入れた。コンソールが光り始める。
                    翌日。学校に行くと、あちこちに違和感があった。
                    廊下の写真が違う。悠斗の顔つきが少しだけ違う。
                    そして――凛が教室にいなかった。
                    席はある。でも、そこに誰かがいた記憶が奏の中でぼんやりしてきた。
                    自分の記憶が薄れていく感覚。
                    スマホを見ると、凛との会話履歴が少しずつ消えていく。
                    「待て――」と奏は呟いた。「待ってくれ」
                    でも止められない。歴史が書き換わっていく。
                    義雄の発明がなかった世界。時間修正局が存在しなかった世界。
                    凛が存在しなかった世界。
                    奏は廊下の真ん中で立ち尽くした。
                    頭の中に、誰かの声の残像がある。落ち着いた、静かな声。
                    でも誰の声だったか。どうしても思い出せない。
                    「……誰かが、いたような気がする」
                    でも、もう遅かった。世界は変わった。そして何かが、永遠に失われた。
                    パラドックスは止まらない。奏自身の記憶も、少しずつ――
                    窓から見える空が、いつもより少し違う色をしていた。
                    でも誰も気にしなかった。もともとそういう空だったのだから。
                    ――物語は、ここで終わる。
                    何かを失ってから気づくのは、いつだって遅すぎる。
                </path>
                <path name="凛と未来へ行く" image="https://picsum.photos/800/400?random=32">
                    「未来を見てから決める」と奏は言った。「凛、連れて行ってくれ」
                    凛が奏を見た。迷うような間があった。
                    「……規則違反です」
                    「でも見もせずに選択するより、正直な方法だろ」
                    渡瀬が装置の設定を変えた。「三人分、2087年に送ることはできる」
                    装置が光った。世界が白く輝いた。
                    次の瞬間、奏は見知らぬ街の路上に立っていた。
                    2087年の東京。空は青かった。でも、どこか違う青さだった。
                    高層ビルが立ち並び、自動運転の乗り物が静かに行き交う、清潔な街並み。
                    「こっちを見てください」と凛が言った。
                    細い路地に入ると、風景が一変した。
                    ビルの影の下に、テントが並んでいる。その前に子供たちが座っていた。
                    「格差が、目に見える形で存在しています」と凛は静かに言った。
                    渡瀬が歯を食いしばった。「これが、俺が変えたかった現実だ」
                    でも奏は、テントの前の子供たちをじっと見ていた。
                    薄汚れた服。でも、目が生きていた。
                    一人の子供が、古い端末で何かを読んでいた。
                    「学んでいる」と奏は言った。
                    「インターネットへのアクセスは、この時代ではほぼ無料です」
                    「義雄さんの技術がその基盤です」と凛が言った。
                    「格差はある。問題はある。でも、この世界は生きています」
                    「義雄さんの発明がなかった世界では――この路地には誰もいません」
                    「2044年の気候崩壊で、この地区自体が浸水しているから」
                    渡瀬の目から、涙が一筋流れ落ちた。
                    「問題があることと、存在することは、別の話だ」と奏は言った。
                    「格差は今から変えられる。でも、存在しない世界は変えられない」
                    渡瀬がゆっくりと頷いた。「……わかった。装置を壊す。タイムラインは元に戻る」
                    三人は現代に戻った。
                    義雄は守られ、歴史は元の道筋を歩み始めた。
                    「格差の問題は残ります」と凛が奏に言った。「未来で解決しなければならない」
                    「それは俺たちの仕事だ。今の、俺たちの」
                    翌日、凛は2087年に帰った。
                    でも奏の中には、確信が残った。
                    問題は山ほどある。でも、それを知った上で生きることが、始まりなのだと。
                    2087年の空は青かった。この時代の空もまだ青い。
                    その青さを守ることが、今日からの奏の仕事だった。
                    ――現実は続く。そして、未来は今ここで、少しずつ作られていく。
                </path>
            </path>
        </path>
    </start>
</gamebook>
        `;

        // 背景画像を設定
        function set_background_image() {
            if (BACKGROUND_IMAGE_URL) {
                document.body.style.backgroundImage = `url(${BACKGROUND_IMAGE_URL})`;
                document.body.style.backgroundSize = 'cover';
                document.body.style.backgroundPosition = 'center';
                document.body.style.backgroundAttachment = 'fixed';
            }
        }

        // クリック音を初期化
        function init_click_sound() {
            if (CLICK_SOUND_URL) {
                try {
                    click_sound = new Audio(CLICK_SOUND_URL);
                    click_sound.volume = 0.3;
                    click_sound.preload = 'auto';
                } catch (e) {
                    console.log('オーディオの初期化をスキップしました:', e);
                }
            }
        }

        // クリック音を再生
        function play_click_sound() {
            if (click_sound) {
                try {
                    click_sound.currentTime = 0;
                    click_sound.play().catch(e => {
                        console.log('クリック音の再生に失敗しました(ファイル不在など):', e);
                    });
                } catch (e) {
                    console.log('クリック音の再生エラー:', e);
                }
            }
        }

        // 画面遷移エフェクト
        function show_transition_effect(callback_, message_ = '読み込み中...') {
            loading_message.textContent = message_;
            transition_overlay.classList.add('active');
            
            setTimeout(() => {
                if (callback_) callback_();
                
                setTimeout(() => {
                    transition_overlay.classList.remove('active');
                }, 300);
            }, 500);
        }

        // XMLファイルを読み込み
        async function load_xml_file(url_) {
            // ローカル環境(file://)かつ標準ファイル名の場合は、CORSエラーを回避するため直接サンプルを使用
            if (window.location.protocol === 'file:' && url_ === XML_URL) {
                console.log('ローカル環境(file://)を検出しました。内蔵サンプルデータを使用します。');
                return parse_xml(sample_xml);
            }

            try {
                const response = await fetch(url_);
                if (!response.ok) {
                    throw new Error(`HTTP error! status: ${response.status}`);
                }
                const xml_text = await response.text();
                return parse_xml(xml_text);
            } catch (error) {
                console.warn(`XMLファイル ${url_} の読み込みに失敗しました:`, error);
                if (url_ === XML_URL) {
                    console.warn('サンプルデータを使用します');
                    return parse_xml(sample_xml);
                } else {
                    throw error;
                }
            }
        }

        // XMLの解析
        function parse_xml(xml_string_) {
            const parser = new DOMParser();
            const xml_doc = parser.parseFromString(xml_string_, 'text/xml');
            
            if (xml_doc.getElementsByTagName('parsererror').length > 0) {
                throw new Error('XMLの解析に失敗しました');
            }
            
            return xml_doc;
        }

        // テキストをページ単位に分割(前後の空白をトリムして空行を除外)
        function split_text_into_pages(text_) {
            const lines = text_.split('\n')
                               .map(line => line.trim())
                               .filter(line => line !== '');
            const pages = [];
            
            for (let i = 0; i < lines.length; i += MAX_LINES_PER_PAGE) {
                const page_lines = lines.slice(i, i + MAX_LINES_PER_PAGE);
                pages.push(page_lines);
            }
            
            return pages;
        }

        // 一文字ずつテキストを表示(スキップ対応)
        function type_text(text_, line_element_, callback_) {
            let char_index = 0;
            line_element_.textContent = '';
            
            function type_next_char() {
                // スキップフラグが立っていたら残りの文字をすべて表示して完了
                if (skip_typing) {
                    line_element_.textContent = text_;
                    if (callback_) callback_();
                    return;
                }

                if (char_index < text_.length) {
                    line_element_.textContent += text_[char_index];
                    char_index++;
                    typing_timeout = setTimeout(type_next_char, TEXT_SPEED_MS);
                } else {
                    if (callback_) callback_();
                }
            }
            
            type_next_char();
        }

        // ページを表示
        function display_page(page_lines_, callback_) {
            is_typing = true;
            skip_typing = false; // 表示開始時にスキップフラグをリセット
            next_indicator.classList.remove('visible');
            
            for (let i = 0; i < MAX_LINES_PER_PAGE; i++) {
                const line_element = document.getElementById(`line_${i}`);
                line_element.textContent = '';
            }
            
            function display_next_line(line_index_) {
                if (line_index_ >= page_lines_.length) {
                    is_typing = false;
                    skip_typing = false;
                    next_indicator.classList.add('visible');
                    if (callback_) callback_();
                    return;
                }
                
                const line_element = document.getElementById(`line_${line_index_}`);
                const line_text = page_lines_[line_index_];
                
                type_text(line_text, line_element, () => {
                    display_next_line(line_index_ + 1);
                });
            }
            
            display_next_line(0);
        }

        // 選択肢を表示
        function display_choices(element_) {
            const path_children = Array.from(element_.children).filter(child => child.tagName === 'path');
            
            if (path_children.length === 0) {
                return;
            }
            
            choices_area.innerHTML = '';
            choices_area.style.display = 'block';
            
            path_children.forEach(path => {
                const choice_name = path.getAttribute('name');
                const link_url = path.getAttribute('link');
                const action = path.getAttribute('action');
                
                if (choice_name) {
                    const button = document.createElement('button');
                    button.className = link_url ? 'choice-button link-choice' : 'choice-button';
                    button.textContent = choice_name;
                    
                    button.onclick = () => {
                        play_click_sound();
                        if (link_url) {
                            load_external_xml(link_url);
                        } else if (action === 'back') {
                            show_transition_effect(() => {
                                choices_area.style.display = 'none';
                                current_element = previous_element;
                                show_element_content(current_element);
                            }, 'シーン切替中...');
                        } else {
                            select_choice(path);
                        }
                    };
                    choices_area.appendChild(button);
                }
            });
        }

        // 外部XMLファイルを読み込んで新しいゲームを開始
        async function load_external_xml(xml_url_) {
            previous_element = current_element; // 元の要素を退避
            
            show_transition_effect(async () => {
                try {
                    const new_xml = await load_xml_file(xml_url_);
                    const gamebook_element = new_xml.getElementsByTagName('gamebook')[0];
                    
                    if (!gamebook_element) {
                        throw new Error('有効なgamebook要素が見つかりません');
                    }
                    
                    game_xml = new_xml;
                    
                    const root_element = gamebook_element.children[0];
                    if (!root_element) {
                        throw new Error('ルート要素が見つかりません');
                    }
                    
                    current_element = root_element;
                    choices_area.style.display = 'none';
                    
                    show_element_content(current_element);
                    
                } catch (error) {
                    console.error(`外部XMLファイル ${xml_url_} の読み込みに失敗しました:`, error);
                    
                    // ローカルCORS制限や不在時の、ゲームがクラッシュしないためのダミーエラー画面表示処理
                    const parser = new DOMParser();
                    const error_xml_str = `
                        <scene>
                            ファイルの読み込みに失敗しました。
                            対象ファイル: ${xml_url_}
                            
                            ローカル環境(file://)でのCORS制限、またはファイルが存在しない可能性があります。
                            下のボタンを押して元のシーンに戻ってください。
                            <path name="元のシーンに戻る" action="back"></path>
                        </scene>
                    `;
                    const error_doc = parser.parseFromString(error_xml_str, 'text/xml');
                    current_element = error_doc.documentElement;
                    
                    choices_area.style.display = 'none';
                    show_element_content(current_element);
                }
            }, `${xml_url_} を読み込み中...`);
        }

        // 選択肢を選択
        function select_choice(selected_element_) {
            show_transition_effect(() => {
                choices_area.style.display = 'none';
                current_element = selected_element_;
                show_element_content(current_element);
            }, 'シーン切替中...');
        }

        // BGMを再生(属性がない場合は現状を維持、stopやnoneで明示的に停止)
        function play_bgm(sound_url_) {
            if (!sound_url_) return; 
            
            if (sound_url_ === 'none' || sound_url_ === 'stop') {
                if (current_audio) {
                    try {
                        current_audio.pause();
                    } catch (e) {}
                    current_audio = null;
                }
                return;
            }

            // 既に同じ曲が流れている場合は再生し直さない
            if (current_audio && current_audio.src.endsWith(sound_url_)) {
                return;
            }

            if (current_audio) {
                try {
                    current_audio.pause();
                } catch (e) {}
            }
            
            try {
                current_audio = new Audio(sound_url_);
                current_audio.loop = true;
                current_audio.volume = 0.5;
                current_audio.play().catch(e => {
                    console.log('音声ファイルの再生に失敗しました(ファイル不在など):', e);
                });
            } catch (e) {
                console.log('BGM初期化エラー:', e);
            }
        }

        // 画像を表示(属性がない場合は維持、noneで非表示)
        function display_image(image_url_) {
            if (!image_url_) return;

            // 動画が再生中なら停止して非表示
            if (current_video && !current_video.paused) {
                try {
                    current_video.pause();
                } catch (e) {}
            }
            current_video.style.display = 'none';
            
            if (image_url_ === 'none' || image_url_ === 'clear') {
                current_image.style.display = 'none';
                return;
            }

            current_image.style.display = 'none'; // 描画時のリンク破綻防止のためロード完了まで非表示
            current_image.src = image_url_;
            current_image.onload = () => {
                current_image.style.display = 'block';
            };
            current_image.onerror = () => {
                current_image.style.display = 'none'; // 画像がない場合は非表示(エラー表示を防止)
            };
        }

        // 動画を表示(属性がない場合は維持、noneで非表示)
        function display_video(video_url_) {
            if (!video_url_) return;

            current_image.style.display = 'none';
            
            if (video_url_ === 'none' || video_url_ === 'clear') {
                try {
                    current_video.pause();
                } catch (e) {}
                current_video.style.display = 'none';
                return;
            }

            current_video.style.display = 'none'; // ロード完了まで非表示
            current_video.src = video_url_;
            current_video.onloadedmetadata = () => {
                current_video.style.display = 'block';
            };
            current_video.onerror = () => {
                current_video.style.display = 'none'; // 動画がない場合は非表示(エラー表示を防止)
            };
            current_video.play().catch(e => {
                console.log('動画の自動再生に失敗しました(ファイル不在など):', e);
            });
        }

        // 要素の内容を表示
        function show_element_content(element_) {
            const image_url = element_.getAttribute('image');
            const video_url = element_.getAttribute('video');
            const sound_url = element_.getAttribute('sound');
            
            // 優先順位: video -> image (両方未指定なら前シーンのメディアを維持)
            if (video_url) {
                display_video(video_url);
            } else if (image_url) {
                display_image(image_url);
            }
            
            play_bgm(sound_url);
            
            let text_content_str = '';
            for (let node of element_.childNodes) {
                // 子要素(<path>)のテキストは除外し、直下のテキストノードのみ取得
                if (node.nodeType === Node.TEXT_NODE) {
                    text_content_str += node.textContent;
                }
            }
            
            current_text = text_content_str;
            current_pages = split_text_into_pages(current_text);
            current_page_index = 0;
            
            if (current_pages.length > 0) {
                display_page(current_pages[current_page_index], () => {
                    if (current_page_index === current_pages.length - 1) {
                        const path_children = Array.from(element_.children).filter(child => child.tagName === 'path');
                        if (path_children.length > 0) {
                            display_choices(element_);
                        }
                    }
                });
            } else {
                // テキストがない場合でも選択肢があれば表示する対応
                const path_children = Array.from(element_.children).filter(child => child.tagName === 'path');
                if (path_children.length > 0) {
                    display_choices(element_);
                }
            }
        }

        // テキストエリアのクリックハンドラ(スキップ対応)
        function handle_text_click() {
            // タイピング中であればスキップフラグを立てて直ちに全表示させる
            if (is_typing) {
                skip_typing = true;
                return;
            }
            
            play_click_sound();
            
            if (current_page_index < current_pages.length - 1) {
                current_page_index++;
                display_page(current_pages[current_page_index], () => {
                    if (current_page_index === current_pages.length - 1) {
                        const path_children = Array.from(current_element.children).filter(child => child.tagName === 'path');
                        if (path_children.length > 0) {
                            display_choices(current_element);
                        }
                    }
                });
            } else {
                const path_children = Array.from(current_element.children).filter(child => child.tagName === 'path');
                if (path_children.length === 0) {
                    show_ending();
                }
            }
        }

        // エンディングを表示
        function show_ending() {
            show_transition_effect(() => {
                if (current_audio) {
                    try {
                        current_audio.pause();
                    } catch (e) {}
                    current_audio = null;
                }
                
                game_screen.style.display = 'none';
                ending_screen.style.display = 'flex';
                
                setTimeout(() => {
                    ending_screen.classList.add('fade-in');
                }, 100);
            }, '物語は終わりに近づいています...');
        }

        // ゲーム開始
        async function start_game() {
            play_click_sound();
            title_screen.classList.add('fade-out');
            
            show_transition_effect(async () => {
                try {
                    game_xml = await load_xml_file(XML_URL);
                    const gamebook_element = game_xml.getElementsByTagName('gamebook')[0];
                    
                    const root_element = gamebook_element.children[0];
                    current_element = root_element;
                    
                    title_screen.style.display = 'none';
                    game_screen.style.display = 'flex';
                    
                    setTimeout(() => {
                        game_screen.classList.add('fade-in');
                        show_element_content(current_element);
                    }, 100);
                } catch (error) {
                    console.error('ゲームの初期化に失敗しました:', error);
                    alert('ゲームの読み込みに失敗しました');
                }
            });
        }

        // 初期化処理
        function init_game() {
            set_background_image();
            init_click_sound();
            
            click_message.onclick = start_game;
            text_area.onclick = handle_text_click;
        }

        window.addEventListener('DOMContentLoaded', init_game);
    </script>
</body>
</html>

いいなと思ったら応援しよう!

コメント

コメントするには、 ログイン または 会員登録 をお願いします。
小ぶりなプログラムを試しに作っているんですが、 ここではその説明書きをしていこうと思います。 こういう機能をつけてみてほしいだとか要望、 コメント欄か、Xのリプライ欄に書いてみて下さい。 ひまをみて対応します。 (未管理著作物裁定制度に定められた問い合わせも受付中。)
ゲームブックをちょっと更新|古井和雄
word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word

mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1