<!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,
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,
-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:
display: flex;
justify-content: center;
align-items: center;
border: 8px solid
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
border-radius: 8px;
pointer-events: none;
}
.image-area img {
max-width: 100 %;
max-height: 100 %;
object-fit: contain;
border-radius: 4px;
}
.text-area {
flex: 1 ;
margin: 20px;
padding: 20px;
background: linear-gradient(145deg,
border: 8px solid
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
border-radius: 8px;
pointer-events: none;
}
.text-content {
min-height: 160px;
position: relative;
padding: 10px;
}
.text-line {
margin-bottom: 8px;
min-height: 24px;
color:
text-shadow: 1px 1px 2px rgba(0 ,0 ,0 ,0.8 );
}
.next-indicator {
text-align: center;
font-size: 24px;
margin-top: 20px;
opacity: 0 ;
transition: opacity 0. 3s ease;
color:
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,
border: 8px solid
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
border-radius: 8px;
pointer-events: none;
}
.choice-button {
display: block;
width: calc(100 % - 20px);
padding: 12px;
margin: 8px 10px;
background: linear-gradient(145deg,
border: 4px solid
border-radius: 8px;
cursor: pointer;
font-family: inherit;
font-size: 16px;
color:
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,
border-color:
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:
background: linear-gradient(145deg,
}
.choice-button.link-choice:hover {
background: linear-gradient(145deg,
border-color:
}
.ending-screen {
width: 100vw;
height: 100vh;
display: none;
justify-content: center;
align-items: center;
background: linear-gradient(135deg,
color:
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 );
}
</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;" >
</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 ; // 約60fps (1000ms / 60fps ≈ 16ms)
const XML_URL = 'game_data.xml' ; // XMLファイルのURL
const CLICK_SOUND_URL = 'click.mp3' ; // クリック音のURL
const BACKGROUND_IMAGE_URL = '' ; // 背景画像のURL(空の場合はグラデーション)
// ゲーム状態
let game_xml = null;
let current_element = null;
let current_text = '' ;
let current_pages = [];
let current_page_index = 0 ;
let is_typing = false;
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 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データ(XMLファイルが読み込めない場合のフォールバック)
const sample_xml = `<?xml version="1.0" encoding="UTF-8"?>
<gamebook title="魔法の森の冒険">
<start image="https://picsum.photos/400/200?random=1" sound="">
あなたは深い森の入り口に立っています。
古い言い伝えによると、この森の奥には伝説の宝物が眠っているとか。
しかし、多くの冒険者がこの森で行方不明になっているのも事実です。
どうしますか?
<path name="森に入る">
勇気を振り絞って森に足を踏み入れました。
木々が鬱蒼と茂り、薄暗い森の中を進んでいきます。
しばらく歩くと、道が二手に分かれています。
<path name="深い道を進む" image="https://picsum.photos/400/200?random=2">
より深い森の奥へと向かいました。
突然、光る石を発見しました!
これが伝説の宝物かもしれません。
おめでとうございます!
あなたの冒険は成功に終わりました。
</path>
<path name="明るい道を進む" image="https://picsum.photos/400/200?random=3">
明るい道を選びました。
安全に森を抜けることができましたが、
宝物は見つかりませんでした。
また別の機会に挑戦しましょう。
今日の冒険はここまでです。
</path>
<path name="別の章へ進む" link="chapter2.xml">
突然、光の扉が現れました!
別の世界への入り口のようです。
扉をくぐりますか?
</path>
</path>
<path name="引き返す">
危険を感じて引き返すことにしました。
時には勇気よりも慎重さが大切です。
今度はもっと準備を整えてから挑戦しましょう。
賢明な判断でした。
</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) {
click_sound = new Audio(CLICK_SOUND_URL);
click_sound.volume = 0.3 ;
click_sound.preload = 'auto' ;
}
}
// クリック音を再生
function play_click_sound() {
if (click_sound) {
click_sound.currentTime = 0 ;
click_sound.play().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_) {
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);
// メインのXMLファイルの場合のみサンプルデータを使用
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' ).filter(line => line.trim() !== '' );
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 (char_index < text_.length) {
line_element_.textContent += text_[char_index];
char_index++;
setTimeout(type_next_char, TEXT_SPEED_MS);
} else {
if (callback_) callback_();
}
}
type_next_char();
}
// ページを表示
function display_page(page_lines_, callback_) {
is_typing = true;
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;
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' );
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) {
// 外部XMLファイルを読み込み
load_external_xml(link_url);
} else {
// 通常の選択肢処理
select_choice(path);
}
};
choices_area.appendChild(button);
}
});
}
// 外部XMLファイルを読み込んで新しいゲームを開始
async function load_external_xml(xml_url_) {
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要素が見つかりません' );
}
// 新しいXMLをセット
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);
alert(`ファイル ${xml_url_} の読み込みに失敗しました。\n${error.message}`);
}
}, `${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を再生
function play_bgm(sound_url_) {
if (current_audio) {
current_audio.pause();
current_audio = null;
}
if (sound_url_) {
current_audio = new Audio(sound_url_);
current_audio.loop = true;
current_audio.volume = 0.5 ;
current_audio.play().catch(e => {
console.log('音声の再生に失敗しました:' , e);
});
}
}
// 画像を表示
function display_image(image_url_) {
if (image_url_) {
current_image.src = image_url_;
current_image.style.display = 'block' ;
current_image.onerror = () => {
current_image.style.display = 'none' ;
};
} else {
current_image.style.display = 'none' ;
}
}
// 要素の内容を表示
function show_element_content(element_) {
// 画像とBGMを設定
const image_url = element_.getAttribute('image' );
const sound_url = element_.getAttribute('sound' );
display_image(image_url);
play_bgm(sound_url);
// テキスト内容を取得
let text_content_str = '' ;
for (let node of element_.childNodes) {
if (node.nodeType === Node.TEXT_NODE) {
text_content_str += node.textContent;
}
}
current_text = text_content_str.trim();
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_);
}
// 選択肢がない場合(葉ノード)は何もしない - ユーザーのクリック待ち
}
});
}
}
// テキストエリアのクリックハンドラ
function handle_text_click() {
if (is_typing) {
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 ) {
// 葉の要素の場合、選択肢は表示しない(エンディング待機状態)
} else {
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) {
current_audio.pause();
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 game_title = gamebook_element.getAttribute('title' ) || 'ゲームブック' ;
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>
copy
コメント