<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Tsumugi</title>
<!-- Favicon -->
<link rel="shortcut icon"
href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path fill='%23667eea' d='M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z'/></svg>"/>
<!-- Tailwind CSS v2 -->
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet"/>
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@6.4.0/css/all.min.css"/>
<style>
:root {
--grad-a: #667eea;
--grad-b: #764ba2;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: radial-gradient(circle at top left, #1f2937 0%, #111827 40%, #020617 100%);
min-height: 100vh;
color: #111827;
}
.glass-effect {
background: radial-gradient(circle at top left, rgba(255,255,255,0.15), rgba(255,255,255,0.03));
backdrop-filter: blur(18px);
border-radius: 18px;
border: 1px solid rgba(255,255,255,0.16);
}
.card-hover { transition: all 0.25s ease; }
.card-hover:hover { transform: translateY(-3px); box-shadow: 0 20px 30px -12px rgba(0,0,0,0.45); }
.gradient-text {
background: linear-gradient(90deg, var(--grad-a), var(--grad-b));
-webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
}
.timeline-post {
background: rgba(15,23,42,0.96);
border-radius: 18px;
box-shadow: 0 14px 30px -16px rgba(0,0,0,0.7);
transition: all 0.25s ease;
border-left: 4px solid var(--grad-a);
}
.timeline-post:hover { transform: translateX(4px); }
.profile-avatar {
width: 100px; height: 100px; border-radius: 50%;
object-fit: cover; border: 3px solid rgba(255,255,255,0.9);
box-shadow: 0 10px 24px rgba(0,0,0,0.35);
}
.mini-avatar {
width: 46px; height: 46px; border-radius: 50%; object-fit: cover;
border: 2px solid rgba(255,255,255,0.9);
}
.btn-primary {
background: linear-gradient(135deg, var(--grad-a), var(--grad-b));
border: none; transition: all 0.2s ease;
}
.btn-primary:hover { transform: translateY(-1px); box-shadow: 0 10px 18px rgba(0,0,0,0.4); }
.section-divider {
height: 3px; background: linear-gradient(90deg, var(--grad-a), var(--grad-b));
border-radius: 999px; margin: 2rem 0;
}
.username-badge {
background: radial-gradient(circle at top left, var(--grad-a), var(--grad-b));
color: white; padding: 0.2rem 0.6rem; border-radius: 999px;
font-size: 0.75rem; font-weight: 600; display: inline-flex;
align-items: center; margin-left: 0.5rem;
}
.username-badge i { margin-right: 4px; }
.share-menu { position: absolute; z-index: 50; min-width: 180px; right: 0; top: 110%; background: #020617; border-radius: 12px; box-shadow: 0 18px 45px rgba(0,0,0,0.75); border: 1px solid rgba(148,163,184,0.5); }
.share-menu button { width: 100%; text-align: left; padding: 10px 20px; border: none; background: none; cursor: pointer; font-size: 0.95rem; color: #e5e7eb; }
.share-menu button:hover { background: rgba(51,65,85,0.9); }
.status-indicator { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; }
.status-active { background-color: #10b981; animation: pulse 1.5s infinite; }
.status-inactive { background-color: #6b7280; }
.log-container {
max-height: 180px; overflow-y: auto; background: rgba(15,23,42,0.85);
border-radius: 10px; padding: 10px; margin-top: 10px; font-size: 0.8rem;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
border: 1px solid rgba(148,163,184,0.5); color: #e5e7eb;
}
.error-message { color: #fecaca; background: rgba(127,29,29,0.6); padding: 8px; border-radius: 8px; margin: 5px 0; }
.success-message { color: #bbf7d0; background: rgba(6,95,70,0.6); padding: 8px; border-radius: 8px; margin: 5px 0; }
@keyframes pulse { 0%,100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.4; transform: scale(1.1); } }
.dark .glass-effect { background: rgba(15,23,42,0.92); border: 1px solid rgba(148,163,184,0.4); }
.dark .timeline-post { background: #020617; color: #f9fafb; border-left-color: #4f46e5; }
.dark .share-menu { background: #020617; color: #e5e7eb; }
.dark .success-message { background: rgba(6,95,70,0.7); }
.dark .error-message { background: rgba(127,29,29,0.7); }
.icon-label {
font-size: 0.8rem; color: #e5e7eb; text-transform: uppercase; letter-spacing: 0.05em;
}
@media print {
body { background: white !important; -webkit-print-color-adjust: exact; }
.glass-effect { background: white !important; backdrop-filter: none !important; border: 1px solid #e5e7eb !important; }
.timeline-post { background: white !important; color: #111827 !important; }
}
</style>
</head>
<body class="dark text-gray-100">
<!-- ログイン/登録モーダル -->
<div id="auth-modal" class="fixed inset-0 bg-black bg-opacity-60 flex items-center justify-center z-50" style="display:none">
<div class="bg-gray-900 rounded-2xl shadow-2xl w-full max-w-sm p-6 border border-gray-700">
<h2 class="text-2xl font-bold mb-4 text-center gradient-text" id="auth-title">ログイン</h2>
<div id="auth-error" class="error-message mb-2" style="display:none"></div>
<form id="auth-form" autocomplete="off">
<div class="mb-3">
<label class="block mb-1 text-xs font-semibold text-gray-300">メールアドレス</label>
<input type="email" id="auth-email" class="w-full border border-gray-700 bg-gray-800 rounded px-3 py-2 text-sm text-gray-100" required>
</div>
<div class="mb-3">
<label class="block mb-1 text-xs font-semibold text-gray-300">パスワード</label>
<input type="password" id="auth-password" class="w-full border border-gray-700 bg-gray-800 rounded px-3 py-2 text-sm text-gray-100" required>
</div>
<button type="submit" class="btn-primary w-full py-2 rounded-lg text-white font-semibold text-sm mt-2">
<i class="fas fa-sign-in-alt mr-2"></i>ログイン
</button>
</form>
<div class="mt-4 text-center">
<button id="toggle-auth-mode" class="text-indigo-400 underline text-xs">新規登録はこちら</button>
</div>
</div>
</div>
<!-- ヘッダー -->
<header class="glass-effect mx-4 mt-4 p-6 border border-indigo-500/40">
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between space-y-4 lg:space-y-0">
<div class="text-left">
<h1 class="text-3xl md:text-4xl font-extrabold text-white mb-1 tracking-tight">
<i class="fas fa-comments mr-3 text-indigo-300"></i>
<span class="gradient-text">Tsumugi</span>
<span class="ml-2 text-xs px-2 py-1 rounded-full bg-indigo-500/20 border border-indigo-400/60 align-middle">Verse Core v3.0</span>
</h1>
<p class="text-indigo-100 text-sm md:text-base opacity-90">
次世代ソーシャルネットワーク • RSS / BOT 専用エディション(AI機能なし)
</p>
</div>
<div class="flex flex-wrap items-center justify-end gap-3">
<div class="flex items-center bg-slate-900/70 rounded-2xl px-3 py-2 shadow-inner border border-slate-700">
<img id="header-profile-icon" class="mini-avatar" src="https://via.placeholder.com/80" alt="プロフィール">
<div class="ml-3 text-left">
<div class="font-semibold text-sm" id="header-username">未設定</div>
<div class="text-xs text-slate-300 opacity-75" id="header-user-email"></div>
</div>
</div>
<button onclick="toggleDarkMode()" class="btn-primary px-4 py-2 rounded-full text-white text-xs flex items-center">
<i class="fas fa-moon mr-2"></i><span>テーマ切替</span>
</button>
<button onclick="showSystemStatus()" class="bg-slate-900 hover:bg-slate-800 px-4 py-2 rounded-full text-white text-xs border border-slate-600 flex items-center">
<i class="fas fa-info-circle mr-2"></i>ステータス
</button>
<button onclick="clearVerseCache()" class="bg-yellow-400 hover:bg-yellow-500 px-4 py-2 rounded-full text-black text-xs flex items-center">
<i class="fas fa-broom mr-2"></i>キャッシュクリア
</button>
<button id="logout-btn" onclick="logout()" class="bg-red-600 hover:bg-red-700 px-4 py-2 rounded-full text-white text-xs flex items-center hidden">
<i class="fas fa-sign-out-alt mr-2"></i>ログアウト
</button>
</div>
</div>
</header>
<!-- メイン -->
<div class="max-w-6xl mx-auto px-4 py-6" id="main-content" style="display:none">
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- 左カラム:プロフィール/BOT/RSS -->
<div class="lg:col-span-1 space-y-6">
<!-- プロフィール -->
<div class="glass-effect p-6 card-hover border border-slate-600">
<h3 class="text-2xl font-bold gradient-text mb-4 flex items-center">
<i class="fas fa-user-circle mr-2 text-indigo-300"></i>プロフィール
</h3>
<div class="text-center mb-6">
<img id="profile-icon" class="profile-avatar mx-auto mb-4" src="https://via.placeholder.com/100" alt="プロフィール">
<input type="file" id="profile-upload" accept="image/*" onchange="uploadProfileIcon(event)" class="hidden">
<button onclick="document.getElementById('profile-upload').click()" class="btn-primary px-4 py-2 rounded-full text-white text-sm">
<i class="fas fa-camera mr-2"></i>プロフィール画像
</button>
</div>
<div class="space-y-4">
<div>
<label class="block text-gray-200 font-semibold mb-1 text-xs">ユーザー名</label>
<input type="text" id="username" class="w-full p-3 border border-slate-600 rounded-lg bg-slate-900 text-gray-100 text-sm" placeholder="ユーザー名を入力" maxlength="20">
</div>
<div>
<label class="block text-gray-200 font-semibold mb-1 text-xs">自己紹介</label>
<textarea id="self-intro" class="w-full p-3 border border-slate-600 rounded-lg bg-slate-900 text-gray-100 text-sm" rows="4" placeholder="自己紹介を入力"></textarea>
</div>
<button onclick="saveProfile()" class="btn-primary w-full py-2 rounded-lg text-white text-sm font-semibold">
<i class="fas fa-save mr-2"></i>プロフィール保存
</button>
<div class="p-3 bg-slate-900/80 rounded-lg border border-slate-700">
<h5 class="font-semibold text-gray-200 mb-2 text-xs">プレビュー</h5>
<div class="text-gray-300 text-sm">
<div class="font-semibold mb-1" id="username-preview">未設定</div>
<div id="self-intro-preview" class="text-xs whitespace-pre-line min-h-8">まだ自己紹介がありません</div>
</div>
</div>
</div>
</div>
<!-- BOT/Feed アイコン設定 -->
<div class="glass-effect p-6 card-hover border border-indigo-500/40">
<h3 class="text-xl font-bold text-indigo-100 mb-4 flex items-center">
<i class="fas fa-icons mr-2 text-indigo-300"></i>BOT / Feed アイコン設定
</h3>
<div class="space-y-4 text-xs">
<div class="flex items-center space-x-3">
<img id="bot-icon-preview" class="mini-avatar" src="https://cdn-icons-png.flaticon.com/512/4712/4712109.png" alt="BOT">
<div class="flex-1">
<div class="icon-label">BOT / Markov BOT</div>
<input type="file" id="bot-icon-upload" accept="image/*" class="hidden" onchange="uploadIcon('bot', event)">
<button onclick="document.getElementById('bot-icon-upload').click()" class="bg-slate-900 hover:bg-slate-800 px-3 py-1 rounded-full text-gray-100 text-[11px] border border-slate-600 mt-1">
<i class="fas fa-robot mr-1"></i>BOTアイコン変更
</button>
</div>
</div>
<div class="flex items-center space-x-3">
<img id="feed-icon-preview" class="mini-avatar" src="https://cdn-icons-png.flaticon.com/512/3416/3416046.png" alt="Feed">
<div class="flex-1">
<div class="icon-label">RSS FEED BOT</div>
<input type="file" id="feed-icon-upload" accept="image/*" class="hidden" onchange="uploadIcon('feed', event)">
<button onclick="document.getElementById('feed-icon-upload').click()" class="bg-slate-900 hover:bg-slate-800 px-3 py-1 rounded-full text-gray-100 text-[11px] border border-slate-600 mt-1">
<i class="fas fa-rss mr-1"></i>Feedアイコン変更
</button>
</div>
</div>
</div>
</div>
<!-- RSS自動投稿機能 -->
<div class="glass-effect p-6 card-hover border border-amber-500/40">
<h3 class="text-xl font-bold text-amber-100 mb-4">
<i class="fas fa-rss mr-2 text-amber-300"></i>RSS自動投稿(全体共有)
<span class="status-indicator" id="rss-status"></span>
<span id="rss-status-text" class="text-xs opacity-75">停止中</span>
</h3>
<div>
<input id="rss-url" type="text" class="w-full p-2 border border-slate-600 rounded-lg bg-slate-900 text-gray-100 text-xs mb-2" placeholder="RSSフィードURLを入力">
<button onclick="addRssFeed()" class="btn-primary w-full py-2 rounded-lg text-white mb-2 text-xs">
<i class="fas fa-plus mr-2"></i>追加
</button>
<div id="rss-list" class="mb-3 text-xs"></div>
<div class="flex items-center space-x-2 mb-2">
<input type="number" id="rss-interval" class="w-1/2 p-2 border border-slate-600 rounded-lg bg-slate-900 text-gray-100 text-xs" min="10" max="3600" value="300" placeholder="間隔(秒)">
<button onclick="setRssInterval()" class="btn-primary flex-1 py-2 rounded-lg text-white text-xs">
<i class="fas fa-clock mr-2"></i>間隔設定
</button>
</div>
<div class="flex items-center space-x-2 mb-2">
<button onclick="fetchRssNow()" class="btn-primary flex-1 py-2 rounded-lg text-white text-xs">
<i class="fas fa-sync mr-2"></i>今すぐ取得
</button>
<button onclick="stopRssAuto()" class="bg-red-600 hover:bg-red-700 flex-1 py-2 rounded-lg text-white text-xs">
<i class="fas fa-stop mr-2"></i>自動停止
</button>
</div>
<div class="flex items-center space-x-2 text-xs">
<button onclick="setAllRssEnabled(true)" class="btn-primary flex-1 py-2 rounded-lg text-white text-[11px]">
<i class="fas fa-toggle-on mr-1"></i>すべてON
</button>
<button onclick="setAllRssEnabled(false)" class="bg-gray-600 hover:bg-gray-700 flex-1 py-2 rounded-lg text-white text-[11px]">
<i class="fas fa-toggle-off mr-1"></i>すべてOFF
</button>
</div>
<div id="rss-log" class="log-container text-xs mt-3"></div>
</div>
</div>
<!-- BOT機能 -->
<div class="glass-effect p-6 card-hover border border-emerald-500/40">
<h3 class="text-xl font-bold text-emerald-100 mb-4">
<i class="fas fa-robot mr-2 text-emerald-300"></i>BOT機能
<span class="status-indicator" id="bot-status"></span>
<span id="bot-status-text" class="text-xs opacity-75">停止中</span>
</h3>
<div class="space-y-4">
<div>
<textarea id="botContent" class="w-full p-3 border border-slate-600 rounded-lg bg-slate-900 text-gray-100 text-sm" rows="3" placeholder="BOT投稿内容"></textarea>
<button onclick="postBotMessage()" class="btn-primary w-full mt-2 py-2 rounded-lg text-white text-xs">
<i class="fas fa-robot mr-2"></i>BOT投稿
</button>
</div>
<div>
<input type="number" id="botIntervalSec" class="w-full p-2 border border-slate-600 rounded-lg bg-slate-900 text-gray-100 text-xs" placeholder="マルコフ自動投稿間隔(秒)" min="10" max="3600" value="60">
<div class="flex space-x-2 mt-2">
<button onclick="postMarkovBot()" class="btn-primary flex-1 py-2 rounded-lg text-white text-xs">
<i class="fas fa-dice mr-2"></i>マルコフ生成
</button>
<button onclick="startBotAutoPost()" class="btn-primary flex-1 py-2 rounded-lg text-white text-xs">
<i class="fas fa-play mr-2"></i>自動開始
</button>
<button onclick="stopBotAutoPost()" class="bg-red-600 hover:bg-red-700 flex-1 py-2 rounded-lg text-white text-xs">
<i class="fas fa-stop mr-2"></i>停止
</button>
</div>
</div>
<div class="text-emerald-100 text-xs opacity-80">
<i class="fas fa-info-circle mr-1"></i>
マルコフ連鎖ではユーザー/BOT投稿のみを学習し、RSS記事本文は学習対象から除外します。
</div>
<div id="bot-log" class="log-container text-xs"></div>
</div>
</div>
</div>
<!-- 右カラム:投稿&タイムライン -->
<div class="lg:col-span-2 space-y-6">
<!-- 新規投稿 -->
<div class="glass-effect p-6 card-hover border border-slate-600">
<h3 class="text-2xl font-bold gradient-text mb-4 flex items-center">
<i class="fas fa-edit mr-2 text-indigo-300"></i>新規投稿
</h3>
<div>
<textarea id="postContent" class="w-full p-4 border border-slate-600 rounded-lg bg-slate-900 text-gray-100 text-sm" rows="4" placeholder="今何を考えていますか?(Ctrl+Enter で投稿)" maxlength="500"></textarea>
<div class="mt-4 flex flex-col md:flex-row md:items-center md:justify-between space-y-3 md:space-y-0">
<div class="text-indigo-100 text-xs opacity-90 flex items-center space-x-2">
<i class="fas fa-info-circle"></i>
<span>あなたの思いを共有しましょう</span>
<span id="char-count" class="ml-2 px-2 py-1 rounded-full bg-slate-900 border border-slate-600">(0/500)</span>
</div>
<div class="flex space-x-2">
<button onclick="createUserPost()" class="btn-primary px-5 py-2 rounded-lg text-white text-xs font-semibold">
<i class="fas fa-paper-plane mr-2"></i>投稿する
</button>
</div>
</div>
</div>
</div>
<div class="section-divider"></div>
<!-- タイムライン -->
<div class="glass-effect p-6 border border-slate-600">
<div class="flex flex-col md:flex-row md:justify-between md:items-center mb-4 space-y-3 md:space-y-0">
<h3 class="text-2xl font-bold text-indigo-100 flex items-center">
<i class="fas fa-stream mr-2 text-indigo-300"></i>タイムライン
<span id="post-count" class="text-sm font-normal opacity-75 ml-2">(0件の投稿)</span>
</h3>
<div class="flex space-x-2">
<button onclick="clearAllPosts()" class="bg-red-600 hover:bg-red-700 px-3 py-1 rounded text-white text-xs flex items-center">
<i class="fas fa-trash mr-1"></i>全削除
</button>
<button onclick="exportData()" class="bg-emerald-600 hover:bg-emerald-700 px-3 py-1 rounded text-white text-xs flex items-center">
<i class="fas fa-download mr-1"></i>エクスポート
</button>
</div>
</div>
<!-- フィルター&検索 -->
<div class="flex flex-col md:flex-row md:items-center md:justify-between mb-4 space-y-3 md:space-y-0">
<div class="flex flex-wrap gap-2 text-xs">
<button id="filter-all" onclick="setFilter('all')" class="px-3 py-1 rounded-full border border-slate-600 bg-indigo-600 text-white flex items-center">
<i class="fas fa-globe mr-1"></i>すべて
</button>
<button id="filter-user" onclick="setFilter('user')" class="px-3 py-1 rounded-full border border-slate-600 text-slate-200 flex items-center">
<i class="fas fa-user mr-1"></i>ユーザー
</button>
<button id="filter-bot" onclick="setFilter('bot')" class="px-3 py-1 rounded-full border border-slate-600 text-slate-200 flex items-center">
<i class="fas fa-robot mr-1"></i>BOT
</button>
<button id="filter-feed" onclick="setFilter('feed')" class="px-3 py-1 rounded-full border border-slate-600 text-slate-200 flex items-center">
<i class="fas fa-rss mr-1"></i>Feed
</button>
</div>
<div class="relative w-full md:w-64">
<input id="timeline-search" type="text" class="w-full pl-8 pr-3 py-2 rounded-full bg-slate-900 border border-slate-600 text-xs text-slate-100" placeholder="キーワード検索(本文・ユーザー名)">
<i class="fas fa-search text-slate-400 text-xs absolute left-2.5 top-1/2 transform -translate-y-1/2"></i>
</div>
</div>
<div id="timeline" class="space-y-4"></div>
<div id="empty-timeline" class="text-center py-12 text-slate-200 opacity-80">
<i class="fas fa-comments text-4xl mb-4 text-indigo-300"></i>
<p class="text-lg">まだ投稿がありません</p>
<p class="text-xs text-slate-300">最初の投稿をして、タイムラインを始めましょう!</p>
</div>
</div>
</div>
</div>
</div>
<footer class="glass-effect mx-4 mb-4 p-4 text-center border border-slate-700">
<p class="text-slate-200 opacity-80 text-xs">
<i class="fas fa-copyright mr-1"></i>
2025 Verse – 次世代ソーシャルネットワーク v3.0
<span class="ml-4 inline-flex items-center">
<i class="fas fa-rss mr-1 text-amber-300"></i>共有RSS / 個別ON/OFF / BOT・マルコフ自動投稿
</span>
</p>
</footer>
<script>
// ==== 初期RSS ====
const PRESET_RSS = [
"http://2ch-2.net/rss/all.xml",
"http://2ch-ranking.net/rss/livemarket1.rdf",
"http://2ch-ranking.net/rss/livemarket2.rdf",
"http://kabumatome.doorblog.jp/index.rdf",
"http://momoniji.com/feed",
"http://oekakigakusyuu.blog97.fc2.com/?xml",
"http://otanews.livedoor.biz/atom.xml",
"http://otanews.livedoor.biz/index.rdf",
"http://news4vip.livedoor.biz/index.rdf",
"http://news.kakaku.com/prdnews/rss.asp",
"http://www.jma-net.go.jp/rss/jma.rss",
"http://rss.asahi.com/rss/asahi/newsheadlines.rdf",
"https://uploadvr.com/feed/",
"http://www.atmarkit.co.jp/rss/rss2dc.xml",
"http://liginc.co.jp/feed",
"http://liginc.co.jp/feed/",
"http://blog.livedoor.jp/shachiani/index.rdf",
"http://manga.lemon-s.com/atom.xml",
"http://b.hatena.ne.jp/search/text?safe=on&q=%E3%82%BB%E3%82%AD%E3%83%A5%E3%83%AA%E3%83%86%E3%82%A3&users=500&mode=rss",
"http://creator-life.net/feed/",
"http://feedblog.ameba.jp/rss/ameblo/ca-1pixel/rss20.xml",
"http://rssblog.ameba.jp/ca-1pixel/rss20.xml",
"http://weekly.ascii.jp/cate/1/rss.xml",
"http://blog.livedoor.jp/coleblog/atom.xml",
"http://2chantena.antenam.biz/rss1s.rss",
"http://www.4gamer.net/rss/index.xml",
"http://www.4gamer.net/rss/news_topics.xml",
"http://nyan.eggtree.net/feed.xml",
"http://nunnnunn.hatenablog.com/rss",
"http://www.nikkansports.com/general/atom.xml",
"http://feeds.afpbb.com/rss/afpbb/access_ranking",
"http://akiba-pc.watch.impress.co.jp/cda/rss/akiba-pc.rdf",
"https://area.autodesk.jp/rss.xml",
"http://av.watch.impress.co.jp/sublink/av.rdf",
"http://rss.allabout.co.jp/aa/latest/ch/netpc/",
"http://www.ar-ch.org/atom.xml",
"http://feeds.arstechnica.com/arstechnica/BAaf",
"https://feeds.feedburner.com/awwwards-sites-of-the-day",
"http://news.bbc.co.uk/rss/newsonline_uk_edition/front_page/rss091.xml",
"http://www.criteo.com/blog/rss/",
"https://blueskyweb.xyz/rss.xml",
"http://boingboing.net/rss.xml",
"http://www.cc2.co.jp/blog/?feed=rss2",
"http://cgarena.com/cgarena.xml",
"http://cgtracking.net/feed",
"http://japan.cnet.com/rss/index.rdf",
"http://newclassic.jp/feed",
"https://www.cssmania.com/feed/",
"http://ceron.jp/top/?type=rss",
"http://blog.btrax.com/jp/comments/feed/",
"http://2ch.logpo.jp/1hour.xml",
"http://menthas.com/javascript/rss",
"http://www.nhk.or.jp/rss/news/cat0.xml",
"http://ozpa-h4.com/feed/",
"https://www.youtube.com/feeds/videos.xml?channel_id=UC1DCedRgGHBdm81E1llLhOQ",
"http://rass.blog43.fc2.com/?xml",
"http://stackoverflow.com/feeds",
"http://www.slideshare.net/rss/latest",
"http://www.jp.square-enix.com/whatsnew2/whatsnew.rdf",
"http://www.ituore.com/feed",
"http://synodos.jp/comments/feed",
"http://www.shinkigensha.co.jp/feed/",
"http://e-shuushuu.net/index.rss",
"http://slashdot.org/index.rss",
"http://feeds.feedburner.com/TheHackersNews?format=xml",
"http://googleblog.blogspot.com/atom.xml",
"http://www.theregister.co.uk/tonys/slashdot.rdf",
"http://thinkit.co.jp/rss.xml",
"http://blog.livedoor.jp/news23vip/atom.xml",
"http://blog.livedoor.jp/news23vip/index.rdf",
"http://www.webcreatorbox.com/feed/",
"http://web-d.navigater.info/atom.xml",
"http://2ch-c.net/?xml_all",
"http://smhn.info/feed",
"http://feeds.japan.zdnet.com/rss/zdnet/all.rdf",
"http://20kaido.com/index.rdf",
"http://2chnode.com/rss/feed/all",
"http://akiba-souken.com/feed/all/",
"http://amaebi.net/index.rdf",
"http://amakakeru.blog59.fc2.com/?xml",
"http://artskype.com/rss/feed.xml",
"http://asitagamienai.blog118.fc2.com/?xml",
"http://beta.egmnow.com/feed/",
"http://blog.livedoor.jp/ogenre/index.rdf",
"http://blog.nicovideo.jp/atom.xml",
"http://blog.tsubuani.com/feed",
"http://blogs.adobe.com/flex/atom.xml",
"http://blogs.adobe.com/index.xml",
"http://bm.s5-style.com/feed",
"http://business.nikkeibp.co.jp/rss/all_nbo.rdf",
"http://createlier.sitemix.jp/feed/",
"http://crocro.com/news/nc.cgi?action=search&skin=rdf_srch_xml",
"http://d.hatena.ne.jp/thk/rss",
"http://damage0.blomaga.jp/index.rdf",
"http://danbooru.donmai.us/posts.atom",
"http://danbooru.donmai.us/posts.atom?tags=rss",
"http://dengekionline.com/cate/11/rss.xml",
"http://dictionary.reference.com/wordoftheday/wotd.rss",
"http://doujin-games88.net/feed",
"http://doujin.sekurosu.com/rss",
"http://dousyoko.blog.fc2.com/?xml",
"http://eroaniblog.blog.fc2.com/?xml",
"http://eroanimedougakan.blog.fc2.com/?xml",
"http://erogetrailers.com/api?md=latest",
"http://eronizimage.blog.fc2.com/?xml",
"http://erosanime.blog121.fc2.com/?xml",
"http://erotaganime.blog.fc2.com/?xml",
"http://feed.nikkeibp.co.jp/rss/nikkeibp/index.rdf",
"http://feed.rssad.jp/rss/gigazine/rss_2.0",
"http://feed.rssad.jp/rss/jcast/index.xml",
"http://feed.rssad.jp/rss/klug/fxnews/rss5.xml",
"http://feedblog.ameba.jp/rss/ameblo/yusayusa0211/rss20.xml",
"http://feeds.adobe.com/xml/rss.cfm?query=byMostRecent&languages=1",
"http://feeds.builder.japan.zdnet.com/rss/builder/all.rdf",
"http://feeds.fc2.com/fc2/xml?host=anrism.blog&format=xml",
"http://feeds.fc2.com/fc2/xml?host=kahouha2jigen.blog&format=xml",
"http://feeds.feedburner.com/gekiura",
"http://feeds.journal.mycom.co.jp/rss/mycom/index",
"http://feeds.reuters.com/reuters/JPTopNews?format=xml",
"http://galten705.blog.fc2.com/?xml",
"http://gamanjiru.net/feed",
"http://gamanjiru.net/feed/atom",
"http://gamebiz.jp/?feed=rss",
"http://gamenode.jp/rss/feed/all",
"http://ggsoku.com/feed/atom/",
"http://girlcelly.blog.fc2.com/?xml&trackback",
"http://hairana.blog.fc2.com/?xml",
"http://haruka-yumenoato.net/static/rss/index.rss",
"http://headline.harikonotora.net/rss2.xml",
"http://hentaidoujinanime.com/?xml",
"http://homepage1.nifty.com/maname/index.rdf",
"http://horiemon.com/feed/",
"http://ideahacker.net/feed/",
"http://itpro.nikkeibp.co.jp/rss/develop.rdf",
"http://itpro.nikkeibp.co.jp/rss/news.rss",
"http://itpro.nikkeibp.co.jp/rss/oss.rdf",
"http://itpro.nikkeibp.co.jp/rss/win.rdf",
"http://japan.internet.com/rss/rdf/index.rdf",
"http://jp.leopard-raws.org/rss.php",
"http://jp.techcrunch.com/feed/",
"http://kakaku.com/trendnews/rss.xml",
"http://kamisoku.blog47.fc2.com/?xml",
"http://kanesoku.com/index.rdf",
"http://kibougamotenai.blog.fc2.com/?xml",
"http://kiisu.jpn.org/rss/now.xml",
"http://konachan.com/post/piclens?page=1&tags=loli",
"http://labo.tv/2chnews/index.xml",
"http://lineblog.me/yamamotoichiro/atom.xml",
"http://majimougen.blog.fc2.com/?xml",
"http://mantan-web.jp/rss/mantan.xml",
"http://matome.naver.jp/feed/hot",
"http://matome.naver.jp/feed/tech",
"http://matome.sekurosu.com/rss",
"http://mizuhonokuni2ch.com/?xml",
"http://momoiroanime.blog.fc2.com/?xml",
"http://moroahedoujin.com/?xml",
"http://nesingazou.blog.fc2.com/?xml",
"http://newnews-moe.com/index.rdf",
"http://news.ameba.jp/index.xml",
"http://news.com.com/2547-1_3-0-5.xml",
"http://news.nicovideo.jp/?rss=2.0",
"http://news.nicovideo.jp/ranking/hot?rss=2.0",
"http://newsbiz.yahoo.co.jp/topnews.rss",
"http://nijitora.blog.fc2.com/?xml",
"http://nodvd21ver2.blog.fc2.com/?xml",
"http://orebibou.com/feed/",
"http://osu.ppy.sh/feed/ranked/",
"http://otakomu.jp/feed",
"http://pcgameconquest.blog.fc2.com/?xml",
"http://picks.dir.yahoo.co.jp/dailypicks/rss/",
"http://piknik2ch.blog76.fc2.com/?xml",
"http://plus.appgiga.jp/feed/user",
"http://purisoku.com/index.rdf",
"http://rdsig.yahoo.co.jp/RV=1/RU=aHR0cDovL3NlYXJjaHJhbmtpbmcueWFob28uY28uanAvcnNzL2J1cnN0X3JhbmtpbmctcnNzLnhtbA--;_ylt=A2RhjFhfAi9XEi0A6Glhdu57",
"http://read2ch.net/rss/",
"http://rss.dailynews.yahoo.co.jp/fc/computer/rss.xml",
"http://rss.rssad.jp/rss/akibapc/akiba-pc.rdf",
"http://rss.rssad.jp/rss/ascii/biz/rss.xml",
"http://rss.rssad.jp/rss/ascii/hobby/rss.xml",
"http://rss.rssad.jp/rss/ascii/it/rss.xml",
"http://rss.rssad.jp/rss/ascii/mac/rss.xml",
"http://rss.rssad.jp/rss/ascii/pc/rss.xml",
"http://rss.rssad.jp/rss/ascii/rss.xml",
"http://rss.rssad.jp/rss/codezine/new/20/index.xml",
"http://rss.rssad.jp/rss/forest/rss.xml",
"http://rss.rssad.jp/rss/gihyo/feed/atom",
"http://rss.rssad.jp/rss/headline/headline.rdf",
"http://rss.rssad.jp/rss/impresswatch/pcwatch.rdf",
"http://rss.rssad.jp/rss/itm/1.0/makoto.xml",
"http://rss.rssad.jp/rss/itm/1.0/netlab.xml",
"http://rss.rssad.jp/rss/itm/2.0/kw_akiba.xml",
"http://rss.rssad.jp/rss/itm/2.0/kw_android_appli.xml",
"http://rss.rssad.jp/rss/itm/2.0/kw_apple.xml",
"http://rss.rssad.jp/rss/itm/2.0/kw_facebook.xml",
"http://rss.rssad.jp/rss/itm/2.0/kw_google.xml",
"http://rss.rssad.jp/rss/itm/2.0/kw_ipad.xml",
"http://rss.rssad.jp/rss/itm/2.0/kw_iphone.xml",
"http://rss.rssad.jp/rss/itm/2.0/kw_iphone_appli.xml",
"http://rss.rssad.jp/rss/itm/2.0/kw_mixi.xml",
"http://rss.rssad.jp/rss/itm/2.0/kw_smartphone.xml",
"http://rss.rssad.jp/rss/itm/2.0/kw_twitter.xml",
"http://rss.rssad.jp/rss/itm/2.0/kw_ustream.xml",
"http://rss.rssad.jp/rss/itm/2.0/kw_youtube.xml",
"http://rss.rssad.jp/rss/itmbizid/1.0/bizid.xml",
"http://rss.rssad.jp/rss/itmnews/2.0/news_bursts.xml",
"http://rss.rssad.jp/rss/japaninternetcom/index.rdf",
"http://rss.rssad.jp/rss/oshietekun/atom.xml",
"http://rss.rssad.jp/rss/slashdot/slashdot.rss",
"http://rss.rssad.jp/rss/zaikeishimbun/main.xml",
"http://rssc.dokoda.jp/r/8a1dd8f128047929ba4390dab3c8065e/http/searchranking.yahoo.co.jp/realtime_buzz/",
"http://sakurabaryo.com/feed/",
"http://sankei.jp.msn.com/rss/news/points.xml",
"http://sankei.jp.msn.com/rss/news/west_points.xml",
"http://search.goo.ne.jp/rss/newkw.rdf",
"http://sekurosu.com/rss",
"http://streaming.yahoo.co.jp/rss/newly/anime/",
"http://sub0000528116.hmk-temp.com/wordpress/?feed=rss2",
"http://sukebei.nyaa.se/?page=rss&sort=2",
"http://tenshoku.mynavi.jp/knowhow/rss.xml",
"http://tensinyakimeshi.blog98.fc2.com/?xml",
"http://thefreedom12.blog41.fc2.com/?xml",
"http://togetter.com/rss/hot/culture/62",
"http://togetter.com/rss/hot/culture/63",
"http://torimatome.main.jp/blogs/comments/feed",
"http://torimatome.main.jp/blogs/feed",
"http://toshinokyouko.com/rss.php",
"http://tvanimedouga.blog93.fc2.com/?xml",
"http://uranourainformation.blog21.fc2.com/?xml",
"http://video.fc2.com/a/feed_popular.php?m=week",
"http://weather.livedoor.com/forecast/rss/area/400010.xml",
"http://wotopi.jp/feed",
"http://www.100shiki.com/feed",
"http://www.alistapart.com/rss.xml",
"http://www.anime-sharing.com/forum/external.php?type=RSS2&forumids=36",
"http://www.anime-sharing.com/forum/external.php?type=RSS2&forumids=38",
"http://www.anime-sharing.com/forum/external.php?type=RSS2&forumids=47",
"http://www.blosxom.com/?feed=rss2",
"http://www.britannica.com/eb/dailycontent/rss",
"http://www.csmonitor.com/rss/top.rss",
"http://www.ehackingnews.com/feeds/posts/default",
"http://www.falcom.co.jp/new.xml",
"http://www.famitsu.com/rss/category/fcom_game.rdf",
"http://www.famitsu.com/rss/fcom_all.rdf",
"http://www.ganganonline.com/rss/index.xml",
"http://www.ideaxidea.com/feed",
"http://www.itnews711.com/index.rdf",
"http://www.jp.playstation.com/whatsnew/whatsnew.rdf",
"http://www.keyman.or.jp/rss/v1/?rss_type=all",
"http://www.koubo.co.jp/rss.xml",
"http://www.nyaa.se/?page=rss&sort=2",
"http://www.nyaa.se/?page=rss&user=118009",
"http://www.nytimes.com/services/xml/rss/userland/HomePage.xml",
"http://www.phianime.tv/feed/",
"http://www.rebootdevelop.hr/feed/",
"http://www.rictus.com/muchado/feed/",
"http://www.sbcr.jp/atom.xml",
"http://www.slashgear.com/comments/feed/",
"http://www.torrent-anime.com/feed",
"http://www.torrent-anime.com/feed/",
"http://www.webimemo.com/feed/",
"http://www.wired.com/news_drop/netcenter/netcenter.rdf",
"http://www.xvideos.com/rss/rss.xml",
"http://www.youtube.com/rss/user/KADOKAWAanime/videos.rss",
"http://www.youtube.com/rss/user/demosouko/videos.rss",
"http://www.yukawanet.com/index.rdf",
"http://www.zou3.net/php/rss/nikkei2rss.php?head=main",
"http://xml.ehgt.org/ehtracker.xml",
"http://xml.metafilter.com/rss.xml",
"http://xvideos.2jiero.info/feed",
"http://yaraon.blog109.fc2.com/?xml",
"http://yusaani.com/home/feed/",
"http://zipdeyaruo.blog42.fc2.com/?xml",
"http://www.portalgraphics.net/rss/latest_image_list.xml",
"http://api.syosetu.com/writernovel/430380.Atom",
"http://creive.me/feed/",
"http://gihyo.jp/dev/feed/atom",
"http://gihyo.jp/feed/rss1",
"http://hakase255.blog135.fc2.com/?xml",
"http://2ch-ranking.net/rss/zenban.rdf",
"http://www.isus.jp/feed/",
"http://www.jiji.com/rss/ranking.rdf",
"http://jp.gamesindustry.biz/rss/index.xml",
"https://www.youtube.com/feeds/videos.xml?channel_id=UCx1nAvtVDIsaGmCMSe8ofsQ",
"http://zakuzaku911.com/index.rdf",
"http://ke-tai.org/blog/feed/",
"http://data.newantenna.net/ero/rss/all.xml",
"http://developer.mixi.co.jp/feed/atom",
"http://neoneetch.blog.fc2.com/?xml",
"http://rss.itmedia.co.jp/rss/1.0/netlab.xml",
"http://netgeek.biz/feed",
"http://blog.esuteru.com/index.rdf",
"http://b.hatena.ne.jp/hotentry/game.rss",
"http://b.hatena.ne.jp/hotentry.rss",
"http://mobile.seisyun.net/rss/hot.rdf",
"http://yomi.mobi/rss/hot.rdf",
"http://saymygame.com/feed/",
"http://blog.webcreativepark.net/atom.xml",
"http://buhidoh.net/?xml",
"http://www.webcyou.com/?feed=rss2",
"http://withnews.jp/rss/consumer/new.rdf",
"https://yande.re/post/atom?tags=loli",
"http://blog.livedoor.jp/nizigami/atom.xml",
"http://nvmzaq.blog.fc2.com/?xml",
"http://keieimanga.net/index.rdf",
"http://megumi.ldblog.jp/atom.xml",
"http://kirik.tea-nifty.com/diary/index.rdf",
"http://sinri.net/comments/feed",
"http://himasoku.com/atom.xml",
"http://himasoku.com/index.rdf",
"http://20kaido.com/index.rdf",
"http://h723.blog.fc2.com/?xml",
"http://onecall2ch.com/index.rdf",
"http://www.forest.impress.co.jp/rss.xml",
"http://www.zaikei.co.jp/rss/sections/it.xml",
"http://akiba.keizai.biz/rss.xml",
"http://agag.tw/feed/2d-popular.rss",
"http://adult-vr.jp/feed/",
"http://www.anige-sokuhouvip.com/?xml",
"http://animeanime.jp/rss/index.rdf",
"http://alfalfalfa.com/index.rdf",
"http://feeds.feedburner.com/fc2/GhfA",
"http://erogetaiken072.blog.fc2.com/?xml",
"http://otanew.jp/atom.xml",
"http://jin115.com/index.rdf",
"http://www.onlinegamer.jp/rss/news.rdf",
"http://karapaia.livedoor.biz/index.rdf",
"http://getnews.jp/feed/ext/orig",
"http://www.gungho.co.jp/news/xml/rss.xml",
"http://blog.livedoor.jp/kinisoku/index.rdf",
"http://feeds.gizmodo.jp/rss/gizmodo/index.xml",
"http://himado.in/?sort=movie_id&rss=1",
"http://k-tai.impress.co.jp/cda/rss/ktai.rdf",
"http://gehasoku.com/atom.xml",
"http://feedblog.ameba.jp/rss/ameblo/principia-ca/rss20.xml",
"http://zai.diamond.jp/list/feed/rssfxnews",
"http://capacitor.blog.fc2.com/?xml",
"http://blog.livedoor.jp/vipsister23/index.rdf",
"http://vipsister23.com/atom.xml",
"http://b.hatena.ne.jp/search/tag?safe=on&q=2ch&users=500&mode=rss",
"http://b.hatena.ne.jp/search/tag?safe=on&q=%E3%83%8D%E3%83%83%E3%83%88%E3%83%AF%E3%83%BC%E3%82%AF&users=500&mode=rss",
"http://b.hatena.ne.jp/search/tag?safe=off&q=%E3%83%97%E3%83%AD%E3%82%B0%E3%83%A9%E3%83%9F%E3%83%B3%E3%82%B0&users=500&mode=rss",
"http://shikaku2ch.doorblog.jp/atom.xml",
"http://dotinstall.com/lessons.rss",
"http://2ch-ranking.net/rss/news4vip.rdf",
"http://blog.livedoor.jp/insidears/index.rdf",
"http://2ch-ranking.net/rss/newsplus.rdf",
"http://2ch-ranking.net/rss/news.rdf",
"http://nullpoantenna.com/game.rdf",
"http://workingnews.blog117.fc2.com/?xml",
"http://bm.s5-style.com/feed",
"http://2ch-ranking.net/rss/ghard.rdf",
"http://www.724685.com/blog/rss.xml",
"http://www.yukawanet.com/index.rdf",
"http://2ch-ranking.net/rss/bizplus.rdf",
"http://www.nicovideo.jp/ranking/fav/daily/all?rss=2.0&lang=ja-jp",
"http://www.tarikin.net/rss0.rdf",
"http://blog.livedoor.jp/dqnplus/index.rdf",
"http://www.seojapan.com/blog/feed",
"http://2ch-ranking.net/rss/morningcoffee.rdf",
"http://2ch-ranking.net/mt50k.rdf",
"http://rssblog.ameba.jp/yandereotto/rss20.xml",
"https://business.nikkei.com/rss/sns/nb.rdf",
"http://daredemopc.blog51.fc2.com/?xml",
"http://erogetaikenban.blog65.fc2.com/?xml",
"http://news.goo.ne.jp/rss/topstories/gootop/index.rdf",
"http://lanovelien.blog121.fc2.com/?xml",
"http://news.livedoor.com/topics/rss/eco.xml",
"http://ragnarokonline.gungho.jp/index.rdf",
"http://rocketnews24.com/feed/",
"https://news.denfaminicogamer.jp/feed",
"http://www.igda.jp/?feed=rss2",
"http://feeds.cnn.co.jp/cnn/rss"
];
// ==== アプリ状態 ====
if (!localStorage.getItem('verse_shared_rssFeeds')) {
localStorage.setItem('verse_shared_rssFeeds', JSON.stringify(PRESET_RSS));
}
let users = JSON.parse(localStorage.getItem('verse_users') || '[]');
let currentUser = JSON.parse(localStorage.getItem('verse_currentUser') || 'null');
let posts = JSON.parse(localStorage.getItem('verse_posts') || '[]');
let isDarkMode = localStorage.getItem('verse_darkMode') === 'true';
let isInitialized = false;
// BOT / RSS 状態
let botInterval = null;
let rssInterval = null;
// タイムラインフィルタ&検索
let currentFilter = 'all';
let currentSearch = '';
// 共有RSS設定
let sharedRssFeeds = JSON.parse(localStorage.getItem('verse_shared_rssFeeds') || '[]');
let sharedRssInterval = Number(localStorage.getItem('verse_shared_rssInterval')) || 300;
let sharedRssLastIds = JSON.parse(localStorage.getItem('verse_shared_rssLastIds') || '{}');
let sharedRssEnabled = JSON.parse(localStorage.getItem('verse_shared_rssEnabled') || '{}');
// アイコン設定(BOT, Feed)
let verseIcons = JSON.parse(localStorage.getItem('verse_icons') || 'null');
if (!verseIcons) {
verseIcons = {
bot: 'https://cdn-icons-png.flaticon.com/512/4712/4712109.png',
feed: 'https://cdn-icons-png.flaticon.com/512/3416/3416046.png'
};
localStorage.setItem('verse_icons', JSON.stringify(verseIcons));
}
function saveIcons() {
localStorage.setItem('verse_icons', JSON.stringify(verseIcons));
updateAllUI();
}
function uploadIcon(type, e) {
const f = e.target.files[0];
if (!f) return;
if (f.size > 5 * 1024 * 1024) { alert('5MB以下にしてください。'); return; }
const r = new FileReader();
r.onload = () => {
verseIcons[type] = r.result;
saveIcons();
alert(type.toUpperCase() + ' アイコンを更新しました');
};
r.readAsDataURL(f);
}
// ==== キャッシュクリア ====
function clearVerseCache() {
if (!confirm('Tsumugi / Verse のローカルキャッシュ(ユーザー, 投稿, RSS設定など)をすべて削除します。よろしいですか?')) return;
Object.keys(localStorage).forEach(k => {
if (k.startsWith('verse_')) localStorage.removeItem(k);
});
alert('ローカルキャッシュを削除しました。ページを再読み込みします。');
location.reload();
}
// ===== 認証UI =====
function showAuthModal(mode = 'login', errorMsg = '') {
document.getElementById('auth-title').textContent = (mode === 'register') ? '新規登録' : 'ログイン';
document.getElementById('auth-form').authMode = mode;
document.getElementById('auth-email').value = '';
document.getElementById('auth-password').value = '';
document.getElementById('auth-modal').style.display = '';
document.getElementById('main-content').style.display = 'none';
document.getElementById('auth-error').textContent = errorMsg || '';
document.getElementById('auth-error').style.display = errorMsg ? '' : 'none';
document.getElementById('toggle-auth-mode').textContent = (mode === 'register') ? 'ログインはこちら' : '新規登録はこちら';
}
function hideAuthModal() {
document.getElementById('auth-modal').style.display = 'none';
document.getElementById('main-content').style.display = '';
}
document.addEventListener('DOMContentLoaded', () => {
document.getElementById('auth-form').onsubmit = function(e) {
e.preventDefault();
const email = document.getElementById('auth-email').value.trim().toLowerCase();
const password = document.getElementById('auth-password').value;
if (!email || !password) {
showAuthModal(this.authMode, 'メールアドレスとパスワードを入力してください');
return;
}
if (this.authMode === 'register') {
if (users.find(u => u.email === email)) {
showAuthModal('register', 'このメールアドレスは既に登録されています');
return;
}
const newUser = {
email,
password,
profile: { icon: 'https://via.placeholder.com/100', username: email.split('@')[0], selfIntro: '' }
};
users.push(newUser);
localStorage.setItem('verse_users', JSON.stringify(users));
currentUser = { email };
localStorage.setItem('verse_currentUser', JSON.stringify(currentUser));
showAuthModal('login', '登録完了!ログインしてください');
} else {
const user = users.find(u => u.email === email && u.password === password);
if (!user) { showAuthModal('login', 'メールアドレスまたはパスワードが違います'); return; }
currentUser = { email };
localStorage.setItem('verse_currentUser', JSON.stringify(currentUser));
hideAuthModal();
initializeApp();
}
};
document.getElementById('toggle-auth-mode').onclick = function() {
const mode = (document.getElementById('auth-title').textContent === '新規登録') ? 'login' : 'register';
showAuthModal(mode);
};
if (!currentUser) showAuthModal('login'); else { hideAuthModal(); initializeApp(); }
});
function logout() {
localStorage.removeItem('verse_currentUser');
currentUser = null;
stopRssAuto();
stopBotAutoPost();
showAuthModal('login');
}
// ===== 初期化 =====
function initializeApp() {
if (isInitialized) return;
if (!currentUser) { showAuthModal('login'); return; }
users = JSON.parse(localStorage.getItem('verse_users') || '[]');
posts = JSON.parse(localStorage.getItem('verse_posts') || '[]');
isDarkMode = localStorage.getItem('verse_darkMode') === 'true';
const user = users.find(u => u.email === currentUser.email);
window.profile = user ? user.profile : { icon: 'https://via.placeholder.com/100', username: 'ゲストユーザー', selfIntro: '' };
updateAllUI();
updateStatusIndicators();
updateRssUI();
if (isDarkMode) {
document.body.classList.add('dark');
} else {
document.body.classList.remove('dark');
}
document.getElementById('main-content').style.display = '';
document.getElementById('logout-btn').classList.remove('hidden');
isInitialized = true;
startRssAuto();
addLog('bot-log', 'BOT機能初期化完了', 'success');
addLog('rss-log', 'RSS自動投稿(全体共有)初期化完了', 'success');
}
// ===== 投稿(ユーザー/BOT/Feed/Markov) =====
function createUserPost() {
const ta = document.getElementById('postContent');
const txt = ta.value.trim();
if (!txt) return alert('投稿内容を入力してください。');
if (!currentUser) return alert('ログインが必要です。');
createPost(txt, 'user', profile.username, profile.icon);
ta.value = '';
updateCharCount();
}
function createPost(content, type = 'user', username = null, icon = null, extra = {}) {
if (!content || !content.trim()) return false;
let finalIcon = icon;
if (!finalIcon) {
if (type === 'bot' || type === 'markov') finalIcon = verseIcons.bot;
else if (type === 'feed') finalIcon = verseIcons.feed;
else finalIcon = profile.icon;
}
const post = {
id: Date.now() + Math.random(),
content: content.trim(),
likes: 0,
timestamp: new Date().toLocaleString('ja-JP'),
type,
username: username || profile.username,
icon: finalIcon,
userEmail: currentUser ? currentUser.email : '',
...extra
};
posts.unshift(post);
saveData();
renderTimeline();
return true;
}
function likePost(id) {
const idx = posts.findIndex(p => p.id === id);
if (idx >= 0) {
posts[idx].likes++;
saveData();
renderTimeline();
}
}
function deletePost(id) {
if (!confirm('この投稿を削除しますか?')) return;
posts = posts.filter(p => p.id !== id);
saveData();
renderTimeline();
}
function clearAllPosts() {
if (!confirm('全ての投稿を削除しますか?')) return;
posts = [];
saveData();
renderTimeline();
}
function exportData() {
const blob = new Blob([JSON.stringify(posts, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'verse_posts.json';
a.click();
URL.revokeObjectURL(url);
}
// ===== タイムライン描画 =====
function escapeHtml(s) {
return (s || '').replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"','\'':'''}[m]));
}
function renderTimeline() {
const tl = document.getElementById('timeline');
const emp = document.getElementById('empty-timeline');
const cnt = document.getElementById('post-count');
if (!tl || !emp || !cnt) return;
let displayPosts = posts.slice();
// フィルター
displayPosts = displayPosts.filter(p => {
if (currentFilter === 'user' && p.type !== 'user') return false;
if (currentFilter === 'bot' && !['bot','markov'].includes(p.type)) return false;
if (currentFilter === 'feed' && p.type !== 'feed') return false;
return true;
});
// 検索
if (currentSearch && currentSearch.trim() !== '') {
const q = currentSearch.trim().toLowerCase();
displayPosts = displayPosts.filter(p => {
const text = (p.content || '') + ' ' + (p.username || '');
return text.toLowerCase().includes(q);
});
}
if (displayPosts.length === 0) {
tl.innerHTML = '';
emp.style.display = 'block';
cnt.textContent = '(0件の投稿)';
return;
}
emp.style.display = 'none';
cnt.textContent = `(${displayPosts.length}件の投稿)`;
tl.innerHTML = displayPosts.map(p => {
const info = {
bot: '<i class="fas fa-robot mr-1"></i>BOT',
markov: '<i class="fas fa-dice mr-1"></i>MarkovBOT',
user: '<i class="fas fa-user mr-1"></i>ユーザー',
feed: '<i class="fas fa-rss mr-1"></i>FEEDBOT'
}[p.type] || '<i class="fas fa-user mr-1"></i>';
const main = p.link
? `<a href="${p.link}" target="_blank" class="text-sky-400 underline">${escapeHtml(p.content)}</a>`
: `${escapeHtml(p.content)}`;
return `
<div class="timeline-post p-6">
<div class="flex justify-between items-start mb-4">
<div class="flex items-center space-x-3">
<img src="${p.icon}" class="w-10 h-10 rounded-full object-cover border border-slate-500" onerror="this.src='https://via.placeholder.com/40'">
<div>
<div class="flex items-center">
<span class="font-semibold text-slate-100">${escapeHtml(p.username)}</span>
<span class="username-badge text-[10px]">${info}</span>
</div>
<div class="text-[11px] text-slate-400">${p.timestamp}</div>
</div>
</div>
</div>
<div class="text-slate-100 mb-4 leading-relaxed text-sm">${main}</div>
<div class="flex items-center space-x-4 pt-4 border-t border-slate-700">
<button onclick="likePost(${p.id})" class="flex items-center space-x-2 text-slate-300 hover:text-red-400 text-xs">
<i class="fas fa-heart"></i><span>${p.likes}</span>
</button>
<div class="relative">
<button onclick="toggleShareMenu(${p.id})" class="flex items-center space-x-2 text-slate-300 hover:text-sky-400 text-xs">
<i class="fas fa-share"></i><span>シェア</span>
</button>
<div id="share-menu-${p.id}" class="share-menu hidden">
<button onclick="shareToX(${p.id})"><i class="fab fa-x-twitter text-sky-400 mr-2"></i>Xでシェア</button>
<button onclick="shareToLine(${p.id})"><i class="fab fa-line text-green-400 mr-2"></i>LINEでシェア</button>
<button onclick="copyPost(${p.id})"><i class="fas fa-copy mr-2"></i>コピー</button>
</div>
</div>
<button onclick="deletePost(${p.id})" class="flex items-center space-x-2 text-slate-400 hover:text-red-400 ml-auto text-xs">
<i class="fas fa-trash"></i><span>削除</span>
</button>
</div>
</div>
`;
}).join('');
}
function getPostContentText(id) {
const p = posts.find(x => x.id === id);
if (!p) return '';
const tmp = document.createElement('div');
tmp.innerHTML = p.content;
return tmp.textContent || tmp.innerText || '';
}
function toggleShareMenu(id) {
document.querySelectorAll('[id^="share-menu-"]').forEach(el => el.classList.add('hidden'));
const m = document.getElementById('share-menu-' + id);
if (m) m.classList.toggle('hidden');
}
function shareToX(id) {
const t = encodeURIComponent(getPostContentText(id));
const u = encodeURIComponent(location.href);
window.open(`https://twitter.com/intent/tweet?text=${t}&url=${u}`, '_blank');
}
function shareToLine(id) {
const u = encodeURIComponent(location.href);
window.open(`https://social-plugins.line.me/lineit/share?url=${u}`, '_blank');
}
function copyPost(id) {
const t = getPostContentText(id);
if (navigator.clipboard) {
navigator.clipboard.writeText(t).then(() => alert('コピーしました')).catch(() => fallbackCopy(t));
} else fallbackCopy(t);
}
function fallbackCopy(t) {
const ta = document.createElement('textarea');
ta.value = t; document.body.appendChild(ta);
ta.select(); document.execCommand('copy');
document.body.removeChild(ta);
alert('コピーしました');
}
// ===== タイムライン フィルタ&検索 =====
function setFilter(f) {
currentFilter = f;
['all','user','bot','feed'].forEach(k => {
const btn = document.getElementById('filter-' + k);
if (!btn) return;
if (k === f) {
btn.classList.add('bg-indigo-600','text-white');
} else {
btn.classList.remove('bg-indigo-600','text-white');
}
});
renderTimeline();
}
// ===== RSS UI(個別ON/OFF + 一括ON/OFF) =====
function updateRssUI() {
const listDiv = document.getElementById('rss-list');
if (!listDiv) return;
if (!sharedRssFeeds || sharedRssFeeds.length === 0) {
listDiv.innerHTML = '<div class="text-slate-200 text-[11px] opacity-80">RSSフィード未登録</div>';
} else {
listDiv.innerHTML = sharedRssFeeds.map((url, i) => {
const enabled = sharedRssEnabled[url] !== false;
const enc = encodeURIComponent(url);
return `
<div class="flex items-center space-x-2 bg-slate-900 rounded px-2 py-2 mb-1 border border-slate-700 text-[11px]">
<input type="checkbox" ${enabled ? 'checked' : ''} onchange="toggleRssEnabled('${enc}', this.checked)" title="ON/OFF">
<div class="truncate flex-1 text-slate-100" title="${escapeHtml(url)}">${escapeHtml(url)}</div>
<button onclick="delRssFeed(${i})" class="text-red-400 hover:text-red-500" title="削除"><i class="fas fa-trash"></i></button>
</div>
`;
}).join('');
}
const iv = document.getElementById('rss-interval');
if (iv) iv.value = sharedRssInterval || 300;
}
function toggleRssEnabled(encUrl, on) {
const url = decodeURIComponent(encUrl);
sharedRssEnabled[url] = !!on;
saveSharedRss();
addLog('rss-log', `FEED ${on ? 'ON' : 'OFF'}: ${url}`, 'info');
}
function setAllRssEnabled(on) {
(sharedRssFeeds || []).forEach(u => sharedRssEnabled[u] = !!on);
saveSharedRss();
updateRssUI();
addLog('rss-log', `全フィードを${on ? 'ON' : 'OFF'}にしました`, 'success');
}
function saveSharedRss() {
localStorage.setItem('verse_shared_rssFeeds', JSON.stringify(sharedRssFeeds));
localStorage.setItem('verse_shared_rssInterval', String(sharedRssInterval));
localStorage.setItem('verse_shared_rssLastIds', JSON.stringify(sharedRssLastIds));
localStorage.setItem('verse_shared_rssEnabled', JSON.stringify(sharedRssEnabled));
}
function addRssFeed() {
const url = document.getElementById('rss-url').value.trim();
if (!/^https?:\/\/.+/.test(url)) { addLog('rss-log', '正しいRSSフィードURLを入力してください', 'error'); return; }
if (!sharedRssFeeds) sharedRssFeeds = [];
if (sharedRssFeeds.includes(url)) { addLog('rss-log', 'すでに登録済みです', 'error'); return; }
sharedRssFeeds.push(url);
sharedRssEnabled[url] = true;
saveSharedRss();
updateRssUI();
addLog('rss-log', `RSS追加: ${url}`, 'success');
document.getElementById('rss-url').value = '';
}
function delRssFeed(i) {
if (!sharedRssFeeds[i]) return;
if (!confirm('このフィードを削除しますか?')) return;
const url = sharedRssFeeds[i];
sharedRssFeeds.splice(i, 1);
delete sharedRssEnabled[url];
delete sharedRssLastIds[url];
saveSharedRss();
updateRssUI();
addLog('rss-log', 'RSS削除', 'info');
}
function setRssInterval() {
const iv = document.getElementById('rss-interval').valueAsNumber || 300;
if (iv < 10) return alert('間隔は10秒以上で設定してください。');
sharedRssInterval = iv;
saveSharedRss();
updateRssUI();
startRssAuto();
addLog('rss-log', `自動投稿間隔を${iv}秒に設定`, 'success');
}
function fetchRssNow() { fetchRssFeeds(); }
function fetchRssFeeds() {
if (!sharedRssFeeds || sharedRssFeeds.length === 0) return;
sharedRssFeeds.forEach(feedUrl => {
if (sharedRssEnabled[feedUrl] === false) {
addLog('rss-log', `OFFのため取得スキップ: ${feedUrl}`, 'info');
return;
}
fetch('https://api.rss2json.com/v1/api.json?rss_url=' + encodeURIComponent(feedUrl))
.then(resp => resp.json())
.then(data => {
if (!data.items || !data.items.length) return;
let lastId = sharedRssLastIds[feedUrl] || '';
let newItems = [];
for (const item of data.items) {
const guid = item.guid || item.link || item.pubDate || item.title;
if (!lastId || String(guid) > String(lastId)) newItems.push(item);
}
if (newItems.length === 0) return;
newItems.reverse().forEach(item => {
const guid = item.guid || item.link || item.pubDate || item.title;
if (!posts.some(p => p.type === 'feed' && p.link === item.link)) {
createPost(item.title, 'feed', 'FEEDBOT', verseIcons.feed, { link: item.link });
addLog('rss-log', `新しい記事: ${item.title}`, 'success');
}
sharedRssLastIds[feedUrl] = guid;
});
saveSharedRss();
})
.catch(() => addLog('rss-log', 'RSS取得エラー: ' + feedUrl, 'error'));
});
}
function startRssAuto() {
stopRssAuto();
fetchRssFeeds();
rssInterval = setInterval(fetchRssFeeds, (sharedRssInterval || 300) * 1000);
updateStatusIndicators();
addLog('rss-log', `RSS自動投稿を開始 (${sharedRssInterval}秒間隔)`, 'success');
}
function stopRssAuto() {
if (rssInterval) clearInterval(rssInterval);
rssInterval = null;
updateStatusIndicators();
addLog('rss-log', 'RSS自動投稿を停止しました', 'info');
}
// ===== BOT =====
function postBotMessage() {
const ta = document.getElementById('botContent');
const txt = ta.value.trim();
if (!txt) return alert('BOT投稿内容を入力してください。');
if (!currentUser) return alert('ログインが必要です。');
if (createPost(txt, 'bot', 'BOT', verseIcons.bot)) {
ta.value = '';
addLog('bot-log', `BOT投稿: "${txt.substring(0, 30)}..."`, 'success');
}
}
function generateMarkovText() {
let text = posts
.filter(p => ['user','bot','markov'].includes(p.type))
.map(p => {
const d = document.createElement('div');
d.innerHTML = p.content;
return (d.textContent || d.innerText || '')
.replace(/\s+/g, ' ').replace(/https?:\/\/\S+/g, '').trim();
}).join(' ');
if (text.length < 20) {
const fallbacks = [
"今日はいい天気ですね!",
"最近面白いニュースありましたか?",
"新しいアイデアが浮かんできました。",
"みんなはどう思いますか?"
];
return fallbacks[Math.floor(Math.random() * fallbacks.length)];
}
const tokens = text.match(/[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff\w]+|[。、!?\r\n]/g) || [];
if (tokens.length < 2) return tokens.join('');
const markov = {};
for (let i = 0; i < tokens.length - 2; i++) {
const key = tokens[i] + '|' + tokens[i+1];
if (!markov[key]) markov[key] = [];
markov[key].push(tokens[i+2]);
}
let idx = Math.floor(Math.random() * (tokens.length - 2));
let key = tokens[idx] + '|' + tokens[idx+1];
let result = [tokens[idx], tokens[idx+1]];
let maxLen = 60 + Math.floor(Math.random() * 40);
for (let i = 0; i < maxLen; i++) {
const nexts = markov[key];
if (!nexts || nexts.length === 0) break;
const next = nexts[Math.floor(Math.random() * nexts.length)];
result.push(next);
if (/[。!?\n]/.test(next)) break;
key = result[result.length - 2] + '|' + result[result.length - 1];
}
return result.join('').replace(/\n/g, '');
}
function postMarkovBot() {
const txt = generateMarkovText();
if (createPost(txt, 'markov', 'MarkovBOT', verseIcons.bot)) {
addLog('bot-log', `マルコフ投稿: "${txt.substring(0, 40)}..."`, 'success');
}
}
function startBotAutoPost() {
const iv = document.getElementById('botIntervalSec').valueAsNumber || 60;
if (iv < 10) { alert('間隔は10秒以上で設定してください。'); return; }
stopBotAutoPost();
setTimeout(postMarkovBot, 3000);
botInterval = setInterval(postMarkovBot, iv * 1000);
updateStatusIndicators();
addLog('bot-log', `マルコフBOT自動投稿開始 (${iv}秒間隔)`, 'success');
}
function stopBotAutoPost() {
if (botInterval) {
clearInterval(botInterval);
botInterval = null;
updateStatusIndicators();
addLog('bot-log', 'マルコフBOT自動投稿を停止しました', 'info');
}
}
// ===== 共通UI/保存 =====
function addLog(id, msg, type = 'info') {
const el = document.getElementById(id);
const ts = new Date().toLocaleTimeString('ja-JP');
const div = document.createElement('div');
const cls = { error: 'error-message', success: 'success-message', info: 'text-slate-100 text-[11px]' }[type] || 'text-slate-100 text-[11px]';
div.className = cls;
div.innerHTML = `<span class="opacity-70">[${ts}]</span> ${escapeHtml(msg)}`;
if (el) {
el.appendChild(div);
el.scrollTop = el.scrollHeight;
while (el.children.length > 100) el.removeChild(el.firstChild);
}
try { console.log(`[${ts}] ${msg}`); } catch(_) {}
}
function updateStatusIndicators() {
const botI = document.getElementById('bot-status');
const botT = document.getElementById('bot-status-text');
if (botI && botT) {
const active = botInterval !== null;
botI.className = `status-indicator ${active ? 'status-active' : 'status-inactive'}`;
botT.textContent = active ? '動作中' : '停止中';
}
const rssI = document.getElementById('rss-status');
const rssT = document.getElementById('rss-status-text');
if (rssI && rssT) {
const active = rssInterval !== null;
rssI.className = `status-indicator ${active ? 'status-active' : 'status-inactive'}`;
rssT.textContent = active ? '動作中' : '停止中';
}
}
function showSystemStatus() {
alert(
`=== Verse システムステータス ===
全体投稿数: ${posts.length}
RSS登録数: ${sharedRssFeeds.length}
BOT投稿数: ${posts.filter(p => ['bot', 'markov'].includes(p.type)).length}
BOT自動投稿: ${botInterval ? '動作中' : '停止中'}
RSS自動投稿: ${rssInterval ? '動作中' : '停止中'}`
);
}
function uploadProfileIcon(e) {
const f = e.target.files[0];
if (!f) return;
if (f.size > 5 * 1024 * 1024) { alert('5MB以下にしてください。'); return; }
const r = new FileReader();
r.onload = () => {
profile.icon = r.result;
saveProfileNoAlert();
updateAllUI();
alert('プロフィール画像更新!');
};
r.readAsDataURL(f);
}
function saveProfile() {
const un = document.getElementById('username').value.trim();
const si = document.getElementById('self-intro').value.trim();
if (un.length > 20) { alert('ユーザー名は20文字以内で。'); return; }
profile.username = un || 'ゲストユーザー';
profile.selfIntro = si;
saveProfileNoAlert();
updateAllUI();
alert('プロフィール保存!');
}
function saveProfileNoAlert() {
users = JSON.parse(localStorage.getItem('verse_users') || '[]');
const idx = users.findIndex(u => u.email === (currentUser && currentUser.email));
if (idx >= 0) {
users[idx].profile = profile;
localStorage.setItem('verse_users', JSON.stringify(users));
}
}
function updateAllUI() {
const pi = document.getElementById('profile-icon');
const hi = document.getElementById('header-profile-icon');
if (pi) pi.src = profile.icon;
if (hi) hi.src = profile.icon;
['username-preview','header-username'].forEach(id => {
const el = document.getElementById(id);
if (el) el.textContent = profile.username;
});
const sip = document.getElementById('self-intro-preview');
if (sip) sip.textContent = profile.selfIntro || 'まだ自己紹介がありません';
const emailEl = document.getElementById('header-user-email');
if (emailEl && currentUser) emailEl.textContent = currentUser.email;
const bi = document.getElementById('bot-icon-preview');
const fi = document.getElementById('feed-icon-preview');
if (bi) bi.src = verseIcons.bot;
if (fi) fi.src = verseIcons.feed;
renderTimeline();
}
function updateCharCount() {
const pc = document.getElementById('postContent');
const cc = document.getElementById('char-count');
if (pc && cc) {
const l = pc.value.length;
cc.textContent = `(${l}/500)`;
cc.style.color = l > 450 ? '#fca5a5' : '';
}
}
function toggleDarkMode() {
isDarkMode = !isDarkMode;
if (isDarkMode) {
document.body.classList.add('dark');
} else {
document.body.classList.remove('dark');
}
localStorage.setItem('verse_darkMode', isDarkMode.toString());
}
function saveData() { localStorage.setItem('verse_posts', JSON.stringify(posts)); }
// 入力UIフック
document.addEventListener('DOMContentLoaded', () => {
const pc = document.getElementById('postContent');
if (pc) {
pc.addEventListener('input', updateCharCount);
pc.addEventListener('keydown', e => { if (e.key === 'Enter' && e.ctrlKey) { e.preventDefault(); createUserPost(); } });
}
const ui = document.getElementById('username');
if (ui) ui.addEventListener('input', () => {
const v = ui.value.trim() || 'ゲストユーザー';
['username-preview','header-username'].forEach(id => {
const el = document.getElementById(id);
if (el) el.textContent = v;
});
});
const si = document.getElementById('self-intro');
if (si) si.addEventListener('input', () => {
const v = si.value.trim() || 'まだ自己紹介がありません';
const el = document.getElementById('self-intro-preview');
if (el) el.textContent = v;
});
const ts = document.getElementById('timeline-search');
if (ts) ts.addEventListener('input', () => {
currentSearch = ts.value || '';
renderTimeline();
});
setFilter('all');
});
window.addEventListener('beforeunload', () => { stopBotAutoPost(); stopRssAuto(); saveData(); });
window.addEventListener('error', e => { addLog('bot-log', `システムエラー: ${e.message}`, 'error'); });
</script>
</body>
</html>
タグ: programming
リアルモンスターARゲーム 企画書
リアルモンスターARゲーム 企画書(ドラフト)
本資料は「現実世界×位置情報×AR×収集・育成・共闘」を核にした新規モバイルゲームの企画書です。既存IP(例:某有名モンスターIP)への依存はせず、完全オリジナルIPを前提とします。
1. エレベーターピッチ / コンセプト
- 一言:街を歩けば“精霊(ファント)”が棲む。現実世界の光・天気・場所が戦略に変わる、散歩が冒険になるARコレクションRPG。
- USP(独自性)
- “現実環境がギミック”:時刻/天気/騒音/明るさ/混雑度をゲームロジックに反映。
- “協調AR”:近接プレイヤーで同一AR個体を同時観測・共同捕獲・共闘。
- “安全×自治体連携”:観光/商店街/防災訓練などの公的イベント連動で社会実装。
- “歩行以外の楽しみ”:自宅ARテラリウム、エンカウントカード、ミニゲーム。
2. ターゲット / ペルソナ
- 主要層:10〜40代のライト〜ミドルゲーマー、散歩/旅行好き、写真・SNS発信層。
- 準主要層:親子(7歳以上推奨)、健康志向ユーザー、コレクター気質。
- 地域:まずは日本主要都市→地方観光地→海外(アジア→グローバル)。
3. 世界観 / 主要キャラクター(例)
- 設定:人の感情と自然のエネルギーから生まれる“ファント”。都市/水辺/森林/寺社/工場などバイオームごとに生態が異なる。
- スターター3体(例):
- フレアット(炎/活力)…晴天・高温時に強化。
- ミズクル(潮/好奇心)…水辺・雨天で出現率UP。
- リーファ(森/癒し)…緑地で回復スキル特化。
4. 主要ゲームサイクル
4.1 コアループ
- 移動 → 2) 現実の地点でスキャン/エンカウント → 3) AR捕獲/バトル → 4) 育成/合成/装備 → 5) ミッション/イベント →(1に戻る)
4.2 ショート/ミドル/ロングサイクル
- ショート(1〜5分):その場スキャン→一体と遭遇→ミニゲームで捕獲→写真→SNS共有。
- ミドル(10〜30分):近隣スポット巡り→連鎖ボーナス→日課ミッション消化→協力レイド。
- ロング(数日〜週):育成/進化ルート選択→地形限定種の収集→シーズンイベント攻略。
5. ゲームシステム詳細
5.1 位置情報×天候×時間
- 出現テーブル:バイオーム×天候(晴/曇/雨/雪/強風)×時間帯(朝/昼/夕/夜)×気温でドロップ/遭遇率を可変。
- 行動制限:夜間は住宅地のスポーンを抑制、駅構内は安全配慮でエンカウント無し。
5.2 AR捕獲(スキルベース)
- ARリングを端末のモーションで合わせる→端末の照度/安定度で成功率変化。
- 近接協力:2〜4人同時の“リンクキャスト”で捕獲ゲージ共有&強個体出現。
5.3 バトル
- 属性:炎/水/森/雷/岩/光/影(例)。2スキル+1奥義のリアルタイム・ライトアクション。
- 位置ギミック:磁場(駅周辺)/潮位(水辺)/騒音(繁華街)でバフ/デバフ。
- レイド:ランドマークに定時/随時で発生、ARで巨大個体が出現。
5.4 育成/進化/合成
- 成長ルート:探索型/戦闘型/支援型の3系統。分岐進化は場所・時刻・天候を条件化。
- 装備(護符):地域素材からクラフト。観光地限定素材でご当地ビルド。
5.5 拠点/テラリウム
- 自宅でARテラリウムを配置。収集個体が生活し、環境飾り(植物/雨/灯り)を置くとパッシブ効果。
5.6 ソーシャル
- 近接ルーム:半径20mで自動ルーム結成→トレード/共闘。
- 写真モード:被写体検出・ポーズ指示・露出/被写界深度調整→#ハッシュ共有。
6. 安全設計・配慮
- ながらスマホ抑止:一定速度以上でプレイ制限/操作簡略化。
- 立入禁止検知:学校/私有地/危険区域はスポーン無効化。
- 夜間配慮:22:00〜6:00は住宅地レイド停止。
- プライバシー:位置情報はボロノイ領域等で空間的匿名化、サーバ保存は最小限(ハッシュ化/丸め)。
7. マネタイズ
- コスメ課金:スキン/ポーズ/ARエフェクト(性能差なし)。
- バトルパス:探索/撮影/協力でポイント→限定装飾。
- ローカルコラボ:商店街/自治体のデジタルスタンプラリー有料参加。
- 広告:任意視聴でブースト(年齢配慮)。
- 確率表示/資金決済法対応:ガチャ導入時は提供割合の明示、天井/重複救済を実装。
8. KPI / 事業目標
- 主要KPI:D1/7/30リテンション、WAU/MAU、課金率、AR写真投稿率、協力レイド参加率、平均歩数。
- 目標(Year1):
- MAU 50万 / 月、課金率 4.0%、AR写真月間投稿 30万件、平均セッション8分×2回/日。
9. コンテンツ運用
- シーズン制(6〜8週):新バイオーム/限定進化/レイド。
- 地域イベント:花見/夏祭り/紅葉/雪祭り/花火大会と連動。
- クリエイタープログラム:写真/ARハントの二次創作を促進(ガイドライン整備)。
10. 技術アーキテクチャ(想定)
- クライアント:Unity + AR Foundation(ARKit/ARCore対応)、LiDAR対応端末は平面/深度優遇。
- サーバ:マッチング/レイドはPhoton Fusion/Realtime、常時はFirebase + Cloud Functions。
- 地図/POI:Mapbox/Google Maps Platform。安全レイヤ(危険/禁止区域)を別テーブル管理。
- データ:ビッグクエリで行動データ解析。位置データは粗度を保つ。
- チート対策:移動速度/加速度の多軸判定、ルート一貫性、端末脱獄検知。
11. 開発体制 / スケジュール(例)
- チーム編成(初期10〜15名):
- PM 1 / プランナー 2 / クライアントエンジニア 4 / サーバ 2 / アート 3 / データ 1 / QA 2
- ロードマップ:
- M0-1(調査):プロトタイプ:AR捕獲、位置/天候連動、近接同期。
- M2-4(α):基本育成・写真・小規模レイド、テラリウム初期版。
- M5-6(CBT):地元2都市でクローズド、KPIチューニング。
- M7-9(OBT):全国主要都市展開、自治体タイアップ開始。
- M10-12(正式):シーズン1開幕、海外ローカライズ準備。
12. 画面遷移 / UIワイヤ(テキスト)
- ホーム/マップ:上部:天気/時間、中央:ARスキャン、下部:近接ルーム/ミッション/撮影。
- 遭遇AR:カメラ起動→平面検出→ファント投影→リング合わせ→捕獲。
- バトル:左スワイプ回避/右タップ攻撃/長押し奥義、上部に環境バフ表示。
- テラリウム:ルーム編集→飾り設置→効果プレビュー→SNS投稿。
13. 法務・コンプライアンス
- 位置情報/個人情報:プライバシーポリシー、DPO設定、オプトイン/オプトアウト。
- 年少者保護:夜間プレイ抑制、課金上限、保護者同意フロー。
- 著作権/商標:キャラ/名称はオリジナル創作。第三者IP非使用方針。
- 景表法:確率型アイテムは表記・救済策・履歴開示。
14. 競合/差別化の要点
- 既存:位置×収集(GO系)、位置×ハンティング(MH系)、歩行×育成(Bloom系)。
- 差別化:環境ギミックの“戦略化”、協調ARの常設、観光/自治体との恒常連携、写真×クリエイター経済。
15. 収支イメージ(概算・初年度)
- 開発費:2.5〜3.5億円(12ヶ月/15名規模、外注含む)。
- 運用費(月):サーバ/地図/分析/CS/イベントで1,500〜2,500万円。
- 売上:MAU50万×ARPPU月1,200円×課金率4% ≒ 月2.4億円想定(広告/コラボ除く)。
16. リスクと対策
- 初期離脱:チュートリアル短縮/“最初の一体”を確実に入手/撮影で成功体験。
- 安全問題:速度検知/危険地帯除外/夜間制限/注意喚起UI。
- 位置チート:速度と加速度の相関、履歴の異常検知、端末整合チェック。
- 季節性/天候偏重:屋内コンテンツ/テラリウム/日替りミッションで平滑化。
17. 成長戦略
- UGC推進:フォトコン、ARフィルタ配布、作品マーケット(審査制)。
- コラボ:ご当地キャラ/観光協会/鉄道/小売。現地での限定クエスト。
- 海外展開:各国の宗教/文化施設はスポーン除外設定を現地監修。
18. 参考KPI計測設計
- ファネル(DL→FTUE→Day1/7/30)、地図ヒートマップ(密度/滞在/回遊)、安全指標(危険接近アラート発生率)。
19. 添付:クエスト例
- “雨上がりの公園で”:雨後2時間以内の緑地で限定個体出現、足跡トレイル追跡ミニゲーム。
- “灯りの路地”:夜19–21時の商店街で光属性が出現、写真投稿でバフ。
次アクション(提案)
- プロトタイプ要件定義(AR捕獲・協調同期・環境バフの3本柱)
- フィールドテスト都市の選定(例:渋谷区/台東区)
- 自治体/商店街のヒアリング開始(安全/回遊/混雑配慮)
YESキリストBOT
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>YESキリスト BOT</title>
<!-- Tailwind(CDN) -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Font Awesome(アイコン) -->
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css" rel="stylesheet">
<meta name="description" content="YESキリストBOT:優しく背中を押してくれるシンプルなチャットボット。今日の励まし、進むべき?などを相談できます。" />
<style>
/* スクロールバー控えめ */
* { scrollbar-width: thin; scrollbar-color: #cbd5e1 transparent; }
*::-webkit-scrollbar { height: 8px; width: 8px; }
*::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 8px; }
/* バブルの三角 */
.bubble:after{
content:""; position:absolute; bottom:-6px; left:16px; border:6px solid transparent; border-top-color:rgba(255,255,255,0.9);
filter: drop-shadow(0 1px 0 rgba(15,23,42,.05));
}
.bubble.me:after{ left:auto; right:16px; border-top-color:#dcfce7; }
</style>
</head>
<body class="min-h-screen bg-gradient-to-b from-slate-50 to-white text-slate-800">
<!-- コンテナ -->
<div class="mx-auto max-w-3xl px-4 py-6">
<!-- ヘッダー -->
<header class="flex items-center justify-between rounded-2xl bg-white/90 backdrop-blur shadow-sm p-4">
<div class="flex items-center gap-3">
<div class="h-10 w-10 rounded-full bg-emerald-500 text-white grid place-items-center">
<i class="fa-solid fa-dove"></i>
</div>
<div>
<h1 class="text-xl font-bold">YESキリスト BOT</h1>
<p class="text-xs text-slate-500">優しく「YES」で背中を押すチャット</p>
</div>
</div>
<div class="flex items-center gap-2">
<button id="btnClear" class="text-xs px-3 py-1.5 rounded-lg bg-slate-100 hover:bg-slate-200 transition">
履歴クリア
</button>
<button id="btnExport" class="text-xs px-3 py-1.5 rounded-lg bg-slate-100 hover:bg-slate-200 transition">
エクスポート
</button>
<label class="text-xs px-3 py-1.5 rounded-lg bg-slate-100 hover:bg-slate-200 transition cursor-pointer">
インポート<input id="fileImport" type="file" accept="application/json" class="hidden">
</label>
</div>
</header>
<!-- プリセット -->
<section class="mt-4 grid grid-cols-2 sm:grid-cols-4 gap-2">
<button class="preset chip">今日の励まし</button>
<button class="preset chip">挑戦していい?</button>
<button class="preset chip">許してもいい?</button>
<button class="preset chip">進むべき?</button>
</section>
<!-- チャット -->
<main id="chat" class="mt-4 h-[60vh] overflow-y-auto rounded-2xl bg-white/90 backdrop-blur p-4 shadow-sm space-y-4">
<!-- 初期メッセージ -->
</main>
<!-- 入力欄 -->
<form id="composer" class="mt-4 flex items-end gap-2">
<textarea id="input" rows="1" placeholder="ここに相談を書いてね(例:新しいことに挑戦しても大丈夫?)"
class="flex-1 resize-none rounded-2xl border border-slate-200 bg-white p-3 focus:outline-none focus:ring-2 focus:ring-emerald-300"></textarea>
<button id="btnSend" type="submit" class="h-11 px-4 rounded-2xl bg-emerald-500 text-white hover:bg-emerald-600 transition">
<i class="fa-solid fa-paper-plane"></i>
</button>
</form>
<!-- 使い方 -->
<details class="mt-4 rounded-2xl bg-slate-50 p-4 text-sm text-slate-600">
<summary class="cursor-pointer font-semibold">使い方</summary>
<ul class="list-disc pl-5 mt-2 space-y-1">
<li>メッセージを送ると、YESキリストが優しく背中を押す言葉で返します。</li>
<li><code>/prayer</code> で短いお祈り風メッセージ、<code>/bless</code> で祝福文。</li>
<li>履歴はブラウザに保存されます(ローカルのみ)。</li>
</ul>
</details>
</div>
<script>
// ====== 設定 ======
const STORAGE_KEY = 'yeschrist_history_v1';
// YESキリストの返答テンプレ
const YES_OPENERS = [
"あなたの心に、静かなYESが灯っています。",
"恐れずに、やさしいYESで一歩を。",
"迷いの中にいても、大丈夫。答えはYESです。",
"小さな信頼が、大きなYESへと育ちます。",
"あなたの良き思いに、YESを重ねましょう。"
];
const YES_ENCOURAGE = [
"試みは愛によって導かれ、愛は前進にYESと言います。",
"完全でなくていい。歩き出す勇気にYES。",
"扉は叩く者に開かれます。ノックにYES。",
"あなたの賜物は隠さずに、光の下へ。YES。",
"やさしさを選ぶ度に、道は明るくなります。YES。"
];
const YES_TAGS = [
"平安がありますように。",
"あなたは一人ではありません。",
"今日の小さな一歩を大切に。",
"心に光を。",
"祝福とともに。"
];
const PRAYERS = [
"天のやさしさがあなたを包み、歩みを照らしますように。アーメン。",
"弱さのときにこそ力が満ちますように。アーメン。",
"迷う心に静けさが与えられますように。アーメン。"
];
const BLESS = [
"あなたの決断に平安が伴いますように。",
"出るにも入るにも祝福が満ちますように。",
"今日の働きに恵みがありますように。"
];
// ====== DOM ======
const chat = document.getElementById('chat');
const input = document.getElementById('input');
const form = document.getElementById('composer');
const btnSend = document.getElementById('btnSend');
const btnExport = document.getElementById('btnExport');
const btnClear = document.getElementById('btnClear');
const fileImport = document.getElementById('fileImport');
document.querySelectorAll('.preset').forEach(el => el.classList.add(
'px-3','py-2','rounded-xl','bg-emerald-50','text-emerald-700','hover:bg-emerald-100','transition','text-sm','chip'
));
// ====== ユーティリティ ======
const nowStr = () => new Date().toLocaleString();
const rand = arr => arr[Math.floor(Math.random() * arr.length)];
const saveHistory = () => {
const items = [...chat.querySelectorAll('[data-msg]')].map(el => ({
role: el.dataset.role, text: el.dataset.msg, time: el.dataset.time
}));
localStorage.setItem(STORAGE_KEY, JSON.stringify(items));
};
const loadHistory = () => {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return [];
try { return JSON.parse(raw); } catch { return []; }
};
function appendMessage(role, text, time = nowStr()) {
const wrapper = document.createElement('div');
wrapper.className = role === 'user'
? 'flex justify-end'
: 'flex justify-start';
const bubble = document.createElement('div');
bubble.className = 'relative max-w-[85%] rounded-2xl p-3 bubble shadow-sm ' +
(role === 'user' ? 'bg-emerald-100 me' : 'bg-white/90');
bubble.textContent = text;
const meta = document.createElement('div');
meta.className = 'mt-1 text-[10px] text-slate-500 ' + (role === 'user' ? 'text-right' : 'text-left');
meta.textContent = time;
const container = document.createElement('div');
container.dataset.msg = text;
container.dataset.role = role;
container.dataset.time = time;
container.className = 'space-y-1';
container.appendChild(bubble);
container.appendChild(meta);
wrapper.appendChild(container);
chat.appendChild(wrapper);
chat.scrollTop = chat.scrollHeight;
}
function systemWelcome() {
appendMessage('assistant',
'ようこそ。YESキリストは、あなたの良き願いに「YES」で寄り添います。/prayer で短いお祈り、/bless で祝福文が届きます。');
}
function composeYesReply(userText) {
const lower = (userText || '').toLowerCase();
let opener = rand(YES_OPENERS);
let body = rand(YES_ENCOURAGE);
let tag = rand(YES_TAGS);
// ほんの少しだけ文脈スパイス
if (/[??]$/.test(userText)) {
opener = "その問いかけに、穏やかなYESが返っています。";
}
if (/(許|ゆる)す/.test(userText)) {
body = "赦しは心を自由にし、あなたを前へ押し出します。YES。";
}
if (/(挑戦|チャレンジ|challenge)/i.test(userText)) {
body = "小さくとも踏み出す一歩は尊く、次の景色を連れてきます。YES。";
}
if (/(進|やめ|辞め|やる|やら)/.test(userText)) {
tag = "平安のあるほうへ。YES。";
}
return `${opener}\n${body}\n${tag}`;
}
async function reply(userText) {
// コマンド
if (userText.trim().startsWith('/prayer')) {
appendMessage('assistant', rand(PRAYERS));
saveHistory(); return;
}
if (userText.trim().startsWith('/bless')) {
appendMessage('assistant', rand(BLESS));
saveHistory(); return;
}
// YES返答
const thinking = document.createElement('div');
thinking.className = 'text-xs text-slate-500';
thinking.textContent = '…考えています';
chat.appendChild(thinking); chat.scrollTop = chat.scrollHeight;
await new Promise(r => setTimeout(r, 300)); // 小さな演出
thinking.remove();
appendMessage('assistant', composeYesReply(userText));
saveHistory();
}
// ====== イベント ======
form.addEventListener('submit', async (e) => {
e.preventDefault();
const text = input.value.trim();
if (!text) return;
appendMessage('user', text);
input.value = '';
input.style.height = '44px';
saveHistory();
reply(text);
});
// 自動リサイズ
input.addEventListener('input', () => {
input.style.height = 'auto';
input.style.height = Math.min(input.scrollHeight, 160) + 'px';
});
// プリセット
document.querySelectorAll('.preset').forEach(btn => {
btn.addEventListener('click', () => {
const q = btn.textContent.trim();
appendMessage('user', q);
saveHistory();
reply(q);
});
});
// クリア
btnClear.addEventListener('click', () => {
if (!confirm('履歴をすべて削除しますか?')) return;
localStorage.removeItem(STORAGE_KEY);
chat.innerHTML = '';
systemWelcome();
});
// エクスポート
btnExport.addEventListener('click', () => {
const data = localStorage.getItem(STORAGE_KEY) ?? '[]';
const blob = new Blob([data], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `yeschrist_history_${Date.now()}.json`;
a.click();
URL.revokeObjectURL(a.href);
});
// インポート
fileImport.addEventListener('change', async (e) => {
const file = e.target.files?.[0];
if (!file) return;
const text = await file.text();
try {
const arr = JSON.parse(text);
if (!Array.isArray(arr)) throw new Error('format');
localStorage.setItem(STORAGE_KEY, text);
chat.innerHTML = '';
arr.forEach(m => appendMessage(m.role, m.text, m.time));
} catch {
alert('インポート失敗:JSON形式が正しくありません。');
} finally {
fileImport.value = '';
}
});
// ====== 初期化 ======
(function init() {
const hist = loadHistory();
if (hist.length === 0) {
systemWelcome();
} else {
hist.forEach(m => appendMessage(m.role, m.text, m.time));
}
// 入力高さ初期
input.style.height = '44px';
})();
</script>
</body>
</html>
BCI出力の2030s
「脳に直接世界を書き込む」革命
→ 2030年代=フルダイブVRの本番スタート
- BCI出力とは?(超簡潔)
方向説明2030sで何が変わる?BCI入力(脳 → PC)思考でカーソル動かす今ある(Neuralink 2025実証)BCI出力(PC → 脳)脳に「映像・触覚・味」を送る2030sで実用化 ← これが鍵
- 2030sのBCI出力:何ができる?
視覚:4Kフルカラー映像を脳に直接投影
触覚:全身の「風」「痛み」「抱擁」
聴覚:立体音 + 音楽を脳内再生
味覚・嗅覚:ステーキの味 + バラの香り
- 2030sロードマップ(根拠付き)
年次技術マイルストーン2025視覚:点滅光を脳に書き込み(人間)2028視覚:16×16ピクセル映像2030視覚:低解像度動画(320p)
触覚:指先の圧力2032視覚:HD映像(1080p)
触覚:全身基本ハプティクス2035五感フルシミュレーション(視覚4K + 全身触覚 + 味覚)2037フルダイブVR商用化
- 2030sのBCI出力:どうやって実現?
技術説明2030sでの進化電極アレイ脳に針を刺して信号送受信1億電極(今:1万)光遺伝学光でニューロンをON/OFF非侵襲レーザーで出力ナノボット血管内を泳いで脳に信号2035年実用化予測AIマッピング個人脳をAIで学習1秒でキャリブレーション
- 2030sの「フルダイブVR」体験例
text【2037年、君の1日】 - 起床 → 思考「ログイン」
- 脳に「朝の光 + コーヒーの香り + 温かいマグカップ」
- 思考「剣を振る」→ 金属音 + 衝撃 + 鉄の匂い
- 思考「ログアウト」→ 現実に戻る
→ 現実と区別不能 - 2030sへの「個人準備」ロードマップ
時期アクション2025Vision Pro 2 + EEGで擬似フルダイブ2026Synchron BCI(血管内)で本物入力2028Neuralink出力ベータ参加2030BCI出力キット購入($10,000)2035フルダイブ常時装着
結論:2030s = 脳が「第二の現実」になる
BCI出力の2030s
→ 脳に「世界」を書き込める時代
→ フルダイブVRの本番スタート
Metaが開発してる超知能AIのしくみ
ざっくり言うと、Metaのいう「超知能(personal superintelligence)」は、Llama系の次世代“超”基盤モデルを中心に、自己改善ループと巨大インフラを噛み合わせた“個人に配る超強いAI”の構想です。公開情報ベースで、仕組みはだいたい下の積み上げになります。
仕組み(公開情報から見える設計)
1) 中核モデル(基盤)
- Llama 4 世代をベースにした“ネイティブ多モーダル”の長文対応モデル群(画像・音声・テキストを最初から扱える系統)。これはLlama 4の発表内容からの延長線上にあり、文脈長の大幅拡張が特徴です。ai.meta.com
- Metaは「個人向けの超知能」を明確に掲げており、UIの表現は“Personal Superintelligence”として整理されています。meta.com+1
2) 自己改善ループ(Self-Improving)
- ザッカーバーグは「人手を最小化した学習」「自己改善」を重視する新ラボ方針を説明。モデルが自律的に学習・改善していく方向を打ち出しています。WIRED
- 7/30の発表では“最初の一歩”として自己改善の兆しに言及し、最強モデルは全面公開しない方針も示唆。Live Science+1
3) データ&データエンジン
- 学習データの整備・合成を加速するため、Scale AI と大型提携(49%出資)・人材登用。大規模データパイプラインで継続的にモデルを回す狙い。AP News
4) インフラ(コンピュート&ネットワーク)
- 「数千億ドル規模」の投資で、複数地域に跨る超大型AIデータセンター群を建設。電力・冷却・高帯域ネットワークを含む“AI工場”化を推進。Reuters+1
- Meta公式エンジニアリングの発信でも、AI時代向けに設計を総張り替え(スケールアウト、AI向けネットワーキング、カスタムシリコンまで)。OCP 2025でも地域分散DCの設計指針を共有。fierce-network.com+3engineering.fb.com+3techblog.comsoc.org+3
5) 公開ポリシー(オープン vs クローズ)
- Llama系は“オープンウエイト”路線を続けつつ、超知能クラスは安全面を見て全部は公開しない可能性を明言。TechCrunch
まとめ(何が“超”なの?)
- モデル能力:多モーダル×超長文×自己改善で、タスク汎用性と継続学習性を押し上げる。WIRED+1
- データエンジン:実データ+合成データ+継続評価の循環で“学習→運用→改善”を高速回転。AP News
- AI工場(インフラ):超巨大DC、広域ネットワーク、(将来的な)カスタムチップまで含めた垂直統合で、莫大な計算を常時供給。Reuters+2engineering.fb.com+2
- 提供形態:一般向けには“個人の参謀”として出しつつ、最上位は慎重運用。meta.com+1
もっと深掘りしますか?たとえば「自己改善の技術的ループ(評価→データ生成→再学習)」「多モーダル推論パイプライン」「データセンター構成(電力・冷却・ネット)」など、図で噛み砕いて解説できます。
VisitionPro2の価格予想
- VisitionPro(= Apple Vision Pro 現行モデル)の価格予想(日本)
→ 税込 599,800円(256GB)を維持と見ます。実際に日本発売時も599,800円で、米国のベース価格は$3,499据え置きのままです。PhoneArena+2Apple+2 - VisitionPro 2(第2世代 or 後継)価格予想(日本)
シナリオ別に幅を持たせます:- マイナー刷新(現行の後継・性能向上版)
→ 税込 599,800円前後(±3万円)
根拠:直近のアップデート版も米国$3,499据え置きで発表されており(日本は為替変動がなければ59.98万円帯が目安)。The Verge+1 - 廉価版(別ライン)
→ 税込 299,800〜349,800円のレンジを予想(精度低め)。
根拠:2025年に約30万円台の低価格版が出るとの報道・噂が過去にあり(正式名称や発売確度は不明)。実現すればこの帯が目安。GIGAZINE+1
- マイナー刷新(現行の後継・性能向上版)
補足(前提と不確実性)
WordPressの基礎
WordPressって?
- 世界シェア最大のCMS(Webサイト管理システム)。ブログからコーポレート、EC(WooCommerce)まで対応。
- WordPress.org(自分でサーバーに入れる・自由度高) / WordPress.com(ホスティング付き・制限あり)
動かすには(要件と準備)
- サーバー:PHP(最新版推奨)、MySQL/MariaDB、HTTPS
- 取得物:独自ドメイン(任意)+レンタルサーバー or ローカル環境(Local/Wamp/MAMPなど)
- 手順:
- サーバーにデータベース作成
- WordPress公式ZIPをアップロード&解凍
wp-config.php設定(画面案内に従う)- 管理画面ログイン(/wp-admin)
管理画面の地図(超重要)
- 投稿(ブログ記事) / 固定ページ(会社概要などの“独立ページ”)
- メディア(画像・動画)
- 外観(テーマ・メニュー・ウィジェット・サイトエディター/カスタマイザー)
- プラグイン(機能追加)
- ユーザー(権限:管理者/編集者/投稿者/寄稿者/購読者)
- 設定(一般/表示/ディスカッション/メディア/パーマリンク)
ブロックエディター(Gutenberg)の使い方の型
- 見出しブロック(H2/H3)で骨組み
- 段落・画像・ギャラリー・ボタン等のブロックを追加
- 再利用ブロック(パーツ化)で共通部品に
- ページは固定ページ、記事は投稿+カテゴリ/タグで整理
テーマとデザイン
- テーマ=サイト全体の見た目(ブロックテーマ/従来テーマ)
- 子テーマ:テーマ更新に備えてカスタマイズを上書きしないための“安全な入れ物”
- テンプレート階層(ざっくり):
front-page.php→home.php→single.php/page.php→archive.php→index.php
最低限入れると楽なプラグイン(指針)
- SEO:基本は1つだけ(例:All in One SEO など)
- キャッシュ/高速化:ページキャッシュ+画像圧縮(WebP)
- セキュリティ:ログイン試行制限、基本的な防御
- 連絡先フォーム:Contact Form 7 等
※ 入れすぎ注意。まずは必要最低限。
パーマリンク(URL)設定
- 初期設定のままだとNG。
設定 → パーマリンク → 投稿名(/%postname%/)が分かりやすい。
バックアップと更新の鉄則
- バックアップ(DB+wp-content)→ テーマ/プラグイン/本体の順で更新
- 重大変更はステージング(テスト環境)で確認してから本番へ
役割分担の考え方
- 投稿:日付順のコンテンツ(ニュース/ブログ)
- 固定ページ:構造ページ(トップ/会社情報/サービス)
- カテゴリ/タグ:情報の“整理”と“横断”
よく使うカスタマイズの入口
- CSSだけで調整:外観 → 追加CSS
- ちょいPHP:子テーマの
functions.php(スニペット集約でもOK) - 独自データが必要:カスタム投稿タイプ+カスタムフィールド(ACFが定番)
失敗しやすいポイント
- プラグイン入れすぎで重い/競合する
- 子テーマを作らずに親テーマを直接編集
- 画像が巨大(→ アップ前に圧縮/リサイズ、WebP化)
- 本番でいきなり更新してレイアウト崩壊(→ 事前バックアップ&ステージング)
すぐ使える最小チェックリスト
- パーマリンクを「投稿名」に変更
- サイト基本情報(タイトル/キャッチ/ロゴ/ファビコン)
- トップ・会社概要・お問い合わせの固定ページ作成
- メニュー作成(外観 → メニュー/サイトエディター)
- お問い合わせフォーム設置&送信テスト
- sitemap.xmlとrobots.txt(SEOプラグインで自動生成可)
- 画像圧縮&キャッシュで表示速度を確保
- バックアップの定期実行設定
QuestFoundry
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Quest Foundry | 世界観からクエスト自動設計</title>
<meta name="description" content="世界観のキーワードからNPC・アイテム・場所・クエストを一括生成。JSON/CSVエクスポート、依存関係、難易度バランス、シード固定対応。" />
<!-- Tailwind CDN (Node不要) -->
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: { sans: ["Noto Sans JP", "ui-sans-serif", "system-ui"] },
colors: { brand: { 50: '#eef2ff', 100:'#e0e7ff', 200:'#c7d2fe', 300:'#a5b4fc', 400:'#818cf8', 500:'#6366f1', 600:'#4f46e5', 700:'#4338ca', 800:'#3730a3', 900:'#312e81'} }
}
}
};
</script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@400;700;900&display=swap" rel="stylesheet">
<style>
html, body { height: 100%; }
.glass { backdrop-filter: blur(10px); background: rgba(255,255,255,0.7); }
.prose pre { white-space: pre-wrap; word-break: break-word; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
.card { @apply rounded-2xl shadow-lg p-5 bg-white; }
.prose h1{font-size:1.5rem;line-height:1.3;margin:0 0 .6rem;font-weight:800}
.prose h2{font-size:1.2rem;line-height:1.35;margin:1.2rem 0 .4rem;font-weight:700;border-left:4px solid #6366f1;padding-left:.6rem}
.prose h3{font-size:1rem;line-height:1.4;margin:1rem 0 .3rem;font-weight:700}
.prose ul{list-style:disc;padding-left:1.25rem;margin:.4rem 0 .8rem}
.prose li{margin:.2rem 0}
.badge{display:inline-block;font-size:.72rem;line-height:1;background:#eef2ff;color:#3730a3;border:1px solid #c7d2fe;border-radius:.5rem;padding:.15rem .45rem;margin-right:.25rem}
details.quest{border:1px solid #e5e7eb;border-radius:.75rem;padding:.6rem .8rem;margin:.5rem 0;background:#fff}
details.quest > summary{cursor:pointer;list-style:none}
details.quest > summary::-webkit-details-marker{display:none}
.kv{display:inline-grid;grid-template-columns:auto auto;gap:.2rem .6rem;align-items:center}
</style>
</head>
<body class="min-h-screen bg-gradient-to-br from-brand-50 to-white text-slate-800">
<header class="sticky top-0 z-40 border-b bg-white/80 backdrop-blur">
<div class="mx-auto max-w-7xl px-4 py-3 flex items-center gap-4">
<div class="text-2xl font-black tracking-tight"><span class="text-brand-700">Quest</span> Foundry</div>
<div class="text-xs text-slate-500">世界観→NPC/アイテム/場所/クエストを自動生成(JSON/CSV出力可)</div>
<div class="ml-auto flex items-center gap-2">
<button id="btnSave" class="px-3 py-2 text-sm rounded-lg border hover:bg-slate-50">保存</button>
<button id="btnLoad" class="px-3 py-2 text-sm rounded-lg border hover:bg-slate-50">読込</button>
<button id="btnPrint" class="px-3 py-2 text-sm rounded-lg border hover:bg-slate-50">印刷/PDF</button>
</div>
</div>
</header>
<main class="mx-auto max-w-7xl px-4 py-6 grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- 左:設定フォーム -->
<section class="lg:col-span-1 card">
<h2 class="text-lg font-bold mb-4">ワールド設定</h2>
<form id="worldForm" class="space-y-4">
<div>
<label class="block text-sm font-medium">世界名</label>
<input id="worldName" type="text" class="w-full mt-1 rounded-lg border px-3 py-2" placeholder="例:アトラティア" />
</div>
<div>
<label class="block text-sm font-medium">テーマ・キーワード(読点・スペース区切り)</label>
<input id="themes" type="text" class="w-full mt-1 rounded-lg border px-3 py-2" placeholder="例:古代遺跡 砂漠 精霊 冒険者ギルド" />
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium">難易度</label>
<select id="difficulty" class="w-full mt-1 rounded-lg border px-3 py-2">
<option value="easy">Easy</option>
<option value="normal" selected>Normal</option>
<option value="hard">Hard</option>
<option value="epic">Epic</option>
</select>
</div>
<div>
<label class="block text-sm font-medium">クエスト数</label>
<input id="questCount" type="number" min="1" max="30" value="8" class="w-full mt-1 rounded-lg border px-3 py-2" />
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium">シード(同じ結果を再現)</label>
<input id="seed" type="text" class="w-full mt-1 rounded-lg border px-3 py-2" placeholder="未入力なら自動" />
</div>
<div class="flex items-end gap-2">
<input id="lockSeed" type="checkbox" class="h-5 w-5" />
<label for="lockSeed" class="text-sm">シード固定(再生成でも変化しない)</label>
</div>
</div>
<div>
<label class="block text-sm font-medium">トーン</label>
<select id="tone" class="w-full mt-1 rounded-lg border px-3 py-2">
<option value="classic" selected>古典ファンタジー</option>
<option value="dark">ダーク</option>
<option value="steampunk">スチームパンク</option>
<option value="myth">神話/叙事詩</option>
<option value="sci">サイファンタジー</option>
</select>
</div>
<div class="flex flex-wrap gap-2 pt-2">
<button id="btnGenerate" type="button" class="px-4 py-2 rounded-xl bg-brand-600 text-white hover:bg-brand-700">生成</button>
<button id="btnRegenerate" type="button" class="px-4 py-2 rounded-xl bg-slate-800 text-white hover:bg-slate-900">再生成(同条件)</button>
<button id="btnShuffleSeed" type="button" class="px-4 py-2 rounded-xl border">シード再抽選</button>
</div>
</form>
<p class="text-xs text-slate-500 mt-4">※外部API不使用。テンプレート×確率モデルでローカル生成。ブラウザ上で完結。</p>
</section>
<!-- 中央:結果(テキスト) -->
<section class="lg:col-span-2 card">
<div class="flex items-center gap-2 mb-4">
<h2 class="text-lg font-bold">生成結果</h2>
<span id="meta" class="ml-auto text-xs text-slate-500"></span>
</div>
<div class="flex flex-wrap gap-2 mb-4">
<button id="btnCopyText" class="px-3 py-2 rounded-lg border">テキストをコピー</button>
<button id="btnDownloadJSON" class="px-3 py-2 rounded-lg border">JSONダウンロード</button>
<button id="btnExportCSV" class="px-3 py-2 rounded-lg border">CSV書き出し</button>
<button id="btnToggleJson" class="px-3 py-2 rounded-lg border">JSON表示切替</button>
</div>
<div id="outText" class="prose max-w-none text-sm leading-6"></div>
<details id="jsonBlock" class="mt-4 hidden">
<summary class="cursor-pointer select-none text-sm text-slate-600">JSON表示</summary>
<pre id="outJSON" class="mono text-xs bg-slate-50 p-3 rounded-lg overflow-x-auto"></pre>
</details>
</section>
<!-- 下:プレビュー(カードレイアウト) -->
<section class="lg:col-span-3 card">
<h2 class="text-lg font-bold mb-4">カードビュー</h2>
<div class="grid md:grid-cols-3 gap-4" id="cards"></div>
</section>
</main>
<footer class="py-8 text-center text-xs text-slate-500">
© 2025 Quest Foundry — Local-first Fantasy Content Generator
</footer>
<script>
/* =========================
* 乱数とユーティリティ
* ========================= */
function cyrb128(str){ let h1=1779033703,h2=3144134277,h3=1013904242,h4=2773480762; for(let i=0;i<str.length;i++){ let k=str.charCodeAt(i); h1=h2^(Math.imul(h1^k,597399067)); h2=h3^(Math.imul(h2^k,2869860233)); h3=h4^(Math.imul(h3^k,951274213)); h4=h1^(Math.imul(h4^k,2716044179)); } h1=Math.imul(h3^(h1>>>18),597399067); h2=Math.imul(h4^(h2>>>22),2869860233); h3=Math.imul(h1^(h3>>>17),951274213); h4=Math.imul(h2^(h4>>>19),2716044179); let r=(h1^h2^h3^h4)>>>0; return r.toString(36); }
function mulberry32(a){ return function(){ let t=a+=0x6D2B79F5; t=Math.imul(t^(t>>>15), t|1); t^=t+Math.imul(t^(t>>>7), t|61); return ((t^(t>>>14))>>>0)/4294967296; } }
function rngFromSeed(seed){ let n=0; for(const ch of seed) n=(n*31 + ch.charCodeAt(0))>>>0; return mulberry32(n||1); }
function choice(r, arr){ return arr[Math.floor(r()*arr.length)] }
function pickN(r, arr, n){ const a=[...arr]; const out=[]; for(let i=0;i<n && a.length;i++){ out.push(a.splice(Math.floor(r()*a.length),1)[0]); } return out; }
function cap(s){ return s.charAt(0).toUpperCase()+s.slice(1) }
function id(prefix, i){ return `${prefix}-${String(i).padStart(3,'0')}` }
function syllableName(r, tone){
const syll = {
classic:["an","ar","bel","ca","da","el","fa","gal","har","is","jor","kel","lir","mor","nel","or","pa","qua","rhi","sa","tor","ur","val","wen","xel","yor","zel"],
dark:["mor","noir","gloam","umb","dol","grav","nek","var","zul","vex","drei","thar","khar","wyrm"],
steampunk:["gear","steam","bolt","cog","brass","tink","pneu","copper","fuse","riv","spindle"],
myth:["aeg","od","ish","ra","zeph","io","sol","lun","tyr","fre","eir","hel"],
sci:["neo","ion","quant","cyber","astra","plasma","proto","omega","nova","phase","flux"]
};
const pool = (syll[tone]||[]).concat(syll.classic);
const len = 2 + Math.floor(r()*2);
let s=""; for(let i=0;i<len;i++) s+= choice(r,pool);
return cap(s);
}
/* =========================
* テンプレ/語彙
* ========================= */
const LEX = {
roles: ["ギルドマスター","考古学者","巡回騎士","密偵","占星術師","錬金術師","旅の商人","巫女","司書","鍛冶師","船乗り","薬師","狩人","吟遊詩人","修道士"],
traits: ["勇敢","狡猾","博識","短気","誠実","猜疑心が強い","陽気","冷静","計算高い","臆病","義理堅い","野心家"],
factions: ["碧星同盟","砂冠商会","螺旋教団","古図書騎士団","白霧旅団","錆鉄工房","風詠み集落","赤砂盗賊団"],
biomes: ["砂漠","湿原","黒森","高地","沿岸","雪原","火山地帯","古代都市跡"],
itemTypes: ["剣","短剣","槍","杖","弓","護符","指輪","書","設計図","薬","鉱石","布","レンズ","コイル"],
rarities: ["Common","Uncommon","Rare","Epic","Legendary"],
verbs: ["救出せよ","護衛せよ","探索せよ","奪還せよ","調査せよ","討伐せよ","修復せよ","封印せよ","交渉せよ","護送せよ","潜入せよ"],
twists: ["依頼主は真犯人","実は時間制限あり","二重スパイがいる","偽物が混じっている","古き呪いが再発","天候異常が発生","儀式の日が前倒し"],
rewardsExtra: ["評判+10","ギルドランク昇格","隠し店舗の解放","旅人の加護","快速移動の解放"]
};
const DIFF_MULT = { easy: 0.8, normal: 1.0, hard: 1.3, epic: 1.7 };
/* =========================
* 生成器
* ========================= */
function genFactions(r, themes){
const count = Math.min(5, 2 + Math.floor(r()*4));
return Array.from({length:count}, (_,i)=>({ id: id('F',i+1), name: `${choice(r,LEX.factions)}`, goal: `${choice(r,["遺物の独占","古文書の解読","交易路の掌握","禁術の復活","辺境防衛"])}`, vibe: choice(r,["協調的","中立","敵対的"]) }));
}
function genLocations(r, themes){
const count = Math.min(8, 4 + Math.floor(r()*5));
return Array.from({length:count}, (_,i)=>({ id: id('L',i+1), name: `${choice(r,LEX.biomes)}の${syllableName(r,'classic')}`, feature: choice(r,["崩れた門","封じ石","光る碑文","隠し水路","浮遊足場","古代機構"]) }));
}
function genNPCs(r, tone, factions){
const count = Math.min(12, 6 + Math.floor(r()*6));
return Array.from({length:count}, (_,i)=>{
const fac = choice(r, factions);
return {
id: id('N',i+1),
name: syllableName(r,tone),
role: choice(r, LEX.roles),
trait: choice(r, LEX.traits),
faction: fac?.id || null
}
});
}
function genItems(r, tone){
const count = Math.min(18, 8 + Math.floor(r()*10));
return Array.from({length:count}, (_,i)=>{
const t = choice(r, LEX.itemTypes);
const rare = choice(r, LEX.rarities);
return {
id: id('I',i+1),
name: `${syllableName(r,tone)}の${t}`,
type: t,
rarity: rare,
value: Math.floor((10+ r()*90) * (1 + 0.3*LEX.rarities.indexOf(rare)))
}
});
}
function genQuests(r, tone, count, npcs, locations, items, difficulty){
const q = [];
const scale = DIFF_MULT[difficulty] || 1.0;
for(let i=0;i<count;i++){
const giver = choice(r, npcs);
const loc = choice(r, locations);
const verb = choice(r, LEX.verbs);
const keyItem = choice(r, items);
const level = Math.max(1, Math.round((i+1)*scale + r()*3));
const objectives = [
`${loc.name}で手掛かりを見つける`,
`${giver.name}(${giver.role})に報告する`,
`${keyItem.name}を入手する`
];
// 依存関係:稀に前のクエストを前提にする
let dependsOn = null;
if(i>0 && r()<0.4){ dependsOn = q[Math.floor(r()*i)].id; }
// ツイストは低確率で
const twist = r()<0.35 ? choice(r, LEX.twists) : null;
const rewardGold = Math.floor((100+ r()*200) * scale * (1 + i*0.05));
const rewardItems = pickN(r, items, r()<0.6?1:2).map(o=>o.id);
q.push({
id: id('Q',i+1),
title: `${verb}:${loc.name}`,
level,
giver: giver.id,
location: loc.id,
objectives,
requires: dependsOn,
reward: { gold: rewardGold, items: rewardItems, extra: r()<0.25? choice(r, LEX.rewardsExtra): null },
twist
});
}
return q;
}
function assembleWorld(input){
const seed = input.seed || `${Date.now().toString(36)}-${cyrb128(input.worldName + (input.themes||''))}`;
const r = rngFromSeed(seed);
const tone = input.tone || 'classic';
const factions = genFactions(r, input.themes);
const locations = genLocations(r, input.themes);
const npcs = genNPCs(r, tone, factions);
const items = genItems(r, tone);
const quests = genQuests(r, tone, input.questCount, npcs, locations, items, input.difficulty);
return { meta: { seed, createdAt: new Date().toISOString(), worldName: input.worldName||syllableName(r,tone), themes: input.themes, difficulty: input.difficulty, tone }, factions, locations, npcs, items, quests };
}
/* =========================
* 出力レンダリング
* ========================= */
function renderText(world){
const idmap = (arr)=> Object.fromEntries(arr.map(a=>[a.id,a]));
const NPC = idmap(world.npcs);
const LOC = idmap(world.locations);
const ITM = idmap(world.items);
const lines = [];
lines.push(`# 世界:${world.meta.worldName}`);
lines.push(`- テーマ:${world.meta.themes||'—'} / トーン:${world.meta.tone} / 難易度:${world.meta.difficulty}`);
lines.push(`- 生成日時:${new Date(world.meta.createdAt).toLocaleString()}`);
lines.push(`- シード:${world.meta.seed}`);
lines.push(`\n## 勢力(${world.factions.length})`);
world.factions.forEach(f=>{ lines.push(`- [${f.id}] ${f.name}|目的:${f.goal}|態度:${f.vibe}`) });
lines.push(`\n## 場所(${world.locations.length})`);
world.locations.forEach(l=>{ lines.push(`- [${l.id}] ${l.name}|特徴:${l.feature}`) });
lines.push(`\n## NPC(${world.npcs.length})`);
world.npcs.forEach(n=>{ lines.push(`- [${n.id}] ${n.name}(${n.role}/${n.trait}) 所属:${n.faction||'なし'}`) });
lines.push(`\n## アイテム(${world.items.length})`);
world.items.forEach(i=>{ lines.push(`- [${i.id}] ${i.name}|種類:${i.type}|希少度:${i.rarity}|価値:${i.value}`) });
lines.push(`\n## クエスト(${world.quests.length})`);
world.quests.forEach(q=>{
const giver = NPC[q.giver]?.name || q.giver;
const loc = LOC[q.location]?.name || q.location;
const req = q.requires? `(前提:${q.requires})` : '';
lines.push(`\n### [${q.id}] ${q.title} Lv.${q.level} ${req}`);
lines.push(`- 依頼主:${giver}`);
lines.push(`- 場所:${loc}`);
lines.push(`- 目的:`);
q.objectives.forEach(o=>lines.push(` - ${o}`));
const rewardItems = q.reward.items.map(id=> ITM[id]?.name || id).join('、');
lines.push(`- 報酬:${q.reward.gold}G / アイテム:${rewardItems}${q.reward.extra? ' / '+q.reward.extra:''}`);
if(q.twist) lines.push(`- ツイスト:${q.twist}`);
});
return lines.join('\n');
}
function renderHTML(world){
const idmap = (arr)=> Object.fromEntries(arr.map(a=>[a.id,a]));
const NPC = idmap(world.npcs);
const LOC = idmap(world.locations);
const ITM = idmap(world.items);
const head = `
<h1>世界:${world.meta.worldName}</h1>
<div class="kv text-sm text-slate-600 gap-x-2">
<span class="badge">トーン:${world.meta.tone}</span>
<span class="badge">難易度:${world.meta.difficulty}</span>
<span class="badge">クエスト:${world.quests.length}</span>
<span class="badge">シード:${world.meta.seed}</span>
</div>
<p class="mt-2 text-sm text-slate-600">テーマ:${world.meta.themes||'—'} / 生成日時:${new Date(world.meta.createdAt).toLocaleString()}</p>
`;
const factions = `
<h2>勢力(${world.factions.length})</h2>
<ul>
${world.factions.map(f=>`<li><code>[${f.id}]</code> ${f.name}|目的:${f.goal}|態度:${f.vibe}</li>`).join('')}
</ul>
`;
const locs = `
<h2>場所(${world.locations.length})</h2>
<ul>
${world.locations.map(l=>`<li><code>[${l.id}]</code> ${l.name}|特徴:${l.feature}</li>`).join('')}
</ul>
`;
const npcs = `
<h2>NPC(${world.npcs.length})</h2>
<ul>
${world.npcs.map(n=>`<li><code>[${n.id}]</code> ${n.name}(${n.role}/${n.trait}) 所属:${n.faction||'なし'}</li>`).join('')}
</ul>
`;
const items = `
<h2>アイテム(${world.items.length})</h2>
<ul>
${world.items.map(i=>`<li><code>[${i.id}]</code> ${i.name}|種類:${i.type}|希少度:${i.rarity}|価値:${i.value}</li>`).join('')}
</ul>
`;
const quests = `
<h2>クエスト(${world.quests.length})</h2>
${world.quests.map(q=>{
const giver = NPC[q.giver]?.name || q.giver;
const loc = LOC[q.location]?.name || q.location;
const req = q.requires? `(前提:${q.requires})` : '';
const rewardItems = q.reward.items.map(id=> ITM[id]?.name || id).join('、');
return `
<details class="quest">
<summary><strong><code>[${q.id}]</code> ${q.title}</strong> <span class="text-sm text-slate-600">Lv.${q.level} ${req}</span></summary>
<div class="mt-2 text-sm">
<div>依頼主:${giver}</div>
<div>場所:${loc}</div>
<div class="mt-1">目的:</div>
<ul>
${q.objectives.map(o=>`<li>${o}</li>`).join('')}
</ul>
<div class="mt-1">報酬:${q.reward.gold}G / アイテム:${rewardItems}${q.reward.extra? ' / '+q.reward.extra:''}</div>
${q.twist? `<div class="mt-1 text-rose-700">ツイスト:${q.twist}</div>`:''}
</div>
</details>`;
}).join('')}
`;
return [head, factions, locs, npcs, items, quests].join('');
}
function renderCards(world){
const $cards = document.getElementById('cards');
$cards.innerHTML = '';
const make = (title, body)=>{
const el = document.createElement('div');
el.className = 'rounded-2xl border p-4 bg-white';
el.innerHTML = `<div class="text-sm font-bold mb-2">${title}</div><div class="text-xs text-slate-700 whitespace-pre-wrap">${body}</div>`;
$cards.appendChild(el);
};
make('ワールド', `名前:${world.meta.worldName}\n難易度:${world.meta.difficulty}\nトーン:${world.meta.tone}\nシード:${world.meta.seed}`);
make('勢力', world.factions.map(f=>`[${f.id}] ${f.name}/目的:${f.goal}`).join('\n'));
make('場所', world.locations.map(l=>`[${l.id}] ${l.name}/${l.feature}`).join('\n'));
make('NPC', world.npcs.slice(0,12).map(n=>`[${n.id}] ${n.name}/${n.role}`).join('\n'));
make('アイテム', world.items.slice(0,15).map(i=>`[${i.id}] ${i.name}/${i.rarity}`).join('\n'));
make('クエスト', world.quests.map(q=>`[${q.id}] ${q.title} Lv.${q.level}${q.requires? '(前提:'+q.requires+')':''}`).join('\n'));
}
/* =========================
* CSV/JSON/コピー/保存
* ========================= */
function toCSV(rows){
return rows.map(r=> r.map(v=>`"${String(v).replaceAll('"','""')}"`).join(',')).join('\n');
}
function exportCSVs(world){
const npcRows = [["id","name","role","trait","faction"]].concat(world.npcs.map(n=>[n.id,n.name,n.role,n.trait,n.faction||'']));
const itemRows = [["id","name","type","rarity","value"]].concat(world.items.map(i=>[i.id,i.name,i.type,i.rarity,i.value]));
const questRows = [["id","title","level","giver","location","requires","objectives","reward_gold","reward_items","twist"]].concat(
world.quests.map(q=>[
q.id, q.title, q.level, q.giver, q.location, q.requires||'', q.objectives.join(' / '), q.reward.gold, q.reward.items.join('|'), q.twist||''
])
);
const files = [
{name:`${world.meta.worldName}_NPC.csv`, data: toCSV(npcRows)},
{name:`${world.meta.worldName}_Items.csv`, data: toCSV(itemRows)},
{name:`${world.meta.worldName}_Quests.csv`, data: toCSV(questRows)}
];
files.forEach(f=>{
const blob = new Blob(["\ufeff"+f.data], {type:'text/csv'});
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = f.name; a.click(); URL.revokeObjectURL(a.href);
});
}
function downloadJSON(world){
const blob = new Blob([JSON.stringify(world, null, 2)], {type:'application/json'});
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `${world.meta.worldName}_world.json`; a.click(); URL.revokeObjectURL(a.href);
}
function copyText(text){
navigator.clipboard.writeText(text).then(()=>{
toast('テキストをコピーしました');
});
}
function saveLocal(world){ localStorage.setItem('quest_foundry_last', JSON.stringify(world)); toast('保存しました'); }
function loadLocal(){ const s=localStorage.getItem('quest_foundry_last'); if(!s){ toast('保存データなし'); return null; } try{ return JSON.parse(s);}catch(e){ toast('読込失敗'); return null; } }
/* =========================
* UI
* ========================= */
function toast(msg){
const t = document.createElement('div');
t.className = 'fixed bottom-4 left-1/2 -translate-x-1/2 bg-slate-900 text-white text-sm px-4 py-2 rounded-xl shadow-lg';
t.textContent = msg; document.body.appendChild(t);
setTimeout(()=>{ t.classList.add('opacity-0'); t.style.transition='opacity .6s'; }, 1600);
setTimeout(()=> t.remove(), 2300);
}
let lastInput = null;
let lastWorld = null;
function currentInput(){
const worldName = document.getElementById('worldName').value.trim();
const themes = document.getElementById('themes').value.trim();
const difficulty = document.getElementById('difficulty').value;
const questCount = Math.max(1, Math.min(30, parseInt(document.getElementById('questCount').value || '8')));
const seed = document.getElementById('seed').value.trim();
const tone = document.getElementById('tone').value;
return { worldName, themes, difficulty, questCount, seed, tone };
}
function applyWorld(world){
lastWorld = world;
document.getElementById('meta').textContent = `ワールド:${world.meta.worldName} / クエスト:${world.quests.length}件`;
document.getElementById('outText').innerHTML = renderHTML(world);
document.getElementById('outJSON').textContent = JSON.stringify(world, null, 2);
renderCards(world);
}
function generate(withNewSeed=false){
const input = currentInput();
if(withNewSeed && !document.getElementById('lockSeed').checked){ input.seed = ''; }
if(!input.seed) { input.seed = cyrb128((input.worldName||'World') + (input.themes||'') + Date.now()); document.getElementById('seed').value = input.seed; }
lastInput = input;
const world = assembleWorld(input);
applyWorld(world);
}
// イベント
document.getElementById('btnGenerate').addEventListener('click', ()=> generate(false));
document.getElementById('btnRegenerate').addEventListener('click', ()=> generate(false));
document.getElementById('btnShuffleSeed').addEventListener('click', ()=> generate(true));
document.getElementById('btnCopyText').addEventListener('click', ()=>{ if(lastWorld) copyText(renderText(lastWorld)); });
document.getElementById('btnDownloadJSON').addEventListener('click', ()=>{ if(lastWorld) downloadJSON(lastWorld); });
document.getElementById('btnExportCSV').addEventListener('click', ()=>{ if(lastWorld) exportCSVs(lastWorld); });
document.getElementById('btnToggleJson').addEventListener('click', ()=>{ document.getElementById('jsonBlock').classList.toggle('hidden'); });
document.getElementById('btnSave').addEventListener('click', ()=>{ if(lastWorld) saveLocal(lastWorld); });
document.getElementById('btnLoad').addEventListener('click', ()=>{ const w=loadLocal(); if(w) applyWorld(w); });
document.getElementById('btnPrint').addEventListener('click', ()=> window.print());
// 初期プレースホルダ生成
window.addEventListener('DOMContentLoaded', ()=>{
document.getElementById('worldName').value = '運命の剣界';
document.getElementById('themes').value = '古代遺跡 風の精霊 砂漠 旅人ギルド';
generate(true);
});
</script>
</body>
</html>
ホロライブAIプロンプトサイト.html
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>ホロライブ AIプロンプト生成サイト</title>
<meta name="description" content="ホロライブのメンバー向けに、画像生成AIで使えるプロンプトをワンクリック作成。日英対応/テンプレ/ネガティブ/コピーボタン/履歴保存。" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@400;700;900&display=swap" rel="stylesheet">
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: { sans: ['Noto Sans JP', 'ui-sans-serif', 'system-ui'] },
colors: {
skin: {
base: '#0b1020',
card: '#0f152b',
accent: '#60a5fa',
soft: '#a5b4fc'
}
},
boxShadow: {
glass: '0 8px 30px rgba(0,0,0,.35)'
}
}
}
}
</script>
<style>
html,body{height:100%}
.glass{backdrop-filter: saturate(140%) blur(12px); background: linear-gradient(180deg, rgba(255,255,255,.06), rgba(255,255,255,.03));}
.chip{border:1px solid rgba(255,255,255,.15);}
.mono{font-feature-settings: "ss01" on, "cv01" on;}
</style>
</head>
<body class="min-h-screen bg-gradient-to-br from-slate-900 via-skin-base to-black text-slate-100 font-sans">
<header class="sticky top-0 z-30 border-b border-white/10 bg-slate-900/70 glass">
<div class="max-w-6xl mx-auto px-4 py-4 flex items-center gap-4">
<div class="size-10 rounded-2xl bg-skin-accent/20 grid place-items-center shadow-glass">
<span class="text-skin-accent font-black">AI</span>
</div>
<div>
<h1 class="text-xl md:text-2xl font-extrabold tracking-tight">ホロライブ AIプロンプト生成</h1>
<p class="text-slate-300 text-sm">メンバーを選ぶ→テンプレを選ぶ→生成! 日/英・ネガティブ・履歴・コピペ完備</p>
</div>
<div class="ms-auto flex items-center gap-3">
<label class="flex items-center gap-2 text-sm"><input id="langToggle" type="checkbox" class="accent-skin-accent"> 英語で出力</label>
<button id="randomBtn" class="px-3 py-2 rounded-xl bg-white/10 hover:bg-white/20 border border-white/10">ランダム</button>
<button id="resetBtn" class="px-3 py-2 rounded-xl bg-white/10 hover:bg-white/20 border border-white/10">リセット</button>
</div>
</div>
</header>
<main class="max-w-6xl mx-auto px-4 py-6 grid lg:grid-cols-2 gap-6">
<!-- 左:入力パネル -->
<section class="glass rounded-2xl p-5 shadow-glass border border-white/10">
<h2 class="font-bold text-lg mb-3">1) メンバー & スタイル設定</h2>
<div class="grid md:grid-cols-2 gap-4">
<div>
<label class="block text-sm mb-1">メンバー</label>
<div class="flex gap-2">
<input id="memberSearch" type="text" placeholder="名前/世代/特徴で検索" class="w-full px-3 py-2 rounded-xl bg-black/30 border border-white/10" />
</div>
<div class="mt-2 max-h-48 overflow-auto pr-1">
<ul id="memberList" class="space-y-1"></ul>
</div>
</div>
<div>
<label class="block text-sm mb-1">テンプレート (用途)</label>
<select id="templateSelect" class="w-full px-3 py-2 rounded-xl bg-black/30 border border-white/10">
<option value="portrait">高品質ポートレート</option>
<option value="fullbody">全身イラスト</option>
<option value="chibi">デフォルメ/ちびキャラ</option>
<option value="vtuber">VTuber配信サムネ</option>
<option value="live2d">Live2D立ち絵</option>
<option value="vrchat">VRChat アバター風</option>
<option value="manga">モノクロ漫画コマ</option>
<option value="poster">キービジュアル/ポスター</option>
<option value="landscape">背景&小さめ人物</option>
</select>
<div class="grid grid-cols-2 gap-2 mt-3">
<label class="text-sm flex items-center gap-2"><input id="nsfwSafe" type="checkbox" checked class="accent-skin-accent"> NSFW禁止</label>
<label class="text-sm flex items-center gap-2"><input id="useNeg" type="checkbox" checked class="accent-skin-accent"> ネガティブ付与</label>
<label class="text-sm flex items-center gap-2"><input id="addPose" type="checkbox" class="accent-skin-accent"> ポーズ指定</label>
<label class="text-sm flex items-center gap-2"><input id="addCamera" type="checkbox" class="accent-skin-accent"> カメラ/レンズ</label>
</div>
</div>
</div>
<div class="grid md:grid-cols-3 gap-4 mt-5">
<div>
<label class="block text-sm mb-1">画風プリセット</label>
<select id="stylePreset" class="w-full px-3 py-2 rounded-xl bg-black/30 border border-white/10">
<option value="anime">アニメ塗り(鮮やか)</option>
<option value="semiReal">セミリアル</option>
<option value="watercolor">水彩/やわらか</option>
<option value="celshade">セルルック</option>
<option value="painterly">厚塗り/絵画風</option>
<option value="3dtoon">3Dトゥーン</option>
</select>
</div>
<div>
<label class="block text-sm mb-1">照明/雰囲気</label>
<select id="moodPreset" class="w-full px-3 py-2 rounded-xl bg-black/30 border border-white/10">
<option value="soft">柔らかい光 / やさしい雰囲気</option>
<option value="dramatic">ドラマチック / リムライト</option>
<option value="studio">スタジオ照明 / クリーン</option>
<option value="sunset">夕焼け / ゴールデンアワー</option>
<option value="night">夜景 / ネオン</option>
</select>
</div>
<div>
<label class="block text-sm mb-1">解像度・比率</label>
<select id="aspectPreset" class="w-full px-3 py-2 rounded-xl bg-black/30 border border-white/10">
<option value="square">正方形(1024×1024)</option>
<option value="portrait">縦長(768×1152)</option>
<option value="landscape">横長(1152×768)</option>
<option value="thumb">サムネ(1280×720)</option>
</select>
</div>
</div>
<div class="mt-5 grid md:grid-cols-2 gap-4">
<div>
<label class="block text-sm mb-1">衣装・小物(任意)</label>
<input id="outfitInput" type="text" placeholder="例: 制服, ライブ衣装, 私服, 王冠, マント" class="w-full px-3 py-2 rounded-xl bg-black/30 border border-white/10" />
</div>
<div>
<label class="block text-sm mb-1">背景・シーン(任意)</label>
<input id="bgInput" type="text" placeholder="例: ステージ, 星空, 教室, サイバーシティ" class="w-full px-3 py-2 rounded-xl bg-black/30 border border-white/10" />
</div>
</div>
<div class="mt-5 grid md:grid-cols-2 gap-4">
<div>
<label class="block text-sm mb-1">モデル/LoRA(任意)</label>
<input id="modelInput" type="text" placeholder="例: anime-v4, AnythingV5, holo_member_lora:0.8" class="w-full px-3 py-2 rounded-xl bg-black/30 border border-white/10" />
</div>
<div>
<label class="block text-sm mb-1">追加キーワード(任意)</label>
<input id="extraInput" type="text" placeholder="例: 1girl, detailed eyes, dynamic lighting" class="w-full px-3 py-2 rounded-xl bg-black/30 border border-white/10" />
</div>
</div>
<div class="mt-6 flex flex-wrap gap-3">
<button id="generateBtn" class="px-5 py-3 rounded-2xl bg-skin-accent text-black font-bold hover:opacity-90">生成</button>
<button id="copyBtn" class="px-5 py-3 rounded-2xl bg-white/10 border border-white/10 hover:bg-white/20">コピー</button>
<button id="saveBtn" class="px-5 py-3 rounded-2xl bg-white/10 border border-white/10 hover:bg-white/20">履歴に保存</button>
<button id="exportBtn" class="px-5 py-3 rounded-2xl bg-white/10 border border-white/10 hover:bg-white/20">JSON書き出し</button>
<button id="importBtn" class="px-5 py-3 rounded-2xl bg-white/10 border border-white/10 hover:bg-white/20">JSON読込</button>
</div>
</section>
<!-- 右:出力/履歴 -->
<section class="space-y-6">
<div class="glass rounded-2xl p-5 shadow-glass border border-white/10">
<h2 class="font-bold text-lg">2) 出力(Positive / Negative / メタ)</h2>
<div class="mt-3 grid gap-3">
<label class="text-sm">Positive Prompt</label>
<textarea id="posOut" rows="6" class="mono w-full px-3 py-2 rounded-xl bg-black/50 border border-white/10" placeholder="ここに生成結果"></textarea>
<label class="text-sm">Negative Prompt</label>
<textarea id="negOut" rows="4" class="mono w-full px-3 py-2 rounded-xl bg-black/50 border border-white/10" placeholder="ここにネガティブ"></textarea>
<div class="grid md:grid-cols-3 gap-3">
<div>
<label class="text-sm">解像度</label>
<input id="metaRes" class="w-full px-3 py-2 rounded-xl bg-black/50 border border-white/10" readonly>
</div>
<div>
<label class="text-sm">推奨CFG/Steps</label>
<input id="metaCfg" class="w-full px-3 py-2 rounded-xl bg-black/50 border border-white/10" value="CFG 6-8 / Steps 28-36">
</div>
<div>
<label class="text-sm">推奨Sampler</label>
<input id="metaSampler" class="w-full px-3 py-2 rounded-xl bg-black/50 border border-white/10" value="DPM++ 2M Karras">
</div>
</div>
</div>
</div>
<div class="glass rounded-2xl p-5 shadow-glass border border-white/10">
<div class="flex items-center gap-3 mb-3">
<h2 class="font-bold text-lg">3) 履歴</h2>
<button id="clearHist" class="ms-auto px-3 py-1.5 text-sm rounded-xl bg-white/10 border border-white/10 hover:bg-white/20">全削除</button>
</div>
<div id="history" class="space-y-3 max-h-72 overflow-auto pr-1"></div>
</div>
</section>
</main>
<footer class="max-w-6xl mx-auto px-4 pb-10 text-slate-400 text-sm">
<div class="glass rounded-2xl p-4 border border-white/10">
<p class="leading-relaxed">注意:本ツールは各メンバーの公式ガイドラインを尊重し、成人向けや誹謗中傷の内容を禁止します。商用利用や二次創作ルールは各社ポリシーをご確認ください。</p>
</div>
</footer>
<script>
// --- データセット(抜粋・追加可) ---
const MEMBERS = [
// JP
{ key:'Tokino Sora', gen:'JP0', color:'#5bc0eb', motifs:['星','リボン'], traits:['清楚','やさしい'], outfit:['セーラー風','リボン'], keywords:['idol','first gen','blue ribbon'], en:true },
{ key:'Shirakami Fubuki', gen:'JP1', color:'#ffffff', motifs:['狐','尻尾'], traits:['元気','明るい'], outfit:['セーラー','マフラー'], keywords:['fox girl','white hair','animal ears'] },
{ key:'Natsuiro Matsuri', gen:'JP1', color:'#f4a261', motifs:['祭','ポニーテール'], traits:['活発','いたずら'], outfit:['体操服','浴衣'], keywords:['cheerful','ponytail'] },
{ key:'Minato Aqua', gen:'JP2', color:'#b388ff', motifs:['メイド','ヘッドドレス'], traits:['ドジっ子','ピンク紫髪'], outfit:['メイド服'], keywords:['maid','twin tails'] },
{ key:'Shion', gen:'JP2', color:'#c084fc', motifs:['魔法','三角帽'], traits:['小悪魔','ツリ目'], outfit:['魔女服'], keywords:['witch','purple hair'] },
{ key:'Nakiri Ayame', gen:'JP2', color:'#ef4444', motifs:['鬼角','和装'], traits:['クール','凛'], outfit:['巫女風'], keywords:['oni horns','kimono style'] },
{ key:'Ookami Mio', gen:'GAMERS', color:'#111827', motifs:['狼','耳'], traits:['頼れる','落ち着き'], outfit:['黒衣装'], keywords:['wolf girl','black outfit'] },
{ key:'Houshou Marine', gen:'JP3', color:'#ef4444', motifs:['海賊帽','錨'], traits:['情熱','大人っぽい'], outfit:['海賊衣装'], keywords:['pirate','captain hat'] },
{ key:'Usada Pekora', gen:'JP3', color:'#93c5fd', motifs:['うさ耳','人参'], traits:['やんちゃ','元気'], outfit:['うさぎパーカー'], keywords:['bunny ears','carrot'] },
{ key:'Shiranui Flare', gen:'JP3', color:'#f59e0b', motifs:['エルフ','耳'], traits:['包容','陽気'], outfit:['冒険者'], keywords:['elf ears','adventurer'] },
{ key:'Shirogane Noel', gen:'JP3', color:'#9ca3af', motifs:['騎士','鎧'], traits:['真面目','力持ち'], outfit:['鎧','マント'], keywords:['knight armor','silver hair'] },
{ key:'Hoshimachi Suisei', gen:'INoNaka/JP', color:'#60a5fa', motifs:['星','アイドル'], traits:['クール','アイドル'], outfit:['青系衣装'], keywords:['star motif','blue idol'] },
{ key:'Amane Kanata', gen:'JP4', color:'#60a5fa', motifs:['天使','羽'], traits:['ストイック'], outfit:['白蒼衣装'], keywords:['angel wings','halo'] },
{ key:'Kiryu Coco', gen:'JP4', color:'#f97316', motifs:['ドラゴン','角'], traits:['豪快'], outfit:['ドラゴンモチーフ'], keywords:['dragon horns','orange hair'] },
{ key:'Tsunomaki Watame', gen:'JP4', color:'#facc15', motifs:['羊','リボン'], traits:['ふわふわ'], outfit:['羊モチーフ'], keywords:['sheep girl','blonde'] },
{ key:'Himemori Luna', gen:'JP4', color:'#f472b6', motifs:['姫','王冠'], traits:['キュート'], outfit:['姫ドレス'], keywords:['princess crown','pink'] },
{ key:'Laplus Darknesss', gen:'HoloX', color:'#6d28d9', motifs:['悪魔','マント'], traits:['いたずら'], outfit:['黒紫コート'], keywords:['devilish','hooded coat'] },
{ key:'Takane Lui', gen:'HoloX', color:'#ef4444', motifs:['スパイ','赤黒'], traits:['クール'], outfit:['スーツ風'], keywords:['spy','red black'] },
{ key:'Sakamata Chloe', gen:'HoloX', color:'#94a3b8', motifs:['シャチ','フード'], traits:['あざとい'], outfit:['白黒フード'], keywords:['orca hoodie','monochrome'] },
{ key:'Hakui Koyori', gen:'HoloX', color:'#fb7185', motifs:['研究','ピンク'], traits:['好奇心'], outfit:['研究白衣'], keywords:['lab coat','pink hair'] },
{ key:'Kazama Iroha', gen:'HoloX', color:'#86efac', motifs:['忍者','刀'], traits:['素直'], outfit:['忍装束'], keywords:['ninja','katana'] },
// EN
{ key:'Mori Calliope', gen:'EN Myth', color:'#ef4444', motifs:['鎌','死神'], traits:['クール'], outfit:['黒×ピンク'], keywords:['reaper scythe','rapper'] },
{ key:'Gawr Gura', gen:'EN Myth', color:'#60a5fa', motifs:['サメ','フード'], traits:['いたずら'], outfit:['サメパーカー'], keywords:['shark hoodie','trident'] },
{ key:'Takanashi Kiara', gen:'EN Myth', color:'#fb923c', motifs:['鳥','オレンジ'], traits:['情熱'], outfit:['アイドル衣装'], keywords:['phoenix','orange hair'] },
{ key:"Ninomae Ina'nis", gen:'EN Myth', color:'#a78bfa', motifs:['触手','本'], traits:['穏やか'], outfit:['修道服風'], keywords:['tentacle motif','violet'] }, <!-- ★ 修正済み:ダブルクォート -->
{ key:'Amelia Watson', gen:'EN Myth', color:'#fbbf24', motifs:['探偵','時計'], traits:['好奇心'], outfit:['探偵コート'], keywords:['detective','magnifying glass'] },
{ key:'Hakos Baelz', gen:'EN Council', color:'#ef4444', motifs:['ネズミ','カオス'], traits:['ハイテンション'], outfit:['赤系衣装'], keywords:['chaos','rat tail'] },
{ key:'IRyS', gen:'EN Project:Hope', color:'#ef5fff', motifs:['天使悪魔','クリスタル'], traits:['希望'], outfit:['黒×赤×紫'], keywords:['nephilim','crystal'] },
// ID (抜粋)
{ key:'Kobo Kanaeru', gen:'ID3', color:'#60a5fa', motifs:['雨','水'], traits:['やんちゃ'], outfit:['青系パーカー'], keywords:['rain theme','blue hair'] },
];
// ネガティブテンプレ
const NEGATIVE = 'nsfw, nude, lowres, low quality, worst quality, extra fingers, deformed hands, poorly drawn, watermark, logo, signature, text, blurry, jpeg artifacts, bad anatomy, out of frame';
// UI取得
const memberSearch = document.getElementById('memberSearch');
const memberList = document.getElementById('memberList');
const templateSelect = document.getElementById('templateSelect');
const stylePreset = document.getElementById('stylePreset');
const moodPreset = document.getElementById('moodPreset');
const aspectPreset = document.getElementById('aspectPreset');
const outfitInput = document.getElementById('outfitInput');
const bgInput = document.getElementById('bgInput');
const modelInput = document.getElementById('modelInput');
const extraInput = document.getElementById('extraInput');
const langToggle = document.getElementById('langToggle');
const nsfwSafe = document.getElementById('nsfwSafe');
const useNeg = document.getElementById('useNeg');
const addPose = document.getElementById('addPose');
const addCamera = document.getElementById('addCamera');
const generateBtn = document.getElementById('generateBtn');
const copyBtn = document.getElementById('copyBtn');
const saveBtn = document.getElementById('saveBtn');
const exportBtn = document.getElementById('exportBtn');
const importBtn = document.getElementById('importBtn');
const randomBtn = document.getElementById('randomBtn');
const resetBtn = document.getElementById('resetBtn');
const posOut = document.getElementById('posOut');
const negOut = document.getElementById('negOut');
const metaRes = document.getElementById('metaRes');
const metaCfg = document.getElementById('metaCfg');
const metaSampler = document.getElementById('metaSampler');
const history = document.getElementById('history');
const clearHist = document.getElementById('clearHist');
// 状態
let selectedMember = null;
// 初期描画
function renderMembers(filter=''){
const f = filter.toLowerCase().trim();
memberList.innerHTML = '';
MEMBERS.filter(m=>{
const s = [m.key, m.gen, ...(m.motifs||[]), ...(m.traits||[]), ...(m.keywords||[])].join(' ').toLowerCase();
return !f || s.includes(f);
}).forEach(m=>{
const li = document.createElement('li');
li.className = 'chip rounded-xl px-3 py-2 flex items-center gap-2 hover:bg-white/5 cursor-pointer';
li.innerHTML = `<span class="inline-block size-3 rounded-full" style="background:${m.color}"></span><span class="font-medium">${m.key}</span><span class="text-xs text-slate-400">(${m.gen})</span>`;
// クリック選択
li.addEventListener('click',()=>{ selectedMember = m; highlightSelection(li); });
// ★ キーボード対応
li.setAttribute('tabindex', '0');
li.setAttribute('role', 'button');
li.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
selectedMember = m;
highlightSelection(li);
}
});
// 既選択のハイライト維持
if (selectedMember && selectedMember.key === m.key) li.classList.add('bg-white/10');
memberList.appendChild(li);
})
}
function highlightSelection(activeLi){
[...memberList.children].forEach(li=> li.classList.remove('bg-white/10'));
activeLi.classList.add('bg-white/10');
}
memberSearch.addEventListener('input', e=> renderMembers(e.target.value));
// アスペクト → 表示解像度
function applyAspect(){
const map = {
square: '1024x1024',
portrait: '768x1152',
landscape: '1152x768',
thumb: '1280x720'
};
metaRes.value = map[aspectPreset.value] || '1024x1024';
}
aspectPreset.addEventListener('change', applyAspect);
applyAspect();
// テンプレ文
function templateText(id, name){
const jp = {
portrait: `${name}の高品質なバストアップポートレート, 視線はこちら, きらめく瞳, 細密な髪, 肌の質感,`,
fullbody: `${name}の全身イラスト, ダイナミックなポーズ,`,
chibi: `${name}のデフォルメちびキャラ, 等身2~3,`,
vtuber: `${name}の配信サムネ風イラスト, サイドライト, 目を引くタイポの余白,`,
live2d: `${name}のLive2D立ち絵, 胸上〜腰上, レイヤー分けしやすいシンプル背景,`,
vrchat: `${name}のVRChatアバター風デザイン, 全身, セルルック,`,
manga: `${name}が登場するモノクロ漫画コマ, スクリーントーン,`,
poster: `${name}のキービジュアル, 迫力のある構図,`,
landscape: `背景美術の中に小さく${name}, 遠景, 雰囲気重視,`
};
const en = {
portrait: `high-quality bust portrait of ${name}, looking at viewer, sparkling eyes, detailed hair, skin texture,`,
fullbody: `full-body illustration of ${name}, dynamic pose,`,
chibi: `super-deformed chibi ${name}, 2~3 heads tall,`,
vtuber: `stream thumbnail style illustration of ${name}, side lighting, space for bold typography,`,
live2d: `Live2D standing illustration of ${name}, bust to waist-up, simple background for easy layer separation,`,
vrchat: `VRChat avatar style design of ${name}, full body, toon shading,`,
manga: `monochrome manga panel featuring ${name}, screen tones,`,
poster: `key visual poster of ${name}, impactful composition,`,
landscape: `cinematic background with small ${name} in scene, distant view, mood-focused,`
}
return (langToggle.checked? en : jp)[id] || '';
}
// スタイル・ムード
function styleText(id){
const jp = {
anime: 'アニメ塗り, 高発色, クリアライン,',
semiReal: 'セミリアル, 繊細なライティング, 細密質感,',
watercolor: '水彩風, 柔らかい発色, にじみ,',
celshade: 'セルシェーディング, シャープな影,',
painterly: '厚塗り, 筆致, 奥行き,',
threetoon: '3Dトゥーン, ノンフォトリアル,'
};
const en = {
anime: 'anime coloring, vivid, clean linework,',
semiReal: 'semi-realistic, delicate lighting, fine textures,',
watercolor: 'watercolor style, soft colors, bleeding,',
celshade: 'cel-shaded, sharp shadows,',
painterly: 'painterly, visible brush strokes, depth,',
threetoon: '3D toon, non-photorealistic,'
};
const key = id === '3dtoon' ? 'threetoon' : id;
return (langToggle.checked? en : jp)[key] || '';
}
function moodText(id){
const jp = {
soft: 'やわらかい環境光, 穏やかな表情,',
dramatic: 'ドラマチックライティング, リムライト, コントラスト強,',
studio: 'スタジオ照明, 均一な光, 背景シンプル,',
sunset: '夕焼けの光, ゴールデンアワー,',
night: '夜景ネオン, グロー, 反射,'
};
const en = {
soft: 'soft ambient light, gentle expression,',
dramatic: 'dramatic lighting, rim light, high contrast,',
studio: 'studio lighting, even illumination, simple background,',
sunset: 'sunset glow, golden hour,',
night: 'neon nightscape, glow, reflections,'
};
return (langToggle.checked? en : jp)[id] || '';
}
function poseText(){
const jp = ['ピースサイン','片手を胸に','ほほえみ','ダンスポーズ','跳躍'];
const en = ['peace sign','hand on chest','gentle smile','dance pose','jumping'];
const arr = langToggle.checked? en : jp;
return arr[Math.floor(Math.random()*arr.length)]
}
function cameraText(){
const jp = ['50mmレンズ相当','f1.8被写界深度','極小ノイズ','シャープ'];
const en = ['50mm lens equivalent','f1.8 shallow depth of field','very low noise','sharp'];
const arr = langToggle.checked? en : jp;
return arr.join(', ');
}
function buildPositive(){
if(!selectedMember){
alert('メンバーを選択してください');
return '';
}
const name = selectedMember.key;
const tp = templateText(templateSelect.value, name);
const st = styleText(stylePreset.value);
const md = moodText(moodPreset.value);
const ex = extraInput.value?.trim();
const pieces = [tp, st, md];
if(outfitInput.value.trim()) pieces.push(langToggle.checked? `outfit: ${outfitInput.value.trim()}` : `衣装: ${outfitInput.value.trim()}`);
if(bgInput.value.trim()) pieces.push(langToggle.checked? `background: ${bgInput.value.trim()}` : `背景: ${bgInput.value.trim()}`);
if(addPose.checked) pieces.push(langToggle.checked? `pose: ${poseText()}` : `ポーズ: ${poseText()}`);
if(addCamera.checked) pieces.push(cameraText());
// メンバー特徴
const motif = (selectedMember.motifs||[]).join(', ');
const trait = (selectedMember.traits||[]).join(', ');
const kw = (selectedMember.keywords||[]).join(', ');
const profJP = `特徴: ${motif}, 性格: ${trait}, キーワード: ${kw}`;
const profEN = `motifs: ${motif}, traits: ${trait}, keywords: ${kw}`;
pieces.push(langToggle.checked? profEN : profJP);
if(ex) pieces.push(ex);
// モデル/LoRA
if(modelInput.value.trim()) pieces.push(`[${modelInput.value.trim()}]`);
// NSFW安全
if(nsfwSafe.checked){
pieces.push(langToggle.checked? 'sfw, wholesome' : '全年齢, 健全');
}
return pieces.filter(Boolean).join(' ');
}
function buildNegative(){
return useNeg.checked ? NEGATIVE : '';
}
function generate(){
const pos = buildPositive();
if (!pos) return;
posOut.value = pos;
negOut.value = buildNegative();
}
function copyAll(){
const txt = `Positive:\n${posOut.value}\n\nNegative:\n${negOut.value}\n\nMeta:\nres=${metaRes.value}, ${metaCfg.value}, sampler=${metaSampler.value}`;
navigator.clipboard.writeText(txt).then(()=>{ toast('コピーしました'); });
}
function toast(msg){
const t = document.createElement('div');
t.textContent = msg;
t.className = 'fixed bottom-5 left-1/2 -translate-x-1/2 px-4 py-2 rounded-xl bg-white/10 border border-white/10 shadow-glass';
document.body.appendChild(t);
setTimeout(()=> t.remove(), 1600);
}
function saveHistory(){
const item = {
time: new Date().toISOString(),
member: selectedMember?.key || '(未選択)',
template: templateSelect.value,
style: stylePreset.value,
mood: moodPreset.value,
aspect: aspectPreset.value,
pos: posOut.value,
neg: negOut.value,
meta: { res: metaRes.value, cfg: metaCfg.value, sampler: metaSampler.value }
};
const arr = JSON.parse(localStorage.getItem('holo_prompt_hist')||'[]');
arr.unshift(item);
// 保存上限:最新から最大100件(文字列長で切るとJSONが壊れるため件数で制御)
const MAX_ITEMS = 100;
localStorage.setItem('holo_prompt_hist', JSON.stringify(arr.slice(0, MAX_ITEMS)));
renderHistory();
toast('履歴に保存しました');
}
function renderHistory(){
const arr = JSON.parse(localStorage.getItem('holo_prompt_hist')||'[]');
history.innerHTML = '';
arr.forEach((it, idx)=>{
const card = document.createElement('div');
card.className = 'rounded-xl p-3 border border-white/10 bg-black/30';
card.innerHTML = `
<div class='flex items-center gap-2 mb-2'>
<span class='text-slate-300 text-sm'>${new Date(it.time).toLocaleString()}</span>
<span class='ms-auto text-xs chip rounded-lg px-2 py-0.5'>${it.member}</span>
</div>
<div class='text-xs text-slate-400 mb-2'>${it.template} / ${it.style} / ${it.mood} / ${it.aspect}</div>
<details class='mb-2'>
<summary class='cursor-pointer text-skin-soft'>Positive</summary>
<pre class='whitespace-pre-wrap text-sm'>${escapeHtml(it.pos)}</pre>
</details>
<details class='mb-2'>
<summary class='cursor-pointer text-skin-soft'>Negative</summary>
<pre class='whitespace-pre-wrap text-sm'>${escapeHtml(it.neg)}</pre>
</details>
<div class='flex gap-2'>
<button class='px-3 py-1.5 rounded-lg bg-white/10 border border-white/10 hover:bg-white/20' data-act='load' data-idx='${idx}'>読み込む</button>
<button class='px-3 py-1.5 rounded-lg bg-white/10 border border-white/10 hover:bg-white/20' data-act='copy' data-idx='${idx}'>コピー</button>
<button class='px-3 py-1.5 rounded-lg bg-white/10 border border-white/10 hover:bg-white/20' data-act='del' data-idx='${idx}'>削除</button>
</div>
`;
card.addEventListener('click', e=>{
const btn = e.target.closest('button');
if(!btn) return;
const { act, idx } = btn.dataset;
const list = JSON.parse(localStorage.getItem('holo_prompt_hist')||'[]');
if(act==='del'){
list.splice(idx,1);
localStorage.setItem('holo_prompt_hist', JSON.stringify(list));
renderHistory();
}else if(act==='copy'){
navigator.clipboard.writeText(`Positive:\n${list[idx].pos}\n\nNegative:\n${list[idx].neg}`);
toast('コピーしました');
}else if(act==='load'){
loadHistoryItem(list[idx]);
}
})
history.appendChild(card);
})
}
function loadHistoryItem(it){
selectedMember = MEMBERS.find(m=> m.key === it.member) || null;
templateSelect.value = it.template;
stylePreset.value = it.style;
moodPreset.value = it.mood;
aspectPreset.value = it.aspect; applyAspect();
posOut.value = it.pos; negOut.value = it.neg;
toast('履歴を読み込みました');
renderMembers(memberSearch.value);
}
clearHist.addEventListener('click', ()=>{
localStorage.removeItem('holo_prompt_hist');
renderHistory();
toast('履歴を削除しました');
});
// JSON入出力
exportBtn.addEventListener('click', ()=>{
const data = localStorage.getItem('holo_prompt_hist')||'[]';
const blob = new Blob([data], {type:'application/json'});
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'holo_prompt_history.json';
a.click();
});
importBtn.addEventListener('click', ()=>{
const inp = document.createElement('input');
inp.type = 'file'; inp.accept = 'application/json';
inp.onchange = () => {
const file = inp.files[0];
if(!file) return;
const reader = new FileReader();
reader.onload = e => {
try{
const arr = JSON.parse(e.target.result);
if(Array.isArray(arr)){
localStorage.setItem('holo_prompt_hist', JSON.stringify(arr));
renderHistory();
toast('JSONを読み込みました');
} else { alert('不正なJSONです'); }
}catch(err){ alert('読み込み失敗: '+err.message); }
};
reader.readAsText(file);
};
inp.click();
});
// ランダム&リセット
randomBtn.addEventListener('click', ()=>{
selectedMember = MEMBERS[Math.floor(Math.random()*MEMBERS.length)];
renderMembers(memberSearch.value);
generate();
toast('ランダム選択しました');
});
resetBtn.addEventListener('click', ()=>{
memberSearch.value=''; selectedMember=null; renderMembers('');
templateSelect.value='portrait'; stylePreset.value='anime'; moodPreset.value='soft'; aspectPreset.value='square'; applyAspect();
outfitInput.value=''; bgInput.value=''; modelInput.value=''; extraInput.value='';
posOut.value=''; negOut.value='';
toast('初期化しました');
});
// 生成/コピー/保存
generateBtn.addEventListener('click', generate);
copyBtn.addEventListener('click', copyAll);
saveBtn.addEventListener('click', saveHistory);
// 言語切替時に再生成
langToggle.addEventListener('change', ()=>{ if(posOut.value) generate(); });
// HTMLエスケープ
function escapeHtml(str=''){
return str.replace(/[&<>"]/g, s=> ({'&':'&','<':'<','>':'>','"':'"'}[s]));
}
// 初期描画
renderMembers('');
renderHistory();
</script>
</body>
</html>
ブラウザゲーム.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>
