<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Aran Red Fantasy - Ultimate</title>
<style>
/* ===============================
基本CSSスタイル
=============================== */
body {
font-family: Arial, sans-serif;
background-color: #f0f0f0;
margin: 0;
padding: 0;
}
header, footer, nav {
background-color: #005ce6;
color: #fff;
text-align: center;
padding: 10px;
}
header h1, footer .container { margin: 0; }
nav a {
color: #fff;
text-decoration: none;
margin: 0 8px;
padding: 5px 8px;
display: inline-block;
}
nav a:hover { background-color: #004bb5; border-radius: 4px; }
nav a.active { background-color: #003a8c; border-radius: 4px; }
main { padding: 20px; }
.container { max-width: 1400px; margin: 0 auto; }
.button {
background-color: #4CAF50;
border: none;
color: white;
padding: 8px 16px;
text-align: center;
text-decoration: none;
font-size: 14px;
margin: 4px 2px;
cursor: pointer;
border-radius: 5px;
}
.button:hover { background-color: #45a049; }
.disabled { opacity: 0.6; cursor: default; }
.muted { color:#667; font-size: 13px; }
/* カード風 */
.card {
background-color: #fff;
border: 1px solid #ddd;
border-radius: 5px;
padding: 16px;
margin-bottom: 20px;
box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.1);
}
.card h3 { margin-top: 0; }
/* プログレスバー */
.progress-bar {
background-color: #ddd;
border-radius: 5px;
height: 20px;
width: 100%;
margin-bottom: 10px;
}
.progress {
background-color: #4CAF50;
height: 100%;
border-radius: 5px;
width: 0%;
}
/* インベントリアイテム表示 */
.inventory-item {
background-color: #fff;
border: 1px solid #ddd;
border-radius: 5px;
display: inline-block;
margin: 5px;
padding: 10px;
min-width: 120px;
text-align: center;
cursor: pointer;
transition: background-color 0.2s;
user-select: none;
}
.inventory-item:hover { background-color: #eef; }
/* メッセージ表示 */
.message {
background-color: #fff8dd;
border: 1px solid #f5c666;
padding: 10px;
margin-bottom: 10px;
border-radius: 5px;
white-space: pre-wrap;
}
/* モーダル */
.modal-bg {
position: fixed;
top: 0; left: 0;
width:100%; height:100%;
background: rgba(0,0,0,.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 999;
}
.modal {
background: #fff;
padding: 20px;
border-radius: 5px;
text-align: center;
max-width: 520px;
width: 92%;
}
.modal h2 { margin-top: 0; }
.modal img { max-width: 100%; height: auto; border-radius: 6px; }
/* キャラクター表示 */
#character-image {
max-width: 420px;
width: 100%;
height: auto;
margin: 20px auto;
display: block;
border-radius: 8px;
border: 1px solid #ddd;
background: #fff;
}
/* バトル用スタイル */
.battle-container {
display: flex;
flex-wrap: wrap;
gap: 20px;
}
.enemy-card {
background-color: #fff;
border: 1px solid #ddd;
border-radius: 5px;
width: 250px;
padding: 16px;
text-align: center;
}
/* メッセージログ */
.log {
background-color: #eef;
border: 1px solid #bbe;
border-radius: 5px;
padding: 10px;
max-height: 300px;
overflow-y: auto;
margin: 10px 0;
white-space: pre-wrap;
}
/* ロケーションボタン */
#location-buttons button { margin-right: 10px; }
/* スキル一覧 */
.skill-list { list-style: none; padding: 0; }
.skill-list li { margin: 5px 0; }
/* クエストログ */
#quest-log-list { list-style: none; padding: 0; }
#quest-log-list li { margin: 4px 0; }
/* 実績一覧 */
#achievement-list { list-style: none; padding: 0; }
#achievement-list li { margin: 5px 0; }
/* ===============================
アートギャラリー
=============================== */
.gallery-grid{
display:grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 14px;
}
.gallery-item{
background:#fff;
border:1px solid #ddd;
border-radius:8px;
overflow:hidden;
box-shadow: 0px 2px 4px rgba(0,0,0,0.08);
display:flex;
flex-direction:column;
}
.gallery-item img{
width:100%;
height:auto;
display:block;
background:#fff;
}
.gallery-meta{
padding:10px;
display:flex;
align-items:center;
justify-content:space-between;
gap:8px;
flex-wrap:wrap;
}
.badge{
display:inline-block;
padding:4px 8px;
border-radius:999px;
font-size:12px;
background:#eef;
border:1px solid #bbe;
color:#223;
}
.badge.owned{
background:#e9ffe9;
border-color:#9fd49f;
color:#1c5a1c;
}
.badge.rarity-ur{
background:#fff2cc;
border-color:#f3d27a;
color:#6b4b00;
}
.badge.rarity-ssr{
background:#e8f0ff;
border-color:#9fb7ff;
color:#133a7a;
}
.gallery-actions{
display:flex;
gap:8px;
flex-wrap:wrap;
padding: 0 10px 12px;
}
/* ガチャUI */
.gacha-row{
display:flex;
align-items:center;
justify-content:space-between;
gap:10px;
flex-wrap:wrap;
}
.gacha-result{
margin-top:10px;
display:flex;
gap:10px;
flex-wrap:wrap;
align-items:flex-start;
}
.gacha-card{
width: 220px;
background:#fff;
border:1px solid #ddd;
border-radius:10px;
overflow:hidden;
box-shadow: 0px 2px 4px rgba(0,0,0,0.08);
}
.gacha-card img{ width:100%; display:block; }
.gacha-card .p{ padding:10px; }
</style>
</head>
<body>
<!-- ===============================
ヘッダー
=============================== -->
<header>
<h1>Aran Red Fantasy - Ultimate</h1>
</header>
<!-- ===============================
ナビゲーション
=============================== -->
<nav>
<div class="container">
<a href="#" id="home-link" onclick="showPage('home')">Home</a>
<a href="#" id="quests-link" onclick="showPage('quests')">Quests</a>
<a href="#" id="items-link" onclick="showPage('items')">Items</a>
<a href="#" id="friends-link" onclick="showPage('friends')">Companions</a>
<a href="#" id="character-link" onclick="showPage('character')">Character</a>
<a href="#" id="art-link" onclick="showPage('art')">Art Gallery</a>
<a href="#" id="battle-link" onclick="showPage('battle')">Battle</a>
<a href="#" id="store-link" onclick="showPage('store')">Store</a>
<a href="#" id="craft-link" onclick="showPage('craft')">Craft</a>
<a href="#" id="skills-link" onclick="showPage('skills')">Skills</a>
<a href="#" id="questlog-link" onclick="showPage('questlog')">QuestLog</a>
<a href="#" id="achievements-link" onclick="showPage('achievements')">Achievements</a>
</div>
</nav>
<!-- ===============================
メインコンテンツ
=============================== -->
<main>
<div class="container" id="content">
<!-- ===============================
Home
=============================== -->
<div id="home">
<h2>Welcome to Aran Red Fantasy!</h2>
<p>Explore the world, complete quests, craft items, recruit companions, and unlock achievements!</p>
<div id="home-message"></div>
<button class="button" onclick="showLoginModal()">Log In / Change User</button>
<button class="button" onclick="logout()">Logout (Reset All Data)</button>
<br/><br/>
<!-- BGM(継続再生対応:audioはページ外に置く) -->
<div class="card">
<h3>BGM</h3>
<p class="muted">※ 最初の1回だけ「Enable BGM」を押してください(ブラウザの自動再生ブロック対策)。以後はページ切替しても継続します。</p>
<button class="button" id="bgm-enable-btn" onclick="enableBGM()">Enable BGM (First Click)</button>
<button class="button" onclick="toggleMusic()">Toggle Music</button>
<div class="muted" id="bgm-status">Status: Off</div>
</div>
<!-- ★ガチャ(SSR/UR追加) -->
<div class="card">
<h3>Art Gacha</h3>
<div class="gacha-row">
<div class="muted">
Cost: <strong>10 Gold</strong> / pull<br/>
SSR/URが出ます。引いたアートは自動で所持になり、アートギャラリーに追加されます。
</div>
<div>
<button class="button" onclick="pullArtGacha(1)">Pull x1</button>
<button class="button" onclick="pullArtGacha(10)">Pull x10</button>
</div>
</div>
<div class="muted" id="gacha-status">—</div>
<div class="gacha-result" id="gacha-result"></div>
</div>
<!-- ランダムイベント/天候表示 -->
<div id="weather-display"></div>
<button class="button" onclick="triggerRandomEvent()">Check Random Event</button>
<!-- 昼夜サイクル -->
<div id="day-night-display"></div>
<button class="button" onclick="advanceTime()">Pass Time (+6h)</button>
<!-- 宿屋で休息 -->
<h3>Inn</h3>
<button class="button" onclick="restAtInn()">Rest at Inn (10 Gold)</button>
<!-- ロケーション移動 -->
<div id="location-section" class="card">
<h3>Locations</h3>
<div id="location-buttons">
<button class="button" onclick="moveLocation('Town')">Move to Town</button>
<button class="button" onclick="moveLocation('Forest')">Move to Forest</button>
<button class="button" onclick="moveLocation('Dungeon')">Move to Dungeon</button>
<button class="button" onclick="moveLocation('Mountain')">Move to Mountain</button>
</div>
<p>Current Location: <span id="current-location">Town</span></p>
<div class="log" id="location-log"></div>
</div>
</div>
<!-- ===============================
Quests
=============================== -->
<div id="quests" style="display: none;">
<h2>Quests</h2>
<h3>Main Quests</h3>
<div class="card" id="dragon-quest">
<h4>Defeat the Dragon</h4>
<p>A fierce dragon has appeared near the village! Defeat it to save the locals.</p>
<div class="progress-bar">
<div class="progress" id="dragon-progress"></div>
</div>
<p>Reward: 100 Gold, 100 XP, Dragon Scale</p>
<button class="button" onclick="startQuest('dragon')">Start Quest</button>
</div>
<div class="card" id="final-quest" style="display: none;">
<h4>The Ancient Evil (Final)</h4>
<p>The final threat emerges after you've proven your strength! Vanquish it!</p>
<div class="progress-bar">
<div class="progress" id="final-progress"></div>
</div>
<p>Reward: 200 Gold, 200 XP, Legendary Relic</p>
<button class="button" onclick="startQuest('final')">Start Quest</button>
</div>
<h3>Side Quests</h3>
<div class="card" id="crystal-quest">
<h4>Collect Magic Crystals</h4>
<p>Gather magical crystals scattered around the forest. Watch out for monsters!</p>
<div class="progress-bar">
<div class="progress" id="crystal-progress"></div>
</div>
<p>Reward: 50 Gold, 50 XP, Magic Crystal</p>
<button class="button" onclick="startQuest('crystal')">Start Quest</button>
</div>
<div class="card" id="orc-quest">
<h4>Eliminate the Orc Bandits</h4>
<p>A group of orc bandits is attacking travelers. Defeat them to restore peace!</p>
<div class="progress-bar">
<div class="progress" id="orc-progress"></div>
</div>
<p>Reward: 80 Gold, 70 XP, Orc Tusk</p>
<button class="button" onclick="startQuest('orc')">Start Quest</button>
</div>
</div>
<!-- ===============================
Items
=============================== -->
<div id="items" style="display: none;">
<h2>Inventory</h2>
<p>Click an item to use/equip/sell it (if applicable).</p>
<div id="inventory"></div>
</div>
<!-- ===============================
Companions
=============================== -->
<div id="friends" style="display: none;">
<h2>Companions</h2>
<p>Hire companions who fight alongside you!</p>
<input type="text" id="friendName" placeholder="Companion name" />
<button class="button" onclick="hireCompanion()">Hire Companion</button>
<h3>Your Companions</h3>
<ul id="companion-list"></ul>
<p class="muted">* Each companion has its own level, HP, and Attack. They also gain XP when you do.</p>
</div>
<!-- ===============================
Character
=============================== -->
<div id="character" style="display: none;">
<h2>Character</h2>
<img src="a.png" alt="Character" id="character-image"/>
<p>Name: <span id="character-name"></span></p>
<p>Level: <span id="character-level"></span></p>
<p>HP: <span id="character-hp"></span> / <span id="character-maxhp"></span></p>
<p>XP: <span id="character-xp"></span> / <span id="character-nextLevelXp"></span></p>
<p>Gold: <span id="character-gold"></span></p>
<p>Attack: <span id="character-attack"></span></p>
<p>Defense: <span id="character-defense"></span></p>
<p>Skill Points: <span id="character-skillpoints"></span></p>
<p>Active Buffs/Debuffs: <span id="character-buffs">None</span></p>
<p>Special Items: <span id="character-items">None</span></p>
<p class="muted">Portrait changes when you buy or pull an art (Store / Gacha).</p>
</div>
<!-- ===============================
Art Gallery
=============================== -->
<div id="art" style="display: none;">
<h2>アートギャラリー</h2>
<p class="muted">所持済みのアートは「Set as Character Art」でキャラクター画像に設定できます。ガチャでも入手できます。</p>
<div class="card">
<h3>Your Art Collection</h3>
<div id="art-collection-summary" class="muted"></div>
</div>
<div class="gallery-grid" id="art-gallery-grid"></div>
</div>
<!-- ===============================
Battle
=============================== -->
<div id="battle" style="display: none;">
<h2>Battle Arena</h2>
<p>Choose an enemy to fight or wait for random encounters in the wild!</p>
<div class="battle-container">
<div class="enemy-card">
<h3>Slime</h3>
<p>HP: 30</p>
<p>Attack: 1-3</p>
<p>Reward: 10 Gold, 10 XP</p>
<button class="button" onclick="startBattle('slime')">Fight Slime</button>
</div>
<div class="enemy-card">
<h3>Goblin</h3>
<p>HP: 50</p>
<p>Attack: 2-5</p>
<p>Reward: 20 Gold, 20 XP</p>
<button class="button" onclick="startBattle('goblin')">Fight Goblin</button>
</div>
<div class="enemy-card">
<h3>Orc Warrior</h3>
<p>HP: 80</p>
<p>Attack: 5-8</p>
<p>Reward: 40 Gold, 40 XP</p>
<button class="button" onclick="startBattle('orcEnemy')">Fight Orc</button>
</div>
</div>
<div class="log" id="battle-log"></div>
</div>
<!-- ===============================
Store
=============================== -->
<div id="store" style="display: none;">
<h2>Store</h2>
<p>Use your gold to purchase or sell items!</p>
<div class="card">
<h3>Buy Items</h3>
<div>
<h4>Minor Health Potion (20 Gold)</h4>
<button class="button" onclick="buyItem('Minor Health Potion')">Buy</button>
</div>
<div>
<h4>Major Health Potion (50 Gold)</h4>
<button class="button" onclick="buyItem('Major Health Potion')">Buy</button>
</div>
<div>
<h4>Iron Sword (80 Gold)</h4>
<button class="button" onclick="buyItem('Iron Sword')">Buy</button>
</div>
<div>
<h4>Steel Armor (100 Gold)</h4>
<button class="button" onclick="buyItem('Steel Armor')">Buy</button>
</div>
<div>
<h4>Lucky Ring (120 Gold)</h4>
<button class="button" onclick="buyItem('Lucky Ring')">Buy</button>
</div>
</div>
<!-- アート購入:購入するとキャラクター絵が変わる -->
<div class="card">
<h3>Art Shop(購入でキャラクター画像が変わる)</h3>
<p class="muted">Buy an art → it becomes “Owned” and you can set it anytime. (Gacha also adds Owned.)</p>
<div id="art-shop-list"></div>
</div>
<div class="card">
<h3>Sell Items</h3>
<p>Click an item in your inventory to sell it, if possible.</p>
<p class="muted">(You can't sell special quest items or currently equipped gear.)</p>
</div>
</div>
<!-- ===============================
Craft
=============================== -->
<div id="craft" style="display: none;">
<h2>Item Crafting</h2>
<p>Combine items to create something new!</p>
<div class="card">
<h3>Example Recipes</h3>
<ul>
<li>Dragon Scale + Orc Tusk => Dragon Tusk Lance (Weapon)</li>
<li>Magic Crystal + Magic Crystal => Greater Crystal (Special)</li>
</ul>
<p>Select any two items from your inventory to craft (if a valid recipe exists).</p>
</div>
<p>Currently Selected: <span id="craft-selection">None</span></p>
<button class="button" id="craft-button" onclick="attemptCraft()" disabled>Craft</button>
</div>
<!-- ===============================
Skills
=============================== -->
<div id="skills" style="display: none;">
<h2>Skills</h2>
<p>Use skill points to learn or upgrade skills!</p>
<p>You have <span id="skill-point-display"></span> skill points.</p>
<ul class="skill-list" id="skill-list"></ul>
</div>
<!-- ===============================
QuestLog
=============================== -->
<div id="questlog" style="display: none;">
<h2>Quest Log</h2>
<ul id="quest-log-list"></ul>
</div>
<!-- ===============================
Achievements
=============================== -->
<div id="achievements" style="display: none;">
<h2>Achievements</h2>
<ul id="achievement-list"></ul>
</div>
</div>
</main>
<!-- ===============================
フッター
=============================== -->
<footer>
<div class="container">
© 2025 Aran Red Fantasy
</div>
</footer>
<!-- ===============================
ログインモーダル
=============================== -->
<div class="modal-bg" id="login-modal-bg" style="display: none;">
<div class="modal">
<h2>Enter Your Name</h2>
<input type="text" id="loginName" placeholder="Your name" />
<br/><br/>
<button class="button" onclick="confirmLogin()">Login</button>
<button class="button" onclick="closeLoginModal()">Cancel</button>
</div>
</div>
<!-- ===============================
アイテム使用モーダル
=============================== -->
<div class="modal-bg" id="item-modal-bg" style="display: none;">
<div class="modal">
<h2 id="item-modal-title">Use/Equip Item</h2>
<p id="item-modal-description"></p>
<button class="button" onclick="confirmItemUse()">Use/Equip</button>
<button class="button" onclick="closeItemModal()">Cancel</button>
</div>
</div>
<!-- ===============================
アートプレビューモーダル
=============================== -->
<div class="modal-bg" id="art-modal-bg" style="display: none;">
<div class="modal">
<h2 id="art-modal-title">Art Preview</h2>
<img id="art-modal-img" alt="Art Preview" />
<p class="muted" id="art-modal-desc"></p>
<div style="margin-top:10px;">
<button class="button" id="art-modal-set-btn" onclick="confirmSetPortrait()">Set as Character Art</button>
<button class="button" onclick="closeArtModal()">Close</button>
</div>
</div>
</div>
<!-- ===============================
BGM本体(継続再生のためページ切替の外に置く)
=============================== -->
<audio id="bgm" loop preload="auto" playsinline>
<source src="http://tyosuke20xx.com/fjordnosundakaze.mp3" type="audio/mpeg">
</audio>
<!-- ===============================
JavaScript
=============================== -->
<script>
// -------------------------------------------
// ページ切り替え
// -------------------------------------------
function showPage(page) {
const pages = [
"home", "quests", "items", "friends", "character",
"art",
"battle", "store", "craft", "skills", "questlog", "achievements"
];
pages.forEach(p => {
const pageElement = document.getElementById(p);
const linkElement = document.getElementById(p + '-link');
if (!pageElement) return;
if (p === page) {
pageElement.style.display = "block";
if (linkElement) linkElement.classList.add("active");
} else {
pageElement.style.display = "none";
if (linkElement) linkElement.classList.remove("active");
}
});
if (page === "skills") refreshSkillList();
if (page === "questlog") updateQuestLog();
if (page === "achievements") updateAchievementList();
if (page === "art") renderArtGallery();
if (page === "store") renderArtShop();
}
// -------------------------------------------
// ローカルストレージキー
// -------------------------------------------
const LS_KEY_USER = "ARF_Username_Ultimate";
const LS_KEY_CHARACTER = "ARF_Character_Ultimate";
const LS_KEY_INVENTORY = "ARF_Inventory_Ultimate";
const LS_KEY_COMPANIONS = "ARF_Companions_Ultimate";
const LS_KEY_QUESTS = "ARF_Quests_Ultimate";
const LS_KEY_SKILLS = "ARF_Skills_Ultimate";
const LS_KEY_DAYTIME = "ARF_Daytime_Ultimate";
const LS_KEY_WEATHER = "ARF_Weather_Ultimate";
const LS_KEY_ACHIEVEMENT = "ARF_Achievement_Ultimate";
// BGM状態
const LS_KEY_BGM = "ARF_BGM_STATE_Ultimate";
// -------------------------------------------
// ★アート定義(SSR1〜SSR3 + UR1〜UR10)
// -------------------------------------------
const ART_LIST = [
// SSR(追加)
{ key:"SSR1", name:"SSR Art 1", url:"http://tyosuke20xx.com/SSR1.png", cost: 20, rarity:"SSR" },
{ key:"SSR2", name:"SSR Art 2", url:"http://tyosuke20xx.com/SSR2.png", cost: 20, rarity:"SSR" },
{ key:"SSR3", name:"SSR Art 3", url:"http://tyosuke20xx.com/SSR3.png", cost: 20, rarity:"SSR" },
// UR
{ key:"UR1", name:"UR Art 1", url:"http://tyosuke20xx.com/UR1.png", cost: 30, rarity:"UR" },
{ key:"UR2", name:"UR Art 2", url:"http://tyosuke20xx.com/UR2.png", cost: 30, rarity:"UR" },
{ key:"UR3", name:"UR Art 3", url:"http://tyosuke20xx.com/UR3.png", cost: 30, rarity:"UR" },
{ key:"UR4", name:"UR Art 4", url:"http://tyosuke20xx.com/UR4.png", cost: 30, rarity:"UR" },
{ key:"UR5", name:"UR Art 5", url:"http://tyosuke20xx.com/UR5.png", cost: 30, rarity:"UR" },
{ key:"UR6", name:"UR Art 6", url:"http://tyosuke20xx.com/UR6.png", cost: 30, rarity:"UR" },
{ key:"UR7", name:"UR Art 7", url:"http://tyosuke20xx.com/UR7.png", cost: 30, rarity:"UR" },
{ key:"UR8", name:"UR Art 8", url:"http://tyosuke20xx.com/UR8.png", cost: 30, rarity:"UR" },
{ key:"UR9", name:"UR Art 9", url:"http://tyosuke20xx.com/UR9.png", cost: 30, rarity:"UR" },
{ key:"UR10", name:"UR Art 10", url:"http://tyosuke20xx.com/UR10.png", cost: 30, rarity:"UR" }
];
// -------------------------------------------
// ★ガチャ設定(SSR/UR抽選)
// -------------------------------------------
const GACHA_COST = 10; // 1回10G
const GACHA_RATE_UR = 10; // UR 10%
const GACHA_RATE_SSR = 90; // SSR 90%(残り)
// -------------------------------------------
// キャラクター情報
// -------------------------------------------
let character = {
name: "Adventurer",
level: 1,
hp: 50,
maxHp: 50,
xp: 0,
nextLevelXp: 100,
gold: 0,
attack: 5,
defense: 2,
skillPoints: 0,
location: "Town",
specialItems: [],
buffs: [],
ownedArtKeys: [],
portraitUrl: "a.png"
};
// -------------------------------------------
// クエスト情報
// -------------------------------------------
let mainQuests = {
dragon: {
name: "Defeat the Dragon",
progress: 0,
reward: { gold: 100, xp: 100, items: ["Dragon Scale"] },
isRunning: false,
isCompleted: false,
unlockNext: "final",
locked: false
},
final: {
name: "The Ancient Evil",
progress: 0,
reward: { gold: 200, xp: 200, items: ["Legendary Relic"] },
isRunning: false,
isCompleted: false,
unlockNext: null,
locked: true
}
};
let sideQuests = {
crystal: {
name: "Collect Magic Crystals",
progress: 0,
reward: { gold: 50, xp: 50, items: ["Magic Crystal"] },
isRunning: false,
isCompleted: false,
unlockNext: null,
locked: false
},
orc: {
name: "Eliminate the Orc Bandits",
progress: 0,
reward: { gold: 80, xp: 70, items: ["Orc Tusk"] },
isRunning: false,
isCompleted: false,
unlockNext: null,
locked: false
}
};
function getAllQuests() {
return { ...mainQuests, ...sideQuests };
}
// -------------------------------------------
// インベントリ
// -------------------------------------------
let inventory = [];
// -------------------------------------------
// 仲間
// -------------------------------------------
let companions = [];
// -------------------------------------------
// スキル
// -------------------------------------------
let skills = {
Fireball: {
name: "Fireball",
level: 0,
maxLevel: 3,
cost: 1,
description: "Deal extra magic damage in battle"
},
Heal: {
name: "Heal",
level: 0,
maxLevel: 3,
cost: 1,
description: "Restores some HP at the start of battle"
}
};
// -------------------------------------------
// バトル用エネミー
// -------------------------------------------
const enemies = {
slime: {
name: "Slime",
hp: 30,
attackMin: 1,
attackMax: 3,
rewardGold: 10,
rewardXp: 10
},
goblin: {
name: "Goblin",
hp: 50,
attackMin: 2,
attackMax: 5,
rewardGold: 20,
rewardXp: 20
},
orcEnemy: {
name: "Orc Warrior",
hp: 80,
attackMin: 5,
attackMax: 8,
rewardGold: 40,
rewardXp: 40
}
};
// -------------------------------------------
// ストアアイテム
// -------------------------------------------
const storeItems = {
"Minor Health Potion": { name: "Minor Health Potion", type: "potion", heal: 20, cost: 20 },
"Major Health Potion": { name: "Major Health Potion", type: "potion", heal: 50, cost: 50 },
"Iron Sword": { name: "Iron Sword", type: "weapon", attack: 5, cost: 80, equipped: false },
"Steel Armor": { name: "Steel Armor", type: "armor", defense: 5, cost: 100, equipped: false },
"Lucky Ring": { name: "Lucky Ring", type: "accessory", attack: 1, defense: 1, cost: 120, equipped: false }
};
// -------------------------------------------
// クラフト用レシピ
// -------------------------------------------
const craftRecipes = [
{
components: ["Dragon Scale", "Orc Tusk"].sort(),
result: { name: "Dragon Tusk Lance", type: "weapon", attack: 10, equipped: false }
},
{
components: ["Magic Crystal", "Magic Crystal"].sort(),
result: { name: "Greater Crystal", type: "special" }
}
];
// -------------------------------------------
// 昼夜 & 天候
// -------------------------------------------
let currentHour = 12;
let currentWeather = "Sunny";
const possibleWeathers = ["Sunny","Rainy","Storm","Cloudy"];
// -------------------------------------------
// 実績
// -------------------------------------------
let achievements = {
firstKill: { name: "First Blood", description: "Defeat your first enemy.", isUnlocked: false },
level5: { name: "Rising Hero", description: "Reach Level 5.", isUnlocked: false },
quest3: { name: "Quest Hunter", description: "Complete 3 Quests.", isUnlocked: false }
};
// -------------------------------------------
// onload
// -------------------------------------------
window.onload = function() {
loadLocalData();
// BGM(継続再生 & 状態保存)
loadBgmState();
wireBgmAutoSave();
updateEnableBtn();
setBgmStatus(isMusicPlaying ? "On (will resume)" : "Off");
resumeBgmOnNextUserActionIfNeeded();
updateCharacterInfo();
updateQuestVisibility();
updateInventoryDisplay();
updateCompanionList();
updateDayNightDisplay();
updateWeatherDisplay();
updateAchievementList();
renderArtShop();
renderArtGallery();
showPage('home');
document.getElementById("current-location").textContent = character.location;
};
// -------------------------------------------
// ローカルストレージ: 読込/保存/リセット
// -------------------------------------------
function loadLocalData() {
let storedName = localStorage.getItem(LS_KEY_USER);
if (storedName) character.name = storedName;
let storedChar = localStorage.getItem(LS_KEY_CHARACTER);
if (storedChar) {
try {
const parsed = JSON.parse(storedChar);
character = { ...character, ...parsed };
} catch(e) {}
}
if (!Array.isArray(character.ownedArtKeys)) character.ownedArtKeys = [];
if (!character.portraitUrl) character.portraitUrl = "a.png";
let storedInv = localStorage.getItem(LS_KEY_INVENTORY);
if (storedInv) { try { inventory = JSON.parse(storedInv); } catch(e) {} }
let storedComp = localStorage.getItem(LS_KEY_COMPANIONS);
if (storedComp) { try { companions = JSON.parse(storedComp); } catch(e) {} }
let storedMQ = localStorage.getItem(LS_KEY_QUESTS+"_main");
if (storedMQ) { try { mainQuests = JSON.parse(storedMQ); } catch(e) {} }
let storedSQ = localStorage.getItem(LS_KEY_QUESTS+"_side");
if (storedSQ) { try { sideQuests = JSON.parse(storedSQ); } catch(e) {} }
let storedSkills = localStorage.getItem(LS_KEY_SKILLS);
if (storedSkills) { try { skills = JSON.parse(storedSkills); } catch(e) {} }
let storedHour = localStorage.getItem(LS_KEY_DAYTIME+"_hour");
if (storedHour) currentHour = parseInt(storedHour, 10);
let storedWeather = localStorage.getItem(LS_KEY_WEATHER);
if (storedWeather) currentWeather = storedWeather;
let storedAchv = localStorage.getItem(LS_KEY_ACHIEVEMENT);
if (storedAchv) { try { achievements = JSON.parse(storedAchv); } catch(e) {} }
}
function saveLocalData() {
localStorage.setItem(LS_KEY_USER, character.name);
localStorage.setItem(LS_KEY_CHARACTER, JSON.stringify(character));
localStorage.setItem(LS_KEY_INVENTORY, JSON.stringify(inventory));
localStorage.setItem(LS_KEY_COMPANIONS, JSON.stringify(companions));
localStorage.setItem(LS_KEY_QUESTS+"_main", JSON.stringify(mainQuests));
localStorage.setItem(LS_KEY_QUESTS+"_side", JSON.stringify(sideQuests));
localStorage.setItem(LS_KEY_SKILLS, JSON.stringify(skills));
localStorage.setItem(LS_KEY_DAYTIME+"_hour", currentHour.toString());
localStorage.setItem(LS_KEY_WEATHER, currentWeather);
localStorage.setItem(LS_KEY_ACHIEVEMENT, JSON.stringify(achievements));
}
function logout() {
if (!confirm("All data will be cleared. Are you sure?")) return;
localStorage.clear();
location.reload();
}
// -------------------------------------------
// ログインモーダル
// -------------------------------------------
function showLoginModal() {
document.getElementById("login-modal-bg").style.display = "flex";
}
function closeLoginModal() {
document.getElementById("login-modal-bg").style.display = "none";
}
function confirmLogin() {
const inputName = document.getElementById("loginName").value.trim();
if (inputName) {
character.name = inputName;
saveLocalData();
updateCharacterInfo();
}
closeLoginModal();
}
// -------------------------------------------
// キャラクター情報表示更新
// -------------------------------------------
function updateCharacterInfo() {
document.getElementById("character-name").textContent = character.name;
document.getElementById("character-level").textContent = character.level;
document.getElementById("character-hp").textContent = character.hp;
document.getElementById("character-maxhp").textContent = character.maxHp;
document.getElementById("character-xp").textContent = character.xp;
document.getElementById("character-nextLevelXp").textContent = character.nextLevelXp;
document.getElementById("character-gold").textContent = character.gold;
document.getElementById("character-attack").textContent = character.attack;
document.getElementById("character-defense").textContent = character.defense;
document.getElementById("character-skillpoints").textContent = character.skillPoints;
const img = document.getElementById("character-image");
if (img) img.src = character.portraitUrl || "a.png";
if (character.specialItems.length > 0) {
document.getElementById("character-items").textContent = character.specialItems.join(", ");
} else {
document.getElementById("character-items").textContent = "None";
}
if (character.buffs.length > 0) {
document.getElementById("character-buffs").textContent = character.buffs.map(b => b.name).join(", ");
} else {
document.getElementById("character-buffs").textContent = "None";
}
saveLocalData();
checkAchievements();
}
// -------------------------------------------
// レベルアップ
// -------------------------------------------
function addXp(amount) {
character.xp += amount;
while (character.xp >= character.nextLevelXp) {
character.level++;
character.xp -= character.nextLevelXp;
character.nextLevelXp = character.level * 100;
character.maxHp += 20;
character.hp = character.maxHp;
character.attack += 1;
character.defense += 1;
character.skillPoints += 1;
showHomeMessage(`Level up! Now Level ${character.level} (+1 Skill Point).`);
for (let c of companions) {
c.level++;
c.hp = c.maxHp;
c.attack++;
}
}
updateCharacterInfo();
}
// -------------------------------------------
// バフ/デバフ
// -------------------------------------------
function addBuff(buffObj) {
character.buffs.push(buffObj);
updateCharacterInfo();
}
function processBuffsEachTurn(logElm) {
for (let i = character.buffs.length - 1; i >= 0; i--) {
const b = character.buffs[i];
if (b.effectType === "dot") {
character.hp -= b.effectValue;
if (character.hp < 0) character.hp = 0;
logMessage(logElm, `[${b.name}] You take ${b.effectValue} damage! (HP: ${character.hp})`);
}
b.turns--;
if (b.turns <= 0) {
logMessage(logElm, `[${b.name}] effect ended.`);
character.buffs.splice(i, 1);
}
}
}
// -------------------------------------------
// ホームメッセージ
// -------------------------------------------
function showHomeMessage(msg) {
const homeMessage = document.getElementById('home-message');
homeMessage.innerHTML = `<div class="message">${msg}</div>`;
}
// -------------------------------------------
// 昼夜
// -------------------------------------------
function updateDayNightDisplay() {
let dnElm = document.getElementById("day-night-display");
let hourStr = (currentHour < 10) ? "0"+currentHour : currentHour;
let isNight = (currentHour >= 18 || currentHour < 6);
let dayNight = isNight ? "Night" : "Day";
dnElm.innerHTML = `<p>Time: ${hourStr}:00 (${dayNight})</p>`;
}
function advanceTime() {
currentHour += 6;
if (currentHour >= 24) currentHour -= 24;
saveLocalData();
updateDayNightDisplay();
showHomeMessage("Time passes by...");
}
// -------------------------------------------
// 天候
// -------------------------------------------
function updateWeatherDisplay() {
const wElm = document.getElementById("weather-display");
wElm.innerHTML = `<p>Weather: ${currentWeather}</p>`;
}
function changeWeatherRandom() {
currentWeather = possibleWeathers[Math.floor(Math.random() * possibleWeathers.length)];
updateWeatherDisplay();
saveLocalData();
}
// -------------------------------------------
// ランダムイベント
// -------------------------------------------
function triggerRandomEvent() {
const randomRoll = Math.random();
let msg = "";
if (randomRoll < 0.2) {
msg = "A traveling merchant appears, offering rare goods (not yet implemented).";
} else if (randomRoll < 0.4) {
changeWeatherRandom();
msg = `The weather suddenly changes to ${currentWeather}!`;
} else if (randomRoll < 0.6) {
addBuff({ name: 'Poison', turns: 3, effectType: 'dot', effectValue: 3 });
msg = "You stepped on a poisonous trap! You are now poisoned.";
} else {
msg = "Nothing special happens.";
}
showHomeMessage(msg);
}
// -------------------------------------------
// 宿屋
// -------------------------------------------
function restAtInn() {
if (character.gold < 10) {
showHomeMessage("Not enough gold to rest at the inn!");
return;
}
character.gold -= 10;
character.hp = character.maxHp;
for (let c of companions) c.hp = c.maxHp;
showHomeMessage("You and your companions rest at the inn and recover full HP.");
updateCharacterInfo();
}
// -------------------------------------------
// ロケーション移動 + ランダムエンカウント
// -------------------------------------------
function moveLocation(newLocation) {
character.location = newLocation;
document.getElementById("current-location").textContent = newLocation;
saveLocalData();
const logElm = document.getElementById('location-log');
logElm.textContent = `You moved to ${newLocation}.`;
let encounterChance = 0;
if (newLocation === "Town") encounterChance = 0;
else if (newLocation === "Forest") encounterChance = 40;
else if (newLocation === "Dungeon") encounterChance = 70;
else if (newLocation === "Mountain") encounterChance = 50;
const roll = Math.random() * 100;
if (roll < encounterChance) {
const enemyKeys = Object.keys(enemies);
const randEnemyKey = enemyKeys[Math.floor(Math.random() * enemyKeys.length)];
logElm.textContent += `\nA wild ${enemies[randEnemyKey].name} appears!`;
startBattle(randEnemyKey);
}
}
// -------------------------------------------
// 仲間の雇用
// -------------------------------------------
function hireCompanion() {
const input = document.getElementById('friendName');
let name = input.value.trim();
if (!name) return;
let newCompanion = { name, level: 1, hp: 30, maxHp: 30, attack: 2 };
companions.push(newCompanion);
input.value = "";
updateCompanionList();
saveLocalData();
showHomeMessage(`${name} joined your party!`);
}
function updateCompanionList() {
const listElm = document.getElementById('companion-list');
listElm.innerHTML = "";
companions.forEach(c => {
const li = document.createElement("li");
li.textContent = `${c.name} (Lv ${c.level}, HP ${c.hp}/${c.maxHp}, ATK ${c.attack})`;
listElm.appendChild(li);
});
}
// -------------------------------------------
// インベントリ表示
// -------------------------------------------
let selectedItemIndex = null;
let selectedForCraft = [];
function updateInventoryDisplay() {
const invElm = document.getElementById('inventory');
invElm.innerHTML = "";
if (inventory.length === 0) {
invElm.innerHTML = "<p>Your inventory is empty.</p>";
return;
}
inventory.forEach((item, index) => {
const div = document.createElement("div");
div.className = "inventory-item";
div.textContent = item.name;
if (item.equipped) div.style.border = "2px solid #4CAF50";
div.onclick = () => onInventoryItemClick(index);
invElm.appendChild(div);
});
}
function onInventoryItemClick(index) {
if (document.getElementById("craft").style.display === "block") {
toggleCraftSelection(index);
return;
}
selectedItemIndex = index;
const item = inventory[index];
const modalTitle = document.getElementById("item-modal-title");
const modalDesc = document.getElementById("item-modal-description");
if (item.type === "potion") {
modalTitle.textContent = `Use ${item.name}?`;
modalDesc.textContent = `This potion restores ${item.heal} HP.`;
} else if (item.type === "weapon") {
modalTitle.textContent = `Equip ${item.name}?`;
modalDesc.textContent = `Weapon (+${item.attack} Attack).`;
} else if (item.type === "armor") {
modalTitle.textContent = `Equip ${item.name}?`;
modalDesc.textContent = `Armor (+${item.defense} Defense).`;
} else if (item.type === "accessory") {
modalTitle.textContent = `Equip ${item.name}?`;
modalDesc.textContent = `Accessory (+${item.attack} ATK, +${item.defense} DEF).`;
} else {
modalTitle.textContent = item.name;
modalDesc.textContent = "A special item. No direct use/equip.";
}
if (canSellItem(item)) {
modalDesc.textContent += `\n(Sell price: ${sellPrice(item)} Gold)`;
}
document.getElementById("item-modal-bg").style.display = "flex";
}
function closeItemModal() {
document.getElementById("item-modal-bg").style.display = "none";
selectedItemIndex = null;
}
function confirmItemUse() {
if (selectedItemIndex === null) return;
const item = inventory[selectedItemIndex];
if (item.type === "potion") {
character.hp += item.heal;
if (character.hp > character.maxHp) character.hp = character.maxHp;
inventory.splice(selectedItemIndex, 1);
showHomeMessage(`${item.name} used! You recovered ${item.heal} HP.`);
}
else if (item.type === "weapon") {
unequipItem("weapon");
item.equipped = true;
character.attack += item.attack;
showHomeMessage(`${item.name} equipped. (+${item.attack} Attack)`);
}
else if (item.type === "armor") {
unequipItem("armor");
item.equipped = true;
character.defense += item.defense;
showHomeMessage(`${item.name} equipped. (+${item.defense} Defense)`);
}
else if (item.type === "accessory") {
unequipItem("accessory");
item.equipped = true;
character.attack += item.attack;
character.defense += item.defense;
showHomeMessage(`${item.name} equipped. (+${item.attack} ATK, +${item.defense} DEF)`);
}
else {
if (canSellItem(item)) {
let price = sellPrice(item);
character.gold += price;
inventory.splice(selectedItemIndex, 1);
showHomeMessage(`You sold ${item.name} for ${price} Gold.`);
} else {
showHomeMessage(`You can't use ${item.name} right now.`);
}
}
updateCharacterInfo();
updateInventoryDisplay();
closeItemModal();
}
function unequipItem(type) {
for (let i = 0; i < inventory.length; i++) {
let it = inventory[i];
if (it.type === type && it.equipped) {
it.equipped = false;
if (type === "weapon") character.attack -= it.attack;
else if (type === "armor") character.defense -= it.defense;
else if (type === "accessory") { character.attack -= it.attack; character.defense -= it.defense; }
}
}
}
function canSellItem(item) {
if (item.equipped) return false;
if (item.type === "special") return false;
return !["weapon","armor","accessory","potion"].includes(item.type) ? true : false;
}
function sellPrice(item) { return 30; }
// -------------------------------------------
// クエストUI
// -------------------------------------------
function updateQuestVisibility() {
const finalQuestCard = document.getElementById("final-quest");
finalQuestCard.style.display = (!mainQuests.final.locked) ? "block" : "none";
}
function startQuest(questKey) {
let q = mainQuests[questKey] || sideQuests[questKey];
if (!q) return;
if (q.isRunning || q.isCompleted) return;
if (q.locked) {
showHomeMessage("This quest is locked. Complete the previous quest first!");
return;
}
q.isRunning = true;
q.progress = 0;
updateProgressBar(questKey);
let progressInterval = setInterval(() => {
q.progress += 5;
if (q.progress > 100) q.progress = 100;
updateProgressBar(questKey);
if (q.progress === 100) {
clearInterval(progressInterval);
completeQuest(questKey);
}
}, 400);
}
function completeQuest(questKey) {
let q = mainQuests[questKey] || sideQuests[questKey];
q.isRunning = false;
q.isCompleted = true;
character.gold += q.reward.gold;
addXp(q.reward.xp);
q.reward.items.forEach(it => character.specialItems.push(it));
showHomeMessage(`${q.name} completed! You got ${q.reward.gold} Gold, ${q.reward.xp} XP, and ${q.reward.items.join(", ")}.`);
if (q.unlockNext) {
if (mainQuests[q.unlockNext]) mainQuests[q.unlockNext].locked = false;
else if (sideQuests[q.unlockNext]) sideQuests[q.unlockNext].locked = false;
}
updateQuestVisibility();
updateCharacterInfo();
saveLocalData();
}
function updateProgressBar(questKey) {
const bar = document.getElementById(questKey + '-progress');
let q = mainQuests[questKey] || sideQuests[questKey];
if (bar) bar.style.width = q.progress + '%';
}
// -------------------------------------------
// バトル
// -------------------------------------------
function startBattle(enemyKey) {
const enemyDef = enemies[enemyKey];
if (!enemyDef) return;
const logElm = document.getElementById('battle-log');
logElm.innerHTML = `A wild ${enemyDef.name} appears! (HP: ${enemyDef.hp})`;
if (skills.Heal.level > 0) {
const healAmount = skills.Heal.level * 10;
character.hp += healAmount;
if (character.hp > character.maxHp) character.hp = character.maxHp;
logMessage(logElm, `[Skill: Heal Lv${skills.Heal.level}] You healed ${healAmount} HP!`);
updateCharacterInfo();
}
let enemyHp = enemyDef.hp;
let battleInterval = setInterval(() => {
processBuffsEachTurn(logElm);
if (character.hp <= 0) {
clearInterval(battleInterval);
logMessage(logElm, "You have been defeated...");
saveLocalData();
return;
}
let baseDamage = getRandomInt(character.attack - 2, character.attack + 2);
if (baseDamage < 1) baseDamage = 1;
if (skills.Fireball.level > 0) {
let extra = skills.Fireball.level * 2;
baseDamage += extra;
logMessage(logElm, `[Fireball Lv${skills.Fireball.level}] Extra ${extra} magic damage!`);
}
let totalCompanionDamage = 0;
companions.forEach(c => { if (c.hp > 0) totalCompanionDamage += c.attack; });
let totalDamage = baseDamage + totalCompanionDamage;
enemyHp -= totalDamage;
logMessage(logElm, `You (and companions) deal ${totalDamage} damage! (Enemy HP: ${Math.max(enemyHp, 0)})`);
if (enemyHp <= 0) {
clearInterval(battleInterval);
logMessage(logElm, `You defeated the ${enemyDef.name}!`);
character.gold += enemyDef.rewardGold;
addXp(enemyDef.rewardXp);
updateCompanionXP(enemyDef.rewardXp);
updateCharacterInfo();
saveLocalData();
achievements.firstKill.isUnlocked = true;
updateAchievementList();
return;
}
let eAtk = getRandomInt(enemyDef.attackMin, enemyDef.attackMax);
let dmgToPlayer = eAtk - character.defense;
if (dmgToPlayer < 1) dmgToPlayer = 1;
character.hp -= dmgToPlayer;
if (character.hp < 0) character.hp = 0;
logMessage(logElm, `The ${enemyDef.name} hits you for ${dmgToPlayer}. (Your HP: ${character.hp})`);
updateCharacterInfo();
if (character.hp <= 0) {
clearInterval(battleInterval);
logMessage(logElm, "You have been defeated...");
saveLocalData();
return;
}
}, 800);
}
function logMessage(logElm, msg) {
const p = document.createElement("p");
p.textContent = msg;
logElm.appendChild(p);
logElm.scrollTop = logElm.scrollHeight;
}
function updateCompanionXP(amount) {
for (let c of companions) {
c.level += Math.floor(amount/50);
c.maxHp += 5;
c.hp = c.maxHp;
c.attack += 1;
}
updateCompanionList();
}
// -------------------------------------------
// ストア購入
// -------------------------------------------
function buyItem(itemKey) {
const itemDef = storeItems[itemKey];
if (!itemDef) return;
if (character.gold < itemDef.cost) {
showHomeMessage(`You don't have enough gold to buy ${itemDef.name}.`);
return;
}
character.gold -= itemDef.cost;
let newItem = JSON.parse(JSON.stringify(itemDef));
if (["weapon","armor","accessory"].includes(newItem.type)) newItem.equipped = false;
inventory.push(newItem);
showHomeMessage(`You bought ${newItem.name}!`);
updateCharacterInfo();
updateInventoryDisplay();
}
// -------------------------------------------
// アート所持/購入/設定
// -------------------------------------------
function isArtOwned(artKey) {
return character.ownedArtKeys.includes(artKey);
}
function grantArt(artKey, setAsPortrait=false) {
const art = ART_LIST.find(a => a.key === artKey);
if (!art) return false;
if (!isArtOwned(art.key)) {
character.ownedArtKeys.push(art.key);
}
if (setAsPortrait) {
character.portraitUrl = art.url;
}
saveLocalData();
updateCharacterInfo();
renderArtShop();
renderArtGallery();
return true;
}
function buyArt(artKey) {
const art = ART_LIST.find(a => a.key === artKey);
if (!art) return;
if (isArtOwned(art.key)) {
showHomeMessage(`You already own ${art.name}.`);
return;
}
if (character.gold < art.cost) {
showHomeMessage(`Not enough gold to buy ${art.name}. Need ${art.cost} Gold.`);
return;
}
character.gold -= art.cost;
// 購入したら即キャラ絵変更
grantArt(art.key, true);
showHomeMessage(`Purchased ${art.name}! Character portrait changed.`);
}
function setPortraitFromArt(artKey) {
const art = ART_LIST.find(a => a.key === artKey);
if (!art) return;
if (!isArtOwned(art.key)) {
showHomeMessage("You don't own this art yet. Buy it in Store or pull it from Gacha.");
return;
}
character.portraitUrl = art.url;
saveLocalData();
updateCharacterInfo();
showHomeMessage(`Character portrait set to ${art.name}.`);
}
// Storeのアート一覧描画
function renderArtShop() {
const wrap = document.getElementById("art-shop-list");
if (!wrap) return;
wrap.innerHTML = "";
ART_LIST.forEach(a => {
const owned = isArtOwned(a.key);
const rarityBadge = a.rarity === "UR"
? `<span class="badge rarity-ur">UR</span>`
: `<span class="badge rarity-ssr">SSR</span>`;
const row = document.createElement("div");
row.style.marginBottom = "10px";
row.innerHTML = `
<div style="display:flex; align-items:center; justify-content:space-between; gap:10px; flex-wrap:wrap;">
<div>
<strong>${a.name}</strong>
${rarityBadge}
<span class="muted">(${a.cost} Gold)</span>
${owned ? `<span class="badge owned">Owned</span>` : `<span class="badge">Not Owned</span>`}
</div>
<div>
<button class="button" onclick="buyArt('${a.key}')" ${owned ? 'disabled class="button disabled"' : ''} ${owned ? 'disabled' : ''}>Buy</button>
<button class="button" onclick="openArtModal('${a.key}')">Preview</button>
</div>
</div>
`;
wrap.appendChild(row);
});
}
// ギャラリー描画
function renderArtGallery() {
const grid = document.getElementById("art-gallery-grid");
const sum = document.getElementById("art-collection-summary");
if (!grid || !sum) return;
const ownedCount = character.ownedArtKeys.length;
sum.textContent = `Owned: ${ownedCount} / ${ART_LIST.length}`;
grid.innerHTML = "";
ART_LIST.forEach(a => {
const owned = isArtOwned(a.key);
const item = document.createElement("div");
item.className = "gallery-item";
const img = document.createElement("img");
img.src = a.url;
img.alt = a.name;
img.loading = "lazy";
img.style.cursor = "pointer";
img.onclick = () => openArtModal(a.key);
const meta = document.createElement("div");
meta.className = "gallery-meta";
meta.innerHTML = `
<div>
<strong>${a.name}</strong><br/>
<span class="muted">${a.cost} Gold</span>
</div>
<div style="display:flex; gap:6px; align-items:center;">
${a.rarity === "UR"
? `<span class="badge rarity-ur">UR</span>`
: `<span class="badge rarity-ssr">SSR</span>`
}
${owned ? `<span class="badge owned">Owned</span>` : `<span class="badge">Not Owned</span>`}
</div>
`;
const actions = document.createElement("div");
actions.className = "gallery-actions";
const btnPreview = document.createElement("button");
btnPreview.className = "button";
btnPreview.textContent = "Preview";
btnPreview.onclick = () => openArtModal(a.key);
const btnSet = document.createElement("button");
btnSet.className = "button";
btnSet.textContent = "Set as Character Art";
btnSet.disabled = !owned;
if (!owned) btnSet.classList.add("disabled");
btnSet.onclick = () => setPortraitFromArt(a.key);
actions.appendChild(btnPreview);
actions.appendChild(btnSet);
item.appendChild(img);
item.appendChild(meta);
item.appendChild(actions);
grid.appendChild(item);
});
}
// -------------------------------------------
// ★ガチャ(SSR1〜SSR3/UR1〜UR10から抽選)
// - 引いたアートはOwnedに追加
// - 1枚目だけは演出的にキャラ絵も即変更(setAsPortrait=true)
// -------------------------------------------
function pullArtGacha(times) {
const totalCost = GACHA_COST * times;
const status = document.getElementById("gacha-status");
const resultWrap = document.getElementById("gacha-result");
resultWrap.innerHTML = "";
if (character.gold < totalCost) {
status.textContent = `Not enough gold. Need ${totalCost} Gold.`;
showHomeMessage(`Not enough gold for gacha. Need ${totalCost} Gold.`);
return;
}
character.gold -= totalCost;
let pulled = [];
for (let i=0; i<times; i++) {
const art = rollOneArt();
pulled.push(art);
// 1枚目だけ即ポートレート変更(継続仕様)
grantArt(art.key, i === 0);
}
updateCharacterInfo();
status.textContent = `Pulled ${times} time(s). Cost ${totalCost} Gold.`;
// 表示
pulled.forEach((a, idx) => {
const card = document.createElement("div");
card.className = "gacha-card";
card.innerHTML = `
<img src="${a.url}" alt="${a.name}">
<div class="p">
<strong>${a.name}</strong><br/>
<span class="muted">${a.rarity}</span>
${isArtOwned(a.key) ? `<span class="badge owned" style="margin-left:6px;">Owned</span>` : ``}
</div>
`;
card.onclick = () => openArtModal(a.key);
resultWrap.appendChild(card);
});
showHomeMessage(`Gacha result: ${pulled.map(a => a.rarity + " " + a.key).join(", ")}`);
saveLocalData();
renderArtShop();
renderArtGallery();
}
function rollOneArt() {
const r = Math.random() * 100;
let rarity = (r < GACHA_RATE_UR) ? "UR" : "SSR";
const pool = ART_LIST.filter(a => a.rarity === rarity);
// 念のため
if (pool.length === 0) return ART_LIST[Math.floor(Math.random() * ART_LIST.length)];
return pool[Math.floor(Math.random() * pool.length)];
}
// -------------------------------------------
// アートプレビュー・モーダル
// -------------------------------------------
let pendingArtKey = null;
function openArtModal(artKey) {
const art = ART_LIST.find(a => a.key === artKey);
if (!art) return;
pendingArtKey = artKey;
document.getElementById("art-modal-title").textContent = `${art.name} (${art.rarity})`;
document.getElementById("art-modal-img").src = art.url;
const owned = isArtOwned(art.key);
document.getElementById("art-modal-desc").textContent =
owned ? "Owned: You can set this as your character art." : "Not owned: Buy it in Store or pull it from Gacha.";
const setBtn = document.getElementById("art-modal-set-btn");
setBtn.disabled = !owned;
if (!owned) setBtn.classList.add("disabled");
else setBtn.classList.remove("disabled");
document.getElementById("art-modal-bg").style.display = "flex";
}
function closeArtModal() {
document.getElementById("art-modal-bg").style.display = "none";
pendingArtKey = null;
}
function confirmSetPortrait() {
if (!pendingArtKey) return;
setPortraitFromArt(pendingArtKey);
closeArtModal();
}
// -------------------------------------------
// クラフト関連
// -------------------------------------------
function toggleCraftSelection(invIndex) {
const item = inventory[invIndex];
if (selectedForCraft.includes(invIndex)) {
selectedForCraft = selectedForCraft.filter(i => i !== invIndex);
} else {
if (selectedForCraft.length >= 2) {
showHomeMessage("You can only select up to 2 items for crafting.");
return;
}
selectedForCraft.push(invIndex);
}
updateCraftSelectionDisplay();
}
function updateCraftSelectionDisplay() {
let names = selectedForCraft.map(i => inventory[i].name);
if (names.length === 0) names.push("None");
document.getElementById("craft-selection").textContent = names.join(" & ");
document.getElementById("craft-button").disabled = (selectedForCraft.length < 2);
}
function attemptCraft() {
if (selectedForCraft.length < 2) return;
let itemA = inventory[selectedForCraft[0]];
let itemB = inventory[selectedForCraft[1]];
let combo = [itemA.name, itemB.name].sort();
let craftedItem = null;
for (let r of craftRecipes) {
if (r.components[0] === combo[0] && r.components[1] === combo[1]) {
craftedItem = r.result;
break;
}
}
if (!craftedItem) {
showHomeMessage("No valid recipe found for these items.");
selectedForCraft = [];
updateCraftSelectionDisplay();
return;
}
let idxA = Math.max(selectedForCraft[0], selectedForCraft[1]);
let idxB = Math.min(selectedForCraft[0], selectedForCraft[1]);
inventory.splice(idxA, 1);
inventory.splice(idxB, 1);
inventory.push(craftedItem);
showHomeMessage(`You crafted: ${craftedItem.name}!`);
selectedForCraft = [];
updateCraftSelectionDisplay();
updateInventoryDisplay();
saveLocalData();
}
// -------------------------------------------
// スキル
// -------------------------------------------
function refreshSkillList() {
document.getElementById("skill-point-display").textContent = character.skillPoints;
const listElm = document.getElementById("skill-list");
listElm.innerHTML = "";
for (let sKey in skills) {
let sk = skills[sKey];
let li = document.createElement("li");
li.innerHTML = `
<strong>${sk.name} (Lv${sk.level}/${sk.maxLevel})</strong>
- ${sk.description}
${
sk.level < sk.maxLevel
? `(<button onclick="learnSkill('${sKey}')">Upgrade (cost ${sk.cost})</button>)`
: ''
}
`;
listElm.appendChild(li);
}
}
function learnSkill(skillKey) {
let skill = skills[skillKey];
if (!skill) return;
if (skill.level >= skill.maxLevel) {
showHomeMessage(`${skill.name} is already at max level.`);
return;
}
if (character.skillPoints < skill.cost) {
showHomeMessage(`Not enough skill points to upgrade ${skill.name}.`);
return;
}
character.skillPoints -= skill.cost;
skill.level++;
showHomeMessage(`You upgraded ${skill.name} to level ${skill.level}.`);
updateCharacterInfo();
refreshSkillList();
}
// -------------------------------------------
// クエストログ
// -------------------------------------------
function updateQuestLog() {
const logElm = document.getElementById("quest-log-list");
logElm.innerHTML = "";
let allQ = getAllQuests();
for (let key in allQ) {
let q = allQ[key];
let status = q.isCompleted ? "Completed" : (q.isRunning ? "In Progress" : "Not Started");
let li = document.createElement("li");
li.textContent = `${q.name}: ${status}`;
logElm.appendChild(li);
}
}
// -------------------------------------------
// 実績
// -------------------------------------------
function checkAchievements() {
if (character.level >= 5) achievements.level5.isUnlocked = true;
let completedCount = 0;
let allQ = getAllQuests();
for (let key in allQ) if (allQ[key].isCompleted) completedCount++;
if (completedCount >= 3) achievements.quest3.isUnlocked = true;
saveLocalData();
updateAchievementList();
}
function updateAchievementList() {
const listElm = document.getElementById("achievement-list");
listElm.innerHTML = "";
for (let aKey in achievements) {
let a = achievements[aKey];
let status = a.isUnlocked ? "Unlocked" : "Locked";
let li = document.createElement("li");
li.textContent = `${a.name} - ${a.description} [${status}]`;
listElm.appendChild(li);
}
}
// -------------------------------------------
// BGM(継続再生 & 状態保存 & 復帰)
// -------------------------------------------
let isMusicPlaying = false;
let bgmUnlocked = false;
let wantAutoResume = false;
function setBgmStatus(text) {
const s = document.getElementById("bgm-status");
if (s) s.textContent = "Status: " + text;
}
function saveBgmState() {
const audio = document.getElementById("bgm");
if (!audio) return;
const state = {
unlocked: bgmUnlocked,
playing: isMusicPlaying,
volume: audio.volume,
time: audio.currentTime
};
localStorage.setItem(LS_KEY_BGM, JSON.stringify(state));
}
function loadBgmState() {
const audio = document.getElementById("bgm");
if (!audio) return;
const raw = localStorage.getItem(LS_KEY_BGM);
if (!raw) return;
try {
const st = JSON.parse(raw);
bgmUnlocked = !!st.unlocked;
isMusicPlaying = !!st.playing;
wantAutoResume = isMusicPlaying;
if (typeof st.volume === "number") audio.volume = st.volume;
if (typeof st.time === "number") {
audio.addEventListener("loadedmetadata", () => {
try { audio.currentTime = Math.max(0, st.time); } catch(e) {}
}, { once: true });
}
} catch(e) {}
}
function wireBgmAutoSave() {
const audio = document.getElementById("bgm");
if (!audio) return;
audio.addEventListener("play", () => { isMusicPlaying = true; saveBgmState(); });
audio.addEventListener("pause", () => { isMusicPlaying = false; saveBgmState(); });
audio.addEventListener("volumechange", saveBgmState);
let lastSave = 0;
audio.addEventListener("timeupdate", () => {
const now = Date.now();
if (now - lastSave > 4000) {
lastSave = now;
saveBgmState();
}
});
}
function updateEnableBtn() {
const btn = document.getElementById("bgm-enable-btn");
if (!btn) return;
if (bgmUnlocked) {
btn.textContent = "BGM Enabled";
btn.classList.add("disabled");
btn.disabled = true;
} else {
btn.textContent = "Enable BGM (First Click)";
btn.classList.remove("disabled");
btn.disabled = false;
}
}
function resumeBgmOnNextUserActionIfNeeded() {
if (!bgmUnlocked || !wantAutoResume) return;
const audio = document.getElementById("bgm");
if (!audio) return;
const resumeOnce = () => {
audio.play().then(() => {
isMusicPlaying = true;
wantAutoResume = false;
setBgmStatus("On (Resumed)");
saveBgmState();
}).catch(() => {
setBgmStatus("Blocked (Enable again)");
});
};
document.addEventListener("pointerdown", resumeOnce, { once: true });
document.addEventListener("keydown", resumeOnce, { once: true });
}
function enableBGM() {
const audio = document.getElementById("bgm");
if (!audio) return;
audio.volume = 0.6;
audio.play().then(() => {
bgmUnlocked = true;
isMusicPlaying = true;
wantAutoResume = false;
updateEnableBtn();
setBgmStatus("On");
showHomeMessage("BGM Enabled & Playing");
saveBgmState();
}).catch(() => {
setBgmStatus("Blocked (Click again)");
showHomeMessage("BGM blocked by browser. Click Enable BGM again.");
});
}
function toggleMusic() {
const audio = document.getElementById("bgm");
if (!audio) return;
if (!bgmUnlocked) {
showHomeMessage("First, click 'Enable BGM (First Click)'.");
setBgmStatus("Locked");
return;
}
if (!isMusicPlaying) {
audio.play().then(() => {
isMusicPlaying = true;
setBgmStatus("On");
showHomeMessage("Music On");
saveBgmState();
}).catch(() => {
setBgmStatus("Blocked");
showHomeMessage("Music could not be played (browser block).");
});
} else {
audio.pause();
isMusicPlaying = false;
setBgmStatus("Off");
showHomeMessage("Music Off");
saveBgmState();
}
}
// -------------------------------------------
// 汎用ランダム整数
// -------------------------------------------
function getRandomInt(min, max) {
return Math.floor(Math.random() * (max - min + 1) + min);
}
</script>
</body>
</html>
カテゴリー: ゲーム開発
ELDER Social VR.html
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no" />
<title>ELDER Social VR - 超完全版(クライアントのみ)</title>
<script src="https://aframe.io/releases/1.4.2/aframe.min.js"></script>
<style>
:root{
--bg: rgba(10,12,14,.82);
--glass: rgba(255,255,255,.06);
--stroke: rgba(255,255,255,.13);
--accent: rgba(120,240,255,.95);
--accent2: rgba(120,160,255,.95);
}
html,body{ margin:0; padding:0; height:100%; overflow:hidden; background:#000; font-family: "Yu Gothic", system-ui, -apple-system, Segoe UI, sans-serif; }
a-scene{ position:fixed; inset:0; z-index:0; }
canvas{ position:fixed !important; inset:0; z-index:0; }
/* ===== HUD (DOM UI) ===== */
#hud{
position: fixed; inset: 0;
display:flex; align-items:flex-start; justify-content:center;
pointer-events:none;
z-index: 999999;
}
#panel{
margin-top: 18px;
width: min(560px, calc(100vw - 26px));
background: linear-gradient(180deg, rgba(255,255,255,.06), rgba(0,0,0,.28));
border: 1px solid var(--stroke);
border-radius: 18px;
box-shadow: 0 18px 60px rgba(0,0,0,.45), 0 0 0 1px rgba(0,0,0,.2) inset;
backdrop-filter: blur(10px);
color:#fff;
padding: 16px;
pointer-events:auto;
touch-action: manipulation;
user-select:none;
position: relative;
}
#panel, #panel *{ pointer-events:auto; }
/* UI隠すボタン */
#btnHideUI{
position:absolute;
top: 12px;
right: 12px;
width: auto;
padding: 10px 12px;
border-radius: 12px;
font-weight: 900;
font-size: 12px;
letter-spacing: .2px;
background: rgba(0,0,0,.35);
border: 1px solid rgba(255,255,255,.16);
cursor: pointer;
display:flex;
align-items:center;
gap:8px;
-webkit-tap-highlight-color: transparent;
}
#btnHideUI:hover{ border-color: rgba(120,240,255,.35); }
#btnHideUI:active{ transform: scale(.99); }
/* HUDを閉じた後に出す小ボタン */
#floatingShowUI{
position: fixed;
right: 14px;
bottom: 14px;
z-index: 9999999;
display: none;
pointer-events:auto;
background: rgba(10,12,14,.72);
border: 1px solid rgba(255,255,255,.18);
backdrop-filter: blur(10px);
color:#fff;
border-radius: 999px;
padding: 12px 14px;
font-weight: 900;
cursor:pointer;
box-shadow: 0 10px 40px rgba(0,0,0,.4);
-webkit-tap-highlight-color: transparent;
}
#floatingShowUI:hover{ border-color: rgba(120,240,255,.35); }
#floatingShowUI:active{ transform: scale(.99); }
.top{
display:flex; align-items:center; justify-content:space-between;
gap: 10px;
margin-bottom: 12px;
padding-right: 86px; /* 隠すボタン分スペース */
}
.brand{
font-weight: 900;
letter-spacing: .5px;
font-size: 22px;
}
.pill{
display:inline-flex; align-items:center; gap:8px;
background: rgba(255,255,255,.06);
border: 1px solid rgba(255,255,255,.14);
padding: 8px 12px;
border-radius: 999px;
font-size: 12px;
opacity:.95;
}
.grid{
display:grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.btn{
display:flex; align-items:center; justify-content:center;
gap: 10px;
padding: 14px 12px;
border-radius: 14px;
background: linear-gradient(180deg, rgba(255,255,255,.08), rgba(0,0,0,.25));
border: 1px solid rgba(255,255,255,.14);
color:#fff;
font-weight: 900;
letter-spacing: .4px;
cursor:pointer;
transform: translateZ(0);
transition: transform .08s, border-color .18s, background .18s;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
}
.btn:hover{ border-color: rgba(120,240,255,.35); }
.btn:active{ transform: scale(.99); }
.btn.primary{ border-color: rgba(120,240,255,.28); box-shadow: 0 0 0 1px rgba(120,240,255,.10) inset; }
.btn.danger{ border-color: rgba(255,120,160,.25); }
.section{
margin-top: 12px;
background: rgba(0,0,0,.22);
border: 1px solid rgba(255,255,255,.10);
border-radius: 16px;
padding: 12px;
}
.sectionTitle{
font-weight: 900;
opacity:.92;
margin-bottom: 8px;
display:flex; align-items:center; justify-content:space-between;
gap: 10px;
}
.statGrid{
display:grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.card{
background: rgba(255,255,255,.04);
border: 1px solid rgba(255,255,255,.10);
border-radius: 14px;
padding: 10px;
}
.label{ opacity:.82; font-size: 12px; }
.big{ font-size: 20px; font-weight: 900; margin-top: 4px; }
.bar{
margin-top: 8px;
height: 10px;
background: rgba(255,255,255,.08);
border-radius: 999px;
overflow:hidden;
border: 1px solid rgba(0,0,0,.22);
}
.bar > div{ height:100%; width:50%; background: linear-gradient(90deg, var(--accent), var(--accent2)); }
.log{
margin-top: 10px;
max-height: 140px;
overflow:auto;
padding: 10px;
border-radius: 14px;
border: 1px solid rgba(255,255,255,.12);
background: rgba(0,0,0,.28);
font-size: 12px;
line-height: 1.35;
}
/* モーダル */
#modalBack{
position: fixed; inset:0;
display:none;
align-items:center; justify-content:center;
background: rgba(0,0,0,.55);
z-index: 9999999;
pointer-events:auto;
}
#modal{
width: min(620px, calc(100vw - 26px));
background: rgba(10,12,14,.92);
border: 1px solid rgba(255,255,255,.14);
border-radius: 18px;
box-shadow: 0 18px 70px rgba(0,0,0,.55);
color:#fff;
padding: 14px;
}
#modal h3{
margin: 0 0 10px 0;
font-size: 18px;
letter-spacing: .3px;
}
.modalBody{ opacity:.92; font-size: 14px; line-height: 1.5; white-space: pre-wrap; }
.modalBtns{ display:flex; gap: 10px; margin-top: 12px; flex-wrap:wrap; }
.modalBtns .btn{ flex: 1 1 140px; }
/* VR中はDOM HUDを隠す */
body.vr #hud{ display:none !important; }
body.vr #floatingShowUI{ display:none !important; }
</style>
</head>
<body>
<!-- HUD -->
<div id="hud">
<div id="panel">
<div id="btnHideUI" title="UIを隠す(ESCで戻せる)">✕ UIを隠す</div>
<div class="top">
<div class="brand">ELDER Social VR</div>
<div class="pill">現在地: <b id="fieldTag">街</b></div>
</div>
<div class="grid" style="margin-bottom:10px;">
<div class="btn primary" id="btnTown">🏘️ 街</div>
<div class="btn primary" id="btnCastle">🏰 城</div>
<div class="btn primary" id="btnCave">🕳️ 洞窟</div>
<div class="btn primary" id="btnRuins">🏛️ 遺跡</div>
</div>
<div class="grid" style="margin-bottom:10px;">
<div class="btn" id="btnEnterVR">🕶️ Enter VR</div>
<div class="btn danger" id="btnExit">⏏ Exit</div>
<div class="btn" id="btnWave">👋 Wave</div>
<div class="btn" id="btnCheer">🎉 Cheer</div>
</div>
<div class="section">
<div class="sectionTitle">
<span>レベル / EXP</span>
<span class="pill">名前: <b id="playerNameLabel">YOU</b></span>
</div>
<div class="statGrid">
<div class="card">
<div class="label">Lv / EXP</div>
<div class="big">Lv.<span id="level">1</span> <span style="opacity:.8;font-size:12px;">EXP</span> <span id="expText">0</span>/<span id="expNeedText">100</span></div>
<div class="bar"><div id="expBar" style="width:0%"></div></div>
</div>
<div class="card">
<div class="label">ゴールド</div>
<div class="big"><span id="goldText">0</span> G</div>
<div class="label" style="margin-top:6px;">街の商人で買い物できる</div>
</div>
</div>
<div class="statGrid" style="margin-top:10px;">
<div class="card">
<div class="label">HP</div>
<div class="big" id="hpText">100</div>
<div class="bar"><div id="hpBar" style="width:100%"></div></div>
</div>
<div class="card">
<div class="label">魔力</div>
<div class="big" id="manaText">100</div>
<div class="bar"><div id="manaBar" style="width:100%"></div></div>
</div>
</div>
</div>
<div class="grid" style="margin-top:10px;">
<div class="btn" id="btnTalk">💬 話す</div>
<div class="btn" id="btnQuest">📜 クエスト</div>
<div class="btn" id="btnShop">🛒 ショップ</div>
<div class="btn" id="btnRest">🛏 休憩</div>
<div class="btn" id="btnSave">💾 セーブ</div>
<div class="btn" id="btnLoad">📂 ロード</div>
</div>
<div class="log" id="log"></div>
<div style="opacity:.7;font-size:11px;margin-top:8px;line-height:1.35;">
操作: WASD移動 / Shiftダッシュ / Spaceジャンプ / マウス視点(クリックでポインタロック)<br/>
VR: Enter VR → コントローラのレーザーで3D UIを押せる(DOM UIは非表示)<br/>
UI: 右上「UIを隠す」or ESCで切替
</div>
</div>
</div>
<!-- HUDを隠した後に出すボタン -->
<div id="floatingShowUI">≡ UIを表示</div>
<!-- Modal -->
<div id="modalBack">
<div id="modal">
<h3 id="modalTitle">TITLE</h3>
<div class="modalBody" id="modalBody"></div>
<div class="modalBtns" id="modalBtns"></div>
</div>
</div>
<!-- A-Frame -->
<a-scene
id="scene"
renderer="colorManagement:true; physicallyCorrectLights:true"
shadow="type:pcfsoft"
webxr="optionalFeatures: local-floor, bounded-floor, hand-tracking"
>
<a-assets>
<!-- BGM(エリアで切替) -->
<audio id="bgmTown" src="https://www.free-stock-music.com/music/scott-buckley/mp3/scott-buckley-beautiful-oblivion.mp3" crossorigin="anonymous"></audio>
<audio id="bgmCastle" src="https://www.free-stock-music.com/music/scott-buckley/mp3/scott-buckley-the-endurance.mp3" crossorigin="anonymous"></audio>
<audio id="bgmCave" src="https://www.free-stock-music.com/music/scott-buckley/mp3/scott-buckley-in-search-of-solitude.mp3" crossorigin="anonymous"></audio>
<audio id="bgmRuins" src="https://www.free-stock-music.com/music/wombat-noises-audio/mp3/wombat-noises-audio-the-ruins-of-atlantis.mp3" crossorigin="anonymous"></audio>
</a-assets>
<!-- 空 -->
<a-sky id="sky" color="#061018"></a-sky>
<!-- 光 -->
<a-light type="ambient" intensity="0.9" color="#dff8ff"></a-light>
<a-light id="sun" type="directional" intensity="1.35" position="30 40 10"
castShadow="true" shadow-mapWidth="2048" shadow-mapHeight="2048"></a-light>
<!-- 海 -->
<a-entity id="ocean" position="0 0 0">
<a-cylinder position="0 -1.4 0" radius="140" height="2.2" open-ended="true"
material="color:#0a2a3a; metalness:0.05; roughness:0.35; opacity:0.95; transparent:true"></a-cylinder>
<a-ring position="0 -0.2 0" radius-inner="65" radius-outer="140"
rotation="-90 0 0"
material="color:#0b3143; opacity:0.88; transparent:true"></a-ring>
</a-entity>
<!-- 島(3段) -->
<a-entity id="island">
<a-cylinder position="0 -0.1 0" radius="62" height="1"
material="color:#cbb48b; roughness:0.95; metalness:0.0"></a-cylinder>
<a-cylinder position="0 0.05 0" radius="50" height="1"
material="color:#2f6a3f; roughness:0.95; metalness:0.0"></a-cylinder>
<a-cylinder position="0 0.35 0" radius="34" height="1.2"
material="color:#2a5f3a; roughness:0.95"></a-cylinder>
<a-ring position="0 0.42 0" radius-inner="14" radius-outer="17" rotation="-90 0 0"
material="color:#3b2f23; roughness:1"></a-ring>
<a-ring position="0 0.42 0" radius-inner="26" radius-outer="29" rotation="-90 0 0"
material="color:#3b2f23; roughness:1; opacity:0.85; transparent:true"></a-ring>
</a-entity>
<!-- BGM -->
<a-entity id="bgm" sound="src:#bgmTown; autoplay:false; loop:true; volume:0.65; positional:false"></a-entity>
<!-- プレイヤー(※yは基準0.55で置く。起動時にJSで“地面にスナップ”して埋まりゼロ) -->
<a-entity id="playerRig" position="0 0.55 18">
<!-- かっこいい勇者(簡易ハイディテール) -->
<a-entity id="hero" position="0 0 0"
animation__idle="property: rotation; dir: alternate; dur: 1800; loop: true; to: 0 1.2 0"
animation__breath="property: scale; dir: alternate; dur: 1200; loop: true; to: 1.01 1.02 1.01">
<!-- 影 -->
<a-circle radius="0.75" rotation="-90 0 0" position="0 0.02 0"
material="color:#000; opacity:0.25; transparent:true"></a-circle>
<!-- マント -->
<a-entity id="cape" position="0 1.22 0.16" rotation="10 0 0"
animation__cape="property: rotation; dir: alternate; dur: 900; loop: true; to: 12 1 0">
<a-plane width="0.95" height="1.35" position="0 -0.58 -0.20"
material="color:#0b1020; opacity:0.96; transparent:true; side:double"></a-plane>
<a-plane width="0.55" height="1.15" position="-0.28 -0.62 -0.21" rotation="0 8 0"
material="color:#0a0f1c; opacity:0.90; transparent:true; side:double"></a-plane>
<a-plane width="0.55" height="1.15" position="0.28 -0.62 -0.21" rotation="0 -8 0"
material="color:#0a0f1c; opacity:0.90; transparent:true; side:double"></a-plane>
</a-entity>
<!-- ブーツ -->
<a-box position="-0.17 0.16 0" width="0.22" height="0.34" depth="0.30"
material="color:#1a1418; roughness:1"></a-box>
<a-box position="0.17 0.16 0" width="0.22" height="0.34" depth="0.30"
material="color:#1a1418; roughness:1"></a-box>
<a-box position="-0.17 0.06 -0.14" width="0.24" height="0.12" depth="0.22"
material="color:#0f0c10; roughness:1"></a-box>
<a-box position="0.17 0.06 -0.14" width="0.24" height="0.12" depth="0.22"
material="color:#0f0c10; roughness:1"></a-box>
<!-- 脚(鎧) -->
<a-box position="-0.17 0.56 0" width="0.26" height="0.54" depth="0.32"
material="color:#2b3140; metalness:0.25; roughness:0.55"></a-box>
<a-box position="0.17 0.56 0" width="0.26" height="0.54" depth="0.32"
material="color:#2b3140; metalness:0.25; roughness:0.55"></a-box>
<!-- 膝ルーン -->
<a-box position="-0.17 0.44 -0.18" width="0.26" height="0.14" depth="0.14"
material="color:#78f0ff; emissive:#2dd; emissiveIntensity:0.20; metalness:0.3; roughness:0.35; opacity:0.55; transparent:true"></a-box>
<a-box position="0.17 0.44 -0.18" width="0.26" height="0.14" depth="0.14"
material="color:#78f0ff; emissive:#2dd; emissiveIntensity:0.20; metalness:0.3; roughness:0.35; opacity:0.55; transparent:true"></a-box>
<!-- 腰 -->
<a-box position="0 0.92 0.00" width="0.72" height="0.14" depth="0.40"
material="color:#15151a; roughness:1"></a-box>
<a-box position="0 0.92 0.23" width="0.16" height="0.12" depth="0.06"
material="color:#ad7b2e; roughness:0.75; metalness:0.25"></a-box>
<!-- 腰布 -->
<a-plane width="0.42" height="0.58" position="0 0.64 0.20"
material="color:#152a52; opacity:0.92; transparent:true; side:double"></a-plane>
<a-plane width="0.35" height="0.36" position="0 0.54 -0.22" rotation="0 180 0"
material="color:#0f1733; opacity:0.85; transparent:true; side:double"></a-plane>
<!-- 胸鎧 -->
<a-box position="0 1.22 0" width="0.74" height="0.82" depth="0.42"
material="color:#3b6b8e; metalness:0.38; roughness:0.22"></a-box>
<!-- コア -->
<a-box position="0 1.22 0.24" width="0.60" height="0.66" depth="0.06"
material="color:#78f0ff; emissive:#2dd; emissiveIntensity:0.30; metalness:0.25; roughness:0.18; opacity:0.55; transparent:true"></a-box>
<a-ring position="0 1.22 0.27" radius-inner="0.12" radius-outer="0.20"
material="color:#78f0ff; emissive:#2dd; emissiveIntensity:0.35; opacity:0.45; transparent:true"></a-ring>
<!-- 肩当て -->
<a-sphere position="-0.50 1.54 0.02" radius="0.22"
material="color:#2f4f6a; metalness:0.35; roughness:0.22"></a-sphere>
<a-sphere position="0.50 1.54 0.02" radius="0.22"
material="color:#2f4f6a; metalness:0.35; roughness:0.22"></a-sphere>
<a-cone position="-0.58 1.58 0.02" radius-bottom="0.10" height="0.22" rotation="0 0 25"
material="color:#cbd3da; metalness:0.55; roughness:0.25"></a-cone>
<a-cone position="0.58 1.58 0.02" radius-bottom="0.10" height="0.22" rotation="0 0 -25"
material="color:#cbd3da; metalness:0.55; roughness:0.25"></a-cone>
<!-- 腕 -->
<a-box position="-0.62 1.18 0.02" width="0.20" height="0.68" depth="0.24"
material="color:#2b3140; metalness:0.25; roughness:0.55"></a-box>
<a-box position="0.62 1.18 0.02" width="0.20" height="0.68" depth="0.24"
material="color:#2b3140; metalness:0.25; roughness:0.55"></a-box>
<a-sphere position="-0.62 0.84 0.02" radius="0.10" material="color:#1a1418; roughness:1"></a-sphere>
<a-sphere position="0.62 0.84 0.02" radius="0.10" material="color:#1a1418; roughness:1"></a-sphere>
<!-- 襟 -->
<a-torus radius="0.24" tube="0.06" position="0 1.58 0.02" rotation="90 0 0"
material="color:#0b1020; roughness:1; opacity:0.96; transparent:true"></a-torus>
<!-- 頭 -->
<a-entity id="headGroup" position="0 1.78 0">
<a-sphere id="heroHead" position="0 0 0" radius="0.22" material="color:#f4d7bd; roughness:0.95"></a-sphere>
<a-sphere position="0 0.05 -0.02" radius="0.24" material="color:#1b1b1f; roughness:0.9; metalness:0.15"></a-sphere>
<a-box position="0 -0.02 -0.18" width="0.32" height="0.18" depth="0.10"
material="color:#0f0f12; roughness:1"></a-box>
<a-box position="0 0.00 -0.24" width="0.36" height="0.10" depth="0.06"
material="color:#78f0ff; emissive:#2dd; emissiveIntensity:0.40; opacity:0.55; transparent:true"></a-box>
<a-cone position="-0.16 0.20 -0.02" radius-bottom="0.06" height="0.18" rotation="0 0 25"
material="color:#cbd3da; metalness:0.55; roughness:0.25"></a-cone>
<a-cone position="0.16 0.20 -0.02" radius-bottom="0.06" height="0.18" rotation="0 0 -25"
material="color:#cbd3da; metalness:0.55; roughness:0.25"></a-cone>
</a-entity>
<!-- 剣 -->
<a-entity id="sword" position="0.40 1.02 0.10" rotation="0 0 18">
<a-box width="0.05" height="0.86" depth="0.06" position="0 0.42 0"
material="color:#cbd3da; metalness:0.78; roughness:0.18"></a-box>
<a-box width="0.02" height="0.84" depth="0.02" position="0 0.42 -0.03"
material="color:#ffffff; opacity:0.18; transparent:true"></a-box>
<a-box width="0.20" height="0.05" depth="0.12" position="0 0.04 0"
material="color:#ad7b2e; metalness:0.35; roughness:0.55"></a-box>
<a-torus radius="0.09" tube="0.012" position="0 0.04 0.07" rotation="90 0 0"
material="color:#78f0ff; emissive:#2dd; emissiveIntensity:0.30; opacity:0.45; transparent:true"></a-torus>
<a-box width="0.07" height="0.18" depth="0.07" position="0 -0.08 0"
material="color:#2b1c12; roughness:1"></a-box>
<a-sphere radius="0.03" position="0 -0.18 0"
material="color:#78f0ff; emissive:#2dd; emissiveIntensity:0.35; opacity:0.6; transparent:true"></a-sphere>
</a-entity>
<!-- 盾 -->
<a-entity id="shield" position="-0.70 1.06 -0.06" rotation="0 0 12">
<a-cylinder radius="0.24" height="0.09" rotation="90 0 0"
material="color:#3a5f7a; metalness:0.28; roughness:0.32"></a-cylinder>
<a-ring radius-inner="0.13" radius-outer="0.24" rotation="90 0 0"
material="color:#78f0ff; emissive:#2dd; emissiveIntensity:0.25; opacity:0.45; transparent:true"></a-ring>
<a-circle radius="0.07" rotation="90 0 0" position="0 0 0.05"
material="color:#ad7b2e; metalness:0.35; roughness:0.55"></a-circle>
</a-entity>
<!-- 名前 -->
<a-text id="name3d" value="YOU" position="0 2.35 0" align="center" width="4" color="#ffffff"
shader="msdf" font="https://cdn.aframe.io/fonts/Roboto-msdf.json"></a-text>
</a-entity>
<a-camera id="cam"
position="0 1.75 3.4"
look-controls="pointerLockEnabled: false"
wasd-controls-enabled="false"
fov="72"
></a-camera>
<a-entity id="rightHand" laser-controls="hand:right" raycaster="objects: .vrbtn" line="opacity:0.75"></a-entity>
<a-entity id="leftHand" laser-controls="hand:left" raycaster="objects: .vrbtn" line="opacity:0.75"></a-entity>
<a-entity id="mouseCursor" cursor="rayOrigin: mouse" raycaster="objects: .vrbtn"></a-entity>
<!-- VR UI -->
<a-entity id="vrUI" position="0 1.55 -1.25" visible="false">
<a-plane width="1.55" height="0.92" material="color:#0b0f14; opacity:0.78; transparent:true"></a-plane>
<a-text value="VRUI" position="0 0.40 0.01" align="center" width="2.6" color="#7ff"
shader="msdf" font="https://cdn.aframe.io/fonts/Roboto-msdf.json"></a-text>
<a-entity position="0 0.12 0.02">
<a-plane class="vrbtn" vr-btn="action:fieldTown" position="-0.48 0.12 0" width="0.48" height="0.16" material="color:#13202b; opacity:0.95"></a-plane>
<a-text value="街" position="-0.48 0.12 0.02" align="center" width="1.6" color="#fff" shader="msdf" font="https://cdn.aframe.io/fonts/Roboto-msdf.json"></a-text>
<a-plane class="vrbtn" vr-btn="action:fieldCastle" position="0.48 0.12 0" width="0.48" height="0.16" material="color:#13202b; opacity:0.95"></a-plane>
<a-text value="城" position="0.48 0.12 0.02" align="center" width="1.6" color="#fff" shader="msdf" font="https://cdn.aframe.io/fonts/Roboto-msdf.json"></a-text>
<a-plane class="vrbtn" vr-btn="action:fieldCave" position="-0.48 -0.08 0" width="0.48" height="0.16" material="color:#13202b; opacity:0.95"></a-plane>
<a-text value="洞窟" position="-0.48 -0.08 0.02" align="center" width="1.6" color="#fff" shader="msdf" font="https://cdn.aframe.io/fonts/Roboto-msdf.json"></a-text>
<a-plane class="vrbtn" vr-btn="action:fieldRuins" position="0.48 -0.08 0" width="0.48" height="0.16" material="color:#13202b; opacity:0.95"></a-plane>
<a-text value="遺跡" position="0.48 -0.08 0.02" align="center" width="1.6" color="#fff" shader="msdf" font="https://cdn.aframe.io/fonts/Roboto-msdf.json"></a-text>
<a-plane class="vrbtn" vr-btn="action:talk" position="-0.48 -0.30 0" width="0.48" height="0.16" material="color:#10261e; opacity:0.95"></a-plane>
<a-text value="話す" position="-0.48 -0.30 0.02" align="center" width="1.6" color="#fff" shader="msdf" font="https://cdn.aframe.io/fonts/Roboto-msdf.json"></a-text>
<a-plane class="vrbtn" vr-btn="action:quest" position="0.48 -0.30 0" width="0.48" height="0.16" material="color:#2a2110; opacity:0.95"></a-plane>
<a-text value="クエスト" position="0.48 -0.30 0.02" align="center" width="1.6" color="#fff" shader="msdf" font="https://cdn.aframe.io/fonts/Roboto-msdf.json"></a-text>
</a-entity>
<a-text value="スティック移動 / トリガーで押す" position="0 -0.43 0.01" align="center" width="2.8" color="#bfefff"
shader="msdf" font="https://cdn.aframe.io/fonts/Roboto-msdf.json"></a-text>
</a-entity>
</a-entity>
<!-- NPC(★snap で“その場所の地面”に自動補正 → 埋まりゼロ) -->
<a-entity id="npcGroup">
<a-entity id="npcGuide" class="npc snap" position="-6 1.15 10" rotation="0 25 0">
<a-cylinder radius="0.35" height="1.2" material="color:#203a4a; roughness:0.9"></a-cylinder>
<a-sphere radius="0.22" position="0 0.86 0" material="color:#f2d7bf; roughness:0.95"></a-sphere>
<a-cone radius-bottom="0.28" height="0.35" position="0 1.12 0" material="color:#0a0f18; roughness:1"></a-cone>
<a-text value="Guide" position="0 1.45 0" align="center" width="4" color="#fff" shader="msdf" font="https://cdn.aframe.io/fonts/Roboto-msdf.json"></a-text>
</a-entity>
<a-entity id="npcKnight" class="npc snap" position="10 1.15 0" rotation="0 -120 0" visible="false">
<a-cylinder radius="0.36" height="1.2" material="color:#3a4652; metalness:0.25; roughness:0.35"></a-cylinder>
<a-sphere radius="0.22" position="0 0.86 0" material="color:#f2d7bf; roughness:0.95"></a-sphere>
<a-box width="0.48" height="0.16" depth="0.06" position="0 0.58 0.2" material="color:#78f0ff; opacity:0.5; transparent:true"></a-box>
<a-text value="Castle Knight" position="0 1.45 0" align="center" width="4" color="#fff" shader="msdf" font="https://cdn.aframe.io/fonts/Roboto-msdf.json"></a-text>
</a-entity>
<a-entity id="npcMiner" class="npc snap" position="-10 1.15 -8" rotation="0 60 0" visible="false">
<a-cylinder radius="0.35" height="1.2" material="color:#3b2f23; roughness:0.95"></a-cylinder>
<a-sphere radius="0.22" position="0 0.86 0" material="color:#f2d7bf; roughness:0.95"></a-sphere>
<a-sphere radius="0.18" position="0 1.06 0" material="color:#2d2d2f; roughness:1"></a-sphere>
<a-text value="Miner" position="0 1.45 0" align="center" width="4" color="#fff" shader="msdf" font="https://cdn.aframe.io/fonts/Roboto-msdf.json"></a-text>
</a-entity>
<a-entity id="npcSage" class="npc snap" position="6 1.15 -12" rotation="0 -30 0" visible="false">
<a-cylinder radius="0.35" height="1.2" material="color:#2a1f2f; roughness:0.95"></a-cylinder>
<a-sphere radius="0.22" position="0 0.86 0" material="color:#f2d7bf; roughness:0.95"></a-sphere>
<a-torus radius="0.34" tube="0.05" position="0 0.95 0" rotation="90 0 0" material="color:#7ff; opacity:0.35; transparent:true"></a-torus>
<a-text value="Ruins Sage" position="0 1.45 0" align="center" width="4" color="#fff" shader="msdf" font="https://cdn.aframe.io/fonts/Roboto-msdf.json"></a-text>
</a-entity>
</a-entity>
<!-- Enemy -->
<a-entity id="enemy" position="0 1.10 -2" visible="true">
<a-entity id="enemyModel">
<a-sphere radius="0.55" material="color:#8a1b2d; metalness:0.15; roughness:0.45; emissive:#200;"></a-sphere>
<a-sphere radius="0.22" position="0 0.52 0.06" material="color:#2a0b10; roughness:0.9"></a-sphere>
<a-cone radius-bottom="0.16" height="0.35" position="-0.22 0.78 0.02" rotation="20 0 40" material="color:#ddd; roughness:0.7"></a-cone>
<a-cone radius-bottom="0.16" height="0.35" position="0.22 0.78 0.02" rotation="20 0 -40" material="color:#ddd; roughness:0.7"></a-cone>
<a-sphere radius="0.06" position="-0.14 0.55 -0.46" material="color:#fff; emissive:#f0f; emissiveIntensity:0.9"></a-sphere>
<a-sphere radius="0.06" position="0.14 0.55 -0.46" material="color:#fff; emissive:#f0f; emissiveIntensity:0.9"></a-sphere>
<a-ring radius-inner="0.62" radius-outer="0.74" rotation="-90 0 0" position="0 -0.35 0"
material="color:#7ff; opacity:0.14; transparent:true"></a-ring>
</a-entity>
<a-text id="enemyName3D" value="Enemy" position="0 1.25 0" align="center" width="4" color="#fff"
shader="msdf" font="https://cdn.aframe.io/fonts/Roboto-msdf.json"></a-text>
</a-entity>
<!-- Fields -->
<a-entity id="field-town" visible="true">
<!-- 噴水(snapで地面補正) -->
<a-entity id="fountain" class="snap" position="0 0 0">
<a-cylinder radius="2.1" height="0.35" position="0 0.725 0"
material="color:#55606a; roughness:0.55; metalness:0.1"></a-cylinder>
<a-cylinder radius="1.35" height="0.42" position="0 1.11 0"
material="color:#3f4a54; roughness:0.55; metalness:0.12"></a-cylinder>
<a-cylinder radius="0.25" height="1.0" position="0 1.82 0"
material="color:#6b7782; roughness:0.5"></a-cylinder>
<a-sphere radius="0.26" position="0 2.42 0"
material="color:#7ff; opacity:0.5; transparent:true; emissive:#2dd; emissiveIntensity:0.25"></a-sphere>
<a-torus radius="0.95" tube="0.06" position="0 0.90 0" rotation="90 0 0"
material="color:#7ff; opacity:0.25; transparent:true"></a-torus>
</a-entity>
<!-- 家(各家をsnapで地面補正) -->
<a-entity id="houses">
<a-entity class="snap" position="-10 1.70 6" rotation="0 35 0">
<a-box width="4" height="2.3" depth="3.2" material="color:#bda982; roughness:0.9"></a-box>
<a-cone radius-bottom="2.8" height="1.4" position="0 1.85 0" material="color:#5a2c1b; roughness:1"></a-cone>
<a-plane width="1.2" height="0.7" position="0 0.6 1.61" material="color:#1b2a35; opacity:0.55; transparent:true"></a-plane>
</a-entity>
<a-entity class="snap" position="10 1.60 8" rotation="0 -20 0">
<a-box width="3.2" height="2.1" depth="3.0" material="color:#c2b08a; roughness:0.9"></a-box>
<a-cone radius-bottom="2.2" height="1.3" position="0 1.7 0" material="color:#6a3a22; roughness:1"></a-cone>
<a-plane width="1.0" height="0.65" position="0.2 0.5 1.51" material="color:#1b2a35; opacity:0.55; transparent:true"></a-plane>
</a-entity>
<a-entity class="snap" position="-14 1.55 -6" rotation="0 70 0">
<a-box width="3.6" height="2.0" depth="2.6" material="color:#b8a27a; roughness:0.9"></a-box>
<a-cone radius-bottom="2.4" height="1.2" position="0 1.6 0" material="color:#4f2a1a; roughness:1"></a-cone>
</a-entity>
<a-entity class="snap" position="13 1.65 -6" rotation="0 -55 0">
<a-box width="4.2" height="2.2" depth="3.2" material="color:#b5a07a; roughness:0.95"></a-box>
<a-cone radius-bottom="2.9" height="1.3" position="0 1.75 0" material="color:#2b1c12; roughness:1"></a-cone>
<a-plane width="2.2" height="0.7" position="0 0.6 1.61" material="color:#0b0f14; opacity:0.65; transparent:true"></a-plane>
<a-text value="SHOP" position="0 1.1 1.65" align="center" width="4" color="#7ff"
shader="msdf" font="https://cdn.aframe.io/fonts/Roboto-msdf.json"></a-text>
</a-entity>
</a-entity>
<!-- 屋台(snapで地面補正) -->
<a-entity class="snap" position="-6 0.55 -4" rotation="0 15 0">
<a-box width="2.2" height="0.5" depth="1.2" position="0 0.7 0" material="color:#6a3a22; roughness:1"></a-box>
<a-plane width="2.4" height="1.2" position="0 1.35 0" rotation="-30 0 0" material="color:#7ff; opacity:0.2; transparent:true"></a-plane>
<a-cylinder radius="0.05" height="1.4" position="-1.05 0.7 -0.55" material="color:#3b2f23"></a-cylinder>
<a-cylinder radius="0.05" height="1.4" position="1.05 0.7 -0.55" material="color:#3b2f23"></a-cylinder>
</a-entity>
<!-- 木&街灯(snapで地面補正) -->
<a-entity>
<a-entity class="snap" position="-18 1.85 2">
<a-cylinder radius="0.18" height="2.6" material="color:#3b2f23; roughness:1"></a-cylinder>
<a-sphere radius="1.2" position="0 2.0 0" material="color:#2a7a45; roughness:1"></a-sphere>
</a-entity>
<a-entity class="snap" position="18 1.85 4">
<a-cylinder radius="0.18" height="2.6" material="color:#3b2f23; roughness:1"></a-cylinder>
<a-sphere radius="1.2" position="0 2.0 0" material="color:#2a7a45; roughness:1"></a-sphere>
</a-entity>
<a-entity class="snap" position="6 1.60 14">
<a-cylinder radius="0.06" height="2.1" material="color:#45515a; roughness:0.6"></a-cylinder>
<a-sphere radius="0.18" position="0 1.08 0" material="color:#fff; emissive:#7ff; emissiveIntensity:0.65; opacity:0.85; transparent:true"></a-sphere>
</a-entity>
</a-entity>
</a-entity>
<a-entity id="field-castle" visible="false">
<a-ring position="0 0.43 0" radius-inner="0" radius-outer="30" rotation="-90 0 0"
material="color:#636b75; roughness:0.9; opacity:0.9; transparent:true"></a-ring>
<a-entity position="0 0.75 -18">
<a-box width="26" height="6" depth="2.8" material="color:#a8b1bb; roughness:0.65; metalness:0.05"></a-box>
<a-box width="7" height="4" depth="2.2" position="0 -0.4 1.1" material="color:#8a939e; roughness:0.65"></a-box>
<a-box width="5.2" height="4.2" depth="0.8" position="0 -0.9 1.8" material="color:#2b1c12; roughness:1"></a-box>
<a-plane width="1.2" height="2.6" position="-4 0.6 1.9" material="color:#7ff; opacity:0.25; transparent:true"></a-plane>
<a-plane width="1.2" height="2.6" position="4 0.6 1.9" material="color:#7ff; opacity:0.25; transparent:true"></a-plane>
</a-entity>
<a-entity>
<a-entity position="-12 1.2 -18">
<a-cylinder radius="2.1" height="8.2" material="color:#9aa3ad; roughness:0.55; metalness:0.05"></a-cylinder>
<a-cone radius-bottom="2.3" height="2.4" position="0 5.2 0" material="color:#6a3a22; roughness:1"></a-cone>
</a-entity>
<a-entity position="12 1.2 -18">
<a-cylinder radius="2.1" height="8.2" material="color:#9aa3ad; roughness:0.55; metalness:0.05"></a-cylinder>
<a-cone radius-bottom="2.3" height="2.4" position="0 5.2 0" material="color:#6a3a22; roughness:1"></a-cone>
</a-entity>
</a-entity>
<a-ring position="0 0.18 -12" radius-inner="18" radius-outer="28" rotation="-90 0 0"
material="color:#0b3143; opacity:0.55; transparent:true"></a-ring>
<a-entity position="0 0.55 -6">
<a-cylinder radius="1.4" height="0.6" material="color:#4a535c; roughness:0.65"></a-cylinder>
<a-sphere radius="0.75" position="0 1.0 0" material="color:#a8b1bb; roughness:0.5"></a-sphere>
<a-torus-knot radius="0.35" tube="0.08" position="0 1.8 0" p="2" q="5"
material="color:#7ff; emissive:#2dd; emissiveIntensity:0.25; opacity:0.5; transparent:true"></a-torus-knot>
</a-entity>
</a-entity>
<a-entity id="field-cave" visible="false">
<a-entity position="0 0.55 -6">
<a-sphere radius="10" material="color:#0b0f14; opacity:0.22; transparent:true" segments-width="18" segments-height="12"></a-sphere>
</a-entity>
<a-entity position="0 0.55 -16">
<a-torus radius="8" tube="2.2" arc="200" rotation="0 0 90"
material="color:#4a3f34; roughness:1; metalness:0"></a-torus>
</a-entity>
<a-entity id="rocks">
<a-sphere radius="5" position="-12 2 -14" material="color:#3a332d; roughness:1"></a-sphere>
<a-sphere radius="6" position="12 1 -16" material="color:#352f2a; roughness:1"></a-sphere>
<a-sphere radius="4.5" position="0 3 -22" material="color:#2f2a25; roughness:1"></a-sphere>
</a-entity>
<a-entity>
<a-cone radius-bottom="0.8" height="2.8" position="-4 6 -14" material="color:#2f2a25; roughness:1"></a-cone>
<a-cone radius-bottom="0.6" height="2.2" position="3 5.7 -16" material="color:#2f2a25; roughness:1"></a-cone>
<a-cone radius-bottom="0.7" height="2.5" position="8 6.2 -12" material="color:#2f2a25; roughness:1"></a-cone>
</a-entity>
<a-entity position="-6 0.55 -10">
<a-octahedron radius="0.9" material="color:#7ff; opacity:0.55; transparent:true; emissive:#2dd; emissiveIntensity:0.35"></a-octahedron>
<a-octahedron radius="0.6" position="1.0 0.2 0.3" material="color:#8cf; opacity:0.55; transparent:true; emissive:#2dd; emissiveIntensity:0.25"></a-octahedron>
<a-light type="point" intensity="0.8" distance="10" color="#7ff"></a-light>
</a-entity>
</a-entity>
<a-entity id="field-ruins" visible="false">
<a-ring position="0 0.43 0" radius-inner="0" radius-outer="30" rotation="-90 0 0"
material="color:#6a6555; roughness:0.95; opacity:0.92; transparent:true"></a-ring>
<a-entity>
<a-entity position="-12 0.55 -8">
<a-cylinder radius="0.8" height="3.2" material="color:#c9c2a3; roughness:0.9"></a-cylinder>
<a-box width="2.2" height="0.35" depth="2.2" position="0 1.85 0" material="color:#bdb493; roughness:0.95"></a-box>
</a-entity>
<a-entity position="12 0.55 -8">
<a-cylinder radius="0.8" height="2.1" material="color:#c9c2a3; roughness:0.9"></a-cylinder>
<a-box width="2.2" height="0.35" depth="2.2" position="0 1.25 0" material="color:#bdb493; roughness:0.95"></a-box>
</a-entity>
<a-entity position="-8 0.55 -18" rotation="0 20 0">
<a-cylinder radius="0.7" height="2.4" material="color:#bdb493; roughness:0.95"></a-cylinder>
<a-box width="1.9" height="0.28" depth="1.9" position="0 1.38 0" material="color:#c9c2a3; roughness:0.95"></a-box>
</a-entity>
<a-entity position="8 0.55 -18" rotation="0 -20 0">
<a-cylinder radius="0.7" height="3.0" material="color:#bdb493; roughness:0.95"></a-cylinder>
<a-box width="1.9" height="0.28" depth="1.9" position="0 1.68 0" material="color:#c9c2a3; roughness:0.95"></a-box>
</a-entity>
</a-entity>
<a-entity position="0 2.0 -16">
<a-torus radius="4.0" tube="0.55" arc="180" rotation="0 0 90" material="color:#c9c2a3; roughness:0.9"></a-torus>
</a-entity>
<a-entity position="0 0.55 -8">
<a-box width="4.2" height="0.8" depth="2.6" material="color:#5a5648; roughness:0.95"></a-box>
<a-ring radius-inner="0.9" radius-outer="1.5" rotation="-90 0 0" position="0 0.41 0"
material="color:#7ff; opacity:0.35; transparent:true; emissive:#2dd; emissiveIntensity:0.25"></a-ring>
<a-light type="point" intensity="0.9" distance="14" color="#7ff" position="0 1.3 0"></a-light>
</a-entity>
<a-entity id="floating" position="0 2.2 -10">
<a-box width="0.6" height="0.35" depth="0.6" position="-1.2 0.3 0" material="color:#c9c2a3; roughness:0.9"></a-box>
<a-box width="0.4" height="0.25" depth="0.4" position="1.0 -0.1 0.5" material="color:#bdb493; roughness:0.9"></a-box>
<a-box width="0.5" height="0.3" depth="0.5" position="0.2 0.5 -0.7" material="color:#c9c2a3; roughness:0.9"></a-box>
</a-entity>
</a-entity>
<a-entity id="confetti" visible="false" position="0 2.4 10">
<a-ring radius-inner="0.2" radius-outer="0.6" rotation="-90 0 0" material="color:#7ff; opacity:0.35; transparent:true"></a-ring>
<a-ring radius-inner="0.6" radius-outer="1.0" rotation="-90 0 0" material="color:#fff; opacity:0.18; transparent:true"></a-ring>
</a-entity>
</a-scene>
<script>
/* 3D VR UI ボタン */
AFRAME.registerComponent('vr-btn', {
schema: { action: { type:'string' } },
init: function(){
this.el.addEventListener('click', () => {
const fn = window[this.data.action];
if(typeof fn === 'function') fn();
});
}
});
/* ===== 状態 ===== */
const state = {
field: "town",
hp: 100,
mana: 100,
level: 1,
exp: 0,
expNeed: 100,
gold: 0,
quest: null,
storyStep: 0,
inVR: false,
audioUnlocked: false,
enemy: { name:"影の獣", hp:80, maxHp:80, atk:10, exp:35, gold:15 }
};
const FIELD_JP = { town:"街", castle:"城", cave:"洞窟", ruins:"遺跡" };
const ENEMIES = {
town: [{ name:"路地のスライム", hp:60, atk:9, exp:35, gold:16 }, { name:"野良ゴブリン", hp:80, atk:12, exp:45, gold:20 }],
castle: [{ name:"亡霊騎士", hp:110, atk:16, exp:70, gold:35 }, { name:"城壁の影", hp:130, atk:18, exp:85, gold:42 }],
cave: [{ name:"洞窟コウモリ", hp:90, atk:15, exp:65, gold:30 }, { name:"岩喰い蜥蜴", hp:140, atk:20, exp:95, gold:55 }],
ruins: [{ name:"封印の番人", hp:170, atk:24, exp:120, gold:70 }, { name:"古代の眼", hp:150, atk:22, exp:110,gold:62 }]
};
/* ★あなたが“配置に使ってきた基準地面” */
const BASE_GROUND_Y = 0.55;
/* ★島は3段。距離で「今いる地面の高さ」を返す(埋まり防止の本体) */
const GROUND_LAYERS = [
{ r: 34, y: 0.95 }, // 上段(radius=34 height=1.2 posY=0.35 → top=0.95)
{ r: 50, y: 0.55 }, // 中段(radius=50 height=1.0 posY=0.05 → top=0.55)
{ r: 62, y: 0.40 } // 下段(radius=62 height=1.0 posY=-0.1 → top=0.40)
];
function groundYAt(x, z){
const r = Math.hypot(x, z);
for(const layer of GROUND_LAYERS){
if(r <= layer.r) return layer.y;
}
return GROUND_LAYERS[GROUND_LAYERS.length - 1].y;
}
function enemyYAt(x, z){
return groundYAt(x, z) + 0.55; // 敵球半径0.55
}
/* ★「基準0.55で置いた物」を、実地面に合わせて持ち上げる */
function snapElToGround(el){
if(!el) return;
const p = el.getAttribute("position");
if(!p || typeof p.x!=="number" || typeof p.z!=="number" || typeof p.y!=="number") return;
const gy = groundYAt(p.x, p.z);
const dy = gy - BASE_GROUND_Y;
if(Math.abs(dy) < 0.0001) return;
el.setAttribute("position", { x:p.x, y:p.y + dy, z:p.z });
}
function snapAll(){
document.querySelectorAll(".snap").forEach(snapElToGround);
}
/* ===== ユーティリティ ===== */
function escapeHtml(s){ return String(s).replace(/[&<>"']/g, m => ({ "&":"&","<":"<",">":">",'"':""","'":"'" }[m])); }
function log(msg){
const el = document.getElementById("log");
const t = new Date().toLocaleTimeString();
el.innerHTML = `<div>【${t}】${escapeHtml(msg)}</div>` + el.innerHTML;
}
function clamp(v,a,b){ return Math.max(a, Math.min(b, v)); }
/* ===== UI表示/非表示 ===== */
let hudVisible = true;
function setHUDVisible(visible){
hudVisible = !!visible;
document.getElementById("hud").style.display = hudVisible ? "flex" : "none";
document.getElementById("floatingShowUI").style.display = (!hudVisible && !state.inVR) ? "block" : "none";
localStorage.setItem("elder_ui_hidden", hudVisible ? "0" : "1");
}
function toggleHUD(){ setHUDVisible(!hudVisible); }
/* ===== UI反映 ===== */
function updateUI(){
document.getElementById("fieldTag").textContent = FIELD_JP[state.field] || state.field;
document.getElementById("hpText").textContent = Math.floor(state.hp);
document.getElementById("manaText").textContent = Math.floor(state.mana);
document.getElementById("level").textContent = state.level;
document.getElementById("expText").textContent = state.exp;
document.getElementById("expNeedText").textContent = state.expNeed;
document.getElementById("goldText").textContent = state.gold;
document.getElementById("hpBar").style.width = clamp(state.hp,0,100) + "%";
document.getElementById("manaBar").style.width = clamp(state.mana,0,100) + "%";
document.getElementById("expBar").style.width = Math.min(100, (state.exp/state.expNeed)*100) + "%";
}
/* ===== DOMボタンを確実に(多重発火を抑える) ===== */
function bindPress(el, fn){
let last = 0;
const handler = (e) => {
const now = performance.now();
if(now - last < 180) return; // 連打/多重防止
last = now;
try{ e.preventDefault(); }catch(_){}
unlockAudio();
fn();
};
el.addEventListener("pointerup", handler, { passive:false });
el.addEventListener("touchend", handler, { passive:false });
el.addEventListener("click", handler, { passive:false });
}
/* ===== オーディオ ===== */
function unlockAudio(){
if(state.audioUnlocked) return;
state.audioUnlocked = true;
const bgm = document.getElementById("bgm");
try{
bgm.components.sound.playSound();
log("🔊 BGM開始(ユーザー操作で解除)");
}catch(e){}
}
function setBGMByField(){
const bgm = document.getElementById("bgm");
const srcMap = { town:"#bgmTown", castle:"#bgmCastle", cave:"#bgmCave", ruins:"#bgmRuins" };
const src = srcMap[state.field] || "#bgmTown";
bgm.setAttribute("sound", `src:${src}; autoplay:false; loop:true; volume:0.65; positional:false`);
try{
bgm.components.sound.stopSound();
if(state.audioUnlocked) bgm.components.sound.playSound();
}catch(e){}
}
/* ===== フィールド切替 ===== */
function setField(field){
state.field = field;
["town","castle","cave","ruins"].forEach(name=>{
document.getElementById("field-"+name).setAttribute("visible", name===field);
});
document.getElementById("npcGuide").setAttribute("visible", field==="town");
document.getElementById("npcKnight").setAttribute("visible", field==="castle");
document.getElementById("npcMiner").setAttribute("visible", field==="cave");
document.getElementById("npcSage").setAttribute("visible", field==="ruins");
const sky = document.getElementById("sky");
const sun = document.getElementById("sun");
if(field==="town"){ sky.setAttribute("color","#061018"); sun.setAttribute("intensity","1.35"); }
if(field==="castle"){ sky.setAttribute("color","#071321"); sun.setAttribute("intensity","1.45"); }
if(field==="cave"){ sky.setAttribute("color","#04070b"); sun.setAttribute("intensity","0.85"); }
if(field==="ruins"){ sky.setAttribute("color","#050b10"); sun.setAttribute("intensity","1.05"); }
setBGMByField();
spawnEnemy();
updateUI();
log(`📍 ${FIELD_JP[field]} に移動した`);
}
/* ===== 敵 ===== */
function spawnEnemy(){
const list = ENEMIES[state.field] || ENEMIES.town;
const e = list[Math.floor(Math.random()*list.length)];
state.enemy = { name:e.name, hp:e.hp, maxHp:e.hp, atk:e.atk, exp:e.exp, gold:e.gold };
document.getElementById("enemyName3D").setAttribute("value", e.name);
const enemy = document.getElementById("enemy");
enemy.setAttribute("visible","true");
enemy.setAttribute("position", { x:0, y:enemyYAt(0, -2), z:-2 });
enemy.setAttribute("animation__pop","property: scale; from: 0.7 0.7 0.7; to: 1 1 1; dur: 220; easing: easeOutBack");
log(`⚠️ ${e.name} が現れた`);
}
function enemyCounter(){
if(Math.random() < 0.18){ log(`💨 ${state.enemy.name} の攻撃は外れた`); return; }
const raw = state.enemy.atk + Math.floor(Math.random()*6) - Math.floor(state.level/4);
const dmg = Math.max(2, raw);
state.hp -= dmg;
log(`🩸 反撃:${state.enemy.name} から ${dmg} ダメージ`);
if(state.hp <= 0){ state.hp = 1; log("🧊 倒れかけた…(HP1で踏みとどまった)"); }
updateUI();
}
function gainRewards(exp, gold){
state.exp += exp;
state.gold += gold;
log(`✅ 報酬:EXP +${exp} / ${gold}G`);
while(state.exp >= state.expNeed){
state.exp -= state.expNeed;
state.level++;
state.expNeed = Math.floor(state.expNeed*1.25 + 25);
state.hp = clamp(state.hp + 18, 0, 100);
state.mana = clamp(state.mana + 12, 0, 100);
log(`🎉 レベルアップ! Lv.${state.level}`);
}
updateUI();
}
function enemyDie(){
const enemy = document.getElementById("enemy");
enemy.setAttribute("animation__die","property: scale; to: 0.01 0.01 0.01; dur: 250; easing: easeInQuad");
setTimeout(()=> enemy.setAttribute("visible","false"), 260);
gainRewards(state.enemy.exp, state.enemy.gold);
setTimeout(()=> spawnEnemy(), 1200);
}
function damageEnemy(dmg, by="攻撃"){
state.enemy.hp -= dmg;
log(`⚔️ ${by}:${state.enemy.name} に ${dmg} ダメージ(残り ${Math.max(0,state.enemy.hp)})`);
document.getElementById("enemyModel").setAttribute("animation__hit","property: rotation; dir: alternate; dur: 70; loop: 4; to: 0 0 12");
if(state.enemy.hp <= 0){ enemyDie(); return; }
enemyCounter();
}
/* ===== 行動 ===== */
function wave(){ document.getElementById("hero").setAttribute("animation__wave","property: rotation; dir: alternate; dur: 180; loop: 6; to: 0 0 8"); log("👋 Wave!"); }
function cheer(){
const conf = document.getElementById("confetti");
conf.setAttribute("visible","true");
conf.setAttribute("animation__up","property: position; from: 0 2.4 10; to: 0 4.2 10; dur: 520; easing: easeOutQuad");
conf.setAttribute("animation__fade","property: material.opacity; from: 0.35; to: 0; dur: 520; easing: easeOutQuad");
setTimeout(()=>{ conf.setAttribute("visible","false"); conf.setAttribute("material","opacity:0.35; transparent:true"); }, 560);
log("🎉 Cheer!");
}
function rest(){
const bhp=state.hp, bmn=state.mana;
state.hp = clamp(state.hp + 40, 0, 100);
state.mana = clamp(state.mana + 40, 0, 100);
updateUI();
log(`🛏 休憩:HP ${bhp}→${state.hp} / 魔力 ${bmn}→${state.mana}`);
}
function attack(){ const dmg = (14 + Math.floor(state.level/2)) + Math.floor(Math.random()*8); damageEnemy(dmg, "通常攻撃"); updateUI(); }
function castSpell(){
if(state.mana < 18){ log("💤 魔力が足りない"); return; }
state.mana -= 18;
const dmg = 24 + Math.floor(state.level*1.2) + Math.floor(Math.random()*10);
damageEnemy(dmg, "魔法");
updateUI();
}
/* ===== モーダル ===== */
function openModal(title, body, buttons){
document.getElementById("modalTitle").textContent = title;
document.getElementById("modalBody").textContent = body;
const area = document.getElementById("modalBtns");
area.innerHTML = "";
buttons.forEach(b=>{
const div = document.createElement("div");
div.className = "btn " + (b.type || "");
div.textContent = b.label;
bindPress(div, ()=>{ try{ b.onClick(); }catch(e){} });
area.appendChild(div);
});
document.getElementById("modalBack").style.display = "flex";
}
function closeModal(){ document.getElementById("modalBack").style.display = "none"; unlockAudio(); }
document.getElementById("modalBack").addEventListener("click", (e)=>{ if(e.target && e.target.id === "modalBack") closeModal(); });
/* ===== 会話/クエスト/ショップ ===== */
function talk(){
const npcName = (state.field==="town") ? "Guide"
: (state.field==="castle") ? "Castle Knight"
: (state.field==="cave") ? "Miner"
: "Ruins Sage";
openModal(`💬 ${npcName}`, `${npcName}:\nここは ${FIELD_JP[state.field]}。\n準備ができたら戦うか、別の場所へ行け。`, [
{ label:"通常攻撃", type:"primary", onClick:()=>{ closeModal(); attack(); } },
{ label:"魔法", type:"primary", onClick:()=>{ closeModal(); castSpell(); } },
{ label:"閉じる", onClick:()=> closeModal() }
]);
}
function quest(){
openModal("📜 クエスト", "今は簡易クエスト(討伐でEXPとGを稼げ)。\n次段階で固定シナリオを増やせる。", [
{ label:"閉じる", onClick:()=> closeModal() }
]);
}
function shop(){
openModal("🛒 ショップ", "(クライアントのみ簡易)\n街でゴールドを稼いで強化できる拡張に対応。", [
{ label:"閉じる", onClick:()=> closeModal() }
]);
}
/* ===== セーブ/ロード ===== */
function saveGame(){
const data = { ...state, audioUnlocked: state.audioUnlocked };
localStorage.setItem("elder_social_vr_save", JSON.stringify(data));
log("💾 セーブ完了");
}
function loadGame(){
const raw = localStorage.getItem("elder_social_vr_save");
if(!raw){ log("📂 セーブデータがない"); return; }
try{
const data = JSON.parse(raw);
Object.assign(state, data || {});
setField(state.field || "town");
updateUI();
log("📂 ロード完了");
}catch(e){
log("❌ ロード失敗:データ破損");
}
}
/* ===== VR Enter/Exit ===== */
function enterVR(){
const scene = document.getElementById("scene");
state.inVR = true;
document.body.classList.add("vr");
document.getElementById("vrUI").setAttribute("visible","true");
document.getElementById("cam").setAttribute("position","0 1.72 0.05");
document.getElementById("heroHead").setAttribute("material","opacity:0.0; transparent:true; color:#f4d7bd");
try{ scene.enterVR(); log("🕶️ VRに入った"); }catch(e){ log("⚠️ WebXRに入れない(疑似VRで続行)"); }
}
function exitApp(){
const scene = document.getElementById("scene");
state.inVR = false;
document.body.classList.remove("vr");
document.getElementById("vrUI").setAttribute("visible","false");
document.getElementById("cam").setAttribute("position","0 1.75 3.4");
document.getElementById("heroHead").setAttribute("material","opacity:1.0; transparent:false; color:#f4d7bd");
try{ scene.exitVR(); }catch(e){}
setHUDVisible(hudVisible);
log("⏏ Exit");
}
function fieldTown(){ setField("town"); }
function fieldCastle(){ setField("castle"); }
function fieldCave(){ setField("cave"); }
function fieldRuins(){ setField("ruins"); }
/* ===== 操作 ===== */
const rig = document.getElementById("playerRig");
const hero = document.getElementById("hero");
const cam = document.getElementById("cam");
const keys = { w:false,a:false,s:false,d:false, shift:false, space:false };
let vy = 0, grounded = true;
function getYaw(){
const rot = cam.getAttribute("rotation");
return (rot && typeof rot.y === "number") ? rot.y : 0;
}
function tickMovement(dt){
const speedBase = keys.shift ? 7.2 : 4.4;
const step = (speedBase * dt) / 1000;
let moveX = 0, moveZ = 0;
if(keys.w) moveZ -= 1;
if(keys.s) moveZ += 1;
if(keys.a) moveX -= 1;
if(keys.d) moveX += 1;
const len = Math.hypot(moveX, moveZ);
if(len > 0){ moveX/=len; moveZ/=len; }
const yaw = (getYaw() * Math.PI) / 180;
const cos = Math.cos(yaw), sin = Math.sin(yaw);
const dx = (moveX * cos - moveZ * sin) * step;
const dz = (moveX * sin + moveZ * cos) * step;
const pos = rig.getAttribute("position");
let nx = pos.x + dx, nz = pos.z + dz;
const r = Math.hypot(nx, nz);
const limit = 44;
if(r > limit){ const k = limit / r; nx *= k; nz *= k; }
if(keys.space && grounded){ vy = 5.2; grounded = false; }
if(!grounded){ vy -= 12.0 * (dt/1000); }
let ny = pos.y + vy * (dt/1000);
/* ★地面は固定じゃない。今いる場所の地面へクランプ(埋まりゼロ) */
const gy = groundYAt(nx, nz);
if(ny <= gy){ ny = gy; vy = 0; grounded = true; }
rig.setAttribute("position", { x:nx, y:ny, z:nz });
if(len > 0){ hero.setAttribute("rotation", { x:0, y:getYaw(), z:0 }); }
// 敵の追従(★yも地面追従)
const enemy = document.getElementById("enemy");
const epos = enemy.getAttribute("position");
const dist = Math.hypot((epos.x - nx), (epos.z - nz));
if(dist > 18){
const ez = nz - 2.5;
enemy.setAttribute("position", { x:nx, y:enemyYAt(nx, ez), z:ez });
}
const floating = document.getElementById("floating");
if(floating){
const t = performance.now() / 1000;
floating.setAttribute("rotation", { x:0, y:(t*18)%360, z:0 });
floating.setAttribute("position", { x:0, y:2.2 + Math.sin(t*1.4)*0.12, z:-10 });
}
}
function hookThumbstick(){
const RH = document.getElementById("rightHand");
const LH = document.getElementById("leftHand");
const onMove = (e)=>{
if(!e || !e.detail) return;
const { x, y } = e.detail;
keys.w = y < -0.2; keys.s = y > 0.2; keys.a = x < -0.2; keys.d = x > 0.2;
};
RH.addEventListener("thumbstickmoved", onMove);
LH.addEventListener("thumbstickmoved", onMove);
}
window.addEventListener("keydown", (e)=>{
if(e.repeat) return;
if(e.code==="KeyW") keys.w = true;
if(e.code==="KeyA") keys.a = true;
if(e.code==="KeyS") keys.s = true;
if(e.code==="KeyD") keys.d = true;
if(e.code==="ShiftLeft" || e.code==="ShiftRight") keys.shift = true;
if(e.code==="Space") keys.space = true;
if(e.code==="KeyJ"){ unlockAudio(); attack(); }
if(e.code==="KeyK"){ unlockAudio(); castSpell(); }
if(e.code==="Escape"){ if(!state.inVR) toggleHUD(); }
});
window.addEventListener("keyup", (e)=>{
if(e.code==="KeyW") keys.w = false;
if(e.code==="KeyA") keys.a = false;
if(e.code==="KeyS") keys.s = false;
if(e.code==="KeyD") keys.d = false;
if(e.code==="ShiftLeft" || e.code==="ShiftRight") keys.shift = false;
if(e.code==="Space") keys.space = false;
});
/* ===== DOMボタン配線 ===== */
function wireButtons(){
bindPress(document.getElementById("btnTown"), ()=> setField("town"));
bindPress(document.getElementById("btnCastle"), ()=> setField("castle"));
bindPress(document.getElementById("btnCave"), ()=> setField("cave"));
bindPress(document.getElementById("btnRuins"), ()=> setField("ruins"));
bindPress(document.getElementById("btnEnterVR"), ()=> enterVR());
bindPress(document.getElementById("btnExit"), ()=> exitApp());
bindPress(document.getElementById("btnWave"), ()=> wave());
bindPress(document.getElementById("btnCheer"), ()=> cheer());
bindPress(document.getElementById("btnTalk"), ()=> talk());
bindPress(document.getElementById("btnQuest"), ()=> quest());
bindPress(document.getElementById("btnShop"), ()=> shop());
bindPress(document.getElementById("btnRest"), ()=> rest());
bindPress(document.getElementById("btnSave"), ()=> saveGame());
bindPress(document.getElementById("btnLoad"), ()=> loadGame());
bindPress(document.getElementById("btnHideUI"), ()=> setHUDVisible(false));
bindPress(document.getElementById("floatingShowUI"), ()=> setHUDVisible(true));
bindPress(document.getElementById("panel"), ()=>{
try{ cam.components["look-controls"].pointerLockEnabled = true; }catch(e){}
});
}
/* ===== 初期化 ===== */
(function init(){
wireButtons();
hookThumbstick();
const hidden = localStorage.getItem("elder_ui_hidden") === "1";
setHUDVisible(!hidden);
/* ★まず“基準0.55で置いた物”を全てスナップ(埋まり解消) */
snapAll();
/* ★プレイヤーは「その場の地面」に強制一致(埋まりゼロ) */
const p0 = rig.getAttribute("position");
if(p0 && typeof p0.x==="number" && typeof p0.z==="number"){
rig.setAttribute("position", { x:p0.x, y:groundYAt(p0.x, p0.z), z:p0.z });
}
updateUI();
setBGMByField();
spawnEnemy();
log("起動。島の段差に合わせて player/NPC/建物を自動補正(埋まりゼロ)。UIは右上で閉じられる。ESCでも切替。");
let last = performance.now();
function loop(now){
const dt = now - last;
last = now;
tickMovement(dt);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
const scene = document.getElementById("scene");
scene.addEventListener("enter-vr", ()=>{
state.inVR = true;
document.body.classList.add("vr");
document.getElementById("vrUI").setAttribute("visible","true");
document.getElementById("cam").setAttribute("position","0 1.72 0.05");
document.getElementById("heroHead").setAttribute("material","opacity:0.0; transparent:true; color:#f4d7bd");
log("🕶️ WebXR: enter-vr");
});
scene.addEventListener("exit-vr", ()=>{
state.inVR = false;
document.body.classList.remove("vr");
document.getElementById("vrUI").setAttribute("visible","false");
document.getElementById("cam").setAttribute("position","0 1.75 3.4");
document.getElementById("heroHead").setAttribute("material","opacity:1.0; transparent:false; color:#f4d7bd");
setHUDVisible(hudVisible);
log("⏏ WebXR: exit-vr");
});
})();
</script>
</body>
</html>
ゼノギアスリメイク企画書
1. プロジェクト概要
- 企画名(仮):『Xenogears Rebirth(ゼノギアス リバース)(仮)』
- ジャンル:ドラマティックRPG / ロボット(ギア)バトルRPG
- 対象ハード:
- PlayStation 5 / PC(Steam) / 次世代Nintendoプラットフォーム
- プレイ人数:1人
- 想定プレイ時間:メインストーリー 60〜80時間+サブ要素 20〜40時間
- 企画区分:既存IPリメイク(※本書は非公式ファン企画書)
2. 企画意図・コンセプト
2-1. 企画意図
- 1990年代に発売された原作は、重厚なストーリー・哲学的テーマ・ギア(巨大ロボ)×人間ドラマが高く評価されている。
- 一方で、
- ハード性能制約による表現不足
- 終盤の駆け足展開
- 現代基準では不便なUI・テンポ
といった課題も指摘されている。
- 本リメイクでは、
**「原作の本質と感情の揺さぶりを守りつつ、現代RPGとしての遊びやすさ・映像・テンポを刷新」**することを目標とする。
2-2. メインコンセプト
- 原作のドラマ性・テーマ性を忠実に再現
- キャラクター性・世界観・大きな物語の流れ・名シーンの構造は尊重。
- ゲーム体験の再構築
- 戦闘テンポ、探索、UI、カメラワークを現代基準に最適化。
- 後半シナリオの再表現
- 当時表現しきれなかったシーンや背景を、プレイアブルパートやイベントとして補完。
- ギアバトルの“憧れ”を実現
- 重厚さとスピード感の両立した、アクション性の高いハイブリッドバトル。
3. ターゲットユーザー
- 原作ファン層(30〜40代中心)
- 当時プレイしたコアファン。
- シナリオ重視・キャラ重視のRPGを好むユーザー。
- 現代のJRPGファン(10〜20代)
- スタイリッシュなビジュアル、手触りの良いアクションRPGに慣れているユーザー。
- 他社の大型RPG(FF・テイルズ・ペルソナなど)を遊ぶ層。
- ロボット・メカファン
- アニメ調のロボットバトル、巨大兵器のスケール感に魅力を感じる層。
4. 世界観・ストーリーテーマ(高レベル)
※具体的なネタバレの細部ではなく、「リメイク方向性」を示すレベルに留める。
- 核となるテーマ
- 「自己との対話」「存在理由」「因果・輪廻」「人と機械・神話の境界」
- トーン&ムード
- 一見牧歌的な日常から、徐々に世界の根源的な闇へ降りていく構成。
- シリアス中心だが、仲間同士の掛け合いや日常シーンで緩急を付ける。
- リメイクでの方針
- 後半の出来事を**ナレーション主体ではなく、可能な限り“実際に操作して体験させる”**形へ再構成。
- 補完要素として、
- 過去編の短いプレイアブルセクション
- サブクエストで描かれる脇役の視点
などを追加し、世界の厚み・説得力を強化。
5. ゲームシステム全体像
5-1. バトルシステム(人間戦 / ギア戦)
共通方針
- ベースはコマンドRPG+コンボ(連携)システムを継承しつつ、
入力タイミングや位置取りを取り入れたアクション性のあるハイブリッドにする。 - 行動順やバフ・デバフなど、戦術RPGとしての深みを維持。
人間戦(オンフット)
- 三ボタン程度の「弱・中・強」攻撃の組み合わせで、コンボ技を発動。
- AP(アクションポイント)制で、行動配分・温存の駆け引きを残す。
- 回避・ガード・距離管理など、ライトなアクション要素を追加。
- 必殺技発動時はカットイン+カメラワーク強化。
ギア戦
- 重量感・速度感を感じるカメラとSEで「巨大兵器を操る感覚」を強調。
- 燃料・装甲・ブースト管理を要素にした中量級“シミュレーションアクション”寄りの手触り。
- ギアごとにロール(格闘型 / 射撃型 / 支援型)を明確化し、パーティ編成の意味を強化。
- 合体技・連携技は演出を刷新し、ボス戦のハイライトに。
5-2. フィールド・ダンジョン
- シームレス3Dフィールド:ローディングを極力隠し、没入感を重視。
- キャラアクション:
- ダッシュ・ジャンプ・よじ登り・壁キックなど、原作の立体的マップを現代仕様にアップグレード。
- ダンジョンデザイン:
- ただ複雑にするのではなく、「ギミック+物語的意味」のある構造へ再調整。
- ショートカット・簡易マップなどで迷いすぎストレスを軽減。
5-3. 町・拠点要素
- 町では
- NPCとの会話
- ショップ
- サブクエスト受注
- 仲間との会話イベント(好感度イベントではなく、あくまでストーリー深堀り)
を配置。
- 宿屋・酒場などで**仲間同士の会話イベント(「キャンプトーク」的なもの)**を追加し、
心情描写を補強。
6. キャラクターデザイン・ビジュアル
- ビジュアルスタイル:
- “ハイエンドなアニメ調3D”を目指し、トゥーンシェーダ+物理ベースレンダリングを両立。
- オリジナルの印象的なデザインを尊重し、細部・質感・装飾を現代的にアップデート。
- ギアデザイン:
- シルエット・武装は原作のイメージを維持しつつ、関節ディテール・マーキング・発光表現で情報量を増加。
- バトル中の変形・形態変化を一部ギアに追加し、差別化と盛り上げに利用。
7. サウンド・音楽
- BGM:
- 原作楽曲をフルオーケストラ・バンド・民族楽器などで再アレンジ。
- 要所では原曲アレンジを使用し、ファン心理を刺激。
- ボイス:
- メインストーリーはフルボイスを基本とし、一部サブイベントもボイス対応。
- SE:
- ギアの駆動音・ブースト音・衝撃音をハイレゾ化し、“質量感”を音でも表現。
8. UI/UX・快適さ
- 戦闘速度は**3段階調整(標準 / 早い / 非常に早い)**を用意。
- オートバトル・オートコンボ機能(難易度EASY向け)を搭載。
- ログ機能、イベントリプレイ機能で、物語の振り返りをサポート。
- ミニマップ&クエストガイド(オン/オフ可能)で迷子ストレスを軽減。
9. 新規要素(リメイクオリジナル)
- サブクエスト強化
- 主要都市・村ごとに「世界観を深めるサブストーリー」を用意。
- 一部はギア専用サブクエスト(闘技場・模擬戦・試作機テストなど)。
- ギアカスタマイズ拡張
- 外見は原作イメージを壊さない範囲で、カラー・マーキング・エンブレム変更を許可。
- パーツ・チップによる能力カスタム要素を拡張。
- フォトモード
- ギア・キャラクターを撮影できるフォトモードを実装し、SNS映えを意識。
- ギャラリーモード
- イラスト・3Dモデル・BGM・過去PVなどを閲覧可能なモードを搭載。
- リメイク制作のメイキング要素を一部公開し、ファン満足度を高める。
10. 開発体制(想定)
- 開発期間:36〜42ヶ月
- チーム規模:メイン開発 80〜120名規模
- ディレクター:1名
- プロデューサー:1〜2名
- シナリオ・演出:数名(原作関係者+新規ライター)
- プログラマー:15〜25名
- アーティスト(キャラ・ギア・背景):25〜35名
- UI/UX:3〜5名
- サウンド:3〜5名
- QA:外部含め多数
11. 開発スケジュール(ラフ)
- プリプロダクション(〜6ヶ月)
- リメイク範囲・シナリオ再構成方針の決定
- 映像コンセプト・バトルプロトタイプ作成
- プロダクション前半(〜18ヶ月)
- メインストーリー実装
- バトルシステム・ギアシステムの実装と調整
- プロダクション後半(〜30ヶ月)
- サブクエスト・追加要素・UI/UX実装
- バランス調整・最適化
- ポリッシュ・QA(〜36〜42ヶ月)
- デバッグ・チューニング・ローカライズ
- マーケティング連動施策準備
12. ビジネス・収益計画(案)
- 販売形態:フルプライスパッケージ+DL版(DLCは最小限)
- DLC案
- サウンドトラック拡張
- ビジュアルアートブック(デジタル)
- ギャラリー用メイキング映像など、世界観を壊さない“資料系DLC”
- 訴求ポイント
- 「伝説的RPGの決定版リメイク」
- 「当時の未完感を、現代の技術と表現で再構築」
- 「ギアバトル×重厚ストーリーの最高峰を、最新ハードで」
13. 注意事項(IP・ブランド)
- 本企画書は非公式ファンメイドの仮想リメイク案であり、
実際の権利は原作の権利元(スクウェア・エニックス等)に帰属する。 - 実際に商業利用を行う場合は、
- 権利元への企画提案
- 商標・著作権の正式なライセンス契約
が必須となる。
光の歩み:パウロの航路
タイトル(仮)
「光の歩み:パウロの航路」
― 初期教会の旅路を体験し、希望・信頼・愛をゲームとして学ぶアドベンチャーRPG
1. 企画概要
- ジャンル:物語主導アドベンチャーRPG+探索+対話パズル(非殺傷系)
- 対象:中高生〜大人。信仰の有無を問わず、歴史物語や倫理的選択を楽しみたい人
- プラットフォーム:PC/コンソール(Switch/PS/Xbox)+後発でモバイル簡易版
- プレイ時間:本編10〜15時間+周回要素
- レーティング想定:CERO B(暴力表現なし、差別・扇動なし)
2. 企画意図・学習目標
- 初期キリスト教の歴史的背景(1世紀ローマ世界・ディアスポラ都市)を偏りなく紹介
- 価値観(信仰=Faith/希望=Hope/愛=Charity)を、選択と結果で体験的に理解
- 実在地・史料に基づく考証のもと、**布教=説得ではなく「対話と理解」**を主眼に
3. コア体験(Game Pillars)
- 対話と調停:噂・誤解・利害が交差する街で、中立的に聞き取り、誠実に橋渡し
- 旅と共同体:船旅で各都市へ。資源(時間・食糧・寄付)と仲間の士気を管理
- 徳の選択:状況に応じて「信仰/希望/愛」を強化する行動を選ぶ(暴力回避)
- 史的没入:当時の市場、会堂、家屋教会、ローマ法廷、港湾などを探索し資料収集
4. ゲームループ
- 街に到着 → 情報収集(傾聴) → 対話パズル(調停/説得/証言の整合) → 共同体支援(施し/労働/手紙) → 船旅準備(資源管理) → 次の地へ
- 章ごとに手紙(Epistle)が解禁され、読了で小さな洞察ブースト(対話オプション解放)
5. メカニクス
- 徳メーター:Faith / Hope / Charity(数値でなく「状態」重視。偏らず三徳のバランスが良いと特殊イベント)
- 祈り(クールダウン制):混乱時に視点を整える。NPCの本心“キーワード”が一瞬だけ可視化
- 証言カード:聞き取った事実・比喩・譬えの断片。論理矛盾がない並べ方で対話パズルを解く
- 共同体ボード:困窮、病、孤立、迫害の度合いを可視化。資源配分で街の状態が改善しクエストが変化
- 非殺傷コンバット:逃走・隠密・法廷弁明・保証人立て等で危機回避(QTE最小)
6. 物語構成(例)
- プロローグ:ダマスコ(出会いと転機)
- 第1章:アンティオキア(多文化の街での誤解解消)
- 第2章:ピシディアのアンティオキア〜リストラ(迫害と連帯)
- 第3章:コリント(商人ギルドと信仰共同体の摩擦を調停)
- 第4章:エフェソス(職人組合と偶像問題、平和的収束)
- 最終章:ローマ(法と良心、希望の証言。エンディング分岐=三徳の在り方)
7. 美術・音響
- アート:温かい筆致のセミリアル。衣装・建築は史料参照、象徴表現はやさしく
- UI:巻物/羊皮紙モチーフ。徳メーターは三つの小さな灯火
- 音楽:弦・笛・竪琴+現代的サウンドスケープ。祈り時は環境音がやわらぐ
8. アクセシビリティ
- 朗読モード(本文読み上げ)、字幕・フォントサイズ調整、色弱配慮配色
- 難易度:ストーリーフォーカス/スタンダード/考証チャレンジ(史料クイズ追加)
9. リサーチと表現ガイドライン
- 宗派横断の監修委員会(神学・歴史・考古の3名以上)を設置
- 固有名詞・儀礼の描写は史料脚注に準拠、論争点は断定せず複数説を匂わせる
- 勧誘的・攻撃的表現は避ける。他宗教・文化への敬意を必須要件に
10. 進行・成長
- 徳の熟達で新しい対話フレーズ、比喩(譬え話)カードが解禁
- 街レベルが上がると共同体ボードの課題が高度化(教育、孤児支援、介護など)
11. マネタイズ
- 基本買い切り(DLCで「ガリラヤの物語集」「詩編黙想パック」など追加コンテンツ)
- 収益の一部を現実のチャリティに寄付する連動企画(透明性レポートをゲーム内掲示板で公開)
12. 競合・差別化
- 既存の宗教題材ゲームは説話紹介が中心。
→ 本作は**“非対立のデザイン”**(調停パズル×徳システム)で能動的体験を提供。
13. 技術スタック
- Unity(ユーザーの希望に合致):URP、Yamlローカライズ、Ink/Dialogue System、Timeline
- セーブはJSON+暗号化、PCとSwitchを初期ターゲット
- 字幕・スクリプトは外部CSVで管理し監修者が直接校正可能
14. 開発体制・期間(目安)
- チーム:10〜14名(Pd/PM1、リードデザ1、レベルデザ2、プログラマ3、美術3、UI/UX1、サウンド1、QA1、歴史/神学監修2)
- 期間:プリプロ3ヶ月/プロダクション10ヶ月/QA・ローカライズ3ヶ月(計16ヶ月)
- 概算:8,000万〜1.4億円規模(外注とキャスト有無で変動)
15. KPI / 品質目標
- ストーリー完走率60%以上、レビューで「尊重/配慮」に関する肯定的言及比率70%以上
- 学習要素:章末クイズ正答率の上昇、資料室閲覧率40%以上
16. リスクと対応
- 神学表現の誤解:監修フローと用語脚注で軽減
- 暴力の回避が地味化:対話パズルに“緊張の演出”(時間制限・人だかり・法廷)を導入
- 文化的感受性:外部レビュー(多宗教・無宗教含む)でプレイテスト
サンプルクエスト(第3章:コリント)
- 状況:商人ギルドが安息日の取引巡りで共同体を批判。街の不満が高まる
- 目的:両者の誤解を解き、市場規約の妥協案を提案
- 手順:
- 市場で聞き取り→証言カード「規約草案」「祭礼日」「巡礼需要」を入手
- 祈りでキーワード「家族」「信用」を取得
- 対話パズルで並べ替え:信用>祭礼日>需要調整
- 結果:ギルドは早朝市を追加、共同体は夕刻会合に変更→街ボード好転
代替案(スピンオフ)
A. 「たとえの箱庭(Parables Builder)」
- ジャンル:箱庭パズル+教育
- イエスのたとえ話を抽象化オブジェクトで組み、他者に伝わるかを評価
- マップ共有・評価でコミュニティ生成、学校向け教材モード有り
B. 「詩編の旋律」
- ジャンル:没入型音×言葉アドベンチャー
- 詩編のテキストを手掛かりに、風景・音景を“調律”して心情を可視化
- メンタルヘルス配慮の瞑想モード、読み上げ+多言語字幕
必要なら、この企画をピッチ用1枚/詳細GDD/予算&スケジュール表に分解してドキュメント化します。どの案を推したい? あるいは「現代日本舞台」「子ども向け」などの方向性も出せます。
ブラウザゲーム.html
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
<title>Endless Dodge ULTRA - Bullet & Boss</title>
<style>
:root{
--bg1:#070816; --bg2:#0f1b38; --accent:#6ee7ff; --accent2:#9bffb7; --danger:#ff6b6b; --panel:rgba(255,255,255,.08);
--text:#eaf2ff; --muted:#b5c0d0; --gold:#ffd166; --purple:#c4a7ff; --emerald:#86efac;
}
*{box-sizing:border-box}
html,body{height:100%;}
body{ margin:0; font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans JP";
color:var(--text);
background: radial-gradient(1200px 800px at 20% 10%, #1b2444 0%, var(--bg1) 50%), linear-gradient(160deg, var(--bg2), var(--bg1));
overflow:hidden;}
.wrap{position:fixed; inset:0; display:grid; grid-template-rows:auto 1fr auto;}
header, footer{display:flex; gap:.75rem; align-items:center; justify-content:space-between; padding:.6rem .9rem; backdrop-filter: blur(6px); background:linear-gradient( to bottom, rgba(255,255,255,.06), rgba(255,255,255,.02)); border-bottom:1px solid rgba(255,255,255,.08)}
header h1{font-size:1rem; margin:0; letter-spacing:.05em; font-weight:700}
header .right{display:flex; gap:.5rem; align-items:center}
.pill{ pointer-events:auto; border:1px solid rgba(255,255,255,.14); background:var(--panel); padding:.5rem .8rem; border-radius:999px; font-size:.9rem; color:var(--text); cursor:pointer; user-select:none; transition:transform .08s ease}
.pill:active{ transform:scale(.97)}
#gamePanel{ position:relative; display:grid; place-items:center;}
canvas{ width: min(94vw, 800px); aspect-ratio: 9/16; border-radius: 18px; box-shadow: 0 10px 40px rgba(0,0,0,.5), inset 0 0 0 1px rgba(255,255,255,.06);
background: radial-gradient(600px 500px at 50% 10%, rgba(110,231,255,.12), transparent 60%), linear-gradient(180deg, rgba(255,255,255,.03), rgba(255,255,255,.02));}
.hud{ position:absolute; inset:0; pointer-events:none;}
.row{ display:flex; justify-content:space-between; align-items:center; padding:10px;}
.score{ font-variant-numeric: tabular-nums; font-size: clamp(18px, 3.5vw, 28px); text-shadow:0 1px 0 rgba(0,0,0,.5)}
.muted{ color: var(--muted)}
.center{ position:absolute; inset:0; display:grid; place-items:center;}
.card{ pointer-events:auto; background:rgba(7,8,22,.92); border:1px solid rgba(255,255,255,.14); border-radius:16px; padding:20px; width:min(92vw, 480px); box-shadow:0 20px 60px rgba(0,0,0,.6)}
.card h2{ margin:0 0 8px; font-size:1.25rem}
.card p{ margin:.25rem 0; color:var(--muted)}
.btn{ display:inline-flex; align-items:center; justify-content:center; gap:.5rem; padding:.7rem 1rem; border-radius:12px; border:1px solid rgba(255,255,255,.16); background:linear-gradient(180deg, rgba(255,255,255,.12), rgba(255,255,255,.06)); color:var(--text); cursor:pointer; font-weight:600}
.btn:hover{ filter:brightness(1.08)}
.btn.primary{ border-color: rgba(110,231,255,.5); box-shadow: 0 0 30px rgba(110,231,255,.15) inset}
.grid{ display:grid; grid-template-columns:1fr 1fr; gap:.6rem}
.touch{ position:absolute; inset:auto 0 10px 0; display:flex; justify-content:center; gap:12px; pointer-events:auto}
.touch button{ width:clamp(64px, 22vw, 106px); aspect-ratio:1/1; border-radius:16px; border:1px solid rgba(255,255,255,.14); background:var(--panel); color:var(--text); font-weight:700; font-size:clamp(16px, 4.5vw, 22px); text-shadow:0 1px 0 rgba(0,0,0,.35)}
.badge{border:1px solid rgba(255,255,255,.14); background:var(--panel); padding:.35rem .6rem; border-radius:999px; font-size:.75rem}
.toast{ position:absolute; left:50%; top:14%; transform:translateX(-50%); pointer-events:none; opacity:0; transition: opacity .2s, transform .2s; background:rgba(0,0,0,.5); border:1px solid rgba(255,255,255,.18); padding:.35rem .7rem; border-radius:10px; font-weight:700}
.toast.show{ opacity:1; transform:translate(-50%, -6px)}
footer{ border-top:1px solid rgba(255,255,255,.08); border-bottom:none; justify-content:center}
a{ color:var(--accent)}
dialog{ border:none; border-radius:16px; background:rgba(7,8,22,.96); color:var(--text); width:min(92vw,560px); }
dialog::backdrop{ background:rgba(0,0,0,.6); }
.field{ display:flex; justify-content:space-between; align-items:center; gap:10px; padding:8px 0; }
.range{ width:58% }
.switch{ appearance:none; width:42px; height:24px; border-radius:999px; background:#445; position:relative; outline:none; cursor:pointer; }
.switch:checked{ background:#2aa }
.switch::after{ content:""; position:absolute; top:3px; left:3px; width:18px; height:18px; border-radius:50%; background:#fff; transition:left .15s}
.switch:checked::after{ left:21px }
.shop-item{ display:grid; grid-template-columns:1fr auto; gap:.4rem; align-items:center; padding:.5rem; border:1px solid rgba(255,255,255,.12); border-radius:12px; margin:.35rem 0; }
.chip{ padding:.2rem .5rem; border:1px solid rgba(255,255,255,.16); border-radius:999px; font-size:.75rem; }
</style>
</head>
<body>
<div class="wrap">
<header>
<h1>Endless Dodge <span class="badge">ULTRA</span></h1>
<div class="right">
<span class="badge">💎 <span id="wallet">0</span></span>
<button id="btnShop" class="pill" aria-label="shop">🛒 ショップ</button>
<button id="btnSkins" class="pill" aria-label="skins">🎨 スキン</button>
<button id="btnPause" class="pill" aria-label="pause">⏸</button>
<button id="btnSound" class="pill" aria-label="sound">🔊</button>
<button id="btnSettings" class="pill" aria-label="settings">⚙</button>
</div>
</header>
<div id="gamePanel">
<canvas id="game" width="360" height="640" aria-label="game canvas"></canvas>
<div class="hud">
<div class="row">
<div class="score">
<span id="score">0</span> pts
· <span class="muted">Best:</span> <span id="best">0</span>
· <span class="muted">Combo:</span> <span id="combo">x1.0</span>
· <span class="muted">Stage:</span> <span id="stage">1</span>
</div>
<div class="row" style="gap:.5rem">
<span class="badge" id="badges">⛨ 0 · 🧲 0 · ⏳ 0</span>
</div>
</div>
<div class="center" id="overlayStart">
<div class="card">
<h2>避けて、撃って、強化して、ボスを倒せ!</h2>
<p>← → / A・D で移動。<strong>Spaceでショット</strong>、<kbd>Shift</kbd>でダッシュ(無敵0.4s)。</p>
<p>パワーアップ:⛨シールド / 🧲マグネット / ⏳スロウ。コンボでスコア倍率UP。</p>
<p>ステージごとにボス戦。ボスは弾幕を発射。ショットでHPを削ろう。</p>
<div class="grid" style="margin-top:10px">
<button class="btn primary" id="btnStart">▶ ゲーム開始</button>
<button class="btn" id="btnHow">❓ 操作</button>
</div>
<div style="margin-top:10px" class="muted" id="missions"></div>
</div>
</div>
<div class="center" id="overlayBoss" style="display:none">
<div class="card" style="text-align:center">
<h2>⚠ B O S S ⚠</h2>
<p>弾幕を避けつつ、Spaceで撃て!Shiftダッシュも活用。</p>
<button class="btn primary" id="btnBossGo">戦闘開始</button>
</div>
</div>
<div class="center" id="overlayGameOver" style="display:none">
<div class="card">
<h2>ゲームオーバー</h2>
<p>スコア: <strong id="finalScore">0</strong> / ベスト: <strong id="finalBest">0</strong> / 💎<strong id="earned">0</strong></p>
<p>達成:<span id="finalMissions" class="muted">-</span></p>
<div class="grid" style="margin-top:10px">
<button class="btn primary" id="btnRetry">↻ リトライ</button>
<button class="btn" id="btnHome">⌂ タイトル</button>
</div>
</div>
</div>
<div class="touch" id="touchControls" aria-hidden="true">
<button id="leftBtn" aria-label="left">⟵</button>
<button id="dashBtn" aria-label="dash">⇧</button>
<button id="rightBtn" aria-label="right">⟶</button>
</div>
<div class="toast" id="toast">Ready</div>
</div>
</div>
<footer>
<small class="muted">© 2025 Endless Dodge ULTRA · 図形のみ · ローカル保存(設定/進行/ウォレット/実績)</small>
</footer>
</div>
<!-- Settings / Shop / Skins (unchanged structure) -->
<dialog id="dlgSettings">
<form method="dialog" style="padding:16px">
<h3 style="margin:0 0 8px">設定</h3>
<div class="field"><span>難易度(速度倍率)</span><input class="range" id="rangeSpeed" type="range" min="0.8" max="1.6" step="0.05"></div>
<div class="field"><span>画面シェイク</span><input id="chkShake" class="switch" type="checkbox"></div>
<div class="field"><span>色弱モード(高コントラスト)</span><input id="chkCB" class="switch" type="checkbox"></div>
<div class="field"><span>省エネ描画(★数減少)</span><input id="chkEco" class="switch" type="checkbox"></div>
<div class="field"><span>操作ヒントの表示</span><input id="chkHints" class="switch" type="checkbox"></div>
<div style="display:flex; gap:.5rem; justify-content:flex-end; margin-top:10px">
<button class="btn" value="cancel">閉じる</button>
<button class="btn primary" id="btnSaveSettings" value="default">保存</button>
</div>
</form>
</dialog>
<dialog id="dlgShop"><form method="dialog" style="padding:16px"><h3 style="margin:0 0 8px">ショップ</h3><p class="muted">💎はプレイ後にスコアから換算(100pts ≒ 1💎)。</p><div id="shopList"></div><div style="display:flex; gap:.5rem; justify-content:flex-end; margin-top:10px"><button class="btn" value="cancel">閉じる</button></div></form></dialog>
<dialog id="dlgSkins"><form method="dialog" style="padding:16px"><h3 style="margin:0 0 8px">スキン</h3><div id="skinList"></div><div style="display:flex; gap:.5rem; justify-content:flex-end; margin-top:10px"><button class="btn" value="cancel">閉じる</button></div></form></dialog>
<script>
// ===== Utilities & Persistence =====
const clamp=(v,min,max)=>Math.max(min,Math.min(max,v));
const rand=(a,b)=>Math.random()*(b-a)+a; const choice=a=>a[(Math.random()*a.length)|0];
const storage={ get(k,def){ try{return JSON.parse(localStorage.getItem(k)) ?? def}catch{ return def }}, set(k,v){ localStorage.setItem(k, JSON.stringify(v)); } };
const SAVE={ best:'ultra-best', opts:'ultra-opts', stats:'ultra-stats', wallet:'ultra-wallet', upgrades:'ultra-upgrades', missions:'ultra-missions', skin:'ultra-skin' };
const opts = Object.assign({ speedMul:1.0, shake:true, colorblind:false, eco:false, hints:true }, storage.get(SAVE.opts, {})); storage.set(SAVE.opts, opts);
const wallet = { gems: storage.get(SAVE.wallet, 0) };
function addGems(n){ wallet.gems = Math.max(0, Math.floor(wallet.gems + n)); storage.set(SAVE.wallet, wallet.gems); walletEl.textContent = wallet.gems; }
const upgrades = Object.assign({ startShield:0, magnetDur:0, dashCD:0, scoreMul:0, extraLife:0 }, storage.get(SAVE.upgrades, {}));
function uLevel(name){ return upgrades[name]||0 } function saveUpgrades(){ storage.set(SAVE.upgrades, upgrades); buildShop(); }
const skins = [
{id:'default', name:'Default', cost:0, color:'#eaf2ff'},
{id:'neon', name:'Neon Blue', cost:50, color:'#7ee0ff'},
{id:'sun', name:'Sun Gold', cost:80, color:'#ffd166'},
{id:'void', name:'Void Purple', cost:120, color:'#c4a7ff'},
{id:'leaf', name:'Leaf Green', cost:120, color:'#86efac'}
];
let currentSkin = storage.get(SAVE.skin, 'default');
function toast(msg, t=1200){ const el=document.getElementById('toast'); el.textContent=msg; el.classList.add('show'); clearTimeout(el._t); el._t=setTimeout(()=>el.classList.remove('show'), t); }
// ===== Audio =====
const AudioKit=(()=>{ let ctx, enabled=false; function ensure(){ if(!ctx){ const C=window.AudioContext||window.webkitAudioContext; if(C){ ctx=new C(); }} return ctx }
function beep(freq=440, dur=0.08, type='sine', gain=0.02){ if(!enabled) return; const c=ensure(); if(!c) return; const o=c.createOscillator(); const g=c.createGain(); o.type=type; o.frequency.setValueAtTime(freq,c.currentTime); g.gain.setValueAtTime(gain,c.currentTime); o.connect(g).connect(c.destination); const t=c.currentTime; o.start(t); o.stop(t+dur); }
function arpeggio(){ if(!enabled) return; const c=ensure(); if(!c) return; const base=220; const seq=[0,4,7,12,7,4]; seq.forEach((st,i)=>{ const o=c.createOscillator(); const g=c.createGain(); o.type='triangle'; o.frequency.setValueAtTime(base*Math.pow(2,st/12), c.currentTime + i*0.08); g.gain.setValueAtTime(0.02, c.currentTime + i*0.08); o.connect(g).connect(c.destination); o.start(c.currentTime + i*0.08); o.stop(c.currentTime + i*0.08 + 0.1); }); }
return{ enable(){ enabled=true; ensure(); }, disable(){ enabled=false; }, toggle(){ enabled=!enabled; if(enabled) ensure(); return enabled; }, hit(){ beep(120,0.18,'square',0.05); }, coin(){ beep(880,0.07,'triangle',0.03); }, tick(){ beep(660,0.02,'sine',0.015); }, power(){ beep(520,0.1,'sawtooth',0.04); }, dash(){ beep(240,0.06,'square',0.05); }, fanfare(){ arpeggio(); }, shoot(){ beep(720,0.04,'square',0.03); } }
})();
// ===== Canvas & World =====
const canvas=document.getElementById('game'); const ctx=canvas.getContext('2d');
let dpr=1; function resize(){ dpr=Math.max(1, Math.min(2, window.devicePixelRatio||1)); const w=canvas.clientWidth; const h=canvas.clientHeight; canvas.width=Math.round(w*dpr); canvas.height=Math.round(h*dpr); ctx.setTransform(dpr,0,0,dpr,0,0); }
new ResizeObserver(resize).observe(canvas); window.addEventListener('orientationchange', resize); resize();
const state={ running:false, over:false, t:0, score:0, best: storage.get(SAVE.best, 0), baseSpeed:120, speed:120, worldW:360, worldH:640, combo:1, comboTime:0, slowed:0, stage:1, boss:false };
const fx={ shakeTime:0, shakeAmp:0 };
const starCount = opts.eco? 40 : 90; const stars=[...Array(starCount)].map(()=>({x:rand(0,360), y:rand(0,640), s:rand(0.5,2), sp:rand(10,40)}));
const player={ x:180, y:560, r:12, vx:0, speed:270, color:'#eaf2ff', alive:true, flash:0, shield:0, magnet:0, dashCD:0, dashT:0, extra:0, fireCD:0 };
const obstacles=[]; const coins=[]; const lasers=[]; const particles=[]; const powerups=[]; const bullets=[]; // boss bullets
const pbullets=[]; // player bullets
// ===== Spawning =====
let lastSpawn=0, spawnInt=0.9; let lastLaser=0, laserInt=6.0; let stageTime=0, nextBossAt=28; // seconds
function spawnBlockRow(yOff=-40){ const gap = clamp(140 - state.t*0.02, 70, 150); const blockW = rand(40, 90); const leftW = rand(10, state.worldW - gap - blockW - 10); const rightX = leftW + gap + blockW; const moving = Math.random()<clamp(0.08 + state.t*0.0006, 0.08, 0.4); const speed = moving? rand(30, 90)* (Math.random()<0.5?-1:1) : 0; obstacles.push({x:0, y:yOff, w:leftW, h:16, vx:0}); obstacles.push({x:rightX, y:yOff, w: state.worldW - rightX, h:16, vx:0}); if(moving){ obstacles.push({x:leftW+4, y:yOff-18, w: blockW-8, h:10, vx:speed}); }
const cx = leftW + gap/2 + rand(-gap*0.35, gap*0.35); const cluster = (Math.random()<0.6) ? 4 : 1; for(let i=0;i<cluster;i++) coins.push({x:cx + (cluster>1?(i-1.5)*10:0), y:yOff-20 - i*8, r:6, vy:0}); if(Math.random()<0.22) powerups.push({x:cx+rand(-gap*0.3,gap*0.3), y:yOff-36, r:8, kind: choice(['shield','magnet','slow'])}); }
function spawnLaser(){ const side = Math.random()<0.5? 'L':'R'; const x = side==='L'? -40 : state.worldW+40; const dir = side==='L'? 1 : -1; lasers.push({x, y: rand(120, state.worldH-160), w:120, h:10, vx: 170*dir, life: 4}); }
// ===== Boss & Bullet Hell =====
let boss=null; let patternT=0, patternId=0, spiralAng=0; // patterns
function enterBoss(){ state.boss=true; show(bossOverlay); }
function startBoss(){ hide(bossOverlay); boss = { x: state.worldW/2, y: 160, r: 22, hp: 6 + state.stage*2, vx: 80 }; bullets.length=0; patternT=0; patternId=0; spiralAng=0; }
function bossShootFan(){ // 扇状(自機狙い)
const dx = player.x - boss.x; const dy = (player.y - boss.y); const base = Math.atan2(dy, dx); const n=5; const spread=0.6; for(let i=0;i<n;i++){ const a = base + (i-(n-1)/2)*spread/n; bullets.push({x:boss.x, y:boss.y, r:4, vx:Math.cos(a)*160, vy:Math.sin(a)*160}); }
}
function bossShootRing(){ // 全方位リング
const n=14; for(let i=0;i<n;i++){ const a = (i/n)*Math.PI*2; bullets.push({x:boss.x, y:boss.y, r:3.5, vx:Math.cos(a)*120, vy:Math.sin(a)*120}); }
}
function bossShootSpiral(){ // 渦巻き
const a1 = spiralAng; const a2 = spiralAng + Math.PI; spiralAng += 0.35; bullets.push({x:boss.x, y:boss.y, r:3.5, vx:Math.cos(a1)*150, vy:Math.sin(a1)*150}); bullets.push({x:boss.x, y:boss.y, r:3.5, vx:Math.cos(a2)*150, vy:Math.sin(a2)*150}); }
function updateBoss(dt){ if(!boss) return; boss.x += boss.vx*dt; if(boss.x<40){ boss.x=40; boss.vx=Math.abs(boss.vx);} if(boss.x>state.worldW-40){ boss.x=state.worldW-40; boss.vx=-Math.abs(boss.vx);} // pattern timeline
patternT += dt; if(patternId===0){ if(patternT>0.6){ bossShootFan(); patternT=0; if(Math.random()<0.25) patternId=1; } }
else if(patternId===1){ bossShootSpiral(); if(patternT>2.4){ patternT=0; patternId=2; } }
else if(patternId===2){ if(patternT>1.0){ bossShootRing(); patternT=0; if(Math.random()<0.5) patternId=0; else patternId=1; } }
// move bullets
for(const b of bullets){ b.x += b.vx*dt; b.y += b.vy*dt; }
for(let i=bullets.length-1;i>=0;i--){ const b=bullets[i]; if(b.x<-40||b.x>state.worldW+40||b.y<-40||b.y>state.worldH+60) bullets.splice(i,1); }
// hit player
for(const b of bullets){ const dx=player.x-b.x, dy=player.y-b.y; if(dx*dx+dy*dy <= (player.r+b.r)*(player.r+b.r)){ if(player.dashT<=0){ if(player.shield>0){ player.shield-=1; emit(player.x,player.y,12,'#6ee7ff'); } else if(player.extra>0){ player.extra--; toast('Extra Life!'); } else { return gameOver(); } } } }
}
function damageBoss(dmg=1){ if(!boss) return; boss.hp-=dmg; emit(boss.x,boss.y,16,'#c4a7ff'); if(boss.hp<=0){ boss=null; state.boss=false; state.stage++; stageTime=0; nextBossAt = clamp(26 - state.stage, 18, 26); addScore(200); toast(`Stage ${state.stage} クリア!`); AudioKit.fanfare(); }
}
function emit(x,y, n=8, col='#a8ffce'){ for(let i=0;i<n;i++){ particles.push({x,y, vx:rand(-90,90), vy:rand(-120,-40), life: rand(.3,.75), col}) } }
// ===== Input =====
let left=false, right=false, dashReq=false, shootHold=false;
window.addEventListener('keydown',e=>{
if(e.key==='ArrowLeft'||e.key==='a'||e.key==='A') left=true;
if(e.key==='ArrowRight'||e.key==='d'||e.key==='D') right=true;
if(e.code==='Space'){ shootHold=true; e.preventDefault(); }
if(e.key==='Shift') dashReq=true;
});
window.addEventListener('keyup',e=>{
if(e.key==='ArrowLeft'||e.key==='a'||e.key==='A') left=false;
if(e.key==='ArrowRight'||e.key==='d'||e.key==='D') right=false;
if(e.code==='Space') shootHold=false;
});
const leftBtn=document.getElementById('leftBtn'); const rightBtn=document.getElementById('rightBtn'); const dashBtn=document.getElementById('dashBtn');
const tp=document.getElementById('touchControls'); const isMobile = /Mobi|Android/i.test(navigator.userAgent); tp.style.display = isMobile? 'flex':'none';
const press=(b)=>{ b.dataset.down='1'; if(b===leftBtn) left=true; else if(b===rightBtn) right=true; else dashReq=true; };
const release=(b)=>{ b.dataset.down='0'; if(b===leftBtn) left=false; else if(b===rightBtn) right=false; };
[leftBtn,rightBtn,dashBtn].forEach(b=>{ b.addEventListener('pointerdown',()=>press(b)); b.addEventListener('pointerup',()=>release(b)); b.addEventListener('pointerleave',()=>release(b)); });
// mobile taps: single tap=shot, two-finger=dash
canvas.addEventListener('touchstart',e=>{ if(e.touches.length>=2) { dashReq=true; } else { shootOnce(); } }, {passive:true});
// desktop click to shoot too
canvas.addEventListener('mousedown', shootOnce);
// ===== Loop =====
let last=performance.now(); function loop(t){ const dt=Math.min(0.033,(t-last)/1000); last=t; if(state.running) update(dt); draw(dt); requestAnimationFrame(loop); } requestAnimationFrame(loop);
// ===== Mechanics =====
const stats = { coins:0, dash:0, maxCombo:1, shield:0, score:0 };
function reset(){ state.running=false; state.over=false; state.t=0; state.score=0; state.stage=1; stageTime=0; nextBossAt=28; state.speed=state.baseSpeed*opts.speedMul; state.combo=1; state.comboTime=0; state.slowed=0; state.boss=false; fx.shakeTime=0; fx.shakeAmp=0; boss=null;
obstacles.length=0; coins.length=0; particles.length=0; lasers.length=0; powerups.length=0; bullets.length=0; pbullets.length=0;
player.x=state.worldW/2; player.alive=true; player.flash=0; player.shield=0; player.magnet=0; player.dashCD=Math.max(0,2.6 - uLevel('dashCD')*0.4); player.dashT=0; player.extra = uLevel('extraLife'); player.fireCD=0;
if(uLevel('startShield')>0) player.shield = 0.8 + 0.4*uLevel('startShield');
spawnBlockRow(0); updateUI(); }
function start(){ state.running=true; hide(startOverlay); hide(gameoverOverlay); hide(bossOverlay); AudioKit.tick(); }
function gameOver(){ state.running=false; state.over=true; player.alive=false; AudioKit.hit(); state.best=Math.max(state.best, Math.floor(state.score)); storage.set(SAVE.best, state.best); const earned = Math.floor((state.score * (1 + 0.1*uLevel('scoreMul')))/100); addGems(earned); finalScore.textContent = Math.floor(state.score); finalBest.textContent = state.best; earnedEl.textContent = earned; finalMissions.textContent = summarizeMissions(); show(gameoverOverlay); updateUI(); }
function updateUI(){ scoreEl.textContent = Math.floor(state.score); bestEl.textContent = state.best; comboEl.textContent = 'x'+state.combo.toFixed(1); badgesEl.textContent = `⛨ ${Math.ceil(player.shield)} · 🧲 ${Math.ceil(player.magnet)} · ⏳ ${Math.ceil(state.slowed)}`; stageEl.textContent = state.stage; walletEl.textContent = wallet.gems; }
function addScore(v){ state.score += v * (1 + 0.1*uLevel('scoreMul')) * state.combo; stats.score = Math.floor(state.score); }
function addCombo(dt){ state.combo = clamp(state.combo + dt*0.05, 1, 5); state.comboTime = 1.8; stats.maxCombo = Math.max(stats.maxCombo, state.combo); }
function doDash(){ if(player.dashT>0 || player.dashCD>0) return; player.dashT=0.4; player.dashCD=Math.max(0.8, 3.0 - uLevel('dashCD')*0.4); stats.dash++; AudioKit.dash(); toast('Dash!'); fx.shakeTime=0.12; fx.shakeAmp=4; }
function applyPower(kind){ if(kind==='shield'){ player.shield = Math.max(player.shield, 1.5 + 0.2*uLevel('startShield')); stats.shield++; toast('Shield ⛨'); }
else if(kind==='magnet'){ player.magnet = Math.max(player.magnet, 4.5 + 0.5*uLevel('magnetDur')); toast('Magnet 🧲'); }
else if(kind==='slow'){ state.slowed = Math.max(state.slowed, 2.5); toast('Slow ⏳'); }
AudioKit.power(); }
function collideCircleRect(cx,cy,cr, r){ const tx=clamp(cx, r.x, r.x+r.w); const ty=clamp(cy, r.y, r.y+r.h); const dx=cx-tx, dy=cy-ty; return dx*dx+dy*dy <= cr*cr; }
function tryFire(){ if(player.fireCD>0) return; // fire 1~3 shots based on combo
const n = (state.combo>=3.5? 3 : (state.combo>=2.0? 2:1));
for(let i=0;i<n;i++){
const off = (n===1)?0:(i-(n-1)/2)*6; pbullets.push({x:player.x+off, y:player.y-player.r-2, r:3, vy:-380});
}
player.fireCD = Math.max(0.08, 0.22 - (state.combo-1)*0.02);
AudioKit.shoot();
}
function shootOnce(){ tryFire(); }
function update(dt){
state.t += dt; stageTime += dt; const speedMul = opts.speedMul * (state.slowed>0? 0.55:1); state.speed = clamp(120 + state.t*6, 120, 540) * speedMul; spawnInt = clamp(0.9 - state.t*0.02, 0.26, 0.9); laserInt = clamp(6.0 - state.t*0.01, 3.0, 6.0);
if(!state.boss && stageTime>=nextBossAt){ enterBoss(); }
lastSpawn += dt; if(lastSpawn>=spawnInt && !state.boss){ lastSpawn=0; spawnBlockRow(-20); }
lastLaser += dt; if(lastLaser>=laserInt && !state.boss){ lastLaser=0; spawnLaser(); }
// Player movement & actions
const dir = (right?1:0) - (left?1:0);
const skinCol = skins.find(s=>s.id===currentSkin)?.color || '#eaf2ff'; player.color = skinCol;
player.vx = dir * player.speed * (player.dashT>0? 1.6:1);
player.x = clamp(player.x + player.vx * dt, player.r+2, state.worldW - player.r-2);
if(dashReq){ doDash(); dashReq=false; }
if(player.dashT>0) player.dashT-=dt; if(player.dashCD>0) player.dashCD-=dt;
if(player.fireCD>0) player.fireCD-=dt; if(shootHold) tryFire();
// Stars
for(const s of stars){ s.y += (state.speed*0.2 + s.sp) * dt; if(s.y>state.worldH) { s.y -= state.worldH; s.x = rand(0,state.worldW);} }
// Entities movement
for(const o of obstacles){ o.y += state.speed * dt; o.x += (o.vx||0) * dt; if(o.x<0){ o.x=0; o.vx=Math.abs(o.vx||0);} if(o.x+o.w>state.worldW){ o.x=state.worldW-o.w; o.vx = -Math.abs(o.vx||0);} }
for(const c of coins){ c.y += (state.speed*0.95) * dt; const ax = (player.magnet>0? (player.x - c.x)*1.6 : 0); const ay = (player.magnet>0? (player.y - c.y)*1.6 : 0); c.x += ax*dt; c.y += ay*dt; }
for(const p of particles){ p.x += p.vx*dt; p.y += p.vy*dt; p.vy += 420*dt; p.life -= dt; }
for(const l of lasers){ l.x += l.vx*dt; l.life -= dt; }
for(const pb of pbullets){ pb.y += pb.vy*dt; }
if(state.boss){ updateBoss(dt); }
// Clean
while(obstacles.length && obstacles[0].y>state.worldH+40) obstacles.shift();
while(coins.length && coins[0].y>state.worldH+40) coins.shift();
for(let i=particles.length-1;i>=0;i--) if(particles[i].life<=0) particles.splice(i,1);
for(let i=lasers.length-1;i>=0;i--) if(lasers[i].life<=0 || lasers[i].x<-160 || lasers[i].x>state.worldW+160) lasers.splice(i,1);
for(let i=pbullets.length-1;i>=0;i--) if(pbullets[i].y<-30) pbullets.splice(i,1);
for(let i=powerups.length-1;i>=0;i--) if(powerups[i].y>state.worldH+40) powerups.splice(i,1);
for(const u of powerups){ u.y += state.speed*0.9*dt; }
// Collisions with hazards
let hit=false; if(!state.boss){ for(const o of obstacles){ if(collideCircleRect(player.x,player.y,player.r, o)) { hit=true; break; } } for(const l of lasers){ const r={x:l.x-4, y:l.y-2, w:l.w+8, h:l.h+4}; if(collideCircleRect(player.x,player.y,player.r, r)) { hit=true; break; } } }
if(hit && player.dashT<=0){ if(player.shield>0){ player.shield-=0.9; emit(player.x, player.y, 14, '#6ee7ff'); fx.shakeTime=0.18; fx.shakeAmp=6; } else if(player.extra>0){ player.extra--; toast('Extra Life!'); emit(player.x,player.y,12,'#86efac'); } else { player.flash=0.18; emit(player.x, player.y, 18, '#ff7777'); return gameOver(); } }
// coins
for(let i=coins.length-1;i>=0;i--){ const c=coins[i]; const dx=player.x-c.x, dy=player.y-c.y; if(dx*dx+dy*dy < (player.r+c.r)*(player.r+c.r)){ coins.splice(i,1); addScore(10); addCombo(0.25); stats.coins++; AudioKit.coin(); emit(c.x,c.y,6,'#ffd166'); if(state.boss && boss){ damageBoss(0.3); } } }
// powerups
for(let i=powerups.length-1;i>=0;i--){ const u=powerups[i]; const dx=player.x-u.x, dy=player.y-u.y; if(dx*dx+dy*dy < (player.r+u.r)*(player.r+u.r)){ powerups.splice(i,1); applyPower(u.kind); addScore(5); } }
// player bullets vs boss
if(boss){ for(let i=pbullets.length-1;i>=0;i--){ const pb=pbullets[i]; const dx=boss.x-pb.x, dy=boss.y-pb.y; if(dx*dx+dy*dy <= (boss.r+pb.r)*(boss.r+pb.r)){ pbullets.splice(i,1); damageBoss(1); addScore(2); } } }
// Effects timers
if(player.shield>0) player.shield=Math.max(0, player.shield-dt);
if(player.magnet>0) player.magnet=Math.max(0, player.magnet-dt);
if(state.slowed>0) state.slowed=Math.max(0, state.slowed-dt);
if(player.flash>0) player.flash=Math.max(0, player.flash-0.016);
if(state.comboTime>0){ state.comboTime-=dt; if(state.comboTime<=0) state.combo = Math.max(1, state.combo-0.1); }
// Score by time
addScore(dt*3); updateUI();
}
// ===== Rendering =====
function draw(){ const w=canvas.width/dpr, h=canvas.height/dpr; const sx = (fx.shakeTime>0 && opts.shake)? (rand(-fx.shakeAmp,fx.shakeAmp)) : 0; const sy = (fx.shakeTime>0 && opts.shake)? (rand(-fx.shakeAmp,fx.shakeAmp)) : 0; if(fx.shakeTime>0) fx.shakeTime -= 1/60; ctx.save(); ctx.clearRect(0,0,w,h); ctx.translate(sx, sy);
const obCol = opts.colorblind? 'rgba(255,255,255,.9)': 'rgba(255,255,255,.14)';
ctx.save(); ctx.globalAlpha=0.9; for(const s of stars){ ctx.fillStyle = `rgba(255,255,255,${0.2 + s.s*0.2})`; ctx.fillRect(s.x, s.y, s.s, s.s); } ctx.restore();
ctx.save(); ctx.globalAlpha=0.06; ctx.lineWidth=1; const grid=20; ctx.beginPath(); for(let x=0;x<w;x+=grid){ ctx.moveTo(x,0); ctx.lineTo(x,h);} for(let y=0;y<h;y+=grid){ ctx.moveTo(0,y); ctx.lineTo(w,y);} ctx.strokeStyle='white'; ctx.stroke(); ctx.restore();
// coins
ctx.save(); for(const c of coins){ ctx.beginPath(); ctx.arc(c.x, c.y, c.r, 0, Math.PI*2); ctx.fillStyle = opts.colorblind? '#ffbf00' : 'var(--gold)'; ctx.fill(); ctx.lineWidth=1; ctx.strokeStyle='rgba(0,0,0,.25)'; ctx.stroke(); } ctx.restore();
// powerups
ctx.save(); for(const u of powerups){ ctx.beginPath(); ctx.arc(u.x, u.y, u.r, 0, Math.PI*2); ctx.fillStyle = u.kind==='shield'? '#6ee7ff' : (u.kind==='magnet'? '#9bffb7' : '#c4a7ff'); ctx.fill(); ctx.strokeStyle='rgba(0,0,0,.3)'; ctx.stroke(); ctx.font='10px system-ui'; ctx.fillStyle='#001'; const sym = u.kind==='shield'? '⛨' : (u.kind==='magnet'? '🧲' : '⏳'); ctx.fillText(sym, u.x-6, u.y+3); } ctx.restore();
// obstacles & lasers (no boss phase)
if(!state.boss){ ctx.save(); ctx.fillStyle=obCol; for(const o of obstacles){ ctx.fillRect(o.x, o.y, o.w, o.h); } ctx.restore(); ctx.save(); for(const l of lasers){ const grad=ctx.createLinearGradient(l.x, l.y, l.x+l.w, l.y+l.h); grad.addColorStop(0,'rgba(255,90,90,.85)'); grad.addColorStop(1,'rgba(255,160,160,.5)'); ctx.fillStyle=grad; ctx.fillRect(l.x, l.y, l.w, l.h); } ctx.restore(); }
// boss
if(state.boss && boss){ ctx.save(); const g=ctx.createRadialGradient(boss.x-6,boss.y-6,4, boss.x,boss.y,boss.r+6); g.addColorStop(0,'#fff'); g.addColorStop(1,'#c4a7ff'); ctx.fillStyle=g; ctx.beginPath(); ctx.arc(boss.x,boss.y,boss.r,0,Math.PI*2); ctx.fill(); ctx.fillStyle='rgba(255,255,255,.8)'; ctx.fillRect(boss.x-24,boss.y-boss.r-16,48,6); ctx.fillStyle='#ff6bcb'; const hpw = clamp((boss.hp/(6+state.stage*2))*48,0,48); ctx.fillRect(boss.x-24,boss.y-boss.r-16,hpw,6); ctx.restore(); ctx.save(); ctx.fillStyle='#ff9d9d'; for(const b of bullets){ ctx.beginPath(); ctx.arc(b.x,b.y,b.r,0,Math.PI*2); ctx.fill(); } ctx.restore(); }
// player bullets
ctx.save(); ctx.fillStyle='#aee3ff'; for(const pb of pbullets){ ctx.beginPath(); ctx.arc(pb.x,pb.y,pb.r,0,Math.PI*2); ctx.fill(); } ctx.restore();
// player
ctx.save(); if(player.flash>0){ ctx.shadowColor=getCSS('--danger', '#ff6b6b'); ctx.shadowBlur=18; }
ctx.beginPath(); ctx.arc(player.x, player.y, player.r, 0, Math.PI*2); const grad=ctx.createRadialGradient(player.x-4,player.y-6,4, player.x,player.y, player.r+6); grad.addColorStop(0, '#ffffff'); grad.addColorStop(1, player.color||'#7ee0ff'); ctx.fillStyle=grad; ctx.fill(); if(player.shield>0){ ctx.globalAlpha=0.25+0.15*Math.sin(performance.now()/120); ctx.beginPath(); ctx.arc(player.x, player.y, player.r+6, 0, Math.PI*2); ctx.strokeStyle='#8ae9ff'; ctx.lineWidth=3; ctx.stroke(); ctx.globalAlpha=1; } if(player.dashT>0){ ctx.globalAlpha=0.5; ctx.beginPath(); ctx.arc(player.x - 10, player.y, player.r*0.9, 0, Math.PI*2); ctx.fillStyle='#bde3ff'; ctx.fill(); ctx.globalAlpha=1; } ctx.restore();
// particles
ctx.save(); for(const p of particles){ ctx.globalAlpha = clamp(p.life,0,1); ctx.fillStyle=p.col||'#a8ffce'; ctx.fillRect(p.x, p.y, 2,2); } ctx.restore();
ctx.restore();
}
function getCSS(name, fallback){ return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback; }
// ===== UI wires =====
const startOverlay=document.getElementById('overlayStart'); const gameoverOverlay=document.getElementById('overlayGameOver'); const bossOverlay=document.getElementById('overlayBoss');
const scoreEl=document.getElementById('score'); const bestEl=document.getElementById('best'); const comboEl=document.getElementById('combo'); const stageEl=document.getElementById('stage'); const badgesEl=document.getElementById('badges');
const btnStart=document.getElementById('btnStart'); const btnRetry=document.getElementById('btnRetry'); const btnHome=document.getElementById('btnHome'); const btnPause=document.getElementById('btnPause'); const btnSound=document.getElementById('btnSound'); const btnSettings=document.getElementById('btnSettings'); const btnShop=document.getElementById('btnShop'); const btnSkins=document.getElementById('btnSkins'); const btnBossGo=document.getElementById('btnBossGo');
const dlgSettings=document.getElementById('dlgSettings'); const rangeSpeed=document.getElementById('rangeSpeed'); const chkShake=document.getElementById('chkShake'); const chkCB=document.getElementById('chkCB'); const chkEco=document.getElementById('chkEco'); const chkHints=document.getElementById('chkHints'); const missionsEl=document.getElementById('missions'); const walletEl=document.getElementById('wallet');
const finalScore=document.getElementById('finalScore'); const finalBest=document.getElementById('finalBest'); const earnedEl=document.getElementById('earned'); const finalMissions=document.getElementById('finalMissions');
function show(el){ el.style.display='grid'; } function hide(el){ el.style.display='none'; }
btnStart.addEventListener('click',()=>{ start(); AudioKit.enable(); }); btnRetry.addEventListener('click',()=>{ reset(); start(); }); btnHome.addEventListener('click',()=>{ reset(); show(startOverlay); });
btnPause.addEventListener('click',()=>{ if(!state.running) resume(); else togglePause(); }); btnSound.addEventListener('click',()=>{ const on = AudioKit.toggle(); btnSound.textContent = on ? '🔊' : '🔇'; if(on) AudioKit.tick(); });
btnSettings.addEventListener('click',()=>{ rangeSpeed.value=opts.speedMul; chkShake.checked=opts.shake; chkCB.checked=opts.colorblind; chkEco.checked=opts.eco; chkHints.checked=opts.hints; dlgSettings.showModal(); });
document.getElementById('btnSaveSettings').addEventListener('click',(e)=>{ e.preventDefault(); opts.speedMul=parseFloat(rangeSpeed.value); opts.shake=chkShake.checked; opts.colorblind=chkCB.checked; opts.eco=chkEco.checked; opts.hints=chkHints.checked; storage.set(SAVE.opts, opts); dlgSettings.close(); toast('設定を保存しました'); });
// Shop & Skins
const dlgShop=document.getElementById('dlgShop'); const shopList=document.getElementById('shopList');
function buildShop(){ shopList.innerHTML=''; const items=[
{key:'startShield', name:'開始時シールド', desc:'+0.4〜のシールドを付与', base:40, max:3},
{key:'magnetDur', name:'マグネット延長', desc:'+0.5s/レベル', base:30, max:5},
{key:'dashCD', name:'ダッシュCD短縮', desc:'-0.4s/レベル', base:45, max:4},
{key:'scoreMul', name:'スコア倍率', desc:'+10%/レベル', base:60, max:5},
{key:'extraLife', name:'エクストラライフ', desc:'1回だけミスを無効化', base:120, max:1},
]; items.forEach(it=>{ const lv=uLevel(it.key); const cost = Math.floor(it.base * Math.pow(1.6, lv)); const can = lv<it.max && wallet.gems>=cost; const row=document.createElement('div'); row.className='shop-item'; row.innerHTML=`<div><strong>${it.name}</strong> <span class="chip">Lv.${lv}/${it.max}</span><div class="muted" style="font-size:.85rem">${it.desc}</div></div><button class="btn ${can?'primary':''}" ${can?'':'disabled'}>${lv>=it.max?'MAX':`購入 💎${cost}`}</button>`; row.querySelector('button').onclick=()=>{ if(lv>=it.max) return; if(wallet.gems<cost){ toast('💎不足'); return; } addGems(-cost); upgrades[it.key]=(upgrades[it.key]||0)+1; saveUpgrades(); toast(`${it.name} Lv.${upgrades[it.key]}`); }; shopList.appendChild(row); }); }
const dlgSkins=document.getElementById('dlgSkins'); const skinList=document.getElementById('skinList');
function buildSkins(){ skinList.innerHTML=''; skins.forEach(s=>{ const owned = (s.cost===0) || storage.get('skin-'+s.id, false); const can = wallet.gems>=s.cost && !owned; const row=document.createElement('div'); row.className='shop-item'; row.innerHTML=`<div><strong>${s.name}</strong> <span class="chip" style="background:${s.color}; color:#000">●</span> ${s.cost?`<span class='muted'>/ 💎${s.cost}</span>`:'<span class="muted">/ Free</span>'}</div><div><button class="btn ${owned?'':'primary'}" data-id="${s.id}">${owned?(currentSkin===s.id?'使用中':'使用'):('購入')}</button></div>`; row.querySelector('button').onclick=()=>{ if(!owned){ if(wallet.gems<s.cost){ toast('💎不足'); return; } addGems(-s.cost); storage.set('skin-'+s.id,true); } currentSkin=s.id; storage.set(SAVE.skin, currentSkin); buildSkins(); toast(`${s.name} を装備`); }; skinList.appendChild(row); }); }
btnShop.addEventListener('click',()=>{ buildShop(); dlgShop.showModal(); }); btnSkins.addEventListener('click',()=>{ buildSkins(); dlgSkins.showModal(); }); btnBossGo.addEventListener('click',()=>{ startBoss(); });
function togglePause(){ if(!state.running || state.over) return; state.running=false; btnPause.textContent='▶'; toast('Pause'); }
function resume(){ if(state.over) return; state.running=true; btnPause.textContent='⏸'; toast('Resume'); }
// Missions
function generateMissions(){ const pool=[
{id:'c80', text:'コインを80枚集める', test: s=>s.coins>=80},
{id:'dash4', text:'1プレイでダッシュを4回', test: s=>s.dash>=4},
{id:'combo35', text:'コンボ倍率3.5達成', test: s=>s.maxCombo>=3.5},
{id:'shield', text:'シールド取得', test: s=>s.shield>0},
{id:'score1200', text:'スコア1200到達', test: s=>s.score>=1200},
]; const chosen=[]; while(chosen.length<3){ const m=choice(pool); if(!chosen.find(c=>c.id===m.id)) chosen.push(m);} return chosen; }
let missions = storage.get(SAVE.missions, null); if(!missions){ missions=generateMissions(); storage.set(SAVE.missions, missions);} missionsEl.innerHTML = '<strong>本日のミッション</strong><br>• '+missions.map(m=>m.text).join('<br>• ');
function summarizeMissions(){ const done = missions.filter(m=>m.test(stats)).map(m=>m.text); return (done.length? done.join(' / ') : 'なし'); }
// Init
state.best = storage.get(SAVE.best, 0); walletEl.textContent=wallet.gems; updateUI(); reset(); show(startOverlay);
</script>
</body>
</html>
『杏アフター / Kyou After』企画書 v1.0
『杏アフター / Kyou After』企画書 v1.0
非公式・二次創作企画書(個人/同人向け)
0. 企画概要(Executive Summary)
- 作品種別:ビジュアルノベル(全年齢・日本語)※将来的なOVA/ドラマCD展開も想定
- 題材:『CLANNAD』(Key/VisualArt’s)世界観の二次創作。藤林 杏 を主軸とした後日譚。
- 開発規模:小〜中規模(スクリプト総量 約10〜14万字 / 本編 6〜8章 + 分岐エンディング3種)
- ターゲット:
- 原作・関連作ファン(特に杏・椋・春原・智代ラインのファン)
- 青春群像・学園→社会移行期ドラマを好む層
- ノスタルジックな地方都市×等身大ロマンスを求める層
- テーマ:
- 「選択のつづき」 —— 卒業後、私たちは何を選び直すのか
- 「家族になること」 —— 恋人から“家族”へ変わる責任と喜び
- 「さよならの効用」 —— 過去への区切りが未来をひらく
- トーン/キーワード:
- ほろ苦い甘さ / 風通しのよい日常 / 初夏の光 / 夕立 / 教室 / 自転車 / 小さな嘘と大きな誠実
1. 権利・ガイドライン
- 本企画は非公式の二次創作です。公式画像・音源・シナリオの転用は行いません。
- 名称・地名等は一般名詞化/迂回表現を基本とし、固有資産の誤使用を回避します。
- 配布・収益化は各権利者の二次創作ガイドラインに準拠(イベント頒布/少数DL販売を想定)。
- クレジット表記にて、原作権利元へのリスペクトを明記。
※正式頒布前に最新版ガイドライン確認・必要なら問い合わせ。
2. 企画目的
- 杏メインの“その後”を丁寧に描き、彼女が大人になる過程とふたりが家族になる瞬間をドラマの核に据える。
- シリアス偏重に寄らず、日常のユーモア(小気味よい台詞)と手触りのある生活感を両立。
- 将来のメディア展開(朗読・ドラマCD・動画化)に耐える章立て構造と演出設計を備える。
3. ストーリーライン
3.1 ログライン
教員を目指す杏と、働き始めた“あなた”(※デフォルト名は設定可能)。 卒業と進路がふたりの距離を少しずつ変えていくなか、過去に置き去りの“選ばれなかった想い”と向き合い、 「家族になる」覚悟を選び直すまでの初夏の物語。
3.2 あらすじ(約300字)
地方都市の春。教育実習を控えた杏は、忙しさを言い訳に“将来”の話を避けがちだ。働き始めたあなたは、 不規則な勤務と責任の重さに揉まれ、すれ違う時間が増えていく。そんな折、杏の前に“教室を嫌う”生徒・澪(みお)が現れ、 杏は初めて「先生」としての壁にぶつかる。過去に置いてきた姉妹の距離、友人たちの旅立ち、雨の日のすれ違い—— 積み重ねた日常はやがて、ひとつの問いに収束する。「わたしたち、家族になる?」
3.3 章立て(想定:全8章 + 分岐ED)
- 春、選択の延長線:新生活のリズム / 小さな嘘 / 指切り
- 教育実習の教室:問題児・澪との出会い / 杏の“先生”としての初試練
- 姉妹の距離:椋の近況と“選ばれなかった側”の痛み / 夕立
- 働くという現実:疲労と責任 / それでも笑わせてくれる人
- 雨の告白:本音の衝突 / 「守りたいのはあなたの強がり」
- 卒業式の教壇:澪の決断 / 杏、教師の覚悟
- 未来の設計図:家族の輪郭 / 指輪と自転車の坂道
- エピローグ:初夏の風 / 新しい鍵(True/Good/Another End)
3.4 分岐・エンディング設計
- True End:澪の背中を押した杏が、自分の背中も押す。夕暮れの坂道で“家族の宣言”。
- Good End:互いの課題を持ち帰り、約束は「もう少し先へ」。静かな希望の灯り。
- Another End:椋が自分の道を歩み出すなか、ふたりは“大切な友だちのまま”。痛みと納得の着地。
4. キャラクター
4.1 藤林 杏(ふじばやし きょう)
- 属性:快活 / 面倒見 / 短気は愛の裏返し / 不器用な優しさ
- 目標:良い先生になる。恋人を“家族”にする覚悟を固める。
- 弱点:強がりが過ぎて、頼ることが苦手。
- 成長:生徒と向き合うことで、頼る・託す勇気を学ぶ。
4.2 あなた(主人公)
- 属性:働き始めの若手 / 体力と責任の板挟み
- 目標:仕事を一人前に。杏と“同じ未来”を見られるようになる。
- 弱点:疲労で言葉が遅れ、誤解を呼びがち。
4.3 藤林 椋(りょう)
- 属性:優しさと決意 / 自分の幸せを選び直す人
- 役割:過去の痛みを整理する“鏡”。杏の成長を促す。
4.4 澪(みお・新規)
- 属性:教室嫌い / 視線恐怖 / 文学好き
- 役割:杏を“先生”にする存在。終盤、教壇の前で小さく頷く。
4.5 友人たち(春原、智代 ほか)
- 機能:軽妙な掛け合い / シリアス緩和 / 人生の先輩の言葉
5. 世界観・舞台
- 地方都市(坂道と並木、川沿いのベンチ、商店街、古い団地)。
- 教室/職員室の生活音、雨上がりのアスファルト、夕暮れの自転車。
- 小動物モチーフ(“いきもの”を大切にする象徴。新規マスコット:**子犬『ポタン』**等の遊び心)
6. 演出・美術・音響
- 美術:透明感のある初夏色。コントラスト低め、空気遠近感重視。
- UI:手書き風トーン + 余白多め。章扉は季節のアイコン(鈴蘭/紫陽花/入道雲)。
- 音響:ピアノ+アコギ中心。日常は3和音モチーフ、雨は単音反復で不安を示唆。
- 演出:
- 雨滴→傘に乗るSE→台詞の“間”。
- 黒フラッシュではなく白フェードで前向きな余韻。
7. ゲーム設計
- プレイ時間:本編6〜8時間 + ED分岐リプレイ
- 選択肢:各章2〜4箇所。好感度(杏/自分の成熟/他者への誠実)3軸で内部判定。
- 回想/ギャラリー:事件順/章順 切替、サムネ自動生成。
- ミニ演出:
- 「メッセージカード」コンポーネント(杏の書置きが日替わりで変化)
- 「雨の日UI」:ガラスの雨筋エフェクト(負荷軽)
8. シナリオ詳細(ダイジェスト)
- 第1章:新生活の慌ただしさ。約束を“明日に回す”二人。
- 第2章:澪と教壇。杏、初めて“言葉が届かない”を知る。
- 第3章:椋の来訪。喪失の痛みは、誠実さでしか癒やせない。
- 第4章:あなたの疲労と欠勤。杏の“頼れない癖”が噴出。
- 第5章:雨の口論→本音の露出→“守りたいのは強がりではなく、あなた自身”。
- 第6章:卒業式。澪の一歩。杏、先生としての第一歩。
- 第7章:未来の設計図を並べる夜。指輪ではなく鍵を渡す演出案。
- 最終章:初夏。坂道の自転車二人乗り(想像の演出)。
9. アセット計画
- 立ち絵:
- 杏:8表情×2衣装(私服/実習用)+ 小物(髪留め、エプロン)
- 主人公:シルエット/腕のみ演出(能動性を下げない)
- 椋:6表情×1衣装
- 澪:6表情×制服/私服
- 背景:教室/廊下/川沿い/商店街/部屋/バス停/坂道/夕焼け屋上 ほか(計14〜18)
- CG:計10〜12枚(雨の口論、手を取る、教壇の前、鍵の受け渡し、夕暮れの坂道など)
- BGM:12〜15曲(テーマ/日常/雨/夜/決意/エピローグ)
- SE:環境音重視(雨/風/チャイム/制服の擦れ/シャープペン)
10. 技術
- エンジン:Ren’Py / 吉里吉里Z / Unity(いずれか)
- 対応:Windows/Mac(日本語フォント同梱ライセンス確認)
- スクリプト構造:章ごとにモジュール化、フラグ管理テーブルを別定義。
- ビルド:CIで自動パッケージング(Win/Mac)・バージョン刻印。
11. スケジュール(目安:24週)
- プリプロ(4週):世界観確定/章割/フロー/試作UI
- シナリオ(8週):初稿→中稿→最終稿(同時進行でボイス想定台本)
- アート(8週):立ち絵→背景→CG→色校
- 実装(6週):演出/スクリプト/分岐/セーブ/ギャラリー
- QA(2週):誤字修正/分岐網羅/体験版切り出し
※並行最適化で全体24週。小規模なら18〜20週圧縮可。
12. 体制・役割
- 企画・脚本:1
- アート(原画/背景/彩色):2〜3
- サウンド:1
- 実装:1
- QA:1
13. 予算(概算・同人規模)
- シナリオ(14万字×@2円)…… 28万円
- 立ち絵/CG/背景 合計………… 60〜90万円(規模により変動)
- BGM/SE…………………………… 15〜25万円
- 実装/演出………………………… 20〜35万円
- QA/雑費…………………………… 5〜10万円
- 計…………………………… 128〜188万円 目安
14. リスク & 回避
- 権利トラブル:ガイドライン順守、名称/意匠の自作・差し替え徹底。
- スケジュール遅延:章ごとマイルストーン・毎週デイリースナップショット。
- 表現の齟齬:ファンレビュー(クローズド)2回実施。
15. プロモーション
- 体験版(第1章まるごと):頒布1ヶ月前公開。
- PV:30秒/90秒(テキストアニメ+BGM)
- SNS運用:制作ノート/ラフ公開/開発ログ(毎週)
- 頒布:イベント(コミティア等)/ BOOTH / itch.io / DLsite(ガイドライン適合時)
16. 成功指標(KPI)
- 体験版DL数 1,000 / 本編頒布数 500〜1,500
- SNSフォロー +1,000 / 口コミ評価★4.5以上
17. 次アクション(To‑Do)
付録A:吉里吉里Z / Ren’Py 想定仕様メモ
- 既読スキップ/選択肢ジャンプ/バックログ/環境設定(フォント/字幕速度/音量)
- スクショ配慮:ロゴ透かし・章名オーバーレイ
- セーブ互換:章頭オートセーブ + 手動10スロット
付録B:CGリスト(案)
- 朝の通学路で小走りの杏(序盤)
- 教壇に立つ杏(実習日)
- 雨の口論(傘の下)
- 椋と杏、夕立後の並木道
- 澪が黒板に向き合う瞬間
- 鍵の受け渡し(夜、部屋の灯り)
- 自転車の二人(夕焼けの坂)
- エピローグの窓辺(初夏)
VRMMORPGβ版
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>エルダークロニクル:VR超完全版 – ロードなし(拾う/装備修正)</title>
<script src="https://aframe.io/releases/1.4.2/aframe.min.js"></script>
<style>
body, html { margin:0; padding:0; overflow:hidden; font-family:'Yu Gothic',system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif; background:#000;}
#ui {
position:absolute; top:20px; left:20px; z-index:10; width:400px; color:#fff;
background:linear-gradient(180deg,rgba(0,0,0,.92),rgba(0,10,30,.85));
border-radius:16px; padding:14px 16px; box-shadow:0 0 25px #0ff,0 0 18px #33f4; font-size:15px;
}
h2 { margin:0 0 8px 0; font-size:22px; letter-spacing:1px;}
.row { display:flex; gap:8px; align-items:center; flex-wrap:wrap;}
.row > * { flex: 1 1 auto; }
input, select { width:100%; padding:6px 8px; border-radius:8px; border:1px solid #024; background:#00131f; color:#cfe9ff; }
button {
background:linear-gradient(90deg,#555,#222 80%);
color:#fff; padding:10px; margin:5px 0; border:none; cursor:pointer; width:100%;
border-radius:8px; font-weight:bold; letter-spacing:1px; box-shadow:0 1px 8px #0cf5; transition:background .25s;
}
button:hover { background:linear-gradient(90deg,#888 40%,#3cf); }
.section-title { font-weight:bold; font-size:17px; margin-top:10px; border-bottom:2px solid #5ef; letter-spacing:1.2px; }
.bar { height:14px; background:#13202a; border-radius:7px; overflow:hidden; margin:6px 0; box-shadow:0 1px 6px #0ff6 inset; }
.bar-inner { height:100%; background:linear-gradient(90deg,#0f0,#3af); transition:width .25s; }
.mana-bar .bar-inner { background:linear-gradient(90deg,#33d,#6ff); }
.enemyhp-bar .bar-inner { background:linear-gradient(90deg,#f55,#fdd); }
#scenario { margin:10px 0 6px 0; background:rgba(0,10,32,0.7); padding:8px 12px; border-radius:8px; min-height:44px; }
.kbd { display:inline-block; padding:1px 6px; border-radius:6px; background:#0a2636; border:1px solid #124; font-family:monospace; }
#hint {
position:absolute; bottom:18px; left:50%; transform:translateX(-50%);
color:#eaffff; background:rgba(0,20,35,.72); border:1px solid #0af; padding:8px 12px; border-radius:10px;
box-shadow:0 0 18px #08f5; font-size:14px; z-index:10;
}
#pickupPrompt {
position:absolute; bottom:70px; left:50%; transform:translateX(-50%);
color:#fff; background:rgba(0,0,0,.7); border:1px solid #38f; padding:8px 12px; border-radius:10px;
display:none; z-index:10;
}
.tag { display:inline-block; padding:2px 6px; border-radius:6px; background:#012233; border:1px solid #1a4e6c; margin-left:6px; font-size:12px; color:#bfe6ff;}
.on { background:#0b3754; border-color:#3db3ff; color:#fff; }
</style>
</head>
<body>
<div id="ui">
<h2>エルダークロニクルVR</h2>
<div class="row">
<span>プレイヤー名:</span>
<input id="playerName" placeholder="名前を入力">
</div>
<div class="row"><span>レベル:</span><span id="level" class="tag on">1</span>
<span>装備:</span><span id="equipment" class="tag">なし</span>
</div>
<div>HP: <span id="hpText">100</span></div>
<div class="bar"><div id="hpBar" class="bar-inner" style="width:100%"></div></div>
<div>魔力: <span id="manaText">100</span></div>
<div class="bar mana-bar"><div id="manaBar" class="bar-inner" style="width:100%"></div></div>
<div>敵HP: <span id="enemyHpText">120</span></div>
<div class="bar enemyhp-bar"><div id="enemyHpBar" class="bar-inner" style="width:100%"></div></div>
<div class="section-title">シナリオ</div>
<div id="scenario"></div>
<div class="section-title">行動</div>
<button onclick="levelUp()">🎉 レベルアップ</button>
<button onclick="castSpell()">🪄 魔法発動</button>
<button onclick="choosePath('wizard')">🧙♂️ 魔導士に話す</button>
<button onclick="choosePath('knight')">🛡️ 騎士に話す</button>
<button onclick="receiveQuest()">📜 クエスト受注</button>
<button onclick="toggleEnvironment()">🌄 昼夜切替</button>
<div class="section-title">移動</div>
<div class="row">
<button onclick="changeField('town')">🏘️ 街</button>
<button onclick="changeField('castle')">🏰 城</button>
</div>
<div class="row">
<button onclick="changeField('cave')">🕳️ 洞窟</button>
<button onclick="changeField('ruins')">🏛️ 遺跡</button>
</div>
<div class="row">
<button onclick="changeField('dungeon')">🧩 ダンジョン</button>
</div>
<div class="section-title">操作</div>
<div style="line-height:1.6">
<span class="kbd">WASD</span> 移動
<span class="kbd">マウス</span> 視点
<span class="kbd">E</span> 拾う
<span class="kbd">1</span> 剣装備
<span class="kbd">2</span> 銃装備
<span class="kbd">クリック</span> 攻撃
</div>
</div>
<div id="pickupPrompt">Eで拾う</div>
<div id="hint">近くの武器に近づいて <span class="kbd">E</span> で拾い、<span class="kbd">1/2</span> で装備、クリックで攻撃!</div>
<a-scene loading-screen="enabled:false" renderer="colorManagement:true" shadow="true">
<a-sky id="sky" color="#0e163e"></a-sky>
<a-entity id="starParticles" position="0 25 -40" visible="false">
<a-entity geometry="primitive:sphere; radius:0.18" material="color:#fff; opacity:0.6" position="-10 3 0"></a-entity>
<a-entity geometry="primitive:sphere; radius:0.12" material="color:#fff; opacity:0.8" position="7 2 -2"></a-entity>
<a-entity geometry="primitive:sphere; radius:0.11" material="color:#eaf6ff; opacity:0.9" position="3 5 2"></a-entity>
<a-entity geometry="primitive:sphere; radius:0.10" material="color:#ffe; opacity:0.7" position="13 3 5"></a-entity>
</a-entity>
<a-entity id="cloudParticles" position="0 30 -35" visible="true">
<a-sphere radius="5" position="8 2 -8" color="#f6fbff" opacity="0.18"></a-sphere>
<a-sphere radius="6" position="-7 3 5" color="#eefbff" opacity="0.14"></a-sphere>
</a-entity>
<a-light type="ambient" color="#fff" intensity="1"></a-light>
<a-light id="sunlight" type="directional" intensity="1.6" position="20 25 -8" castShadow="true" shadow-mapWidth="1024" shadow-mapHeight="1024"></a-light>
<a-light type="point" color="#cff" intensity="2.2" distance="50" position="2 9 -3"></a-light>
<a-light type="spot" color="#55aaff" position="0 15 -10" intensity="1.2" angle="30" penumbra="0.7"></a-light>
<a-plane id="ground" position="0 0 0" rotation="-90 0 0" width="120" height="120" color="#375047" shadow="receive:true"></a-plane>
<a-entity id="field-town" visible="true">
<a-circle position="0 0 -6" radius="4" color="#7d7d7d" material="roughness:.9; metalness:.05; opacity:.84; transparent:true"></a-circle>
<a-entity position="-2 0 -7">
<a-box width="3.6" height="2.6" depth="2.4" color="#c0a47b" material="roughness:0.3; metalness:0.05" position="0 1.3 0"></a-box>
<a-cone position="0 3 -0.1" radius-bottom="2.1" height="1.4" color="#7b5322"></a-cone>
<a-text value="街の家" position="0 3.7 0" color="#fff" width="5" align="center"></a-text>
</a-entity>
<a-entity position="2 0 -9">
<a-box width="2.2" height="2.2" depth="2.4" color="#a86f23" position="0 1.1 0"></a-box>
<a-cone position="0 2.6 0" radius-bottom="1.4" height="1.0" color="#5c3a12"></a-cone>
</a-entity>
<a-entity position="5 0 -5">
<a-cylinder radius="0.25" height="4" color="#3a2a1a" position="0 2 0"></a-cylinder>
<a-sphere radius="1.2" color="#174d1f" position="0 3.2 0"></a-sphere>
</a-entity>
</a-entity>
<a-entity id="field-castle" visible="false">
<a-box position="0 2.8 -12" depth="6" height="6" width="10" color="#ccd2df" material="roughness:0.25; metalness:0.12"></a-box>
<a-cylinder position="-4 1.2 -13" radius="1.1" height="3.8" color="#858ca3"></a-cylinder>
<a-cylinder position="4 1.2 -13" radius="1.1" height="3.8" color="#858ca3"></a-cylinder>
<a-cone position="0 6.4 -12" radius-bottom="3.2" height="2" color="#dba"></a-cone>
<a-entity geometry="primitive:torus; radius:2.5; tube:0.07" material="color:#66aaff; opacity:.25; transparent:true" position="0 4.5 -12"></a-entity>
<a-text value="王の城" position="0 6.3 -12" color="#0bf" width="6" align="center"></a-text>
</a-entity>
<a-entity id="field-cave" visible="false">
<a-torus position="2 1.1 -10" radius="1.8" tube="0.8" arc="230" color="#363636" rotation="40 0 90"></a-torus>
<a-sphere position="2 0.4 -10" radius="1.1" color="#151515"></a-sphere>
<a-entity position="-2 0 -8">
<a-cone radius-bottom="0.8" height="2.2" color="#2a2a2a"></a-cone>
<a-cone radius-bottom="0.5" height="1.4" color="#393939" position="0.7 0 0.4"></a-cone>
</a-entity>
<a-text value="洞窟の入口" position="2 2.9 -10" color="#fff" width="6" align="center"></a-text>
</a-entity>
<a-entity id="field-ruins" visible="false">
<a-cylinder position="-2 1.1 -9" radius="0.8" height="2.6" color="#babbb2"></a-cylinder>
<a-box position="-3.1 1.8 -9" width="3.5" height="0.32" depth="0.7" color="#e0dfc7"></a-box>
<a-torus position="-2 2.3 -9" radius="0.6" tube="0.09" arc="340" color="#fffeee"></a-torus>
<a-entity geometry="primitive:torusKnot; p:2; q:7; radius:0.7; tube:0.07" position="2 1.8 -8" material="color:#bcd; opacity:.7; transparent:true"></a-entity>
<a-text value="古代の遺構" position="0 3.3 -8.5" color="#fff" width="6" align="center"></a-text>
</a-entity>
<a-entity id="field-dungeon" visible="false">
<a-box position="2 1.2 -8.8" depth="3.3" height="2.3" width="3.6" color="#161651"></a-box>
<a-torus-knot position="-1.5 2.7 -8.2" radius="0.9" tube="0.12" p="3" q="7" color="#64eaff"></a-torus-knot>
<a-cylinder position="-3 0.7 -9.6" radius="0.8" height="1.2" color="#333"></a-cylinder>
<a-entity geometry="primitive:torus; radius:1.4; tube:0.05" material="color:#aaeeff; opacity:.18; transparent:true" position="0 2.1 -8.6" rotation="90 0 0"></a-entity>
<a-text value="ダンジョン入口" position="0 3.1 -8.4" color="#fff" width="6" align="center"></a-text>
</a-entity>
<!-- 武器ピックアップ -->
<a-entity id="swordPickup" class="pickup" data-weapon="sword" position="-1 0 -5">
<a-box width="0.16" height="1.3" depth="0.08" color="#cfe7ff" material="metalness:0.8; roughness:0.15" position="0 0.75 0"></a-box>
<a-box width="0.5" height="0.08" depth="0.08" color="#333" position="0 0.1 0"></a-box>
<a-cylinder radius="0.06" height="0.42" color="#7c5a2b" position="0 -0.1 0"></a-cylinder>
<a-entity geometry="primitive:ring; radiusInner:0.45; radiusOuter:0.5" rotation="-90 0 0" position="0 0 0" material="color:#44d; opacity:.35; transparent:true"></a-entity>
<a-text value="剣" color="#fff" position="0 1.7 0" align="center"></a-text>
</a-entity>
<a-entity id="gunPickup" class="pickup" data-weapon="gun" position="2 0 -5.5">
<a-box width="0.7" height="0.18" depth="0.18" color="#222" position="0 0.4 0"></a-box>
<a-box width="0.3" height="0.28" depth="0.18" color="#444" position="-0.2 0.2 0"></a-box>
<a-box width="0.12" height="0.40" depth="0.16" color="#333" position="0.2 0.15 0"></a-box>
<a-cylinder radius="0.06" height="0.28" color="#555" position="0.34 0.46 0" rotation="0 0 90"></a-cylinder>
<a-entity geometry="primitive:ring; radiusInner:0.45; radiusOuter:0.5" rotation="-90 0 0" position="0 0 0" material="color:#0aa; opacity:.35; transparent:true"></a-entity>
<a-text value="銃" color="#fff" position="0 1.0 0" align="center"></a-text>
</a-entity>
<!-- プレイヤー(リグが移動する:★修正) -->
<a-entity id="rig" position="0 0 0" wasd-controls="acceleration:30">
<a-entity id="cam" camera look-controls="pointerLockEnabled:true" position="0 1.6 3"></a-entity>
<a-entity id="player" position="0 0 -1.5" rotation="0 180 0" shadow="cast:true">
<a-sphere radius="0.18" color="#ffd7f0" position="0 1.58 0"></a-sphere>
<a-cylinder radius="0.23" height="0.9" color="#9ad" position="0 1.02 0"></a-cylinder>
<a-cylinder radius="0.08" height="0.55" color="#9ad" position="-0.16 0.74 0" rotation="0 0 18"></a-cylinder>
<a-cylinder radius="0.08" height="0.55" color="#9ad" position="0.16 0.74 0" rotation="0 0 -18"></a-cylinder>
<a-cylinder radius="0.09" height="0.7" color="#79b" position="-0.10 0.35 0"></a-cylinder>
<a-cylinder radius="0.09" height="0.7" color="#79b" position="0.10 0.35 0"></a-cylinder>
</a-entity>
</a-entity>
<!-- 敵 -->
<a-entity id="enemy" position="0 0 -9" visible="true">
<a-sphere id="enemyBody" radius="1.25" color="#9b1e1e" material="metalness:0.35; roughness:.25; emissive:#330000"></a-sphere>
<a-entity geometry="primitive:torus; radius:1.4; tube:0.05" material="color:#ffeeaa; opacity:.18; transparent:true" position="0 0.3 0" rotation="90 0 0"></a-entity>
<a-entity geometry="primitive:torusKnot; radius:0.45; tube:0.05; p:2; q:5" material="color:#ffa033; opacity:.22; transparent:true" position="0 1.0 0"></a-entity>
<a-sphere position="-0.38 0.42 0.95" radius="0.14" color="#fff" material="metalness:.9; roughness:.05; emissive:#a0f"></a-sphere>
<a-sphere position="0.38 0.42 0.95" radius="0.14" color="#fff" material="metalness:.9; roughness:.05; emissive:#a0f"></a-sphere>
<a-cone position="-0.45 1.35 0.15" radius-bottom="0.16" height="0.62" color="#f5f3ff" rotation="15 0 60"></a-cone>
<a-cone position="0.45 1.35 0.15" radius-bottom="0.16" height="0.62" color="#f5f3ff" rotation="15 0 -60"></a-cone>
<a-text value="敵モンスター" position="0 2.15 0" align="center" color="#fff"></a-text>
</a-entity>
<a-entity id="spellEffect" geometry="primitive:sphere; radius:0.55"
material="color:#72f3ff; opacity:.86; emissive:#88f; transparent:true"
position="0 1.7 -3" visible="false"
animation__move="property: position; to: 0 3.1 -6; dur:500; dir:alternate; loop:false">
<a-entity geometry="primitive:torus; radius:0.7; tube:0.08" material="color:#fff; opacity:.3; transparent:true"></a-entity>
</a-entity>
<a-entity id="game" game-manager></a-entity>
</a-scene>
<script>
let isDay=false, hp=100, mana=100, level=1;
let enemyHP=120, enemyHPMax=120;
function updateBars(){
document.getElementById("hpText").innerText = Math.max(0,Math.floor(hp));
document.getElementById("manaText").innerText = Math.max(0,Math.floor(mana));
document.getElementById("hpBar").style.width = Math.max(0,Math.min(100,hp))+"%";
document.getElementById("manaBar").style.width = Math.max(0,Math.min(100,mana))+"%";
document.getElementById("level").innerText = level;
document.getElementById("enemyHpText").innerText = Math.max(0,enemyHP);
const w = Math.max(0, Math.min(100, enemyHP*100/enemyHPMax));
document.getElementById("enemyHpBar").style.width = w+"%";
}
function levelUp(){ level++; hp=Math.min(100,hp+20); mana+=30; showScenario("🎉 レベルアップ!新しい力が湧いてくる!"); updateBars(); }
function castSpell(){
if(mana<20){ showScenario("💤 魔力が足りない!"); return; }
mana-=20; updateBars();
const effect=document.getElementById("spellEffect");
effect.setAttribute("visible","true");
setTimeout(()=>{ effect.setAttribute("visible","false"); applyDamageToEnemy(30,"魔法ヒット"); },700);
}
function choosePath(choice){
if(choice==="wizard"){ document.getElementById("equipment").innerText="魔導士のローブ"; mana+=50; showScenario("🧙♂️ 魔導士の試練が始まる…"); }
else{ document.getElementById("equipment").innerText="騎士の剣"; hp+=30; showScenario("⚔️ 騎士と共に魔王城へ向かう!"); }
updateBars();
}
function toggleEnvironment(){
const sky=document.getElementById("sky");
const star=document.getElementById("starParticles");
const cloud=document.getElementById("cloudParticles");
const ground=document.getElementById("ground");
const sunlight=document.getElementById("sunlight");
if(isDay){
sky.setAttribute("color","#0e163e"); star.setAttribute("visible","false"); cloud.setAttribute("visible","true");
ground.setAttribute("color","#375047"); sunlight.setAttribute("intensity","1.6"); showScenario("🌌 夜の世界へ…");
}else{
sky.setAttribute("color","#7ddfff"); star.setAttribute("visible","true"); cloud.setAttribute("visible","false");
ground.setAttribute("color","#b8ffcc"); sunlight.setAttribute("intensity","2.1"); showScenario("🌞 昼の世界へ…");
}
isDay=!isDay;
}
function changeField(fieldName){
const fields=['town','castle','cave','ruins','dungeon'];
fields.forEach(name=>{
const el=document.getElementById(`field-${name}`);
el.setAttribute('visible', name===fieldName);
});
showScenario(`📍 ${fieldName} に移動しました`);
}
function receiveQuest(){
const quests=[
"魔導士の塔で失われた書を探せ!","騎士団の旗を取り戻せ!","洞窟の奥に眠る魔石を発見せよ!",
"遺跡に隠された封印を解け!","ダンジョンの魔王を討伐せよ!"
];
const index=Math.floor(Math.random()*quests.length);
showScenario("📜 クエスト受注: "+quests[index]);
}
const scenarioList=[
"目覚めたあなたは不思議な世界にいた。","最初のクエストを受注しよう。","フィールド移動で冒険の扉が開く。",
"行動や魔法でストーリーが動く。","街で情報を集め、仲間と出会おう。","クエストを進め、魔王に立ち向かえ!"
];
let scenarioIndex=0;
function showScenario(text){ document.getElementById('scenario').innerHTML=text; }
function advanceScenario(){ if(scenarioIndex<scenarioList.length){ showScenario(scenarioList[scenarioIndex]); scenarioIndex++; } }
['levelUp','castSpell','choosePath','changeField','receiveQuest'].forEach(fn=>{
const orig=window[fn]; window[fn]=function(){ orig.apply(this, arguments); advanceScenario(); }
});
/* ---- ここが拾う/装備の中核 ---- */
const rigEl=()=>document.getElementById('rig');
const camEl=()=>document.getElementById('cam');
const enemyEl=()=>document.getElementById('enemy');
const enemyBodyEl=()=>document.getElementById('enemyBody');
let hasSword=false, hasGun=false, equipped='none';
let attackCooldown=false;
const bullets=[];
function setEquipmentLabel(){
let label="なし";
if(equipped==='sword') label="剣";
if(equipped==='gun') label="銃";
document.getElementById('equipment').innerText=label;
}
function tryPickup(){
const rpos=rigEl().object3D.position; // ★リグが移動するのでOK
const items=[document.getElementById('swordPickup'),document.getElementById('gunPickup')];
for(const it of items){
if(!it.getAttribute('visible')) continue;
const wpos=it.object3D.position;
const d=rpos.distanceTo(wpos);
if(d<2.0){
const w=it.getAttribute('data-weapon');
it.setAttribute('visible','false');
if(w==='sword'){ hasSword=true; if(equipped==='none') equipped='sword'; showScenario("🗡️ 剣を拾った! 1で装備。"); }
if(w==='gun'){ hasGun=true; if(equipped==='none') equipped='gun'; showScenario("🔫 銃を拾った! 2で装備。"); }
setEquipmentLabel();
return;
}
}
showScenario("近くに拾えるものはない。");
}
function equipSword(){ if(hasSword){ equipped='sword'; setEquipmentLabel(); showScenario("🗡️ 剣を装備した。"); } else { showScenario("剣をまだ拾っていない。"); } }
function equipGun(){ if(hasGun){ equipped='gun'; setEquipmentLabel(); showScenario("🔫 銃を装備した。"); } else { showScenario("銃をまだ拾っていない。"); } }
function attack(){
if(attackCooldown) return;
if(equipped==='sword'){ swordSlash(); }
else if(equipped==='gun'){ shootBullet(); }
else{ showScenario("装備がありません。剣や銃を拾ってください。"); }
}
function swordSlash(){
attackCooldown=true;
const slash=document.createElement('a-entity');
slash.setAttribute('geometry','primitive: torus; radius:0.9; tube:0.08; arc:200');
slash.setAttribute('material','color:#fff; opacity:.6; transparent:true');
const dir=new AFRAME.THREE.Vector3(); camEl().object3D.getWorldDirection(dir); dir.y=0; dir.normalize();
const base=rigEl().object3D.position.clone().add(new AFRAME.THREE.Vector3(0,1.2,0)).add(dir.clone().multiplyScalar(0.8));
slash.setAttribute('position',`${base.x} ${base.y} ${base.z}`);
const rotY=Math.atan2(dir.x,dir.z)*AFRAME.THREE.MathUtils.RAD2DEG;
slash.setAttribute('rotation',`0 ${rotY} 0`);
slash.setAttribute('animation__fade','property: material.opacity; to:0; dur:200; easing:easeOutQuad');
document.querySelector('a-scene').appendChild(slash);
setTimeout(()=>slash.parentNode && slash.parentNode.removeChild(slash),220);
const enemyPos=enemyEl().object3D.position.clone();
const toEnemy=enemyPos.clone().sub(rigEl().object3D.position.clone().add(new AFRAME.THREE.Vector3(0,1.0,0)));
const dist=toEnemy.length();
toEnemy.y=0; toEnemy.normalize();
const forward=dir.clone();
const angle=forward.dot(toEnemy);
if(dist<2.6 && angle>0.5 && enemyEl().getAttribute('visible')){
applyDamageToEnemy(25,"斬撃");
} else {
showScenario("空振り…");
}
setTimeout(()=>attackCooldown=false,280);
}
function shootBullet(){
attackCooldown=true;
const bullet=document.createElement('a-sphere');
bullet.setAttribute('radius','0.07');
bullet.setAttribute('color','#e6f7ff');
bullet.setAttribute('material','emissive:#88d; metalness:.6; roughness:.2');
const dir=new AFRAME.THREE.Vector3(); camEl().object3D.getWorldDirection(dir); dir.normalize();
const start=rigEl().object3D.position.clone().add(new AFRAME.THREE.Vector3(0,1.3,0)).add(dir.clone().multiplyScalar(0.9));
bullet.setAttribute('position',`${start.x} ${start.y} ${start.z}`);
document.querySelector('a-scene').appendChild(bullet);
bullets.push({el:bullet, dir:dir.clone(), life:1800, speed:24});
setTimeout(()=>attackCooldown=false,120);
}
function applyDamageToEnemy(dmg,label){
if(!enemyEl().getAttribute('visible')) return;
enemyHP=Math.max(0, enemyHP - dmg);
updateBars();
const origColor=enemyBodyEl().getAttribute('color');
enemyBodyEl().setAttribute('color','#ffffff');
setTimeout(()=>enemyBodyEl().setAttribute('color', origColor), 80);
spawnDamageText(dmg,label);
if(enemyHP<=0){ killEnemy(); }
}
function spawnDamageText(dmg,label){
const t=document.createElement('a-text');
t.setAttribute('value',`${label} -${dmg}`);
t.setAttribute('color','#ffe6e6');
t.setAttribute('align','center');
const p=enemyEl().object3D.position.clone();
t.setAttribute('position',`${p.x} ${p.y+2.4} ${p.z}`);
t.setAttribute('animation__rise','property: position; to: '+`${p.x} ${p.y+3.2} ${p.z}`+'; dur:650; easing:easeOutQuad');
t.setAttribute('animation__fade','property: opacity; to:0; dur:650; easing:easeOutQuad');
document.querySelector('a-scene').appendChild(t);
setTimeout(()=>t.parentNode && t.parentNode.removeChild(t),700);
}
function killEnemy(){
enemyEl().setAttribute('visible','false');
showScenario("✅ 敵を倒した! 5秒後に再出現する…");
setTimeout(()=>respawnEnemy(), 5000);
}
function respawnEnemy(){
enemyHP=enemyHPMax; updateBars();
const e=enemyEl();
const x = (Math.random()*6 - 3);
const z = -8.5 + (Math.random()*3 - 1.5);
e.setAttribute('position',`${x} 0 ${z}`);
e.setAttribute('visible','true');
showScenario("⚠️ 新たな敵が現れた!");
}
/* 入力(E/1/2 の互換強化:★修正) */
AFRAME.registerComponent('game-manager',{
init:function(){
window.addEventListener('keydown',(e)=>{
const k=e.key;
const c=e.code;
if(c==='KeyE' || k==='e' || k==='E') tryPickup();
if(c==='Digit1' || c==='Numpad1' || k==='1') equipSword();
if(c==='Digit2' || c==='Numpad2' || k==='2') equipGun();
});
window.addEventListener('mousedown',()=>attack());
updateBars(); advanceScenario(); setEquipmentLabel();
},
tick:function(time,dt){
const delta=dt/1000;
const prompt=document.getElementById('pickupPrompt');
const rpos=rigEl().object3D.position;
let near=false;
['swordPickup','gunPickup'].forEach(id=>{
const el=document.getElementById(id);
if(el.getAttribute('visible')){
const d=rpos.distanceTo(el.object3D.position);
if(d<2.0) near=true;
}
});
prompt.style.display = near?'block':'none';
// 弾
for(let i=bullets.length-1;i>=0;i--){
const b=bullets[i];
if(!b.el.parentNode){ bullets.splice(i,1); continue; }
b.life -= dt;
const pos=b.el.object3D.position;
pos.add(b.dir.clone().multiplyScalar(24*delta));
if(b.life<=0){ b.el.parentNode.removeChild(b.el); bullets.splice(i,1); continue; }
if(enemyEl().getAttribute('visible')){
const d=pos.distanceTo(enemyEl().object3D.position);
if(d<1.35){
applyDamageToEnemy(15,"射撃");
b.el.parentNode.removeChild(b.el);
bullets.splice(i,1);
}
}
}
enemyEl().object3D.rotation.y += delta*0.3;
}
});
updateBars();
advanceScenario();
</script>
</body>
</html>
『ファンタシースター2 VR(仮題)』企画書
『ファンタシースター2 VR(仮題)』企画書
1. 概要
タイトル名:ファンタシースター2 VR(仮)
ジャンル:VR対応3DアクションRPG(ダンジョン探索+戦術バトル)
対応機種:Meta Quest 3 / SteamVR / PS VR2(検討)
プレイ人数:1人(将来的にCO-OP対応を検討)
対象年齢:12歳以上
2. 開発コンセプト
名作『ファンタシースターⅡ』を現代VR技術で完全リメイク。
原作の世界観とキャラクター、シナリオを尊重しつつ、
・没入感ある一人称視点探索
・直感的なバトル操作(モーション・アクション)
・拡張された都市・バイオシステム・ダンジョンの再現
を実現する。
3. 特徴とVRならではの要素
A. 没入型探索
- 一人称視点によるダンジョン探索
- 全3D再構築された「モタビア」都市、研究所、バイオドーム、地下施設
- 「パイプ・テレポーター」による瞬間移動ギミック(VR演出あり)
B. 戦闘システム
- 武器の物理操作(ソード、ガン、テクニック発動)
- 旧作のターン制をアレンジした**「ハイブリッド・リアルタイム戦闘」**
(タイムゲージ制+行動入力:手をかざす/武器を振る/ボタンパネル操作) - パーティAI(仲間キャラは自動行動、状況に応じた戦略切替)
C. テクニック(魔法)演出
- VRでの魔法詠唱アクション(ジェスチャー or インタラクション)
- 代表例:「レスタ」(回復)、「ゾンデ」(雷撃)など
4. ストーリー概要(原作準拠+VR補完)
西暦1284年。モタビア星ではマザーブレインによる管理社会が完成していた。
だが、突如として現れたバイオモンスターの脅威、そして…
主人公ユーシス(VRではプレイヤー自身)は、恋人の死をきっかけに陰謀の核心へと迫る。
※VRでは原作イベントに新たな視点や演出を加え、臨場感を演出(例:ネイの死のシーンなど)
5. 対応予定機能
| 機能 | 内容 |
| VR移動方式 | スムーズ移動/テレポート移動(選択可) |
| UI | ホログラムUIパネル、音声ナビ |
| 戦闘補助 | 自動ロックオン、ジェスチャーガイド |
| サウンド | 原作BGMのVRリミックス+3D空間音響 |
| コーディネート要素 | 装備外観変更、第一人称での確認可 |
6. 開発スケジュール(想定)
| フェーズ | 期間 | 内容 |
| 企画・検証 | ~2ヶ月 | 原作調査、VRプロトタイプ制作 |
| 開発前半 | 3ヶ月 | 戦闘・探索システム実装 |
| 開発後半 | 6ヶ月 | マップ・イベント実装、UI・ボイス収録 |
| テスト | 2ヶ月 | VRチューニング、デバッグ、最終調整 |
7. 収益化・販売戦略(案)
- 基本パッケージ売切型(4,980円想定)
- DLC追加シナリオ(ネイ専用編、隠しボスなど)
- コレクターズエディション(原作アートブック、BGM集)
8. ターゲット層
| 層 | 特徴 |
| 昔のPSシリーズファン | 懐かしさ+現代的な再体験 |
| VR RPGプレイヤー | 没入感ある探索・バトル体験 |
| SF好き/JRPG好き | 独特の世界観・設定・BGM |
ご希望があれば、次の内容も追加できます:
- マップ構成と画面イメージ図
- ジェスチャー一覧
- ストーリー詳細分岐
- UIデザインのモックアップ
必要に応じて続き作成しますか?
🎮 ゲーム企画書:『カスタムロボ RE:BOOST(仮)』
🎮 ゲーム企画書:『カスタムロボ RE:BOOST(仮)』
1. タイトル案
- 『カスタムロボ RE:BOOST』
- 『カスタムロボ R:Reboot』
- 『カスタムロボ∞(インフィニティ)』
2. コンセプト
「少年の夢が、再び現実になる。」
プレイヤーはカスタムロボのパイロットとして、機体を自由にカスタマイズし、リアルかつスピーディな戦闘を繰り広げる。かつてのロボットバトルの熱狂を、現代のビジュアルとシステムで完全再現し、シリーズ未体験の若年層と当時のファン両方に向けた“進化系リメイク”。
3. ターゲットユーザー
- 【主】20~40代:旧作ファン層(N64 / GC / DSユーザー)
- 【副】10~20代:現代のロボットアクションゲームファン(スプラトゥーン、ARMORED CORE、ガンダム系)
4. プラットフォーム
- Nintendo Switch 2(想定)
- PC(Steam)
- PS5(マルチプラットフォーム想定)
5. ゲームジャンル
- 対戦型ロボットカスタマイズバトルアクション
(3Dアクション+パーツ収集+オンライン対戦)
6. 主なゲーム要素
| 要素 | 内容 |
| ロボットのカスタマイズ | 頭・胴・脚・ガン・ボム・ポッドなど1000種類以上のパーツ。性能とビジュアルに影響。 |
| スピード感重視のバトル | 従来のダッシュ・ジャンプ・バーストに加え、新アクション「ブーストチェイン」導入。 |
| シングルモード | オリジナルストーリー+リメイク要素。「ラウンドダクロン」のようなドラマ展開。 |
| オンライン対戦 | ランクマッチ・ルームマッチ・トーナメント。観戦モードあり。 |
| ギルド/クラブ要素 | ユーザー同士でクランを組み、週替わりのイベントに参加。 |
| スキン/アバター | キャラやロボの見た目を変更できる。課金なしでも入手可能。 |
7. 世界観とストーリー(概要)
西暦2089年。AR空間で行われる仮想バトル「ホロロイド」が世界大会に昇格し、ロボット競技は再び脚光を浴びていた。
主人公は「伝説のロボ乗り」の息子として大会に挑む。背後には国家機密とされる人工知能兵器の陰謀が…。
8. ビジュアルイメージ
- キャラデザイン:アニメ調+SFテイスト(イラスト例:天神英貴・貞本義行風)
- ロボデザイン:トイ的なかわいさとメカのかっこよさを両立(例:LBX×AC)
9. 技術・開発情報
- エンジン:Unreal Engine 5
- ネットワーク:Photon Fusion / Epic Online Services
- カスタムAI:バディAIとの協力バトルモードあり
10. マネタイズ・展開
| 方式 | 内容 |
| 買い切り型 | 基本価格:6,980円(DLCパックあり) |
| DLC | ストーリー拡張・新パーツセット(例:コラボ機体・歴代作品機体) |
| コラボ展開 | 他ゲーム/玩具/アニメとコラボ(例:メダロット、LBX、ガンダム) |
| メディア展開 | アニメ化・コミカライズ・プラモデル販売(3Dプリント連携も) |
11. 差別化ポイント
- パーツごとの「フィジカル挙動」シミュレーションでリアル感UP
- AIロボ同士のオート戦も可能(観戦専用モード)
- 「仮想バトル空間」×「現実の世界」の行き来するハイブリッドストーリー
12. 開発スケジュール(案)
| フェーズ | 内容 | 期間 |
| 企画・プロトタイプ | プロトタイピング/ビジュアル検証 | 3ヶ月 |
| α開発 | 基本システム構築 | 6ヶ月 |
| β開発 | 全体完成・バグ修正 | 3ヶ月 |
| PR・リリース | 体験版配布/事前登録/製品版発売 | 2ヶ月 |
『デビルチルドレン リメイク』企画書
1. タイトル案
『デビルチルドレン:リバースコード』
2. 企画意図
2000年代初頭に登場し、少年少女が悪魔と契約して戦うという世界観で人気を博した『デビルチルドレン』シリーズを、現代の技術とニーズに合わせてリメイク。ペルソナやポケモン、デジモンの人気が再燃する中、悪魔との契約・育成・融合・人間ドラマを融合させたRPGとして、懐かしさと新しさを両立させます。
3. 想定プラットフォーム
- Nintendo Switch
- PlayStation 5
- Steam(PC)
- スマートフォン(クラウド連携 or 外伝展開)
4. ゲーム概要
ジャンル
- 悪魔育成RPG+ダークファンタジーADV
主な要素
- ターン制バトル(3対3)
- 悪魔の仲間化・進化・合体(従来の悪魔合体に加え、個体ごとの性格やスキル成長分岐あり)
- 悪魔と絆を深める「契約イベント」や「共鳴システム」
- 選択によるストーリールート分岐(LAW / CHAOS / NEUTRAL)
- ジュブナイル・ホラー要素(子供視点で描く社会の闇)
5. ストーリー構成(例)
あらすじ(リメイク用に刷新)
主人公(カイ / アミ)は、異世界「魔界」と現実世界の交差点である「黄昏区(たそがれく)」に迷い込む。悪魔と契約する能力を持つ「契約者」として覚醒し、2つの世界の崩壊を止める旅へ出る。だが、選ぶ道によって人類の運命も変わる──。
6. メインキャラクター(案)
- カイ(主人公・男性)
- アミ(主人公・女性)
- ロキ(主人公に付き添う謎の悪魔)
- セト(CHAOSルートの導き手)
- メタトロン(LAWルートの守護天使)
- ルシフェル(真実を知る者)
7. 主な新要素
| 項目 | 内容 |
|---|---|
| 3Dグラフィック化 | Unity / Unreal Engineによるリアルタイム3D |
| 悪魔フルボイス化 | 合体時や契約時に専用セリフあり |
| 難易度調整機能 | EASY〜CHAOSまで選択可能 |
| マルチエンディング | ルート分岐と「真・人類ルート」追加 |
| オンライン育成交換要素 | プレイヤー間で悪魔を交換できる |
8. 想定スケジュール
| フェーズ | 期間 | 内容 |
|---|---|---|
| 企画・プリプロ | 3か月 | シナリオ/仕様書/キャラデザイン |
| 開発 | 12〜18か月 | モデリング/実装/収録/テスト |
| テスト・調整 | 3か月 | バランス調整/デバッグ |
| 発売 | XX年XX月 | パッケージ&DL版展開 |
9. 想定ターゲット
- 元祖『デビチル』ファン(20〜40代)
- 現代のRPGファン(ペルソナ、ポケモン、デジモン層)
- ダークジュブナイルや退廃SFが好きなプレイヤー
10. プロモーション戦略案
- 初報ティザーPV公開(『デビチル』ロゴを強調)
- キャラ人気投票キャンペーン
- 悪魔デザインコンテスト(ユーザー参加型)
- オリジナルサウンドトラック配信
- コラボカフェ、限定グッズ展開
