include<stdio.h>
void main() {
printf(“HelloWorld.\n”);
printf(“ABC\n”);
printf(“日本語でも大丈夫\n”);
}
void main() {
printf(“HelloWorld.\n”);
printf(“ABC\n”);
printf(“日本語でも大丈夫\n”);
}
それ、方向性はめちゃくちゃアリ。**「BlueprintをAIで自動化」**は実際に現実的にできるし、作り方次第で“作業の8割”をAIに投げられる。
ただし結論から言うと、
例:「Eキーで拾う」「APが1分で回復」「HP0でGameOver」みたいな文章から、
AIが
「BPで組むと大変」なやつ(AP回復、セーブ、データ管理、複雑なステート)は
UEにはEditor Utility Blueprint / Editor Utility Widgetがあって、
エディタ上で作業を自動化できる。
ここを使うと「定型BPを大量に作る」系に効く。
※ただ、BPグラフを完全に自動で組むのは、UEのAPIやプラグイン領域が絡むから、
「まずはテンプレ生成」から入るのが現実的。
AIがやる:
あなたがやる:
このループが回ると、体感「BPが自動で組み上がっていく」になる。
やりたい機能を1個だけ文章で書く(例):
「Eキーで近くの武器を拾ってインベントリに入れて、UIに表示。すでに持ってたら入れ替え」
これを投げてくれたら、俺がそのまま
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
<title>Voogle - Mini Browser</title>
<style>
:root{
--bg:#0b0f14;
--panel: rgba(255,255,255,.06);
--stroke: rgba(255,255,255,.10);
--ink:#eaf0ff;
--muted: rgba(234,240,255,.70);
--accent:#7cf0ff;
--accent2:#7ca0ff;
--danger:#ff6b6b;
--ok:#79ffa7;
--shadow: 0 18px 50px rgba(0,0,0,.35);
--radius:16px;
--radius2:22px;
--glass: blur(14px) saturate(1.2);
}
[data-theme="light"]{
--bg:#f6f7fb;
--panel: rgba(0,0,0,.05);
--stroke: rgba(0,0,0,.10);
--ink:#101828;
--muted: rgba(16,24,40,.68);
--shadow: 0 18px 50px rgba(16,24,40,.12);
}
*{box-sizing:border-box}
html,body{height:100%}
body{
margin:0;
background: radial-gradient(1200px 800px at 20% 10%, rgba(124,240,255,.15), transparent 60%),
radial-gradient(1200px 800px at 80% 20%, rgba(124,160,255,.14), transparent 60%),
var(--bg);
color:var(--ink);
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans JP", Arial;
overflow:hidden;
}
.app{
height:100%;
display:grid;
grid-template-columns: 320px 1fr;
gap: 12px;
padding: 12px;
}
.card{
background: linear-gradient(180deg, rgba(255,255,255,.08), rgba(255,255,255,.03));
border: 1px solid var(--stroke);
border-radius: var(--radius2);
box-shadow: var(--shadow);
backdrop-filter: var(--glass);
overflow:hidden;
min-height:0;
}
.sidebar{display:flex; flex-direction:column; min-height:0;}
.main{display:flex; flex-direction:column; min-height:0;}
/* Topbar */
.topbar{
display:flex;
align-items:center;
gap:10px;
padding:10px;
border-bottom:1px solid var(--stroke);
background: rgba(0,0,0,.08);
}
[data-theme="light"] .topbar{ background: rgba(255,255,255,.55); }
.brand{
display:flex; align-items:center; gap:10px;
padding:10px;
border-bottom:1px solid var(--stroke);
}
.logo{
width:34px; height:34px; border-radius:12px;
background: radial-gradient(circle at 30% 30%, var(--accent), rgba(124,240,255,.0) 55%),
radial-gradient(circle at 70% 70%, var(--accent2), rgba(124,160,255,.0) 55%),
rgba(255,255,255,.06);
border:1px solid var(--stroke);
box-shadow: 0 12px 30px rgba(124,240,255,.14);
}
.brand h1{
font-size:14px; margin:0; letter-spacing:.4px;
}
.brand p{margin:0; font-size:12px; color:var(--muted)}
.btn{
appearance:none;
border:1px solid var(--stroke);
background: rgba(255,255,255,.06);
color:var(--ink);
padding:8px 10px;
border-radius: 12px;
cursor:pointer;
transition: transform .08s ease, background .15s ease, border-color .15s ease;
user-select:none;
white-space:nowrap;
}
.btn:hover{ background: rgba(255,255,255,.10); border-color: rgba(124,240,255,.28); }
.btn:active{ transform: scale(.98); }
.btn.primary{
border-color: rgba(124,240,255,.35);
background: linear-gradient(180deg, rgba(124,240,255,.18), rgba(124,160,255,.10));
}
.btn.danger{
border-color: rgba(255,107,107,.35);
background: linear-gradient(180deg, rgba(255,107,107,.16), rgba(255,107,107,.08));
}
.btn.ok{
border-color: rgba(121,255,167,.35);
background: linear-gradient(180deg, rgba(121,255,167,.14), rgba(121,255,167,.07));
}
.icon{
width:18px;height:18px;display:inline-grid;place-items:center;
font-weight:700; opacity:.9;
}
/* Address */
.addr{
flex:1;
display:flex;
gap:10px;
align-items:center;
min-width:0;
}
.addr input{
width:100%;
min-width:0;
padding:10px 12px;
border-radius: 14px;
border:1px solid var(--stroke);
background: rgba(0,0,0,.14);
color:var(--ink);
outline:none;
}
[data-theme="light"] .addr input{ background: rgba(255,255,255,.75); }
.hint{
font-size:12px;
color:var(--muted);
padding: 0 12px 10px;
}
/* Tabs */
.tabs{
display:flex;
gap:8px;
padding:10px;
border-bottom:1px solid var(--stroke);
overflow:auto;
}
.tab{
display:flex; align-items:center; gap:8px;
padding:8px 10px;
border-radius: 14px;
border:1px solid var(--stroke);
background: rgba(255,255,255,.06);
cursor:pointer;
min-width: 160px;
max-width: 260px;
flex: 0 0 auto;
}
.tab.active{
border-color: rgba(124,240,255,.45);
background: linear-gradient(180deg, rgba(124,240,255,.16), rgba(124,160,255,.10));
}
.tab .title{
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
font-size:13px;
flex:1;
}
.pill{
font-size:11px;
color:var(--muted);
border:1px solid var(--stroke);
padding:2px 8px;
border-radius:999px;
background: rgba(0,0,0,.10);
}
[data-theme="light"] .pill{ background: rgba(255,255,255,.6); }
.x{
width:24px;height:24px; border-radius:10px;
display:grid; place-items:center;
border:1px solid var(--stroke);
background: rgba(0,0,0,.10);
opacity:.9;
}
.x:hover{ border-color: rgba(255,107,107,.5); }
[data-theme="light"] .x{ background: rgba(255,255,255,.6); }
/* Viewport */
.viewport{
position:relative;
flex:1;
min-height:0;
background: rgba(0,0,0,.10);
}
[data-theme="light"] .viewport{ background: rgba(0,0,0,.03); }
.frame{
position:absolute; inset:0;
width:100%; height:100%;
border:0;
background: transparent;
}
.overlay{
position:absolute; inset: 14px;
border-radius: 18px;
border:1px dashed rgba(124,240,255,.35);
display:none;
place-items:center;
text-align:center;
padding:18px;
background: rgba(0,0,0,.35);
backdrop-filter: blur(10px);
}
[data-theme="light"] .overlay{ background: rgba(255,255,255,.78); }
.overlay.show{ display:grid; }
.overlay h2{margin:0 0 8px; font-size:16px;}
.overlay p{margin:0 0 12px; color:var(--muted); font-size:13px;}
.overlay .row{display:flex; gap:10px; flex-wrap:wrap; justify-content:center}
/* Sidebar content */
.section{
padding:12px;
border-top:1px solid var(--stroke);
min-height:0;
overflow:auto;
}
.section h3{
margin:0 0 10px;
font-size:12px;
color:var(--muted);
letter-spacing:.18em;
}
.list{
display:flex;
flex-direction:column;
gap:8px;
}
.item{
display:flex;
gap:10px;
align-items:center;
padding:10px 10px;
border-radius: 14px;
border:1px solid var(--stroke);
background: rgba(255,255,255,.05);
cursor:pointer;
}
.item:hover{ border-color: rgba(124,240,255,.28); background: rgba(255,255,255,.08); }
.item .meta{flex:1; min-width:0}
.item .meta .t{
font-size:13px;
overflow:hidden; white-space:nowrap; text-overflow:ellipsis;
}
.item .meta .s{
font-size:12px; color:var(--muted);
overflow:hidden; white-space:nowrap; text-overflow:ellipsis;
}
.tag{
font-size:11px;
padding:2px 8px;
border-radius: 999px;
border:1px solid var(--stroke);
color: var(--muted);
}
.footerbar{
padding:10px 12px;
border-top:1px solid var(--stroke);
font-size:12px;
color:var(--muted);
display:flex;
gap:12px;
align-items:center;
justify-content:space-between;
}
.kbd{
border:1px solid var(--stroke);
border-bottom-width:2px;
padding:2px 6px;
border-radius:8px;
background: rgba(0,0,0,.10);
font-size:11px;
color:var(--muted);
white-space:nowrap;
}
[data-theme="light"] .kbd{ background: rgba(255,255,255,.6); }
.row{
display:flex; gap:8px; flex-wrap:wrap;
}
.mini{
font-size:12px;
padding:6px 8px;
border-radius: 12px;
}
.toast{
position:fixed;
right:14px; bottom:14px;
padding:10px 12px;
border-radius: 14px;
border:1px solid var(--stroke);
background: rgba(0,0,0,.50);
backdrop-filter: blur(10px);
color:var(--ink);
box-shadow: var(--shadow);
transform: translateY(10px);
opacity:0;
transition: .22s ease;
pointer-events:none;
max-width: min(420px, calc(100vw - 28px));
}
[data-theme="light"] .toast{ background: rgba(255,255,255,.86); }
.toast.show{ transform: translateY(0); opacity:1; }
.toast .small{ font-size:12px; color:var(--muted); margin-top:2px; }
@media (max-width: 980px){
.app{ grid-template-columns: 1fr; }
.sidebar{ display:none; }
}
</style>
</head>
<body data-theme="dark">
<div class="app">
<!-- Sidebar -->
<aside class="card sidebar">
<div class="brand">
<div class="logo" aria-hidden="true"></div>
<div>
<h1>Voogle</h1>
<p>Mini Browser (1-file)</p>
</div>
</div>
<div class="section" style="border-top:none">
<div class="row">
<button class="btn mini primary" id="btnNewTab"><span class="icon">+</span>新規タブ</button>
<button class="btn mini" id="btnToggleTheme"><span class="icon">☾</span>テーマ</button>
<button class="btn mini" id="btnExport"><span class="icon">⤓</span>データ出力</button>
<label class="btn mini" style="display:inline-flex; align-items:center; gap:8px; cursor:pointer;">
<span class="icon">⤒</span>データ取込
<input id="importFile" type="file" accept="application/json" style="display:none" />
</label>
</div>
<div class="hint">※多くの外部サイトは埋め込み禁止。開けない時は「新しいタブで開く」。</div>
</div>
<div class="section">
<h3>クイック</h3>
<div class="list" id="quickList"></div>
</div>
<div class="section">
<h3>ブックマーク</h3>
<div class="list" id="bmList"></div>
</div>
<div class="section">
<h3>履歴(最新20件)</h3>
<div class="list" id="histList"></div>
</div>
<div class="footerbar">
<div class="row" style="gap:6px">
<span class="kbd">Ctrl</span>+<span class="kbd">L</span> アドレス
<span class="kbd">Ctrl</span>+<span class="kbd">T</span> 新規
<span class="kbd">Ctrl</span>+<span class="kbd">W</span> 閉じる
</div>
<span id="statusText">Ready</span>
</div>
</aside>
<!-- Main -->
<main class="card main">
<div class="topbar">
<button class="btn" id="btnBack" title="戻る"><span class="icon">←</span></button>
<button class="btn" id="btnForward" title="進む"><span class="icon">→</span></button>
<button class="btn" id="btnReload" title="更新"><span class="icon">↻</span></button>
<div class="addr">
<input id="addrInput" placeholder="URL または 検索ワード(例: https://example.com / openai)" autocomplete="off" />
</div>
<button class="btn primary" id="btnGo" title="移動"><span class="icon">⏎</span></button>
<button class="btn" id="btnBookmark" title="ブックマーク"><span class="icon">☆</span></button>
<button class="btn" id="btnOpenExternal" title="新しいタブで開く"><span class="icon">↗</span></button>
<button class="btn" id="btnPip" title="PiP(対応サイトのみ)"><span class="icon">▣</span></button>
</div>
<div class="tabs" id="tabs"></div>
<div class="viewport">
<iframe id="frame" class="frame" sandbox="allow-forms allow-modals allow-popups allow-same-origin allow-scripts allow-downloads"></iframe>
<div class="overlay" id="overlay">
<div>
<h2>このページは埋め込みを拒否してる</h2>
<p>
たいていはサイト側のセキュリティ(X-Frame-Options / CSP)です。<br>
下のボタンで外部タブとして開け。
</p>
<div class="row">
<button class="btn ok" id="overlayOpenExternal"><span class="icon">↗</span>新しいタブで開く</button>
<button class="btn" id="overlayTrySearch"><span class="icon">⌕</span>検索で開く</button>
<button class="btn danger" id="overlayClose"><span class="icon">×</span>閉じる</button>
</div>
</div>
</div>
</div>
<div class="footerbar">
<div class="row" style="gap:10px; align-items:center">
<span class="tag" id="originTag">—</span>
<span class="tag" id="secureTag">—</span>
<span class="tag" id="embedTag">—</span>
</div>
<div class="row" style="gap:8px">
<button class="btn mini" id="btnHome">ホーム</button>
<button class="btn mini" id="btnClearHistory">履歴クリア</button>
<button class="btn mini danger" id="btnResetAll">全リセット</button>
</div>
</div>
</main>
</div>
<div class="toast" id="toast"></div>
<script>
(() => {
// =========================
// Utilities
// =========================
const $ = (sel) => document.querySelector(sel);
const escapeHTML = (s) => (s ?? "").toString()
.replaceAll("&","&").replaceAll("<","<")
.replaceAll(">",">").replaceAll('"',""").replaceAll("'","'");
const nowISO = () => new Date().toISOString();
const isUrlLike = (text) => {
const t = (text || "").trim();
if (!t) return false;
if (/^https?:\/\//i.test(t)) return true;
if (/^[a-z0-9.-]+\.[a-z]{2,}([\/?#].*)?$/i.test(t)) return true;
if (/^localhost(:\d+)?(\/.*)?$/i.test(t)) return true;
if (/^\d{1,3}(\.\d{1,3}){3}(:\d+)?(\/.*)?$/.test(t)) return true;
return false;
};
const normalizeToUrl = (text) => {
let t = (text || "").trim();
if (!t) return "";
if (/^https?:\/\//i.test(t)) return t;
if (isUrlLike(t)) return "https://" + t;
return "";
};
const buildSearchUrl = (q) => "https://www.google.com/search?q=" + encodeURIComponent(q);
const toast = (title, detail="") => {
const el = $("#toast");
el.innerHTML = `<div><b>${escapeHTML(title)}</b></div>${detail ? `<div class="small">${escapeHTML(detail)}</div>` : ""}`;
el.classList.add("show");
clearTimeout(toast._t);
toast._t = setTimeout(() => el.classList.remove("show"), 2400);
};
const setStatus = (s) => { $("#statusText").textContent = s; };
// =========================
// Storage
// =========================
const KEY = "voogle.v1";
const defaultState = () => ({
theme: "dark",
tabs: [
{ id: crypto.randomUUID(), title: "Home", url: "about:home", history: ["about:home"], hIndex: 0, createdAt: nowISO() }
],
activeTabId: null,
bookmarks: [
{ title:"OpenAI", url:"https://openai.com", addedAt: nowISO() },
{ title:"Wikipedia", url:"https://ja.wikipedia.org", addedAt: nowISO() },
{ title:"GitHub", url:"https://github.com", addedAt: nowISO() },
{ title:"MDN", url:"https://developer.mozilla.org/ja/", addedAt: nowISO() },
{ title:"YouTube", url:"https://www.youtube.com", addedAt: nowISO() },
],
history: [],
quick: [
{ title:"ニュース", url:"https://news.google.com/?hl=ja&gl=JP&ceid=JP:ja" },
{ title:"X", url:"https://x.com" },
{ title:"Reddit", url:"https://www.reddit.com" },
{ title:"Qiita", url:"https://qiita.com" },
{ title:"Zenn", url:"https://zenn.dev" },
{ title:"Google", url:"https://www.google.com" },
],
settings: {
homeUrl: "about:home",
maxHistory: 200,
sidebarHistoryView: 20
}
});
const loadState = () => {
try{
const raw = localStorage.getItem(KEY);
if(!raw) return defaultState();
const s = JSON.parse(raw);
// minimal migrate
if(!s.tabs?.length) return defaultState();
return s;
}catch(e){
console.warn(e);
return defaultState();
}
};
const saveState = () => localStorage.setItem(KEY, JSON.stringify(state));
let state = loadState();
// =========================
// Tabs
// =========================
const getActiveTab = () => state.tabs.find(t => t.id === state.activeTabId) || state.tabs[0];
const setActive = (id) => {
state.activeTabId = id;
saveState();
renderAll();
loadTabToViewport(getActiveTab());
};
const newTab = (url = "about:home", title = "New Tab") => {
const tab = {
id: crypto.randomUUID(),
title,
url,
history: [url],
hIndex: 0,
createdAt: nowISO()
};
state.tabs.push(tab);
state.activeTabId = tab.id;
saveState();
renderAll();
loadTabToViewport(tab);
toast("新規タブ", title);
};
const closeTab = (id) => {
if(state.tabs.length <= 1){
toast("これ以上閉じれない", "最低1タブは残る");
return;
}
const idx = state.tabs.findIndex(t => t.id === id);
if(idx < 0) return;
const wasActive = state.activeTabId === id;
const closed = state.tabs[idx];
state.tabs.splice(idx,1);
if(wasActive){
const fallback = state.tabs[Math.max(0, idx-1)];
state.activeTabId = fallback.id;
}
saveState();
renderAll();
loadTabToViewport(getActiveTab());
toast("タブを閉じた", closed.title);
};
const setTabTitle = (tab, title) => {
tab.title = (title || "Untitled").slice(0, 60);
saveState();
renderTabs();
};
const pushHistory = (tab, url) => {
if(tab.history[tab.hIndex] === url) return;
tab.history = tab.history.slice(0, tab.hIndex + 1);
tab.history.push(url);
tab.hIndex = tab.history.length - 1;
};
// =========================
// History + Bookmarks
// =========================
const addGlobalHistory = (url, title="") => {
if(!url || url === "about:home") return;
state.history.unshift({ url, title, at: nowISO() });
// de-dupe
const seen = new Set();
state.history = state.history.filter(h => {
const k = h.url;
if(seen.has(k)) return false;
seen.add(k);
return true;
});
state.history = state.history.slice(0, state.settings.maxHistory || 200);
saveState();
renderSidebar();
};
const isBookmarked = (url) => state.bookmarks.some(b => b.url === url);
const toggleBookmark = () => {
const tab = getActiveTab();
const url = tab.url;
if(!url || url === "about:home") return toast("ホームは登録しない");
if(isBookmarked(url)){
state.bookmarks = state.bookmarks.filter(b => b.url !== url);
saveState();
renderSidebar();
toast("ブックマーク削除", url);
}else{
state.bookmarks.unshift({ title: tab.title || url, url, addedAt: nowISO() });
saveState();
renderSidebar();
toast("ブックマーク追加", tab.title || url);
}
renderIndicators();
};
// =========================
// Viewport Loader
// =========================
const frame = $("#frame");
const overlay = $("#overlay");
let embedBlockedTimer = null;
const setOverlay = (show) => {
overlay.classList.toggle("show", !!show);
};
const updateAddressBar = (tab) => {
$("#addrInput").value = tab.url === "about:home" ? "" : tab.url;
};
const renderIndicators = () => {
const tab = getActiveTab();
const url = tab.url || "";
const originTag = $("#originTag");
const secureTag = $("#secureTag");
const embedTag = $("#embedTag");
let origin = "—";
try{
if(url.startsWith("about:")) origin = "about";
else origin = (new URL(url)).hostname;
}catch(e){ origin = "—"; }
const secure = url.startsWith("https://") ? "HTTPS" : (url.startsWith("http://") ? "HTTP" : "—");
originTag.textContent = origin;
secureTag.textContent = secure;
const bm = isBookmarked(url) ? "Bookmarked" : "Not bookmarked";
embedTag.textContent = bm;
};
const loadTabToViewport = (tab) => {
setOverlay(false);
clearTimeout(embedBlockedTimer);
renderIndicators();
updateAddressBar(tab);
const url = tab.url;
if(url === "about:home"){
frame.removeAttribute("src");
frame.srcdoc = homeHTML();
setStatus("Home");
return;
}
frame.removeAttribute("srcdoc");
frame.src = url;
setStatus("Loading…");
// "埋め込みブロック" は確実に検知できないが、
// 一定時間で表示されなければ overlay を出して逃げ道を用意する。
embedBlockedTimer = setTimeout(() => {
// about:blank だったり、何も表示されないケースを想定
// ここは「保険」なので強制表示ではなく、状況を見て出す
// → タブのURLが外部なら基本出す
if(getActiveTab().url === url){
setOverlay(true);
setStatus("Embed blocked (maybe)");
}
}, 1400);
};
frame.addEventListener("load", () => {
clearTimeout(embedBlockedTimer);
const tab = getActiveTab();
// タイトルの推定は、クロスオリジンだと取れないのでURLから作る
if(tab.url.startsWith("about:")){
setStatus("Ready");
return;
}
setOverlay(false);
setStatus("Ready");
addGlobalHistory(tab.url, tab.title);
// タイトル推定
let title = tab.title;
try{
const u = new URL(tab.url);
title = u.hostname;
if(u.pathname && u.pathname !== "/") title += u.pathname.slice(0, 14) + (u.pathname.length > 14 ? "…" : "");
}catch(e){}
setTabTitle(tab, title);
renderIndicators();
});
frame.addEventListener("error", () => {
clearTimeout(embedBlockedTimer);
setOverlay(true);
setStatus("Load error");
});
const navigate = (input) => {
const tab = getActiveTab();
const raw = (input ?? $("#addrInput").value).trim();
if(!raw){
tab.url = "about:home";
pushHistory(tab, tab.url);
saveState();
loadTabToViewport(tab);
renderTabs();
return;
}
const url = normalizeToUrl(raw) || buildSearchUrl(raw);
tab.url = url;
pushHistory(tab, url);
saveState();
renderTabs();
loadTabToViewport(tab);
toast("移動", url);
};
const back = () => {
const tab = getActiveTab();
if(tab.hIndex <= 0) return toast("戻れない");
tab.hIndex -= 1;
tab.url = tab.history[tab.hIndex];
saveState();
renderTabs();
loadTabToViewport(tab);
};
const forward = () => {
const tab = getActiveTab();
if(tab.hIndex >= tab.history.length - 1) return toast("進めない");
tab.hIndex += 1;
tab.url = tab.history[tab.hIndex];
saveState();
renderTabs();
loadTabToViewport(tab);
};
const reload = () => {
const tab = getActiveTab();
if(tab.url === "about:home"){
loadTabToViewport(tab);
return;
}
try{
frame.contentWindow.location.reload();
}catch(e){
// クロスオリジンは reload 制限があるので src 再設定
frame.src = tab.url;
}
setStatus("Reloading…");
};
const openExternal = () => {
const tab = getActiveTab();
const url = tab.url === "about:home" ? buildSearchUrl($("#addrInput").value.trim() || "home") : tab.url;
window.open(url, "_blank", "noopener,noreferrer");
toast("外部で開いた", url);
};
const tryPip = async () => {
try{
const doc = frame.contentDocument;
if(!doc) throw new Error("Cross-origin");
const video = doc.querySelector("video");
if(!video) return toast("動画が見つからない", "このページに <video> がない");
if(document.pictureInPictureElement) await document.exitPictureInPicture();
await video.requestPictureInPicture();
toast("PiP", "Picture-in-Picture");
}catch(e){
toast("PiP不可", "多くの外部サイトは制限がある");
}
};
// =========================
// UI Render
// =========================
const renderTabs = () => {
const el = $("#tabs");
const activeId = state.activeTabId || state.tabs[0]?.id;
if(!state.activeTabId) state.activeTabId = activeId;
el.innerHTML = state.tabs.map(t => {
const active = t.id === activeId ? "active" : "";
const pill = t.url === "about:home" ? "HOME" : (t.url.startsWith("https://") ? "HTTPS" : (t.url.startsWith("http://") ? "HTTP" : "—"));
return `
<div class="tab ${active}" data-tab="${t.id}">
<div class="title">${escapeHTML(t.title || "Untitled")}</div>
<span class="pill">${escapeHTML(pill)}</span>
<div class="x" title="閉じる" data-close="${t.id}">×</div>
</div>
`;
}).join("");
el.querySelectorAll(".tab").forEach(tabEl => {
tabEl.addEventListener("click", (ev) => {
const closeId = ev.target?.getAttribute?.("data-close");
if(closeId){
ev.stopPropagation();
closeTab(closeId);
return;
}
const id = tabEl.getAttribute("data-tab");
setActive(id);
});
});
};
const renderSidebar = () => {
// Quick
const q = $("#quickList");
q.innerHTML = state.quick.map(x => `
<div class="item" data-url="${escapeHTML(x.url)}">
<div class="icon">⚡</div>
<div class="meta">
<div class="t">${escapeHTML(x.title)}</div>
<div class="s">${escapeHTML(x.url)}</div>
</div>
<span class="tag">OPEN</span>
</div>
`).join("");
// Bookmarks
const b = $("#bmList");
b.innerHTML = (state.bookmarks.length ? state.bookmarks : [{title:"(なし)", url:""}]).map(x => `
<div class="item" data-url="${escapeHTML(x.url || "")}" ${x.url ? "" : "style='opacity:.6; cursor:default'"}>
<div class="icon">☆</div>
<div class="meta">
<div class="t">${escapeHTML(x.title)}</div>
<div class="s">${escapeHTML(x.url)}</div>
</div>
${x.url ? `<span class="tag">OPEN</span>` : `<span class="tag">—</span>`}
</div>
`).join("");
// History
const h = $("#histList");
const max = state.settings.sidebarHistoryView || 20;
const hist = state.history.slice(0, max);
h.innerHTML = (hist.length ? hist : [{title:"(なし)", url:""}]).map(x => `
<div class="item" data-url="${escapeHTML(x.url || "")}" ${x.url ? "" : "style='opacity:.6; cursor:default'"}>
<div class="icon">⟲</div>
<div class="meta">
<div class="t">${escapeHTML(x.title || x.url || "(なし)")}</div>
<div class="s">${escapeHTML(x.url || "")}</div>
</div>
${x.url ? `<span class="tag">OPEN</span>` : `<span class="tag">—</span>`}
</div>
`).join("");
// Handlers
const bindOpen = (root) => {
root.querySelectorAll(".item").forEach(it => {
it.addEventListener("click", () => {
const url = it.getAttribute("data-url");
if(!url) return;
navigate(url);
});
});
};
bindOpen(q); bindOpen(b); bindOpen(h);
};
const renderTheme = () => {
document.body.setAttribute("data-theme", state.theme || "dark");
$("#btnToggleTheme").innerHTML = state.theme === "dark"
? `<span class="icon">☾</span>テーマ`
: `<span class="icon">☀</span>テーマ`;
};
const renderAll = () => {
renderTheme();
renderTabs();
renderSidebar();
renderIndicators();
};
// =========================
// Home page (srcdoc)
// =========================
const homeHTML = () => {
const quick = state.quick.slice(0, 6).map(x => `
<a class="card" href="${x.url}" target="_blank" rel="noopener noreferrer">
<div class="t">${escapeHTML(x.title)}</div>
<div class="s">${escapeHTML(x.url)}</div>
</a>
`).join("");
const bm = state.bookmarks.slice(0, 6).map(x => `
<a class="card" href="${x.url}" target="_blank" rel="noopener noreferrer">
<div class="t">☆ ${escapeHTML(x.title)}</div>
<div class="s">${escapeHTML(x.url)}</div>
</a>
`).join("");
return `<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Home</title>
<style>
:root{ color-scheme: dark; }
body{
margin:0;
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans JP", Arial;
background: radial-gradient(1000px 600px at 20% 20%, rgba(124,240,255,.18), transparent 60%),
radial-gradient(1000px 600px at 80% 20%, rgba(124,160,255,.16), transparent 60%),
#0b0f14;
color:#eaf0ff;
}
.wrap{ padding: 18px; }
h1{ font-size:18px; margin:0 0 8px; }
p{ margin:0 0 14px; opacity:.75; font-size:13px; }
.grid{
display:grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap:10px;
}
.card{
display:block;
padding:12px 12px;
border-radius: 16px;
border:1px solid rgba(255,255,255,.10);
background: rgba(255,255,255,.06);
text-decoration:none;
color:inherit;
}
.card:hover{ border-color: rgba(124,240,255,.35); background: rgba(255,255,255,.09); }
.t{ font-size:14px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.s{ font-size:12px; opacity:.70; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; margin-top:4px; }
.row{ display:flex; gap:8px; flex-wrap:wrap; margin-top:12px; }
.pill{
border:1px solid rgba(255,255,255,.10);
padding:6px 10px;
border-radius: 999px;
background: rgba(255,255,255,.06);
font-size:12px;
opacity:.85;
}
</style>
</head>
<body>
<div class="wrap">
<h1>Voogle Home</h1>
<p>アドレスバーにURLか検索ワードを入れて Enter。埋め込み不可サイトは外部タブで開く。</p>
<div class="row">
<span class="pill">Ctrl+L: アドレス</span>
<span class="pill">Ctrl+T: 新規タブ</span>
<span class="pill">Ctrl+W: タブ閉じる</span>
<span class="pill">Ctrl+R: 更新</span>
</div>
<h1 style="margin-top:18px">Quick</h1>
<div class="grid">${quick}</div>
<h1 style="margin-top:18px">Bookmarks</h1>
<div class="grid">${bm}</div>
</div>
</body>
</html>`;
};
// =========================
// Export / Import
// =========================
const exportData = () => {
const data = JSON.stringify(state, null, 2);
const blob = new Blob([data], {type:"application/json"});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "voogle-data.json";
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
toast("出力した", "voogle-data.json");
};
const importData = async (file) => {
try{
const text = await file.text();
const obj = JSON.parse(text);
if(!obj || !obj.tabs || !Array.isArray(obj.tabs)) throw new Error("Invalid");
state = obj;
saveState();
renderAll();
loadTabToViewport(getActiveTab());
toast("取込完了", "データを復元した");
}catch(e){
toast("取込失敗", "JSONが壊れてるか形式が違う");
}
};
const resetAll = () => {
if(!confirm("全データを初期化する?")) return;
state = defaultState();
saveState();
renderAll();
loadTabToViewport(getActiveTab());
toast("初期化した");
};
// =========================
// Bindings
// =========================
$("#btnGo").addEventListener("click", () => navigate());
$("#addrInput").addEventListener("keydown", (e) => {
if(e.key === "Enter") navigate();
});
$("#btnBack").addEventListener("click", back);
$("#btnForward").addEventListener("click", forward);
$("#btnReload").addEventListener("click", reload);
$("#btnBookmark").addEventListener("click", toggleBookmark);
$("#btnOpenExternal").addEventListener("click", openExternal);
$("#btnPip").addEventListener("click", tryPip);
$("#btnNewTab").addEventListener("click", () => newTab("about:home", "Home"));
$("#btnToggleTheme").addEventListener("click", () => {
state.theme = (state.theme === "dark") ? "light" : "dark";
saveState();
renderTheme();
toast("テーマ", state.theme);
});
$("#btnExport").addEventListener("click", exportData);
$("#importFile").addEventListener("change", (e) => {
const f = e.target.files?.[0];
if(f) importData(f);
e.target.value = "";
});
$("#btnHome").addEventListener("click", () => navigate("about:home"));
$("#btnClearHistory").addEventListener("click", () => {
state.history = [];
saveState();
renderSidebar();
toast("履歴クリア");
});
$("#btnResetAll").addEventListener("click", resetAll);
// Overlay buttons
$("#overlayOpenExternal").addEventListener("click", openExternal);
$("#overlayTrySearch").addEventListener("click", () => {
const tab = getActiveTab();
const q = tab.url && tab.url !== "about:home" ? tab.url : ($("#addrInput").value.trim() || "home");
newTab(buildSearchUrl(q), "Search");
});
$("#overlayClose").addEventListener("click", () => {
setOverlay(false);
toast("閉じた");
});
// Keyboard shortcuts
window.addEventListener("keydown", (e) => {
const ctrl = e.ctrlKey || e.metaKey;
if(ctrl && e.key.toLowerCase() === "l"){ e.preventDefault(); $("#addrInput").focus(); $("#addrInput").select(); }
if(ctrl && e.key.toLowerCase() === "t"){ e.preventDefault(); newTab("about:home", "Home"); }
if(ctrl && e.key.toLowerCase() === "w"){ e.preventDefault(); closeTab(getActiveTab().id); }
if(ctrl && e.key.toLowerCase() === "r"){ e.preventDefault(); reload(); }
if(ctrl && e.key === "Enter"){ e.preventDefault(); openExternal(); }
});
// Init
if(!state.activeTabId) state.activeTabId = state.tabs[0].id;
renderAll();
loadTabToViewport(getActiveTab());
})();
</script>
</body>
</html>
“言葉(真の名)で世界を変えるRPG”
剣で殴るだけのRPGはもう古い。プレイヤーが強くなる理由を、**魔法体系(言霊・均衡・真の名)**で説明できるゲームにする。
群島世界。島ごとに文化も価値観も違う。
世界は“言葉”で支えられていて、真の名を知る者は自然・獣・風・火に命令できる。だが、命令は必ず代償を要求する。均衡が崩れると、死者の国の扉が軋み、影が漏れる。
※名前は開発用。後で差し替え可能。
必要なら、この企画書を次のどれかに“完成形”まで一気に落とすよ。
import javax.swing.*;
import javax.swing.border.EmptyBorder;
import java.awt.*;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Random;
public class OmikujiGUI extends JFrame {
private final JLabel resultLabel = new JLabel("—", SwingConstants.CENTER);
private final JLabel messageLabel = new JLabel("ボタンを押して引け", SwingConstants.CENTER);
private final JLabel luckyLabel = new JLabel("ラッキー:—", SwingConstants.CENTER);
private final JTextArea historyArea = new JTextArea(10, 30);
private final Random random = new Random();
private static final String[] RESULTS = {"大吉", "中吉", "小吉", "吉", "末吉", "凶", "大凶"};
private static final String[] MESSAGES = {
"最強。今日は攻めていい。",
"良い流れ。焦らず積み上げろ。",
"小さく勝てる日。丁寧にいこう。",
"安定。普通が一番強い。",
"油断すると崩れる。慎重に。",
"無理は禁物。守りでいけ。",
"今日は撤退が正解。休め。"
};
private static final String[] COLORS = {"赤", "青", "緑", "黒", "白", "金", "紫"};
public OmikujiGUI() {
super("おみくじ(GUI版)");
setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
setMinimumSize(new Dimension(520, 520));
setLocationRelativeTo(null);
// 全体レイアウト
JPanel root = new JPanel(new BorderLayout(12, 12));
root.setBorder(new EmptyBorder(16, 16, 16, 16));
setContentPane(root);
// 上部:タイトル
JLabel title = new JLabel("おみくじアプリ", SwingConstants.LEFT);
title.setFont(title.getFont().deriveFont(Font.BOLD, 22f));
root.add(title, BorderLayout.NORTH);
// 中央:結果表示パネル
JPanel center = new JPanel();
center.setLayout(new BoxLayout(center, BoxLayout.Y_AXIS));
root.add(center, BorderLayout.CENTER);
resultLabel.setFont(resultLabel.getFont().deriveFont(Font.BOLD, 56f));
resultLabel.setBorder(new EmptyBorder(18, 12, 10, 12));
messageLabel.setFont(messageLabel.getFont().deriveFont(Font.PLAIN, 16f));
messageLabel.setBorder(new EmptyBorder(6, 12, 6, 12));
luckyLabel.setFont(luckyLabel.getFont().deriveFont(Font.PLAIN, 15f));
luckyLabel.setBorder(new EmptyBorder(6, 12, 14, 12));
center.add(wrapCard(resultLabel));
center.add(Box.createVerticalStrut(10));
center.add(wrapCard(messageLabel));
center.add(Box.createVerticalStrut(8));
center.add(wrapCard(luckyLabel));
center.add(Box.createVerticalStrut(12));
// 履歴
JLabel historyTitle = new JLabel("履歴");
historyTitle.setFont(historyTitle.getFont().deriveFont(Font.BOLD, 16f));
historyTitle.setBorder(new EmptyBorder(4, 2, 6, 2));
center.add(historyTitle);
historyArea.setEditable(false);
historyArea.setLineWrap(true);
historyArea.setWrapStyleWord(true);
historyArea.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 13));
historyArea.setText("(まだありません)");
JScrollPane scroll = new JScrollPane(historyArea);
scroll.setBorder(BorderFactory.createLineBorder(new Color(220, 220, 220)));
center.add(scroll);
// 下部:ボタン
JPanel bottom = new JPanel(new GridLayout(1, 3, 10, 10));
JButton drawBtn = new JButton("引く");
JButton clearBtn = new JButton("履歴クリア");
JButton exitBtn = new JButton("終了");
drawBtn.setFont(drawBtn.getFont().deriveFont(Font.BOLD, 14f));
drawBtn.addActionListener(e -> drawOmikuji());
clearBtn.addActionListener(e -> clearHistory());
exitBtn.addActionListener(e -> dispose());
bottom.add(drawBtn);
bottom.add(clearBtn);
bottom.add(exitBtn);
root.add(bottom, BorderLayout.SOUTH);
}
private JPanel wrapCard(JComponent comp) {
JPanel p = new JPanel(new BorderLayout());
p.setBorder(BorderFactory.createCompoundBorder(
BorderFactory.createLineBorder(new Color(225, 225, 225)),
new EmptyBorder(6, 8, 6, 8)
));
p.add(comp, BorderLayout.CENTER);
return p;
}
private void drawOmikuji() {
int idx = random.nextInt(RESULTS.length);
String result = RESULTS[idx];
String message = MESSAGES[idx];
int luckyNumber = random.nextInt(99) + 1; // 1〜99
String luckyColor = COLORS[random.nextInt(COLORS.length)];
resultLabel.setText(result);
messageLabel.setText(message);
luckyLabel.setText("ラッキーナンバー:" + luckyNumber + " / ラッキーカラー:" + luckyColor);
String time = new SimpleDateFormat("yyyy/MM/dd HH:mm").format(new Date());
String line = time + " → " + result + "(#" + luckyNumber + " / " + luckyColor + ")";
appendHistory(line);
}
private void appendHistory(String line) {
String current = historyArea.getText();
if (current.equals("(まだありません)")) current = "";
if (!current.isEmpty()) current = current + "\n";
historyArea.setText(current + line);
historyArea.setCaretPosition(historyArea.getDocument().getLength());
}
private void clearHistory() {
historyArea.setText("(まだありません)");
resultLabel.setText("—");
messageLabel.setText("ボタンを押して引け");
luckyLabel.setText("ラッキー:—");
}
public static void main(String[] args) {
SwingUtilities.invokeLater(() -> new OmikujiGUI().setVisible(true));
}
}
public class CTest11 {
public static void main(String[] args) {
Soarer soarer = new Soarer();
System.out.println(soarer.status());
// ルーフ操作
soarer.openRoof();
System.out.println(soarer.status());
// 加速
for(int i=0; i<6; i++){
soarer.accele();
}
System.out.println(soarer.status());
// 走行中にルーフを開けようとする(失敗する)
soarer.openRoof();
System.out.println(soarer.status());
// 減速して停止
for(int i=0; i<20; i++){
soarer.brake();
}
System.out.println(soarer.status());
// 停止中なら閉じられる
soarer.closeRoof();
System.out.println(soarer.status());
}
}
class Car {
private int speed = 0;
private final int maxSpeed;
public Car(int maxSpeed){
this.maxSpeed = maxSpeed;
}
public void accele(){
setSpeed(speed + 5);
}
public void brake(){
setSpeed(speed - 5);
}
protected void setSpeed(int newSpeed){
if(newSpeed < 0) newSpeed = 0;
if(newSpeed > maxSpeed) newSpeed = maxSpeed;
speed = newSpeed;
}
public int getSpeed(){
return speed;
}
public int getMaxSpeed(){
return maxSpeed;
}
public boolean isStopped(){
return speed == 0;
}
public String status(){
return "速度: " + speed + "km/h (MAX " + maxSpeed + ")";
}
}
class Soarer extends Car {
private boolean roofOpen = false;
// 例:ソアラは最高180
public Soarer(){
super(180);
}
// 車種特性:加速が少し強い
@Override
public void accele(){
setSpeed(getSpeed() + 10);
// 一定速度以上なら安全のため自動で閉じる
if(getSpeed() >= 60 && roofOpen){
roofOpen = false;
}
}
@Override
public void brake(){
setSpeed(getSpeed() - 10);
}
public void openRoof(){
// 走行中は危ないので禁止(停止中のみ)
if(isStopped()){
roofOpen = true;
}
}
public void closeRoof(){
roofOpen = false; // ←バグ修正:trueじゃなくてfalse
}
public boolean isRoofOpen(){
return roofOpen;
}
@Override
public String status(){
String roof = roofOpen ? "屋根: OPEN" : "屋根: CLOSED";
return super.status() + " / " + roof;
}
}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Aran Red Fantasy - Ultimate</title>
<style>
/* ===============================
基本CSSスタイル
=============================== */
body {
font-family: Arial, sans-serif;
background-color: #f0f0f0;
margin: 0;
padding: 0;
}
header, footer, nav {
background-color: #005ce6;
color: #fff;
text-align: center;
padding: 10px;
}
header h1, footer .container { margin: 0; }
nav a {
color: #fff;
text-decoration: none;
margin: 0 8px;
padding: 5px 8px;
display: inline-block;
}
nav a:hover { background-color: #004bb5; border-radius: 4px; }
nav a.active { background-color: #003a8c; border-radius: 4px; }
main { padding: 20px; }
.container { max-width: 1400px; margin: 0 auto; }
.button {
background-color: #4CAF50;
border: none;
color: white;
padding: 8px 16px;
text-align: center;
text-decoration: none;
font-size: 14px;
margin: 4px 2px;
cursor: pointer;
border-radius: 5px;
}
.button:hover { background-color: #45a049; }
.disabled { opacity: 0.6; cursor: default; }
.muted { color:#667; font-size: 13px; }
/* カード風 */
.card {
background-color: #fff;
border: 1px solid #ddd;
border-radius: 5px;
padding: 16px;
margin-bottom: 20px;
box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.1);
}
.card h3 { margin-top: 0; }
/* プログレスバー */
.progress-bar {
background-color: #ddd;
border-radius: 5px;
height: 20px;
width: 100%;
margin-bottom: 10px;
}
.progress {
background-color: #4CAF50;
height: 100%;
border-radius: 5px;
width: 0%;
}
/* インベントリアイテム表示 */
.inventory-item {
background-color: #fff;
border: 1px solid #ddd;
border-radius: 5px;
display: inline-block;
margin: 5px;
padding: 10px;
min-width: 120px;
text-align: center;
cursor: pointer;
transition: background-color 0.2s;
user-select: none;
}
.inventory-item:hover { background-color: #eef; }
/* メッセージ表示 */
.message {
background-color: #fff8dd;
border: 1px solid #f5c666;
padding: 10px;
margin-bottom: 10px;
border-radius: 5px;
white-space: pre-wrap;
}
/* モーダル */
.modal-bg {
position: fixed;
top: 0; left: 0;
width:100%; height:100%;
background: rgba(0,0,0,.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 999;
}
.modal {
background: #fff;
padding: 20px;
border-radius: 5px;
text-align: center;
max-width: 520px;
width: 92%;
}
.modal h2 { margin-top: 0; }
.modal img { max-width: 100%; height: auto; border-radius: 6px; }
/* キャラクター表示 */
#character-image {
max-width: 420px;
width: 100%;
height: auto;
margin: 20px auto;
display: block;
border-radius: 8px;
border: 1px solid #ddd;
background: #fff;
}
/* バトル用スタイル */
.battle-container {
display: flex;
flex-wrap: wrap;
gap: 20px;
}
.enemy-card {
background-color: #fff;
border: 1px solid #ddd;
border-radius: 5px;
width: 250px;
padding: 16px;
text-align: center;
}
/* メッセージログ */
.log {
background-color: #eef;
border: 1px solid #bbe;
border-radius: 5px;
padding: 10px;
max-height: 300px;
overflow-y: auto;
margin: 10px 0;
white-space: pre-wrap;
}
/* ロケーションボタン */
#location-buttons button { margin-right: 10px; }
/* スキル一覧 */
.skill-list { list-style: none; padding: 0; }
.skill-list li { margin: 5px 0; }
/* クエストログ */
#quest-log-list { list-style: none; padding: 0; }
#quest-log-list li { margin: 4px 0; }
/* 実績一覧 */
#achievement-list { list-style: none; padding: 0; }
#achievement-list li { margin: 5px 0; }
/* ===============================
アートギャラリー
=============================== */
.gallery-grid{
display:grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 14px;
}
.gallery-item{
background:#fff;
border:1px solid #ddd;
border-radius:8px;
overflow:hidden;
box-shadow: 0px 2px 4px rgba(0,0,0,0.08);
display:flex;
flex-direction:column;
}
.gallery-item img{
width:100%;
height:auto;
display:block;
background:#fff;
}
.gallery-meta{
padding:10px;
display:flex;
align-items:center;
justify-content:space-between;
gap:8px;
flex-wrap:wrap;
}
.badge{
display:inline-block;
padding:4px 8px;
border-radius:999px;
font-size:12px;
background:#eef;
border:1px solid #bbe;
color:#223;
}
.badge.owned{
background:#e9ffe9;
border-color:#9fd49f;
color:#1c5a1c;
}
.badge.rarity-ur{
background:#fff2cc;
border-color:#f3d27a;
color:#6b4b00;
}
.badge.rarity-ssr{
background:#e8f0ff;
border-color:#9fb7ff;
color:#133a7a;
}
.gallery-actions{
display:flex;
gap:8px;
flex-wrap:wrap;
padding: 0 10px 12px;
}
/* ガチャUI */
.gacha-row{
display:flex;
align-items:center;
justify-content:space-between;
gap:10px;
flex-wrap:wrap;
}
.gacha-result{
margin-top:10px;
display:flex;
gap:10px;
flex-wrap:wrap;
align-items:flex-start;
}
.gacha-card{
width: 220px;
background:#fff;
border:1px solid #ddd;
border-radius:10px;
overflow:hidden;
box-shadow: 0px 2px 4px rgba(0,0,0,0.08);
}
.gacha-card img{ width:100%; display:block; }
.gacha-card .p{ padding:10px; }
</style>
</head>
<body>
<!-- ===============================
ヘッダー
=============================== -->
<header>
<h1>Aran Red Fantasy - Ultimate</h1>
</header>
<!-- ===============================
ナビゲーション
=============================== -->
<nav>
<div class="container">
<a href="#" id="home-link" onclick="showPage('home')">Home</a>
<a href="#" id="quests-link" onclick="showPage('quests')">Quests</a>
<a href="#" id="items-link" onclick="showPage('items')">Items</a>
<a href="#" id="friends-link" onclick="showPage('friends')">Companions</a>
<a href="#" id="character-link" onclick="showPage('character')">Character</a>
<a href="#" id="art-link" onclick="showPage('art')">Art Gallery</a>
<a href="#" id="battle-link" onclick="showPage('battle')">Battle</a>
<a href="#" id="store-link" onclick="showPage('store')">Store</a>
<a href="#" id="craft-link" onclick="showPage('craft')">Craft</a>
<a href="#" id="skills-link" onclick="showPage('skills')">Skills</a>
<a href="#" id="questlog-link" onclick="showPage('questlog')">QuestLog</a>
<a href="#" id="achievements-link" onclick="showPage('achievements')">Achievements</a>
</div>
</nav>
<!-- ===============================
メインコンテンツ
=============================== -->
<main>
<div class="container" id="content">
<!-- ===============================
Home
=============================== -->
<div id="home">
<h2>Welcome to Aran Red Fantasy!</h2>
<p>Explore the world, complete quests, craft items, recruit companions, and unlock achievements!</p>
<div id="home-message"></div>
<button class="button" onclick="showLoginModal()">Log In / Change User</button>
<button class="button" onclick="logout()">Logout (Reset All Data)</button>
<br/><br/>
<!-- BGM(継続再生対応:audioはページ外に置く) -->
<div class="card">
<h3>BGM</h3>
<p class="muted">※ 最初の1回だけ「Enable BGM」を押してください(ブラウザの自動再生ブロック対策)。以後はページ切替しても継続します。</p>
<button class="button" id="bgm-enable-btn" onclick="enableBGM()">Enable BGM (First Click)</button>
<button class="button" onclick="toggleMusic()">Toggle Music</button>
<div class="muted" id="bgm-status">Status: Off</div>
</div>
<!-- ★ガチャ(SSR/UR追加) -->
<div class="card">
<h3>Art Gacha</h3>
<div class="gacha-row">
<div class="muted">
Cost: <strong>10 Gold</strong> / pull<br/>
SSR/URが出ます。引いたアートは自動で所持になり、アートギャラリーに追加されます。
</div>
<div>
<button class="button" onclick="pullArtGacha(1)">Pull x1</button>
<button class="button" onclick="pullArtGacha(10)">Pull x10</button>
</div>
</div>
<div class="muted" id="gacha-status">—</div>
<div class="gacha-result" id="gacha-result"></div>
</div>
<!-- ランダムイベント/天候表示 -->
<div id="weather-display"></div>
<button class="button" onclick="triggerRandomEvent()">Check Random Event</button>
<!-- 昼夜サイクル -->
<div id="day-night-display"></div>
<button class="button" onclick="advanceTime()">Pass Time (+6h)</button>
<!-- 宿屋で休息 -->
<h3>Inn</h3>
<button class="button" onclick="restAtInn()">Rest at Inn (10 Gold)</button>
<!-- ロケーション移動 -->
<div id="location-section" class="card">
<h3>Locations</h3>
<div id="location-buttons">
<button class="button" onclick="moveLocation('Town')">Move to Town</button>
<button class="button" onclick="moveLocation('Forest')">Move to Forest</button>
<button class="button" onclick="moveLocation('Dungeon')">Move to Dungeon</button>
<button class="button" onclick="moveLocation('Mountain')">Move to Mountain</button>
</div>
<p>Current Location: <span id="current-location">Town</span></p>
<div class="log" id="location-log"></div>
</div>
</div>
<!-- ===============================
Quests
=============================== -->
<div id="quests" style="display: none;">
<h2>Quests</h2>
<h3>Main Quests</h3>
<div class="card" id="dragon-quest">
<h4>Defeat the Dragon</h4>
<p>A fierce dragon has appeared near the village! Defeat it to save the locals.</p>
<div class="progress-bar">
<div class="progress" id="dragon-progress"></div>
</div>
<p>Reward: 100 Gold, 100 XP, Dragon Scale</p>
<button class="button" onclick="startQuest('dragon')">Start Quest</button>
</div>
<div class="card" id="final-quest" style="display: none;">
<h4>The Ancient Evil (Final)</h4>
<p>The final threat emerges after you've proven your strength! Vanquish it!</p>
<div class="progress-bar">
<div class="progress" id="final-progress"></div>
</div>
<p>Reward: 200 Gold, 200 XP, Legendary Relic</p>
<button class="button" onclick="startQuest('final')">Start Quest</button>
</div>
<h3>Side Quests</h3>
<div class="card" id="crystal-quest">
<h4>Collect Magic Crystals</h4>
<p>Gather magical crystals scattered around the forest. Watch out for monsters!</p>
<div class="progress-bar">
<div class="progress" id="crystal-progress"></div>
</div>
<p>Reward: 50 Gold, 50 XP, Magic Crystal</p>
<button class="button" onclick="startQuest('crystal')">Start Quest</button>
</div>
<div class="card" id="orc-quest">
<h4>Eliminate the Orc Bandits</h4>
<p>A group of orc bandits is attacking travelers. Defeat them to restore peace!</p>
<div class="progress-bar">
<div class="progress" id="orc-progress"></div>
</div>
<p>Reward: 80 Gold, 70 XP, Orc Tusk</p>
<button class="button" onclick="startQuest('orc')">Start Quest</button>
</div>
</div>
<!-- ===============================
Items
=============================== -->
<div id="items" style="display: none;">
<h2>Inventory</h2>
<p>Click an item to use/equip/sell it (if applicable).</p>
<div id="inventory"></div>
</div>
<!-- ===============================
Companions
=============================== -->
<div id="friends" style="display: none;">
<h2>Companions</h2>
<p>Hire companions who fight alongside you!</p>
<input type="text" id="friendName" placeholder="Companion name" />
<button class="button" onclick="hireCompanion()">Hire Companion</button>
<h3>Your Companions</h3>
<ul id="companion-list"></ul>
<p class="muted">* Each companion has its own level, HP, and Attack. They also gain XP when you do.</p>
</div>
<!-- ===============================
Character
=============================== -->
<div id="character" style="display: none;">
<h2>Character</h2>
<img src="a.png" alt="Character" id="character-image"/>
<p>Name: <span id="character-name"></span></p>
<p>Level: <span id="character-level"></span></p>
<p>HP: <span id="character-hp"></span> / <span id="character-maxhp"></span></p>
<p>XP: <span id="character-xp"></span> / <span id="character-nextLevelXp"></span></p>
<p>Gold: <span id="character-gold"></span></p>
<p>Attack: <span id="character-attack"></span></p>
<p>Defense: <span id="character-defense"></span></p>
<p>Skill Points: <span id="character-skillpoints"></span></p>
<p>Active Buffs/Debuffs: <span id="character-buffs">None</span></p>
<p>Special Items: <span id="character-items">None</span></p>
<p class="muted">Portrait changes when you buy or pull an art (Store / Gacha).</p>
</div>
<!-- ===============================
Art Gallery
=============================== -->
<div id="art" style="display: none;">
<h2>アートギャラリー</h2>
<p class="muted">所持済みのアートは「Set as Character Art」でキャラクター画像に設定できます。ガチャでも入手できます。</p>
<div class="card">
<h3>Your Art Collection</h3>
<div id="art-collection-summary" class="muted"></div>
</div>
<div class="gallery-grid" id="art-gallery-grid"></div>
</div>
<!-- ===============================
Battle
=============================== -->
<div id="battle" style="display: none;">
<h2>Battle Arena</h2>
<p>Choose an enemy to fight or wait for random encounters in the wild!</p>
<div class="battle-container">
<div class="enemy-card">
<h3>Slime</h3>
<p>HP: 30</p>
<p>Attack: 1-3</p>
<p>Reward: 10 Gold, 10 XP</p>
<button class="button" onclick="startBattle('slime')">Fight Slime</button>
</div>
<div class="enemy-card">
<h3>Goblin</h3>
<p>HP: 50</p>
<p>Attack: 2-5</p>
<p>Reward: 20 Gold, 20 XP</p>
<button class="button" onclick="startBattle('goblin')">Fight Goblin</button>
</div>
<div class="enemy-card">
<h3>Orc Warrior</h3>
<p>HP: 80</p>
<p>Attack: 5-8</p>
<p>Reward: 40 Gold, 40 XP</p>
<button class="button" onclick="startBattle('orcEnemy')">Fight Orc</button>
</div>
</div>
<div class="log" id="battle-log"></div>
</div>
<!-- ===============================
Store
=============================== -->
<div id="store" style="display: none;">
<h2>Store</h2>
<p>Use your gold to purchase or sell items!</p>
<div class="card">
<h3>Buy Items</h3>
<div>
<h4>Minor Health Potion (20 Gold)</h4>
<button class="button" onclick="buyItem('Minor Health Potion')">Buy</button>
</div>
<div>
<h4>Major Health Potion (50 Gold)</h4>
<button class="button" onclick="buyItem('Major Health Potion')">Buy</button>
</div>
<div>
<h4>Iron Sword (80 Gold)</h4>
<button class="button" onclick="buyItem('Iron Sword')">Buy</button>
</div>
<div>
<h4>Steel Armor (100 Gold)</h4>
<button class="button" onclick="buyItem('Steel Armor')">Buy</button>
</div>
<div>
<h4>Lucky Ring (120 Gold)</h4>
<button class="button" onclick="buyItem('Lucky Ring')">Buy</button>
</div>
</div>
<!-- アート購入:購入するとキャラクター絵が変わる -->
<div class="card">
<h3>Art Shop(購入でキャラクター画像が変わる)</h3>
<p class="muted">Buy an art → it becomes “Owned” and you can set it anytime. (Gacha also adds Owned.)</p>
<div id="art-shop-list"></div>
</div>
<div class="card">
<h3>Sell Items</h3>
<p>Click an item in your inventory to sell it, if possible.</p>
<p class="muted">(You can't sell special quest items or currently equipped gear.)</p>
</div>
</div>
<!-- ===============================
Craft
=============================== -->
<div id="craft" style="display: none;">
<h2>Item Crafting</h2>
<p>Combine items to create something new!</p>
<div class="card">
<h3>Example Recipes</h3>
<ul>
<li>Dragon Scale + Orc Tusk => Dragon Tusk Lance (Weapon)</li>
<li>Magic Crystal + Magic Crystal => Greater Crystal (Special)</li>
</ul>
<p>Select any two items from your inventory to craft (if a valid recipe exists).</p>
</div>
<p>Currently Selected: <span id="craft-selection">None</span></p>
<button class="button" id="craft-button" onclick="attemptCraft()" disabled>Craft</button>
</div>
<!-- ===============================
Skills
=============================== -->
<div id="skills" style="display: none;">
<h2>Skills</h2>
<p>Use skill points to learn or upgrade skills!</p>
<p>You have <span id="skill-point-display"></span> skill points.</p>
<ul class="skill-list" id="skill-list"></ul>
</div>
<!-- ===============================
QuestLog
=============================== -->
<div id="questlog" style="display: none;">
<h2>Quest Log</h2>
<ul id="quest-log-list"></ul>
</div>
<!-- ===============================
Achievements
=============================== -->
<div id="achievements" style="display: none;">
<h2>Achievements</h2>
<ul id="achievement-list"></ul>
</div>
</div>
</main>
<!-- ===============================
フッター
=============================== -->
<footer>
<div class="container">
© 2025 Aran Red Fantasy
</div>
</footer>
<!-- ===============================
ログインモーダル
=============================== -->
<div class="modal-bg" id="login-modal-bg" style="display: none;">
<div class="modal">
<h2>Enter Your Name</h2>
<input type="text" id="loginName" placeholder="Your name" />
<br/><br/>
<button class="button" onclick="confirmLogin()">Login</button>
<button class="button" onclick="closeLoginModal()">Cancel</button>
</div>
</div>
<!-- ===============================
アイテム使用モーダル
=============================== -->
<div class="modal-bg" id="item-modal-bg" style="display: none;">
<div class="modal">
<h2 id="item-modal-title">Use/Equip Item</h2>
<p id="item-modal-description"></p>
<button class="button" onclick="confirmItemUse()">Use/Equip</button>
<button class="button" onclick="closeItemModal()">Cancel</button>
</div>
</div>
<!-- ===============================
アートプレビューモーダル
=============================== -->
<div class="modal-bg" id="art-modal-bg" style="display: none;">
<div class="modal">
<h2 id="art-modal-title">Art Preview</h2>
<img id="art-modal-img" alt="Art Preview" />
<p class="muted" id="art-modal-desc"></p>
<div style="margin-top:10px;">
<button class="button" id="art-modal-set-btn" onclick="confirmSetPortrait()">Set as Character Art</button>
<button class="button" onclick="closeArtModal()">Close</button>
</div>
</div>
</div>
<!-- ===============================
BGM本体(継続再生のためページ切替の外に置く)
=============================== -->
<audio id="bgm" loop preload="auto" playsinline>
<source src="http://tyosuke20xx.com/fjordnosundakaze.mp3" type="audio/mpeg">
</audio>
<!-- ===============================
JavaScript
=============================== -->
<script>
// -------------------------------------------
// ページ切り替え
// -------------------------------------------
function showPage(page) {
const pages = [
"home", "quests", "items", "friends", "character",
"art",
"battle", "store", "craft", "skills", "questlog", "achievements"
];
pages.forEach(p => {
const pageElement = document.getElementById(p);
const linkElement = document.getElementById(p + '-link');
if (!pageElement) return;
if (p === page) {
pageElement.style.display = "block";
if (linkElement) linkElement.classList.add("active");
} else {
pageElement.style.display = "none";
if (linkElement) linkElement.classList.remove("active");
}
});
if (page === "skills") refreshSkillList();
if (page === "questlog") updateQuestLog();
if (page === "achievements") updateAchievementList();
if (page === "art") renderArtGallery();
if (page === "store") renderArtShop();
}
// -------------------------------------------
// ローカルストレージキー
// -------------------------------------------
const LS_KEY_USER = "ARF_Username_Ultimate";
const LS_KEY_CHARACTER = "ARF_Character_Ultimate";
const LS_KEY_INVENTORY = "ARF_Inventory_Ultimate";
const LS_KEY_COMPANIONS = "ARF_Companions_Ultimate";
const LS_KEY_QUESTS = "ARF_Quests_Ultimate";
const LS_KEY_SKILLS = "ARF_Skills_Ultimate";
const LS_KEY_DAYTIME = "ARF_Daytime_Ultimate";
const LS_KEY_WEATHER = "ARF_Weather_Ultimate";
const LS_KEY_ACHIEVEMENT = "ARF_Achievement_Ultimate";
// BGM状態
const LS_KEY_BGM = "ARF_BGM_STATE_Ultimate";
// -------------------------------------------
// ★アート定義(SSR1〜SSR3 + UR1〜UR10)
// -------------------------------------------
const ART_LIST = [
// SSR(追加)
{ key:"SSR1", name:"SSR Art 1", url:"http://tyosuke20xx.com/SSR1.png", cost: 20, rarity:"SSR" },
{ key:"SSR2", name:"SSR Art 2", url:"http://tyosuke20xx.com/SSR2.png", cost: 20, rarity:"SSR" },
{ key:"SSR3", name:"SSR Art 3", url:"http://tyosuke20xx.com/SSR3.png", cost: 20, rarity:"SSR" },
// UR
{ key:"UR1", name:"UR Art 1", url:"http://tyosuke20xx.com/UR1.png", cost: 30, rarity:"UR" },
{ key:"UR2", name:"UR Art 2", url:"http://tyosuke20xx.com/UR2.png", cost: 30, rarity:"UR" },
{ key:"UR3", name:"UR Art 3", url:"http://tyosuke20xx.com/UR3.png", cost: 30, rarity:"UR" },
{ key:"UR4", name:"UR Art 4", url:"http://tyosuke20xx.com/UR4.png", cost: 30, rarity:"UR" },
{ key:"UR5", name:"UR Art 5", url:"http://tyosuke20xx.com/UR5.png", cost: 30, rarity:"UR" },
{ key:"UR6", name:"UR Art 6", url:"http://tyosuke20xx.com/UR6.png", cost: 30, rarity:"UR" },
{ key:"UR7", name:"UR Art 7", url:"http://tyosuke20xx.com/UR7.png", cost: 30, rarity:"UR" },
{ key:"UR8", name:"UR Art 8", url:"http://tyosuke20xx.com/UR8.png", cost: 30, rarity:"UR" },
{ key:"UR9", name:"UR Art 9", url:"http://tyosuke20xx.com/UR9.png", cost: 30, rarity:"UR" },
{ key:"UR10", name:"UR Art 10", url:"http://tyosuke20xx.com/UR10.png", cost: 30, rarity:"UR" }
];
// -------------------------------------------
// ★ガチャ設定(SSR/UR抽選)
// -------------------------------------------
const GACHA_COST = 10; // 1回10G
const GACHA_RATE_UR = 10; // UR 10%
const GACHA_RATE_SSR = 90; // SSR 90%(残り)
// -------------------------------------------
// キャラクター情報
// -------------------------------------------
let character = {
name: "Adventurer",
level: 1,
hp: 50,
maxHp: 50,
xp: 0,
nextLevelXp: 100,
gold: 0,
attack: 5,
defense: 2,
skillPoints: 0,
location: "Town",
specialItems: [],
buffs: [],
ownedArtKeys: [],
portraitUrl: "a.png"
};
// -------------------------------------------
// クエスト情報
// -------------------------------------------
let mainQuests = {
dragon: {
name: "Defeat the Dragon",
progress: 0,
reward: { gold: 100, xp: 100, items: ["Dragon Scale"] },
isRunning: false,
isCompleted: false,
unlockNext: "final",
locked: false
},
final: {
name: "The Ancient Evil",
progress: 0,
reward: { gold: 200, xp: 200, items: ["Legendary Relic"] },
isRunning: false,
isCompleted: false,
unlockNext: null,
locked: true
}
};
let sideQuests = {
crystal: {
name: "Collect Magic Crystals",
progress: 0,
reward: { gold: 50, xp: 50, items: ["Magic Crystal"] },
isRunning: false,
isCompleted: false,
unlockNext: null,
locked: false
},
orc: {
name: "Eliminate the Orc Bandits",
progress: 0,
reward: { gold: 80, xp: 70, items: ["Orc Tusk"] },
isRunning: false,
isCompleted: false,
unlockNext: null,
locked: false
}
};
function getAllQuests() {
return { ...mainQuests, ...sideQuests };
}
// -------------------------------------------
// インベントリ
// -------------------------------------------
let inventory = [];
// -------------------------------------------
// 仲間
// -------------------------------------------
let companions = [];
// -------------------------------------------
// スキル
// -------------------------------------------
let skills = {
Fireball: {
name: "Fireball",
level: 0,
maxLevel: 3,
cost: 1,
description: "Deal extra magic damage in battle"
},
Heal: {
name: "Heal",
level: 0,
maxLevel: 3,
cost: 1,
description: "Restores some HP at the start of battle"
}
};
// -------------------------------------------
// バトル用エネミー
// -------------------------------------------
const enemies = {
slime: {
name: "Slime",
hp: 30,
attackMin: 1,
attackMax: 3,
rewardGold: 10,
rewardXp: 10
},
goblin: {
name: "Goblin",
hp: 50,
attackMin: 2,
attackMax: 5,
rewardGold: 20,
rewardXp: 20
},
orcEnemy: {
name: "Orc Warrior",
hp: 80,
attackMin: 5,
attackMax: 8,
rewardGold: 40,
rewardXp: 40
}
};
// -------------------------------------------
// ストアアイテム
// -------------------------------------------
const storeItems = {
"Minor Health Potion": { name: "Minor Health Potion", type: "potion", heal: 20, cost: 20 },
"Major Health Potion": { name: "Major Health Potion", type: "potion", heal: 50, cost: 50 },
"Iron Sword": { name: "Iron Sword", type: "weapon", attack: 5, cost: 80, equipped: false },
"Steel Armor": { name: "Steel Armor", type: "armor", defense: 5, cost: 100, equipped: false },
"Lucky Ring": { name: "Lucky Ring", type: "accessory", attack: 1, defense: 1, cost: 120, equipped: false }
};
// -------------------------------------------
// クラフト用レシピ
// -------------------------------------------
const craftRecipes = [
{
components: ["Dragon Scale", "Orc Tusk"].sort(),
result: { name: "Dragon Tusk Lance", type: "weapon", attack: 10, equipped: false }
},
{
components: ["Magic Crystal", "Magic Crystal"].sort(),
result: { name: "Greater Crystal", type: "special" }
}
];
// -------------------------------------------
// 昼夜 & 天候
// -------------------------------------------
let currentHour = 12;
let currentWeather = "Sunny";
const possibleWeathers = ["Sunny","Rainy","Storm","Cloudy"];
// -------------------------------------------
// 実績
// -------------------------------------------
let achievements = {
firstKill: { name: "First Blood", description: "Defeat your first enemy.", isUnlocked: false },
level5: { name: "Rising Hero", description: "Reach Level 5.", isUnlocked: false },
quest3: { name: "Quest Hunter", description: "Complete 3 Quests.", isUnlocked: false }
};
// -------------------------------------------
// onload
// -------------------------------------------
window.onload = function() {
loadLocalData();
// BGM(継続再生 & 状態保存)
loadBgmState();
wireBgmAutoSave();
updateEnableBtn();
setBgmStatus(isMusicPlaying ? "On (will resume)" : "Off");
resumeBgmOnNextUserActionIfNeeded();
updateCharacterInfo();
updateQuestVisibility();
updateInventoryDisplay();
updateCompanionList();
updateDayNightDisplay();
updateWeatherDisplay();
updateAchievementList();
renderArtShop();
renderArtGallery();
showPage('home');
document.getElementById("current-location").textContent = character.location;
};
// -------------------------------------------
// ローカルストレージ: 読込/保存/リセット
// -------------------------------------------
function loadLocalData() {
let storedName = localStorage.getItem(LS_KEY_USER);
if (storedName) character.name = storedName;
let storedChar = localStorage.getItem(LS_KEY_CHARACTER);
if (storedChar) {
try {
const parsed = JSON.parse(storedChar);
character = { ...character, ...parsed };
} catch(e) {}
}
if (!Array.isArray(character.ownedArtKeys)) character.ownedArtKeys = [];
if (!character.portraitUrl) character.portraitUrl = "a.png";
let storedInv = localStorage.getItem(LS_KEY_INVENTORY);
if (storedInv) { try { inventory = JSON.parse(storedInv); } catch(e) {} }
let storedComp = localStorage.getItem(LS_KEY_COMPANIONS);
if (storedComp) { try { companions = JSON.parse(storedComp); } catch(e) {} }
let storedMQ = localStorage.getItem(LS_KEY_QUESTS+"_main");
if (storedMQ) { try { mainQuests = JSON.parse(storedMQ); } catch(e) {} }
let storedSQ = localStorage.getItem(LS_KEY_QUESTS+"_side");
if (storedSQ) { try { sideQuests = JSON.parse(storedSQ); } catch(e) {} }
let storedSkills = localStorage.getItem(LS_KEY_SKILLS);
if (storedSkills) { try { skills = JSON.parse(storedSkills); } catch(e) {} }
let storedHour = localStorage.getItem(LS_KEY_DAYTIME+"_hour");
if (storedHour) currentHour = parseInt(storedHour, 10);
let storedWeather = localStorage.getItem(LS_KEY_WEATHER);
if (storedWeather) currentWeather = storedWeather;
let storedAchv = localStorage.getItem(LS_KEY_ACHIEVEMENT);
if (storedAchv) { try { achievements = JSON.parse(storedAchv); } catch(e) {} }
}
function saveLocalData() {
localStorage.setItem(LS_KEY_USER, character.name);
localStorage.setItem(LS_KEY_CHARACTER, JSON.stringify(character));
localStorage.setItem(LS_KEY_INVENTORY, JSON.stringify(inventory));
localStorage.setItem(LS_KEY_COMPANIONS, JSON.stringify(companions));
localStorage.setItem(LS_KEY_QUESTS+"_main", JSON.stringify(mainQuests));
localStorage.setItem(LS_KEY_QUESTS+"_side", JSON.stringify(sideQuests));
localStorage.setItem(LS_KEY_SKILLS, JSON.stringify(skills));
localStorage.setItem(LS_KEY_DAYTIME+"_hour", currentHour.toString());
localStorage.setItem(LS_KEY_WEATHER, currentWeather);
localStorage.setItem(LS_KEY_ACHIEVEMENT, JSON.stringify(achievements));
}
function logout() {
if (!confirm("All data will be cleared. Are you sure?")) return;
localStorage.clear();
location.reload();
}
// -------------------------------------------
// ログインモーダル
// -------------------------------------------
function showLoginModal() {
document.getElementById("login-modal-bg").style.display = "flex";
}
function closeLoginModal() {
document.getElementById("login-modal-bg").style.display = "none";
}
function confirmLogin() {
const inputName = document.getElementById("loginName").value.trim();
if (inputName) {
character.name = inputName;
saveLocalData();
updateCharacterInfo();
}
closeLoginModal();
}
// -------------------------------------------
// キャラクター情報表示更新
// -------------------------------------------
function updateCharacterInfo() {
document.getElementById("character-name").textContent = character.name;
document.getElementById("character-level").textContent = character.level;
document.getElementById("character-hp").textContent = character.hp;
document.getElementById("character-maxhp").textContent = character.maxHp;
document.getElementById("character-xp").textContent = character.xp;
document.getElementById("character-nextLevelXp").textContent = character.nextLevelXp;
document.getElementById("character-gold").textContent = character.gold;
document.getElementById("character-attack").textContent = character.attack;
document.getElementById("character-defense").textContent = character.defense;
document.getElementById("character-skillpoints").textContent = character.skillPoints;
const img = document.getElementById("character-image");
if (img) img.src = character.portraitUrl || "a.png";
if (character.specialItems.length > 0) {
document.getElementById("character-items").textContent = character.specialItems.join(", ");
} else {
document.getElementById("character-items").textContent = "None";
}
if (character.buffs.length > 0) {
document.getElementById("character-buffs").textContent = character.buffs.map(b => b.name).join(", ");
} else {
document.getElementById("character-buffs").textContent = "None";
}
saveLocalData();
checkAchievements();
}
// -------------------------------------------
// レベルアップ
// -------------------------------------------
function addXp(amount) {
character.xp += amount;
while (character.xp >= character.nextLevelXp) {
character.level++;
character.xp -= character.nextLevelXp;
character.nextLevelXp = character.level * 100;
character.maxHp += 20;
character.hp = character.maxHp;
character.attack += 1;
character.defense += 1;
character.skillPoints += 1;
showHomeMessage(`Level up! Now Level ${character.level} (+1 Skill Point).`);
for (let c of companions) {
c.level++;
c.hp = c.maxHp;
c.attack++;
}
}
updateCharacterInfo();
}
// -------------------------------------------
// バフ/デバフ
// -------------------------------------------
function addBuff(buffObj) {
character.buffs.push(buffObj);
updateCharacterInfo();
}
function processBuffsEachTurn(logElm) {
for (let i = character.buffs.length - 1; i >= 0; i--) {
const b = character.buffs[i];
if (b.effectType === "dot") {
character.hp -= b.effectValue;
if (character.hp < 0) character.hp = 0;
logMessage(logElm, `[${b.name}] You take ${b.effectValue} damage! (HP: ${character.hp})`);
}
b.turns--;
if (b.turns <= 0) {
logMessage(logElm, `[${b.name}] effect ended.`);
character.buffs.splice(i, 1);
}
}
}
// -------------------------------------------
// ホームメッセージ
// -------------------------------------------
function showHomeMessage(msg) {
const homeMessage = document.getElementById('home-message');
homeMessage.innerHTML = `<div class="message">${msg}</div>`;
}
// -------------------------------------------
// 昼夜
// -------------------------------------------
function updateDayNightDisplay() {
let dnElm = document.getElementById("day-night-display");
let hourStr = (currentHour < 10) ? "0"+currentHour : currentHour;
let isNight = (currentHour >= 18 || currentHour < 6);
let dayNight = isNight ? "Night" : "Day";
dnElm.innerHTML = `<p>Time: ${hourStr}:00 (${dayNight})</p>`;
}
function advanceTime() {
currentHour += 6;
if (currentHour >= 24) currentHour -= 24;
saveLocalData();
updateDayNightDisplay();
showHomeMessage("Time passes by...");
}
// -------------------------------------------
// 天候
// -------------------------------------------
function updateWeatherDisplay() {
const wElm = document.getElementById("weather-display");
wElm.innerHTML = `<p>Weather: ${currentWeather}</p>`;
}
function changeWeatherRandom() {
currentWeather = possibleWeathers[Math.floor(Math.random() * possibleWeathers.length)];
updateWeatherDisplay();
saveLocalData();
}
// -------------------------------------------
// ランダムイベント
// -------------------------------------------
function triggerRandomEvent() {
const randomRoll = Math.random();
let msg = "";
if (randomRoll < 0.2) {
msg = "A traveling merchant appears, offering rare goods (not yet implemented).";
} else if (randomRoll < 0.4) {
changeWeatherRandom();
msg = `The weather suddenly changes to ${currentWeather}!`;
} else if (randomRoll < 0.6) {
addBuff({ name: 'Poison', turns: 3, effectType: 'dot', effectValue: 3 });
msg = "You stepped on a poisonous trap! You are now poisoned.";
} else {
msg = "Nothing special happens.";
}
showHomeMessage(msg);
}
// -------------------------------------------
// 宿屋
// -------------------------------------------
function restAtInn() {
if (character.gold < 10) {
showHomeMessage("Not enough gold to rest at the inn!");
return;
}
character.gold -= 10;
character.hp = character.maxHp;
for (let c of companions) c.hp = c.maxHp;
showHomeMessage("You and your companions rest at the inn and recover full HP.");
updateCharacterInfo();
}
// -------------------------------------------
// ロケーション移動 + ランダムエンカウント
// -------------------------------------------
function moveLocation(newLocation) {
character.location = newLocation;
document.getElementById("current-location").textContent = newLocation;
saveLocalData();
const logElm = document.getElementById('location-log');
logElm.textContent = `You moved to ${newLocation}.`;
let encounterChance = 0;
if (newLocation === "Town") encounterChance = 0;
else if (newLocation === "Forest") encounterChance = 40;
else if (newLocation === "Dungeon") encounterChance = 70;
else if (newLocation === "Mountain") encounterChance = 50;
const roll = Math.random() * 100;
if (roll < encounterChance) {
const enemyKeys = Object.keys(enemies);
const randEnemyKey = enemyKeys[Math.floor(Math.random() * enemyKeys.length)];
logElm.textContent += `\nA wild ${enemies[randEnemyKey].name} appears!`;
startBattle(randEnemyKey);
}
}
// -------------------------------------------
// 仲間の雇用
// -------------------------------------------
function hireCompanion() {
const input = document.getElementById('friendName');
let name = input.value.trim();
if (!name) return;
let newCompanion = { name, level: 1, hp: 30, maxHp: 30, attack: 2 };
companions.push(newCompanion);
input.value = "";
updateCompanionList();
saveLocalData();
showHomeMessage(`${name} joined your party!`);
}
function updateCompanionList() {
const listElm = document.getElementById('companion-list');
listElm.innerHTML = "";
companions.forEach(c => {
const li = document.createElement("li");
li.textContent = `${c.name} (Lv ${c.level}, HP ${c.hp}/${c.maxHp}, ATK ${c.attack})`;
listElm.appendChild(li);
});
}
// -------------------------------------------
// インベントリ表示
// -------------------------------------------
let selectedItemIndex = null;
let selectedForCraft = [];
function updateInventoryDisplay() {
const invElm = document.getElementById('inventory');
invElm.innerHTML = "";
if (inventory.length === 0) {
invElm.innerHTML = "<p>Your inventory is empty.</p>";
return;
}
inventory.forEach((item, index) => {
const div = document.createElement("div");
div.className = "inventory-item";
div.textContent = item.name;
if (item.equipped) div.style.border = "2px solid #4CAF50";
div.onclick = () => onInventoryItemClick(index);
invElm.appendChild(div);
});
}
function onInventoryItemClick(index) {
if (document.getElementById("craft").style.display === "block") {
toggleCraftSelection(index);
return;
}
selectedItemIndex = index;
const item = inventory[index];
const modalTitle = document.getElementById("item-modal-title");
const modalDesc = document.getElementById("item-modal-description");
if (item.type === "potion") {
modalTitle.textContent = `Use ${item.name}?`;
modalDesc.textContent = `This potion restores ${item.heal} HP.`;
} else if (item.type === "weapon") {
modalTitle.textContent = `Equip ${item.name}?`;
modalDesc.textContent = `Weapon (+${item.attack} Attack).`;
} else if (item.type === "armor") {
modalTitle.textContent = `Equip ${item.name}?`;
modalDesc.textContent = `Armor (+${item.defense} Defense).`;
} else if (item.type === "accessory") {
modalTitle.textContent = `Equip ${item.name}?`;
modalDesc.textContent = `Accessory (+${item.attack} ATK, +${item.defense} DEF).`;
} else {
modalTitle.textContent = item.name;
modalDesc.textContent = "A special item. No direct use/equip.";
}
if (canSellItem(item)) {
modalDesc.textContent += `\n(Sell price: ${sellPrice(item)} Gold)`;
}
document.getElementById("item-modal-bg").style.display = "flex";
}
function closeItemModal() {
document.getElementById("item-modal-bg").style.display = "none";
selectedItemIndex = null;
}
function confirmItemUse() {
if (selectedItemIndex === null) return;
const item = inventory[selectedItemIndex];
if (item.type === "potion") {
character.hp += item.heal;
if (character.hp > character.maxHp) character.hp = character.maxHp;
inventory.splice(selectedItemIndex, 1);
showHomeMessage(`${item.name} used! You recovered ${item.heal} HP.`);
}
else if (item.type === "weapon") {
unequipItem("weapon");
item.equipped = true;
character.attack += item.attack;
showHomeMessage(`${item.name} equipped. (+${item.attack} Attack)`);
}
else if (item.type === "armor") {
unequipItem("armor");
item.equipped = true;
character.defense += item.defense;
showHomeMessage(`${item.name} equipped. (+${item.defense} Defense)`);
}
else if (item.type === "accessory") {
unequipItem("accessory");
item.equipped = true;
character.attack += item.attack;
character.defense += item.defense;
showHomeMessage(`${item.name} equipped. (+${item.attack} ATK, +${item.defense} DEF)`);
}
else {
if (canSellItem(item)) {
let price = sellPrice(item);
character.gold += price;
inventory.splice(selectedItemIndex, 1);
showHomeMessage(`You sold ${item.name} for ${price} Gold.`);
} else {
showHomeMessage(`You can't use ${item.name} right now.`);
}
}
updateCharacterInfo();
updateInventoryDisplay();
closeItemModal();
}
function unequipItem(type) {
for (let i = 0; i < inventory.length; i++) {
let it = inventory[i];
if (it.type === type && it.equipped) {
it.equipped = false;
if (type === "weapon") character.attack -= it.attack;
else if (type === "armor") character.defense -= it.defense;
else if (type === "accessory") { character.attack -= it.attack; character.defense -= it.defense; }
}
}
}
function canSellItem(item) {
if (item.equipped) return false;
if (item.type === "special") return false;
return !["weapon","armor","accessory","potion"].includes(item.type) ? true : false;
}
function sellPrice(item) { return 30; }
// -------------------------------------------
// クエストUI
// -------------------------------------------
function updateQuestVisibility() {
const finalQuestCard = document.getElementById("final-quest");
finalQuestCard.style.display = (!mainQuests.final.locked) ? "block" : "none";
}
function startQuest(questKey) {
let q = mainQuests[questKey] || sideQuests[questKey];
if (!q) return;
if (q.isRunning || q.isCompleted) return;
if (q.locked) {
showHomeMessage("This quest is locked. Complete the previous quest first!");
return;
}
q.isRunning = true;
q.progress = 0;
updateProgressBar(questKey);
let progressInterval = setInterval(() => {
q.progress += 5;
if (q.progress > 100) q.progress = 100;
updateProgressBar(questKey);
if (q.progress === 100) {
clearInterval(progressInterval);
completeQuest(questKey);
}
}, 400);
}
function completeQuest(questKey) {
let q = mainQuests[questKey] || sideQuests[questKey];
q.isRunning = false;
q.isCompleted = true;
character.gold += q.reward.gold;
addXp(q.reward.xp);
q.reward.items.forEach(it => character.specialItems.push(it));
showHomeMessage(`${q.name} completed! You got ${q.reward.gold} Gold, ${q.reward.xp} XP, and ${q.reward.items.join(", ")}.`);
if (q.unlockNext) {
if (mainQuests[q.unlockNext]) mainQuests[q.unlockNext].locked = false;
else if (sideQuests[q.unlockNext]) sideQuests[q.unlockNext].locked = false;
}
updateQuestVisibility();
updateCharacterInfo();
saveLocalData();
}
function updateProgressBar(questKey) {
const bar = document.getElementById(questKey + '-progress');
let q = mainQuests[questKey] || sideQuests[questKey];
if (bar) bar.style.width = q.progress + '%';
}
// -------------------------------------------
// バトル
// -------------------------------------------
function startBattle(enemyKey) {
const enemyDef = enemies[enemyKey];
if (!enemyDef) return;
const logElm = document.getElementById('battle-log');
logElm.innerHTML = `A wild ${enemyDef.name} appears! (HP: ${enemyDef.hp})`;
if (skills.Heal.level > 0) {
const healAmount = skills.Heal.level * 10;
character.hp += healAmount;
if (character.hp > character.maxHp) character.hp = character.maxHp;
logMessage(logElm, `[Skill: Heal Lv${skills.Heal.level}] You healed ${healAmount} HP!`);
updateCharacterInfo();
}
let enemyHp = enemyDef.hp;
let battleInterval = setInterval(() => {
processBuffsEachTurn(logElm);
if (character.hp <= 0) {
clearInterval(battleInterval);
logMessage(logElm, "You have been defeated...");
saveLocalData();
return;
}
let baseDamage = getRandomInt(character.attack - 2, character.attack + 2);
if (baseDamage < 1) baseDamage = 1;
if (skills.Fireball.level > 0) {
let extra = skills.Fireball.level * 2;
baseDamage += extra;
logMessage(logElm, `[Fireball Lv${skills.Fireball.level}] Extra ${extra} magic damage!`);
}
let totalCompanionDamage = 0;
companions.forEach(c => { if (c.hp > 0) totalCompanionDamage += c.attack; });
let totalDamage = baseDamage + totalCompanionDamage;
enemyHp -= totalDamage;
logMessage(logElm, `You (and companions) deal ${totalDamage} damage! (Enemy HP: ${Math.max(enemyHp, 0)})`);
if (enemyHp <= 0) {
clearInterval(battleInterval);
logMessage(logElm, `You defeated the ${enemyDef.name}!`);
character.gold += enemyDef.rewardGold;
addXp(enemyDef.rewardXp);
updateCompanionXP(enemyDef.rewardXp);
updateCharacterInfo();
saveLocalData();
achievements.firstKill.isUnlocked = true;
updateAchievementList();
return;
}
let eAtk = getRandomInt(enemyDef.attackMin, enemyDef.attackMax);
let dmgToPlayer = eAtk - character.defense;
if (dmgToPlayer < 1) dmgToPlayer = 1;
character.hp -= dmgToPlayer;
if (character.hp < 0) character.hp = 0;
logMessage(logElm, `The ${enemyDef.name} hits you for ${dmgToPlayer}. (Your HP: ${character.hp})`);
updateCharacterInfo();
if (character.hp <= 0) {
clearInterval(battleInterval);
logMessage(logElm, "You have been defeated...");
saveLocalData();
return;
}
}, 800);
}
function logMessage(logElm, msg) {
const p = document.createElement("p");
p.textContent = msg;
logElm.appendChild(p);
logElm.scrollTop = logElm.scrollHeight;
}
function updateCompanionXP(amount) {
for (let c of companions) {
c.level += Math.floor(amount/50);
c.maxHp += 5;
c.hp = c.maxHp;
c.attack += 1;
}
updateCompanionList();
}
// -------------------------------------------
// ストア購入
// -------------------------------------------
function buyItem(itemKey) {
const itemDef = storeItems[itemKey];
if (!itemDef) return;
if (character.gold < itemDef.cost) {
showHomeMessage(`You don't have enough gold to buy ${itemDef.name}.`);
return;
}
character.gold -= itemDef.cost;
let newItem = JSON.parse(JSON.stringify(itemDef));
if (["weapon","armor","accessory"].includes(newItem.type)) newItem.equipped = false;
inventory.push(newItem);
showHomeMessage(`You bought ${newItem.name}!`);
updateCharacterInfo();
updateInventoryDisplay();
}
// -------------------------------------------
// アート所持/購入/設定
// -------------------------------------------
function isArtOwned(artKey) {
return character.ownedArtKeys.includes(artKey);
}
function grantArt(artKey, setAsPortrait=false) {
const art = ART_LIST.find(a => a.key === artKey);
if (!art) return false;
if (!isArtOwned(art.key)) {
character.ownedArtKeys.push(art.key);
}
if (setAsPortrait) {
character.portraitUrl = art.url;
}
saveLocalData();
updateCharacterInfo();
renderArtShop();
renderArtGallery();
return true;
}
function buyArt(artKey) {
const art = ART_LIST.find(a => a.key === artKey);
if (!art) return;
if (isArtOwned(art.key)) {
showHomeMessage(`You already own ${art.name}.`);
return;
}
if (character.gold < art.cost) {
showHomeMessage(`Not enough gold to buy ${art.name}. Need ${art.cost} Gold.`);
return;
}
character.gold -= art.cost;
// 購入したら即キャラ絵変更
grantArt(art.key, true);
showHomeMessage(`Purchased ${art.name}! Character portrait changed.`);
}
function setPortraitFromArt(artKey) {
const art = ART_LIST.find(a => a.key === artKey);
if (!art) return;
if (!isArtOwned(art.key)) {
showHomeMessage("You don't own this art yet. Buy it in Store or pull it from Gacha.");
return;
}
character.portraitUrl = art.url;
saveLocalData();
updateCharacterInfo();
showHomeMessage(`Character portrait set to ${art.name}.`);
}
// Storeのアート一覧描画
function renderArtShop() {
const wrap = document.getElementById("art-shop-list");
if (!wrap) return;
wrap.innerHTML = "";
ART_LIST.forEach(a => {
const owned = isArtOwned(a.key);
const rarityBadge = a.rarity === "UR"
? `<span class="badge rarity-ur">UR</span>`
: `<span class="badge rarity-ssr">SSR</span>`;
const row = document.createElement("div");
row.style.marginBottom = "10px";
row.innerHTML = `
<div style="display:flex; align-items:center; justify-content:space-between; gap:10px; flex-wrap:wrap;">
<div>
<strong>${a.name}</strong>
${rarityBadge}
<span class="muted">(${a.cost} Gold)</span>
${owned ? `<span class="badge owned">Owned</span>` : `<span class="badge">Not Owned</span>`}
</div>
<div>
<button class="button" onclick="buyArt('${a.key}')" ${owned ? 'disabled class="button disabled"' : ''} ${owned ? 'disabled' : ''}>Buy</button>
<button class="button" onclick="openArtModal('${a.key}')">Preview</button>
</div>
</div>
`;
wrap.appendChild(row);
});
}
// ギャラリー描画
function renderArtGallery() {
const grid = document.getElementById("art-gallery-grid");
const sum = document.getElementById("art-collection-summary");
if (!grid || !sum) return;
const ownedCount = character.ownedArtKeys.length;
sum.textContent = `Owned: ${ownedCount} / ${ART_LIST.length}`;
grid.innerHTML = "";
ART_LIST.forEach(a => {
const owned = isArtOwned(a.key);
const item = document.createElement("div");
item.className = "gallery-item";
const img = document.createElement("img");
img.src = a.url;
img.alt = a.name;
img.loading = "lazy";
img.style.cursor = "pointer";
img.onclick = () => openArtModal(a.key);
const meta = document.createElement("div");
meta.className = "gallery-meta";
meta.innerHTML = `
<div>
<strong>${a.name}</strong><br/>
<span class="muted">${a.cost} Gold</span>
</div>
<div style="display:flex; gap:6px; align-items:center;">
${a.rarity === "UR"
? `<span class="badge rarity-ur">UR</span>`
: `<span class="badge rarity-ssr">SSR</span>`
}
${owned ? `<span class="badge owned">Owned</span>` : `<span class="badge">Not Owned</span>`}
</div>
`;
const actions = document.createElement("div");
actions.className = "gallery-actions";
const btnPreview = document.createElement("button");
btnPreview.className = "button";
btnPreview.textContent = "Preview";
btnPreview.onclick = () => openArtModal(a.key);
const btnSet = document.createElement("button");
btnSet.className = "button";
btnSet.textContent = "Set as Character Art";
btnSet.disabled = !owned;
if (!owned) btnSet.classList.add("disabled");
btnSet.onclick = () => setPortraitFromArt(a.key);
actions.appendChild(btnPreview);
actions.appendChild(btnSet);
item.appendChild(img);
item.appendChild(meta);
item.appendChild(actions);
grid.appendChild(item);
});
}
// -------------------------------------------
// ★ガチャ(SSR1〜SSR3/UR1〜UR10から抽選)
// - 引いたアートはOwnedに追加
// - 1枚目だけは演出的にキャラ絵も即変更(setAsPortrait=true)
// -------------------------------------------
function pullArtGacha(times) {
const totalCost = GACHA_COST * times;
const status = document.getElementById("gacha-status");
const resultWrap = document.getElementById("gacha-result");
resultWrap.innerHTML = "";
if (character.gold < totalCost) {
status.textContent = `Not enough gold. Need ${totalCost} Gold.`;
showHomeMessage(`Not enough gold for gacha. Need ${totalCost} Gold.`);
return;
}
character.gold -= totalCost;
let pulled = [];
for (let i=0; i<times; i++) {
const art = rollOneArt();
pulled.push(art);
// 1枚目だけ即ポートレート変更(継続仕様)
grantArt(art.key, i === 0);
}
updateCharacterInfo();
status.textContent = `Pulled ${times} time(s). Cost ${totalCost} Gold.`;
// 表示
pulled.forEach((a, idx) => {
const card = document.createElement("div");
card.className = "gacha-card";
card.innerHTML = `
<img src="${a.url}" alt="${a.name}">
<div class="p">
<strong>${a.name}</strong><br/>
<span class="muted">${a.rarity}</span>
${isArtOwned(a.key) ? `<span class="badge owned" style="margin-left:6px;">Owned</span>` : ``}
</div>
`;
card.onclick = () => openArtModal(a.key);
resultWrap.appendChild(card);
});
showHomeMessage(`Gacha result: ${pulled.map(a => a.rarity + " " + a.key).join(", ")}`);
saveLocalData();
renderArtShop();
renderArtGallery();
}
function rollOneArt() {
const r = Math.random() * 100;
let rarity = (r < GACHA_RATE_UR) ? "UR" : "SSR";
const pool = ART_LIST.filter(a => a.rarity === rarity);
// 念のため
if (pool.length === 0) return ART_LIST[Math.floor(Math.random() * ART_LIST.length)];
return pool[Math.floor(Math.random() * pool.length)];
}
// -------------------------------------------
// アートプレビュー・モーダル
// -------------------------------------------
let pendingArtKey = null;
function openArtModal(artKey) {
const art = ART_LIST.find(a => a.key === artKey);
if (!art) return;
pendingArtKey = artKey;
document.getElementById("art-modal-title").textContent = `${art.name} (${art.rarity})`;
document.getElementById("art-modal-img").src = art.url;
const owned = isArtOwned(art.key);
document.getElementById("art-modal-desc").textContent =
owned ? "Owned: You can set this as your character art." : "Not owned: Buy it in Store or pull it from Gacha.";
const setBtn = document.getElementById("art-modal-set-btn");
setBtn.disabled = !owned;
if (!owned) setBtn.classList.add("disabled");
else setBtn.classList.remove("disabled");
document.getElementById("art-modal-bg").style.display = "flex";
}
function closeArtModal() {
document.getElementById("art-modal-bg").style.display = "none";
pendingArtKey = null;
}
function confirmSetPortrait() {
if (!pendingArtKey) return;
setPortraitFromArt(pendingArtKey);
closeArtModal();
}
// -------------------------------------------
// クラフト関連
// -------------------------------------------
function toggleCraftSelection(invIndex) {
const item = inventory[invIndex];
if (selectedForCraft.includes(invIndex)) {
selectedForCraft = selectedForCraft.filter(i => i !== invIndex);
} else {
if (selectedForCraft.length >= 2) {
showHomeMessage("You can only select up to 2 items for crafting.");
return;
}
selectedForCraft.push(invIndex);
}
updateCraftSelectionDisplay();
}
function updateCraftSelectionDisplay() {
let names = selectedForCraft.map(i => inventory[i].name);
if (names.length === 0) names.push("None");
document.getElementById("craft-selection").textContent = names.join(" & ");
document.getElementById("craft-button").disabled = (selectedForCraft.length < 2);
}
function attemptCraft() {
if (selectedForCraft.length < 2) return;
let itemA = inventory[selectedForCraft[0]];
let itemB = inventory[selectedForCraft[1]];
let combo = [itemA.name, itemB.name].sort();
let craftedItem = null;
for (let r of craftRecipes) {
if (r.components[0] === combo[0] && r.components[1] === combo[1]) {
craftedItem = r.result;
break;
}
}
if (!craftedItem) {
showHomeMessage("No valid recipe found for these items.");
selectedForCraft = [];
updateCraftSelectionDisplay();
return;
}
let idxA = Math.max(selectedForCraft[0], selectedForCraft[1]);
let idxB = Math.min(selectedForCraft[0], selectedForCraft[1]);
inventory.splice(idxA, 1);
inventory.splice(idxB, 1);
inventory.push(craftedItem);
showHomeMessage(`You crafted: ${craftedItem.name}!`);
selectedForCraft = [];
updateCraftSelectionDisplay();
updateInventoryDisplay();
saveLocalData();
}
// -------------------------------------------
// スキル
// -------------------------------------------
function refreshSkillList() {
document.getElementById("skill-point-display").textContent = character.skillPoints;
const listElm = document.getElementById("skill-list");
listElm.innerHTML = "";
for (let sKey in skills) {
let sk = skills[sKey];
let li = document.createElement("li");
li.innerHTML = `
<strong>${sk.name} (Lv${sk.level}/${sk.maxLevel})</strong>
- ${sk.description}
${
sk.level < sk.maxLevel
? `(<button onclick="learnSkill('${sKey}')">Upgrade (cost ${sk.cost})</button>)`
: ''
}
`;
listElm.appendChild(li);
}
}
function learnSkill(skillKey) {
let skill = skills[skillKey];
if (!skill) return;
if (skill.level >= skill.maxLevel) {
showHomeMessage(`${skill.name} is already at max level.`);
return;
}
if (character.skillPoints < skill.cost) {
showHomeMessage(`Not enough skill points to upgrade ${skill.name}.`);
return;
}
character.skillPoints -= skill.cost;
skill.level++;
showHomeMessage(`You upgraded ${skill.name} to level ${skill.level}.`);
updateCharacterInfo();
refreshSkillList();
}
// -------------------------------------------
// クエストログ
// -------------------------------------------
function updateQuestLog() {
const logElm = document.getElementById("quest-log-list");
logElm.innerHTML = "";
let allQ = getAllQuests();
for (let key in allQ) {
let q = allQ[key];
let status = q.isCompleted ? "Completed" : (q.isRunning ? "In Progress" : "Not Started");
let li = document.createElement("li");
li.textContent = `${q.name}: ${status}`;
logElm.appendChild(li);
}
}
// -------------------------------------------
// 実績
// -------------------------------------------
function checkAchievements() {
if (character.level >= 5) achievements.level5.isUnlocked = true;
let completedCount = 0;
let allQ = getAllQuests();
for (let key in allQ) if (allQ[key].isCompleted) completedCount++;
if (completedCount >= 3) achievements.quest3.isUnlocked = true;
saveLocalData();
updateAchievementList();
}
function updateAchievementList() {
const listElm = document.getElementById("achievement-list");
listElm.innerHTML = "";
for (let aKey in achievements) {
let a = achievements[aKey];
let status = a.isUnlocked ? "Unlocked" : "Locked";
let li = document.createElement("li");
li.textContent = `${a.name} - ${a.description} [${status}]`;
listElm.appendChild(li);
}
}
// -------------------------------------------
// BGM(継続再生 & 状態保存 & 復帰)
// -------------------------------------------
let isMusicPlaying = false;
let bgmUnlocked = false;
let wantAutoResume = false;
function setBgmStatus(text) {
const s = document.getElementById("bgm-status");
if (s) s.textContent = "Status: " + text;
}
function saveBgmState() {
const audio = document.getElementById("bgm");
if (!audio) return;
const state = {
unlocked: bgmUnlocked,
playing: isMusicPlaying,
volume: audio.volume,
time: audio.currentTime
};
localStorage.setItem(LS_KEY_BGM, JSON.stringify(state));
}
function loadBgmState() {
const audio = document.getElementById("bgm");
if (!audio) return;
const raw = localStorage.getItem(LS_KEY_BGM);
if (!raw) return;
try {
const st = JSON.parse(raw);
bgmUnlocked = !!st.unlocked;
isMusicPlaying = !!st.playing;
wantAutoResume = isMusicPlaying;
if (typeof st.volume === "number") audio.volume = st.volume;
if (typeof st.time === "number") {
audio.addEventListener("loadedmetadata", () => {
try { audio.currentTime = Math.max(0, st.time); } catch(e) {}
}, { once: true });
}
} catch(e) {}
}
function wireBgmAutoSave() {
const audio = document.getElementById("bgm");
if (!audio) return;
audio.addEventListener("play", () => { isMusicPlaying = true; saveBgmState(); });
audio.addEventListener("pause", () => { isMusicPlaying = false; saveBgmState(); });
audio.addEventListener("volumechange", saveBgmState);
let lastSave = 0;
audio.addEventListener("timeupdate", () => {
const now = Date.now();
if (now - lastSave > 4000) {
lastSave = now;
saveBgmState();
}
});
}
function updateEnableBtn() {
const btn = document.getElementById("bgm-enable-btn");
if (!btn) return;
if (bgmUnlocked) {
btn.textContent = "BGM Enabled";
btn.classList.add("disabled");
btn.disabled = true;
} else {
btn.textContent = "Enable BGM (First Click)";
btn.classList.remove("disabled");
btn.disabled = false;
}
}
function resumeBgmOnNextUserActionIfNeeded() {
if (!bgmUnlocked || !wantAutoResume) return;
const audio = document.getElementById("bgm");
if (!audio) return;
const resumeOnce = () => {
audio.play().then(() => {
isMusicPlaying = true;
wantAutoResume = false;
setBgmStatus("On (Resumed)");
saveBgmState();
}).catch(() => {
setBgmStatus("Blocked (Enable again)");
});
};
document.addEventListener("pointerdown", resumeOnce, { once: true });
document.addEventListener("keydown", resumeOnce, { once: true });
}
function enableBGM() {
const audio = document.getElementById("bgm");
if (!audio) return;
audio.volume = 0.6;
audio.play().then(() => {
bgmUnlocked = true;
isMusicPlaying = true;
wantAutoResume = false;
updateEnableBtn();
setBgmStatus("On");
showHomeMessage("BGM Enabled & Playing");
saveBgmState();
}).catch(() => {
setBgmStatus("Blocked (Click again)");
showHomeMessage("BGM blocked by browser. Click Enable BGM again.");
});
}
function toggleMusic() {
const audio = document.getElementById("bgm");
if (!audio) return;
if (!bgmUnlocked) {
showHomeMessage("First, click 'Enable BGM (First Click)'.");
setBgmStatus("Locked");
return;
}
if (!isMusicPlaying) {
audio.play().then(() => {
isMusicPlaying = true;
setBgmStatus("On");
showHomeMessage("Music On");
saveBgmState();
}).catch(() => {
setBgmStatus("Blocked");
showHomeMessage("Music could not be played (browser block).");
});
} else {
audio.pause();
isMusicPlaying = false;
setBgmStatus("Off");
showHomeMessage("Music Off");
saveBgmState();
}
}
// -------------------------------------------
// 汎用ランダム整数
// -------------------------------------------
function getRandomInt(min, max) {
return Math.floor(Math.random() * (max - min + 1) + min);
}
</script>
</body>
</html>
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no" />
<title>ELDER Social VR - 超完全版(クライアントのみ)</title>
<script src="https://aframe.io/releases/1.4.2/aframe.min.js"></script>
<style>
:root{
--bg: rgba(10,12,14,.82);
--glass: rgba(255,255,255,.06);
--stroke: rgba(255,255,255,.13);
--accent: rgba(120,240,255,.95);
--accent2: rgba(120,160,255,.95);
}
html,body{ margin:0; padding:0; height:100%; overflow:hidden; background:#000; font-family: "Yu Gothic", system-ui, -apple-system, Segoe UI, sans-serif; }
a-scene{ position:fixed; inset:0; z-index:0; }
canvas{ position:fixed !important; inset:0; z-index:0; }
/* ===== HUD (DOM UI) ===== */
#hud{
position: fixed; inset: 0;
display:flex; align-items:flex-start; justify-content:center;
pointer-events:none;
z-index: 999999;
}
#panel{
margin-top: 18px;
width: min(560px, calc(100vw - 26px));
background: linear-gradient(180deg, rgba(255,255,255,.06), rgba(0,0,0,.28));
border: 1px solid var(--stroke);
border-radius: 18px;
box-shadow: 0 18px 60px rgba(0,0,0,.45), 0 0 0 1px rgba(0,0,0,.2) inset;
backdrop-filter: blur(10px);
color:#fff;
padding: 16px;
pointer-events:auto;
touch-action: manipulation;
user-select:none;
position: relative;
}
#panel, #panel *{ pointer-events:auto; }
/* UI隠すボタン */
#btnHideUI{
position:absolute;
top: 12px;
right: 12px;
width: auto;
padding: 10px 12px;
border-radius: 12px;
font-weight: 900;
font-size: 12px;
letter-spacing: .2px;
background: rgba(0,0,0,.35);
border: 1px solid rgba(255,255,255,.16);
cursor: pointer;
display:flex;
align-items:center;
gap:8px;
-webkit-tap-highlight-color: transparent;
}
#btnHideUI:hover{ border-color: rgba(120,240,255,.35); }
#btnHideUI:active{ transform: scale(.99); }
/* HUDを閉じた後に出す小ボタン */
#floatingShowUI{
position: fixed;
right: 14px;
bottom: 14px;
z-index: 9999999;
display: none;
pointer-events:auto;
background: rgba(10,12,14,.72);
border: 1px solid rgba(255,255,255,.18);
backdrop-filter: blur(10px);
color:#fff;
border-radius: 999px;
padding: 12px 14px;
font-weight: 900;
cursor:pointer;
box-shadow: 0 10px 40px rgba(0,0,0,.4);
-webkit-tap-highlight-color: transparent;
}
#floatingShowUI:hover{ border-color: rgba(120,240,255,.35); }
#floatingShowUI:active{ transform: scale(.99); }
.top{
display:flex; align-items:center; justify-content:space-between;
gap: 10px;
margin-bottom: 12px;
padding-right: 86px; /* 隠すボタン分スペース */
}
.brand{
font-weight: 900;
letter-spacing: .5px;
font-size: 22px;
}
.pill{
display:inline-flex; align-items:center; gap:8px;
background: rgba(255,255,255,.06);
border: 1px solid rgba(255,255,255,.14);
padding: 8px 12px;
border-radius: 999px;
font-size: 12px;
opacity:.95;
}
.grid{
display:grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.btn{
display:flex; align-items:center; justify-content:center;
gap: 10px;
padding: 14px 12px;
border-radius: 14px;
background: linear-gradient(180deg, rgba(255,255,255,.08), rgba(0,0,0,.25));
border: 1px solid rgba(255,255,255,.14);
color:#fff;
font-weight: 900;
letter-spacing: .4px;
cursor:pointer;
transform: translateZ(0);
transition: transform .08s, border-color .18s, background .18s;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
}
.btn:hover{ border-color: rgba(120,240,255,.35); }
.btn:active{ transform: scale(.99); }
.btn.primary{ border-color: rgba(120,240,255,.28); box-shadow: 0 0 0 1px rgba(120,240,255,.10) inset; }
.btn.danger{ border-color: rgba(255,120,160,.25); }
.section{
margin-top: 12px;
background: rgba(0,0,0,.22);
border: 1px solid rgba(255,255,255,.10);
border-radius: 16px;
padding: 12px;
}
.sectionTitle{
font-weight: 900;
opacity:.92;
margin-bottom: 8px;
display:flex; align-items:center; justify-content:space-between;
gap: 10px;
}
.statGrid{
display:grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.card{
background: rgba(255,255,255,.04);
border: 1px solid rgba(255,255,255,.10);
border-radius: 14px;
padding: 10px;
}
.label{ opacity:.82; font-size: 12px; }
.big{ font-size: 20px; font-weight: 900; margin-top: 4px; }
.bar{
margin-top: 8px;
height: 10px;
background: rgba(255,255,255,.08);
border-radius: 999px;
overflow:hidden;
border: 1px solid rgba(0,0,0,.22);
}
.bar > div{ height:100%; width:50%; background: linear-gradient(90deg, var(--accent), var(--accent2)); }
.log{
margin-top: 10px;
max-height: 140px;
overflow:auto;
padding: 10px;
border-radius: 14px;
border: 1px solid rgba(255,255,255,.12);
background: rgba(0,0,0,.28);
font-size: 12px;
line-height: 1.35;
}
/* モーダル */
#modalBack{
position: fixed; inset:0;
display:none;
align-items:center; justify-content:center;
background: rgba(0,0,0,.55);
z-index: 9999999;
pointer-events:auto;
}
#modal{
width: min(620px, calc(100vw - 26px));
background: rgba(10,12,14,.92);
border: 1px solid rgba(255,255,255,.14);
border-radius: 18px;
box-shadow: 0 18px 70px rgba(0,0,0,.55);
color:#fff;
padding: 14px;
}
#modal h3{
margin: 0 0 10px 0;
font-size: 18px;
letter-spacing: .3px;
}
.modalBody{ opacity:.92; font-size: 14px; line-height: 1.5; white-space: pre-wrap; }
.modalBtns{ display:flex; gap: 10px; margin-top: 12px; flex-wrap:wrap; }
.modalBtns .btn{ flex: 1 1 140px; }
/* VR中はDOM HUDを隠す */
body.vr #hud{ display:none !important; }
body.vr #floatingShowUI{ display:none !important; }
</style>
</head>
<body>
<!-- HUD -->
<div id="hud">
<div id="panel">
<div id="btnHideUI" title="UIを隠す(ESCで戻せる)">✕ UIを隠す</div>
<div class="top">
<div class="brand">ELDER Social VR</div>
<div class="pill">現在地: <b id="fieldTag">街</b></div>
</div>
<div class="grid" style="margin-bottom:10px;">
<div class="btn primary" id="btnTown">🏘️ 街</div>
<div class="btn primary" id="btnCastle">🏰 城</div>
<div class="btn primary" id="btnCave">🕳️ 洞窟</div>
<div class="btn primary" id="btnRuins">🏛️ 遺跡</div>
</div>
<div class="grid" style="margin-bottom:10px;">
<div class="btn" id="btnEnterVR">🕶️ Enter VR</div>
<div class="btn danger" id="btnExit">⏏ Exit</div>
<div class="btn" id="btnWave">👋 Wave</div>
<div class="btn" id="btnCheer">🎉 Cheer</div>
</div>
<div class="section">
<div class="sectionTitle">
<span>レベル / EXP</span>
<span class="pill">名前: <b id="playerNameLabel">YOU</b></span>
</div>
<div class="statGrid">
<div class="card">
<div class="label">Lv / EXP</div>
<div class="big">Lv.<span id="level">1</span> <span style="opacity:.8;font-size:12px;">EXP</span> <span id="expText">0</span>/<span id="expNeedText">100</span></div>
<div class="bar"><div id="expBar" style="width:0%"></div></div>
</div>
<div class="card">
<div class="label">ゴールド</div>
<div class="big"><span id="goldText">0</span> G</div>
<div class="label" style="margin-top:6px;">街の商人で買い物できる</div>
</div>
</div>
<div class="statGrid" style="margin-top:10px;">
<div class="card">
<div class="label">HP</div>
<div class="big" id="hpText">100</div>
<div class="bar"><div id="hpBar" style="width:100%"></div></div>
</div>
<div class="card">
<div class="label">魔力</div>
<div class="big" id="manaText">100</div>
<div class="bar"><div id="manaBar" style="width:100%"></div></div>
</div>
</div>
</div>
<div class="grid" style="margin-top:10px;">
<div class="btn" id="btnTalk">💬 話す</div>
<div class="btn" id="btnQuest">📜 クエスト</div>
<div class="btn" id="btnShop">🛒 ショップ</div>
<div class="btn" id="btnRest">🛏 休憩</div>
<div class="btn" id="btnSave">💾 セーブ</div>
<div class="btn" id="btnLoad">📂 ロード</div>
</div>
<div class="log" id="log"></div>
<div style="opacity:.7;font-size:11px;margin-top:8px;line-height:1.35;">
操作: WASD移動 / Shiftダッシュ / Spaceジャンプ / マウス視点(クリックでポインタロック)<br/>
VR: Enter VR → コントローラのレーザーで3D UIを押せる(DOM UIは非表示)<br/>
UI: 右上「UIを隠す」or ESCで切替
</div>
</div>
</div>
<!-- HUDを隠した後に出すボタン -->
<div id="floatingShowUI">≡ UIを表示</div>
<!-- Modal -->
<div id="modalBack">
<div id="modal">
<h3 id="modalTitle">TITLE</h3>
<div class="modalBody" id="modalBody"></div>
<div class="modalBtns" id="modalBtns"></div>
</div>
</div>
<!-- A-Frame -->
<a-scene
id="scene"
renderer="colorManagement:true; physicallyCorrectLights:true"
shadow="type:pcfsoft"
webxr="optionalFeatures: local-floor, bounded-floor, hand-tracking"
>
<a-assets>
<!-- BGM(エリアで切替) -->
<audio id="bgmTown" src="https://www.free-stock-music.com/music/scott-buckley/mp3/scott-buckley-beautiful-oblivion.mp3" crossorigin="anonymous"></audio>
<audio id="bgmCastle" src="https://www.free-stock-music.com/music/scott-buckley/mp3/scott-buckley-the-endurance.mp3" crossorigin="anonymous"></audio>
<audio id="bgmCave" src="https://www.free-stock-music.com/music/scott-buckley/mp3/scott-buckley-in-search-of-solitude.mp3" crossorigin="anonymous"></audio>
<audio id="bgmRuins" src="https://www.free-stock-music.com/music/wombat-noises-audio/mp3/wombat-noises-audio-the-ruins-of-atlantis.mp3" crossorigin="anonymous"></audio>
</a-assets>
<!-- 空 -->
<a-sky id="sky" color="#061018"></a-sky>
<!-- 光 -->
<a-light type="ambient" intensity="0.9" color="#dff8ff"></a-light>
<a-light id="sun" type="directional" intensity="1.35" position="30 40 10"
castShadow="true" shadow-mapWidth="2048" shadow-mapHeight="2048"></a-light>
<!-- 海 -->
<a-entity id="ocean" position="0 0 0">
<a-cylinder position="0 -1.4 0" radius="140" height="2.2" open-ended="true"
material="color:#0a2a3a; metalness:0.05; roughness:0.35; opacity:0.95; transparent:true"></a-cylinder>
<a-ring position="0 -0.2 0" radius-inner="65" radius-outer="140"
rotation="-90 0 0"
material="color:#0b3143; opacity:0.88; transparent:true"></a-ring>
</a-entity>
<!-- 島(3段) -->
<a-entity id="island">
<a-cylinder position="0 -0.1 0" radius="62" height="1"
material="color:#cbb48b; roughness:0.95; metalness:0.0"></a-cylinder>
<a-cylinder position="0 0.05 0" radius="50" height="1"
material="color:#2f6a3f; roughness:0.95; metalness:0.0"></a-cylinder>
<a-cylinder position="0 0.35 0" radius="34" height="1.2"
material="color:#2a5f3a; roughness:0.95"></a-cylinder>
<a-ring position="0 0.42 0" radius-inner="14" radius-outer="17" rotation="-90 0 0"
material="color:#3b2f23; roughness:1"></a-ring>
<a-ring position="0 0.42 0" radius-inner="26" radius-outer="29" rotation="-90 0 0"
material="color:#3b2f23; roughness:1; opacity:0.85; transparent:true"></a-ring>
</a-entity>
<!-- BGM -->
<a-entity id="bgm" sound="src:#bgmTown; autoplay:false; loop:true; volume:0.65; positional:false"></a-entity>
<!-- プレイヤー(※yは基準0.55で置く。起動時にJSで“地面にスナップ”して埋まりゼロ) -->
<a-entity id="playerRig" position="0 0.55 18">
<!-- かっこいい勇者(簡易ハイディテール) -->
<a-entity id="hero" position="0 0 0"
animation__idle="property: rotation; dir: alternate; dur: 1800; loop: true; to: 0 1.2 0"
animation__breath="property: scale; dir: alternate; dur: 1200; loop: true; to: 1.01 1.02 1.01">
<!-- 影 -->
<a-circle radius="0.75" rotation="-90 0 0" position="0 0.02 0"
material="color:#000; opacity:0.25; transparent:true"></a-circle>
<!-- マント -->
<a-entity id="cape" position="0 1.22 0.16" rotation="10 0 0"
animation__cape="property: rotation; dir: alternate; dur: 900; loop: true; to: 12 1 0">
<a-plane width="0.95" height="1.35" position="0 -0.58 -0.20"
material="color:#0b1020; opacity:0.96; transparent:true; side:double"></a-plane>
<a-plane width="0.55" height="1.15" position="-0.28 -0.62 -0.21" rotation="0 8 0"
material="color:#0a0f1c; opacity:0.90; transparent:true; side:double"></a-plane>
<a-plane width="0.55" height="1.15" position="0.28 -0.62 -0.21" rotation="0 -8 0"
material="color:#0a0f1c; opacity:0.90; transparent:true; side:double"></a-plane>
</a-entity>
<!-- ブーツ -->
<a-box position="-0.17 0.16 0" width="0.22" height="0.34" depth="0.30"
material="color:#1a1418; roughness:1"></a-box>
<a-box position="0.17 0.16 0" width="0.22" height="0.34" depth="0.30"
material="color:#1a1418; roughness:1"></a-box>
<a-box position="-0.17 0.06 -0.14" width="0.24" height="0.12" depth="0.22"
material="color:#0f0c10; roughness:1"></a-box>
<a-box position="0.17 0.06 -0.14" width="0.24" height="0.12" depth="0.22"
material="color:#0f0c10; roughness:1"></a-box>
<!-- 脚(鎧) -->
<a-box position="-0.17 0.56 0" width="0.26" height="0.54" depth="0.32"
material="color:#2b3140; metalness:0.25; roughness:0.55"></a-box>
<a-box position="0.17 0.56 0" width="0.26" height="0.54" depth="0.32"
material="color:#2b3140; metalness:0.25; roughness:0.55"></a-box>
<!-- 膝ルーン -->
<a-box position="-0.17 0.44 -0.18" width="0.26" height="0.14" depth="0.14"
material="color:#78f0ff; emissive:#2dd; emissiveIntensity:0.20; metalness:0.3; roughness:0.35; opacity:0.55; transparent:true"></a-box>
<a-box position="0.17 0.44 -0.18" width="0.26" height="0.14" depth="0.14"
material="color:#78f0ff; emissive:#2dd; emissiveIntensity:0.20; metalness:0.3; roughness:0.35; opacity:0.55; transparent:true"></a-box>
<!-- 腰 -->
<a-box position="0 0.92 0.00" width="0.72" height="0.14" depth="0.40"
material="color:#15151a; roughness:1"></a-box>
<a-box position="0 0.92 0.23" width="0.16" height="0.12" depth="0.06"
material="color:#ad7b2e; roughness:0.75; metalness:0.25"></a-box>
<!-- 腰布 -->
<a-plane width="0.42" height="0.58" position="0 0.64 0.20"
material="color:#152a52; opacity:0.92; transparent:true; side:double"></a-plane>
<a-plane width="0.35" height="0.36" position="0 0.54 -0.22" rotation="0 180 0"
material="color:#0f1733; opacity:0.85; transparent:true; side:double"></a-plane>
<!-- 胸鎧 -->
<a-box position="0 1.22 0" width="0.74" height="0.82" depth="0.42"
material="color:#3b6b8e; metalness:0.38; roughness:0.22"></a-box>
<!-- コア -->
<a-box position="0 1.22 0.24" width="0.60" height="0.66" depth="0.06"
material="color:#78f0ff; emissive:#2dd; emissiveIntensity:0.30; metalness:0.25; roughness:0.18; opacity:0.55; transparent:true"></a-box>
<a-ring position="0 1.22 0.27" radius-inner="0.12" radius-outer="0.20"
material="color:#78f0ff; emissive:#2dd; emissiveIntensity:0.35; opacity:0.45; transparent:true"></a-ring>
<!-- 肩当て -->
<a-sphere position="-0.50 1.54 0.02" radius="0.22"
material="color:#2f4f6a; metalness:0.35; roughness:0.22"></a-sphere>
<a-sphere position="0.50 1.54 0.02" radius="0.22"
material="color:#2f4f6a; metalness:0.35; roughness:0.22"></a-sphere>
<a-cone position="-0.58 1.58 0.02" radius-bottom="0.10" height="0.22" rotation="0 0 25"
material="color:#cbd3da; metalness:0.55; roughness:0.25"></a-cone>
<a-cone position="0.58 1.58 0.02" radius-bottom="0.10" height="0.22" rotation="0 0 -25"
material="color:#cbd3da; metalness:0.55; roughness:0.25"></a-cone>
<!-- 腕 -->
<a-box position="-0.62 1.18 0.02" width="0.20" height="0.68" depth="0.24"
material="color:#2b3140; metalness:0.25; roughness:0.55"></a-box>
<a-box position="0.62 1.18 0.02" width="0.20" height="0.68" depth="0.24"
material="color:#2b3140; metalness:0.25; roughness:0.55"></a-box>
<a-sphere position="-0.62 0.84 0.02" radius="0.10" material="color:#1a1418; roughness:1"></a-sphere>
<a-sphere position="0.62 0.84 0.02" radius="0.10" material="color:#1a1418; roughness:1"></a-sphere>
<!-- 襟 -->
<a-torus radius="0.24" tube="0.06" position="0 1.58 0.02" rotation="90 0 0"
material="color:#0b1020; roughness:1; opacity:0.96; transparent:true"></a-torus>
<!-- 頭 -->
<a-entity id="headGroup" position="0 1.78 0">
<a-sphere id="heroHead" position="0 0 0" radius="0.22" material="color:#f4d7bd; roughness:0.95"></a-sphere>
<a-sphere position="0 0.05 -0.02" radius="0.24" material="color:#1b1b1f; roughness:0.9; metalness:0.15"></a-sphere>
<a-box position="0 -0.02 -0.18" width="0.32" height="0.18" depth="0.10"
material="color:#0f0f12; roughness:1"></a-box>
<a-box position="0 0.00 -0.24" width="0.36" height="0.10" depth="0.06"
material="color:#78f0ff; emissive:#2dd; emissiveIntensity:0.40; opacity:0.55; transparent:true"></a-box>
<a-cone position="-0.16 0.20 -0.02" radius-bottom="0.06" height="0.18" rotation="0 0 25"
material="color:#cbd3da; metalness:0.55; roughness:0.25"></a-cone>
<a-cone position="0.16 0.20 -0.02" radius-bottom="0.06" height="0.18" rotation="0 0 -25"
material="color:#cbd3da; metalness:0.55; roughness:0.25"></a-cone>
</a-entity>
<!-- 剣 -->
<a-entity id="sword" position="0.40 1.02 0.10" rotation="0 0 18">
<a-box width="0.05" height="0.86" depth="0.06" position="0 0.42 0"
material="color:#cbd3da; metalness:0.78; roughness:0.18"></a-box>
<a-box width="0.02" height="0.84" depth="0.02" position="0 0.42 -0.03"
material="color:#ffffff; opacity:0.18; transparent:true"></a-box>
<a-box width="0.20" height="0.05" depth="0.12" position="0 0.04 0"
material="color:#ad7b2e; metalness:0.35; roughness:0.55"></a-box>
<a-torus radius="0.09" tube="0.012" position="0 0.04 0.07" rotation="90 0 0"
material="color:#78f0ff; emissive:#2dd; emissiveIntensity:0.30; opacity:0.45; transparent:true"></a-torus>
<a-box width="0.07" height="0.18" depth="0.07" position="0 -0.08 0"
material="color:#2b1c12; roughness:1"></a-box>
<a-sphere radius="0.03" position="0 -0.18 0"
material="color:#78f0ff; emissive:#2dd; emissiveIntensity:0.35; opacity:0.6; transparent:true"></a-sphere>
</a-entity>
<!-- 盾 -->
<a-entity id="shield" position="-0.70 1.06 -0.06" rotation="0 0 12">
<a-cylinder radius="0.24" height="0.09" rotation="90 0 0"
material="color:#3a5f7a; metalness:0.28; roughness:0.32"></a-cylinder>
<a-ring radius-inner="0.13" radius-outer="0.24" rotation="90 0 0"
material="color:#78f0ff; emissive:#2dd; emissiveIntensity:0.25; opacity:0.45; transparent:true"></a-ring>
<a-circle radius="0.07" rotation="90 0 0" position="0 0 0.05"
material="color:#ad7b2e; metalness:0.35; roughness:0.55"></a-circle>
</a-entity>
<!-- 名前 -->
<a-text id="name3d" value="YOU" position="0 2.35 0" align="center" width="4" color="#ffffff"
shader="msdf" font="https://cdn.aframe.io/fonts/Roboto-msdf.json"></a-text>
</a-entity>
<a-camera id="cam"
position="0 1.75 3.4"
look-controls="pointerLockEnabled: false"
wasd-controls-enabled="false"
fov="72"
></a-camera>
<a-entity id="rightHand" laser-controls="hand:right" raycaster="objects: .vrbtn" line="opacity:0.75"></a-entity>
<a-entity id="leftHand" laser-controls="hand:left" raycaster="objects: .vrbtn" line="opacity:0.75"></a-entity>
<a-entity id="mouseCursor" cursor="rayOrigin: mouse" raycaster="objects: .vrbtn"></a-entity>
<!-- VR UI -->
<a-entity id="vrUI" position="0 1.55 -1.25" visible="false">
<a-plane width="1.55" height="0.92" material="color:#0b0f14; opacity:0.78; transparent:true"></a-plane>
<a-text value="VRUI" position="0 0.40 0.01" align="center" width="2.6" color="#7ff"
shader="msdf" font="https://cdn.aframe.io/fonts/Roboto-msdf.json"></a-text>
<a-entity position="0 0.12 0.02">
<a-plane class="vrbtn" vr-btn="action:fieldTown" position="-0.48 0.12 0" width="0.48" height="0.16" material="color:#13202b; opacity:0.95"></a-plane>
<a-text value="街" position="-0.48 0.12 0.02" align="center" width="1.6" color="#fff" shader="msdf" font="https://cdn.aframe.io/fonts/Roboto-msdf.json"></a-text>
<a-plane class="vrbtn" vr-btn="action:fieldCastle" position="0.48 0.12 0" width="0.48" height="0.16" material="color:#13202b; opacity:0.95"></a-plane>
<a-text value="城" position="0.48 0.12 0.02" align="center" width="1.6" color="#fff" shader="msdf" font="https://cdn.aframe.io/fonts/Roboto-msdf.json"></a-text>
<a-plane class="vrbtn" vr-btn="action:fieldCave" position="-0.48 -0.08 0" width="0.48" height="0.16" material="color:#13202b; opacity:0.95"></a-plane>
<a-text value="洞窟" position="-0.48 -0.08 0.02" align="center" width="1.6" color="#fff" shader="msdf" font="https://cdn.aframe.io/fonts/Roboto-msdf.json"></a-text>
<a-plane class="vrbtn" vr-btn="action:fieldRuins" position="0.48 -0.08 0" width="0.48" height="0.16" material="color:#13202b; opacity:0.95"></a-plane>
<a-text value="遺跡" position="0.48 -0.08 0.02" align="center" width="1.6" color="#fff" shader="msdf" font="https://cdn.aframe.io/fonts/Roboto-msdf.json"></a-text>
<a-plane class="vrbtn" vr-btn="action:talk" position="-0.48 -0.30 0" width="0.48" height="0.16" material="color:#10261e; opacity:0.95"></a-plane>
<a-text value="話す" position="-0.48 -0.30 0.02" align="center" width="1.6" color="#fff" shader="msdf" font="https://cdn.aframe.io/fonts/Roboto-msdf.json"></a-text>
<a-plane class="vrbtn" vr-btn="action:quest" position="0.48 -0.30 0" width="0.48" height="0.16" material="color:#2a2110; opacity:0.95"></a-plane>
<a-text value="クエスト" position="0.48 -0.30 0.02" align="center" width="1.6" color="#fff" shader="msdf" font="https://cdn.aframe.io/fonts/Roboto-msdf.json"></a-text>
</a-entity>
<a-text value="スティック移動 / トリガーで押す" position="0 -0.43 0.01" align="center" width="2.8" color="#bfefff"
shader="msdf" font="https://cdn.aframe.io/fonts/Roboto-msdf.json"></a-text>
</a-entity>
</a-entity>
<!-- NPC(★snap で“その場所の地面”に自動補正 → 埋まりゼロ) -->
<a-entity id="npcGroup">
<a-entity id="npcGuide" class="npc snap" position="-6 1.15 10" rotation="0 25 0">
<a-cylinder radius="0.35" height="1.2" material="color:#203a4a; roughness:0.9"></a-cylinder>
<a-sphere radius="0.22" position="0 0.86 0" material="color:#f2d7bf; roughness:0.95"></a-sphere>
<a-cone radius-bottom="0.28" height="0.35" position="0 1.12 0" material="color:#0a0f18; roughness:1"></a-cone>
<a-text value="Guide" position="0 1.45 0" align="center" width="4" color="#fff" shader="msdf" font="https://cdn.aframe.io/fonts/Roboto-msdf.json"></a-text>
</a-entity>
<a-entity id="npcKnight" class="npc snap" position="10 1.15 0" rotation="0 -120 0" visible="false">
<a-cylinder radius="0.36" height="1.2" material="color:#3a4652; metalness:0.25; roughness:0.35"></a-cylinder>
<a-sphere radius="0.22" position="0 0.86 0" material="color:#f2d7bf; roughness:0.95"></a-sphere>
<a-box width="0.48" height="0.16" depth="0.06" position="0 0.58 0.2" material="color:#78f0ff; opacity:0.5; transparent:true"></a-box>
<a-text value="Castle Knight" position="0 1.45 0" align="center" width="4" color="#fff" shader="msdf" font="https://cdn.aframe.io/fonts/Roboto-msdf.json"></a-text>
</a-entity>
<a-entity id="npcMiner" class="npc snap" position="-10 1.15 -8" rotation="0 60 0" visible="false">
<a-cylinder radius="0.35" height="1.2" material="color:#3b2f23; roughness:0.95"></a-cylinder>
<a-sphere radius="0.22" position="0 0.86 0" material="color:#f2d7bf; roughness:0.95"></a-sphere>
<a-sphere radius="0.18" position="0 1.06 0" material="color:#2d2d2f; roughness:1"></a-sphere>
<a-text value="Miner" position="0 1.45 0" align="center" width="4" color="#fff" shader="msdf" font="https://cdn.aframe.io/fonts/Roboto-msdf.json"></a-text>
</a-entity>
<a-entity id="npcSage" class="npc snap" position="6 1.15 -12" rotation="0 -30 0" visible="false">
<a-cylinder radius="0.35" height="1.2" material="color:#2a1f2f; roughness:0.95"></a-cylinder>
<a-sphere radius="0.22" position="0 0.86 0" material="color:#f2d7bf; roughness:0.95"></a-sphere>
<a-torus radius="0.34" tube="0.05" position="0 0.95 0" rotation="90 0 0" material="color:#7ff; opacity:0.35; transparent:true"></a-torus>
<a-text value="Ruins Sage" position="0 1.45 0" align="center" width="4" color="#fff" shader="msdf" font="https://cdn.aframe.io/fonts/Roboto-msdf.json"></a-text>
</a-entity>
</a-entity>
<!-- Enemy -->
<a-entity id="enemy" position="0 1.10 -2" visible="true">
<a-entity id="enemyModel">
<a-sphere radius="0.55" material="color:#8a1b2d; metalness:0.15; roughness:0.45; emissive:#200;"></a-sphere>
<a-sphere radius="0.22" position="0 0.52 0.06" material="color:#2a0b10; roughness:0.9"></a-sphere>
<a-cone radius-bottom="0.16" height="0.35" position="-0.22 0.78 0.02" rotation="20 0 40" material="color:#ddd; roughness:0.7"></a-cone>
<a-cone radius-bottom="0.16" height="0.35" position="0.22 0.78 0.02" rotation="20 0 -40" material="color:#ddd; roughness:0.7"></a-cone>
<a-sphere radius="0.06" position="-0.14 0.55 -0.46" material="color:#fff; emissive:#f0f; emissiveIntensity:0.9"></a-sphere>
<a-sphere radius="0.06" position="0.14 0.55 -0.46" material="color:#fff; emissive:#f0f; emissiveIntensity:0.9"></a-sphere>
<a-ring radius-inner="0.62" radius-outer="0.74" rotation="-90 0 0" position="0 -0.35 0"
material="color:#7ff; opacity:0.14; transparent:true"></a-ring>
</a-entity>
<a-text id="enemyName3D" value="Enemy" position="0 1.25 0" align="center" width="4" color="#fff"
shader="msdf" font="https://cdn.aframe.io/fonts/Roboto-msdf.json"></a-text>
</a-entity>
<!-- Fields -->
<a-entity id="field-town" visible="true">
<!-- 噴水(snapで地面補正) -->
<a-entity id="fountain" class="snap" position="0 0 0">
<a-cylinder radius="2.1" height="0.35" position="0 0.725 0"
material="color:#55606a; roughness:0.55; metalness:0.1"></a-cylinder>
<a-cylinder radius="1.35" height="0.42" position="0 1.11 0"
material="color:#3f4a54; roughness:0.55; metalness:0.12"></a-cylinder>
<a-cylinder radius="0.25" height="1.0" position="0 1.82 0"
material="color:#6b7782; roughness:0.5"></a-cylinder>
<a-sphere radius="0.26" position="0 2.42 0"
material="color:#7ff; opacity:0.5; transparent:true; emissive:#2dd; emissiveIntensity:0.25"></a-sphere>
<a-torus radius="0.95" tube="0.06" position="0 0.90 0" rotation="90 0 0"
material="color:#7ff; opacity:0.25; transparent:true"></a-torus>
</a-entity>
<!-- 家(各家をsnapで地面補正) -->
<a-entity id="houses">
<a-entity class="snap" position="-10 1.70 6" rotation="0 35 0">
<a-box width="4" height="2.3" depth="3.2" material="color:#bda982; roughness:0.9"></a-box>
<a-cone radius-bottom="2.8" height="1.4" position="0 1.85 0" material="color:#5a2c1b; roughness:1"></a-cone>
<a-plane width="1.2" height="0.7" position="0 0.6 1.61" material="color:#1b2a35; opacity:0.55; transparent:true"></a-plane>
</a-entity>
<a-entity class="snap" position="10 1.60 8" rotation="0 -20 0">
<a-box width="3.2" height="2.1" depth="3.0" material="color:#c2b08a; roughness:0.9"></a-box>
<a-cone radius-bottom="2.2" height="1.3" position="0 1.7 0" material="color:#6a3a22; roughness:1"></a-cone>
<a-plane width="1.0" height="0.65" position="0.2 0.5 1.51" material="color:#1b2a35; opacity:0.55; transparent:true"></a-plane>
</a-entity>
<a-entity class="snap" position="-14 1.55 -6" rotation="0 70 0">
<a-box width="3.6" height="2.0" depth="2.6" material="color:#b8a27a; roughness:0.9"></a-box>
<a-cone radius-bottom="2.4" height="1.2" position="0 1.6 0" material="color:#4f2a1a; roughness:1"></a-cone>
</a-entity>
<a-entity class="snap" position="13 1.65 -6" rotation="0 -55 0">
<a-box width="4.2" height="2.2" depth="3.2" material="color:#b5a07a; roughness:0.95"></a-box>
<a-cone radius-bottom="2.9" height="1.3" position="0 1.75 0" material="color:#2b1c12; roughness:1"></a-cone>
<a-plane width="2.2" height="0.7" position="0 0.6 1.61" material="color:#0b0f14; opacity:0.65; transparent:true"></a-plane>
<a-text value="SHOP" position="0 1.1 1.65" align="center" width="4" color="#7ff"
shader="msdf" font="https://cdn.aframe.io/fonts/Roboto-msdf.json"></a-text>
</a-entity>
</a-entity>
<!-- 屋台(snapで地面補正) -->
<a-entity class="snap" position="-6 0.55 -4" rotation="0 15 0">
<a-box width="2.2" height="0.5" depth="1.2" position="0 0.7 0" material="color:#6a3a22; roughness:1"></a-box>
<a-plane width="2.4" height="1.2" position="0 1.35 0" rotation="-30 0 0" material="color:#7ff; opacity:0.2; transparent:true"></a-plane>
<a-cylinder radius="0.05" height="1.4" position="-1.05 0.7 -0.55" material="color:#3b2f23"></a-cylinder>
<a-cylinder radius="0.05" height="1.4" position="1.05 0.7 -0.55" material="color:#3b2f23"></a-cylinder>
</a-entity>
<!-- 木&街灯(snapで地面補正) -->
<a-entity>
<a-entity class="snap" position="-18 1.85 2">
<a-cylinder radius="0.18" height="2.6" material="color:#3b2f23; roughness:1"></a-cylinder>
<a-sphere radius="1.2" position="0 2.0 0" material="color:#2a7a45; roughness:1"></a-sphere>
</a-entity>
<a-entity class="snap" position="18 1.85 4">
<a-cylinder radius="0.18" height="2.6" material="color:#3b2f23; roughness:1"></a-cylinder>
<a-sphere radius="1.2" position="0 2.0 0" material="color:#2a7a45; roughness:1"></a-sphere>
</a-entity>
<a-entity class="snap" position="6 1.60 14">
<a-cylinder radius="0.06" height="2.1" material="color:#45515a; roughness:0.6"></a-cylinder>
<a-sphere radius="0.18" position="0 1.08 0" material="color:#fff; emissive:#7ff; emissiveIntensity:0.65; opacity:0.85; transparent:true"></a-sphere>
</a-entity>
</a-entity>
</a-entity>
<a-entity id="field-castle" visible="false">
<a-ring position="0 0.43 0" radius-inner="0" radius-outer="30" rotation="-90 0 0"
material="color:#636b75; roughness:0.9; opacity:0.9; transparent:true"></a-ring>
<a-entity position="0 0.75 -18">
<a-box width="26" height="6" depth="2.8" material="color:#a8b1bb; roughness:0.65; metalness:0.05"></a-box>
<a-box width="7" height="4" depth="2.2" position="0 -0.4 1.1" material="color:#8a939e; roughness:0.65"></a-box>
<a-box width="5.2" height="4.2" depth="0.8" position="0 -0.9 1.8" material="color:#2b1c12; roughness:1"></a-box>
<a-plane width="1.2" height="2.6" position="-4 0.6 1.9" material="color:#7ff; opacity:0.25; transparent:true"></a-plane>
<a-plane width="1.2" height="2.6" position="4 0.6 1.9" material="color:#7ff; opacity:0.25; transparent:true"></a-plane>
</a-entity>
<a-entity>
<a-entity position="-12 1.2 -18">
<a-cylinder radius="2.1" height="8.2" material="color:#9aa3ad; roughness:0.55; metalness:0.05"></a-cylinder>
<a-cone radius-bottom="2.3" height="2.4" position="0 5.2 0" material="color:#6a3a22; roughness:1"></a-cone>
</a-entity>
<a-entity position="12 1.2 -18">
<a-cylinder radius="2.1" height="8.2" material="color:#9aa3ad; roughness:0.55; metalness:0.05"></a-cylinder>
<a-cone radius-bottom="2.3" height="2.4" position="0 5.2 0" material="color:#6a3a22; roughness:1"></a-cone>
</a-entity>
</a-entity>
<a-ring position="0 0.18 -12" radius-inner="18" radius-outer="28" rotation="-90 0 0"
material="color:#0b3143; opacity:0.55; transparent:true"></a-ring>
<a-entity position="0 0.55 -6">
<a-cylinder radius="1.4" height="0.6" material="color:#4a535c; roughness:0.65"></a-cylinder>
<a-sphere radius="0.75" position="0 1.0 0" material="color:#a8b1bb; roughness:0.5"></a-sphere>
<a-torus-knot radius="0.35" tube="0.08" position="0 1.8 0" p="2" q="5"
material="color:#7ff; emissive:#2dd; emissiveIntensity:0.25; opacity:0.5; transparent:true"></a-torus-knot>
</a-entity>
</a-entity>
<a-entity id="field-cave" visible="false">
<a-entity position="0 0.55 -6">
<a-sphere radius="10" material="color:#0b0f14; opacity:0.22; transparent:true" segments-width="18" segments-height="12"></a-sphere>
</a-entity>
<a-entity position="0 0.55 -16">
<a-torus radius="8" tube="2.2" arc="200" rotation="0 0 90"
material="color:#4a3f34; roughness:1; metalness:0"></a-torus>
</a-entity>
<a-entity id="rocks">
<a-sphere radius="5" position="-12 2 -14" material="color:#3a332d; roughness:1"></a-sphere>
<a-sphere radius="6" position="12 1 -16" material="color:#352f2a; roughness:1"></a-sphere>
<a-sphere radius="4.5" position="0 3 -22" material="color:#2f2a25; roughness:1"></a-sphere>
</a-entity>
<a-entity>
<a-cone radius-bottom="0.8" height="2.8" position="-4 6 -14" material="color:#2f2a25; roughness:1"></a-cone>
<a-cone radius-bottom="0.6" height="2.2" position="3 5.7 -16" material="color:#2f2a25; roughness:1"></a-cone>
<a-cone radius-bottom="0.7" height="2.5" position="8 6.2 -12" material="color:#2f2a25; roughness:1"></a-cone>
</a-entity>
<a-entity position="-6 0.55 -10">
<a-octahedron radius="0.9" material="color:#7ff; opacity:0.55; transparent:true; emissive:#2dd; emissiveIntensity:0.35"></a-octahedron>
<a-octahedron radius="0.6" position="1.0 0.2 0.3" material="color:#8cf; opacity:0.55; transparent:true; emissive:#2dd; emissiveIntensity:0.25"></a-octahedron>
<a-light type="point" intensity="0.8" distance="10" color="#7ff"></a-light>
</a-entity>
</a-entity>
<a-entity id="field-ruins" visible="false">
<a-ring position="0 0.43 0" radius-inner="0" radius-outer="30" rotation="-90 0 0"
material="color:#6a6555; roughness:0.95; opacity:0.92; transparent:true"></a-ring>
<a-entity>
<a-entity position="-12 0.55 -8">
<a-cylinder radius="0.8" height="3.2" material="color:#c9c2a3; roughness:0.9"></a-cylinder>
<a-box width="2.2" height="0.35" depth="2.2" position="0 1.85 0" material="color:#bdb493; roughness:0.95"></a-box>
</a-entity>
<a-entity position="12 0.55 -8">
<a-cylinder radius="0.8" height="2.1" material="color:#c9c2a3; roughness:0.9"></a-cylinder>
<a-box width="2.2" height="0.35" depth="2.2" position="0 1.25 0" material="color:#bdb493; roughness:0.95"></a-box>
</a-entity>
<a-entity position="-8 0.55 -18" rotation="0 20 0">
<a-cylinder radius="0.7" height="2.4" material="color:#bdb493; roughness:0.95"></a-cylinder>
<a-box width="1.9" height="0.28" depth="1.9" position="0 1.38 0" material="color:#c9c2a3; roughness:0.95"></a-box>
</a-entity>
<a-entity position="8 0.55 -18" rotation="0 -20 0">
<a-cylinder radius="0.7" height="3.0" material="color:#bdb493; roughness:0.95"></a-cylinder>
<a-box width="1.9" height="0.28" depth="1.9" position="0 1.68 0" material="color:#c9c2a3; roughness:0.95"></a-box>
</a-entity>
</a-entity>
<a-entity position="0 2.0 -16">
<a-torus radius="4.0" tube="0.55" arc="180" rotation="0 0 90" material="color:#c9c2a3; roughness:0.9"></a-torus>
</a-entity>
<a-entity position="0 0.55 -8">
<a-box width="4.2" height="0.8" depth="2.6" material="color:#5a5648; roughness:0.95"></a-box>
<a-ring radius-inner="0.9" radius-outer="1.5" rotation="-90 0 0" position="0 0.41 0"
material="color:#7ff; opacity:0.35; transparent:true; emissive:#2dd; emissiveIntensity:0.25"></a-ring>
<a-light type="point" intensity="0.9" distance="14" color="#7ff" position="0 1.3 0"></a-light>
</a-entity>
<a-entity id="floating" position="0 2.2 -10">
<a-box width="0.6" height="0.35" depth="0.6" position="-1.2 0.3 0" material="color:#c9c2a3; roughness:0.9"></a-box>
<a-box width="0.4" height="0.25" depth="0.4" position="1.0 -0.1 0.5" material="color:#bdb493; roughness:0.9"></a-box>
<a-box width="0.5" height="0.3" depth="0.5" position="0.2 0.5 -0.7" material="color:#c9c2a3; roughness:0.9"></a-box>
</a-entity>
</a-entity>
<a-entity id="confetti" visible="false" position="0 2.4 10">
<a-ring radius-inner="0.2" radius-outer="0.6" rotation="-90 0 0" material="color:#7ff; opacity:0.35; transparent:true"></a-ring>
<a-ring radius-inner="0.6" radius-outer="1.0" rotation="-90 0 0" material="color:#fff; opacity:0.18; transparent:true"></a-ring>
</a-entity>
</a-scene>
<script>
/* 3D VR UI ボタン */
AFRAME.registerComponent('vr-btn', {
schema: { action: { type:'string' } },
init: function(){
this.el.addEventListener('click', () => {
const fn = window[this.data.action];
if(typeof fn === 'function') fn();
});
}
});
/* ===== 状態 ===== */
const state = {
field: "town",
hp: 100,
mana: 100,
level: 1,
exp: 0,
expNeed: 100,
gold: 0,
quest: null,
storyStep: 0,
inVR: false,
audioUnlocked: false,
enemy: { name:"影の獣", hp:80, maxHp:80, atk:10, exp:35, gold:15 }
};
const FIELD_JP = { town:"街", castle:"城", cave:"洞窟", ruins:"遺跡" };
const ENEMIES = {
town: [{ name:"路地のスライム", hp:60, atk:9, exp:35, gold:16 }, { name:"野良ゴブリン", hp:80, atk:12, exp:45, gold:20 }],
castle: [{ name:"亡霊騎士", hp:110, atk:16, exp:70, gold:35 }, { name:"城壁の影", hp:130, atk:18, exp:85, gold:42 }],
cave: [{ name:"洞窟コウモリ", hp:90, atk:15, exp:65, gold:30 }, { name:"岩喰い蜥蜴", hp:140, atk:20, exp:95, gold:55 }],
ruins: [{ name:"封印の番人", hp:170, atk:24, exp:120, gold:70 }, { name:"古代の眼", hp:150, atk:22, exp:110,gold:62 }]
};
/* ★あなたが“配置に使ってきた基準地面” */
const BASE_GROUND_Y = 0.55;
/* ★島は3段。距離で「今いる地面の高さ」を返す(埋まり防止の本体) */
const GROUND_LAYERS = [
{ r: 34, y: 0.95 }, // 上段(radius=34 height=1.2 posY=0.35 → top=0.95)
{ r: 50, y: 0.55 }, // 中段(radius=50 height=1.0 posY=0.05 → top=0.55)
{ r: 62, y: 0.40 } // 下段(radius=62 height=1.0 posY=-0.1 → top=0.40)
];
function groundYAt(x, z){
const r = Math.hypot(x, z);
for(const layer of GROUND_LAYERS){
if(r <= layer.r) return layer.y;
}
return GROUND_LAYERS[GROUND_LAYERS.length - 1].y;
}
function enemyYAt(x, z){
return groundYAt(x, z) + 0.55; // 敵球半径0.55
}
/* ★「基準0.55で置いた物」を、実地面に合わせて持ち上げる */
function snapElToGround(el){
if(!el) return;
const p = el.getAttribute("position");
if(!p || typeof p.x!=="number" || typeof p.z!=="number" || typeof p.y!=="number") return;
const gy = groundYAt(p.x, p.z);
const dy = gy - BASE_GROUND_Y;
if(Math.abs(dy) < 0.0001) return;
el.setAttribute("position", { x:p.x, y:p.y + dy, z:p.z });
}
function snapAll(){
document.querySelectorAll(".snap").forEach(snapElToGround);
}
/* ===== ユーティリティ ===== */
function escapeHtml(s){ return String(s).replace(/[&<>"']/g, m => ({ "&":"&","<":"<",">":">",'"':""","'":"'" }[m])); }
function log(msg){
const el = document.getElementById("log");
const t = new Date().toLocaleTimeString();
el.innerHTML = `<div>【${t}】${escapeHtml(msg)}</div>` + el.innerHTML;
}
function clamp(v,a,b){ return Math.max(a, Math.min(b, v)); }
/* ===== UI表示/非表示 ===== */
let hudVisible = true;
function setHUDVisible(visible){
hudVisible = !!visible;
document.getElementById("hud").style.display = hudVisible ? "flex" : "none";
document.getElementById("floatingShowUI").style.display = (!hudVisible && !state.inVR) ? "block" : "none";
localStorage.setItem("elder_ui_hidden", hudVisible ? "0" : "1");
}
function toggleHUD(){ setHUDVisible(!hudVisible); }
/* ===== UI反映 ===== */
function updateUI(){
document.getElementById("fieldTag").textContent = FIELD_JP[state.field] || state.field;
document.getElementById("hpText").textContent = Math.floor(state.hp);
document.getElementById("manaText").textContent = Math.floor(state.mana);
document.getElementById("level").textContent = state.level;
document.getElementById("expText").textContent = state.exp;
document.getElementById("expNeedText").textContent = state.expNeed;
document.getElementById("goldText").textContent = state.gold;
document.getElementById("hpBar").style.width = clamp(state.hp,0,100) + "%";
document.getElementById("manaBar").style.width = clamp(state.mana,0,100) + "%";
document.getElementById("expBar").style.width = Math.min(100, (state.exp/state.expNeed)*100) + "%";
}
/* ===== DOMボタンを確実に(多重発火を抑える) ===== */
function bindPress(el, fn){
let last = 0;
const handler = (e) => {
const now = performance.now();
if(now - last < 180) return; // 連打/多重防止
last = now;
try{ e.preventDefault(); }catch(_){}
unlockAudio();
fn();
};
el.addEventListener("pointerup", handler, { passive:false });
el.addEventListener("touchend", handler, { passive:false });
el.addEventListener("click", handler, { passive:false });
}
/* ===== オーディオ ===== */
function unlockAudio(){
if(state.audioUnlocked) return;
state.audioUnlocked = true;
const bgm = document.getElementById("bgm");
try{
bgm.components.sound.playSound();
log("🔊 BGM開始(ユーザー操作で解除)");
}catch(e){}
}
function setBGMByField(){
const bgm = document.getElementById("bgm");
const srcMap = { town:"#bgmTown", castle:"#bgmCastle", cave:"#bgmCave", ruins:"#bgmRuins" };
const src = srcMap[state.field] || "#bgmTown";
bgm.setAttribute("sound", `src:${src}; autoplay:false; loop:true; volume:0.65; positional:false`);
try{
bgm.components.sound.stopSound();
if(state.audioUnlocked) bgm.components.sound.playSound();
}catch(e){}
}
/* ===== フィールド切替 ===== */
function setField(field){
state.field = field;
["town","castle","cave","ruins"].forEach(name=>{
document.getElementById("field-"+name).setAttribute("visible", name===field);
});
document.getElementById("npcGuide").setAttribute("visible", field==="town");
document.getElementById("npcKnight").setAttribute("visible", field==="castle");
document.getElementById("npcMiner").setAttribute("visible", field==="cave");
document.getElementById("npcSage").setAttribute("visible", field==="ruins");
const sky = document.getElementById("sky");
const sun = document.getElementById("sun");
if(field==="town"){ sky.setAttribute("color","#061018"); sun.setAttribute("intensity","1.35"); }
if(field==="castle"){ sky.setAttribute("color","#071321"); sun.setAttribute("intensity","1.45"); }
if(field==="cave"){ sky.setAttribute("color","#04070b"); sun.setAttribute("intensity","0.85"); }
if(field==="ruins"){ sky.setAttribute("color","#050b10"); sun.setAttribute("intensity","1.05"); }
setBGMByField();
spawnEnemy();
updateUI();
log(`📍 ${FIELD_JP[field]} に移動した`);
}
/* ===== 敵 ===== */
function spawnEnemy(){
const list = ENEMIES[state.field] || ENEMIES.town;
const e = list[Math.floor(Math.random()*list.length)];
state.enemy = { name:e.name, hp:e.hp, maxHp:e.hp, atk:e.atk, exp:e.exp, gold:e.gold };
document.getElementById("enemyName3D").setAttribute("value", e.name);
const enemy = document.getElementById("enemy");
enemy.setAttribute("visible","true");
enemy.setAttribute("position", { x:0, y:enemyYAt(0, -2), z:-2 });
enemy.setAttribute("animation__pop","property: scale; from: 0.7 0.7 0.7; to: 1 1 1; dur: 220; easing: easeOutBack");
log(`⚠️ ${e.name} が現れた`);
}
function enemyCounter(){
if(Math.random() < 0.18){ log(`💨 ${state.enemy.name} の攻撃は外れた`); return; }
const raw = state.enemy.atk + Math.floor(Math.random()*6) - Math.floor(state.level/4);
const dmg = Math.max(2, raw);
state.hp -= dmg;
log(`🩸 反撃:${state.enemy.name} から ${dmg} ダメージ`);
if(state.hp <= 0){ state.hp = 1; log("🧊 倒れかけた…(HP1で踏みとどまった)"); }
updateUI();
}
function gainRewards(exp, gold){
state.exp += exp;
state.gold += gold;
log(`✅ 報酬:EXP +${exp} / ${gold}G`);
while(state.exp >= state.expNeed){
state.exp -= state.expNeed;
state.level++;
state.expNeed = Math.floor(state.expNeed*1.25 + 25);
state.hp = clamp(state.hp + 18, 0, 100);
state.mana = clamp(state.mana + 12, 0, 100);
log(`🎉 レベルアップ! Lv.${state.level}`);
}
updateUI();
}
function enemyDie(){
const enemy = document.getElementById("enemy");
enemy.setAttribute("animation__die","property: scale; to: 0.01 0.01 0.01; dur: 250; easing: easeInQuad");
setTimeout(()=> enemy.setAttribute("visible","false"), 260);
gainRewards(state.enemy.exp, state.enemy.gold);
setTimeout(()=> spawnEnemy(), 1200);
}
function damageEnemy(dmg, by="攻撃"){
state.enemy.hp -= dmg;
log(`⚔️ ${by}:${state.enemy.name} に ${dmg} ダメージ(残り ${Math.max(0,state.enemy.hp)})`);
document.getElementById("enemyModel").setAttribute("animation__hit","property: rotation; dir: alternate; dur: 70; loop: 4; to: 0 0 12");
if(state.enemy.hp <= 0){ enemyDie(); return; }
enemyCounter();
}
/* ===== 行動 ===== */
function wave(){ document.getElementById("hero").setAttribute("animation__wave","property: rotation; dir: alternate; dur: 180; loop: 6; to: 0 0 8"); log("👋 Wave!"); }
function cheer(){
const conf = document.getElementById("confetti");
conf.setAttribute("visible","true");
conf.setAttribute("animation__up","property: position; from: 0 2.4 10; to: 0 4.2 10; dur: 520; easing: easeOutQuad");
conf.setAttribute("animation__fade","property: material.opacity; from: 0.35; to: 0; dur: 520; easing: easeOutQuad");
setTimeout(()=>{ conf.setAttribute("visible","false"); conf.setAttribute("material","opacity:0.35; transparent:true"); }, 560);
log("🎉 Cheer!");
}
function rest(){
const bhp=state.hp, bmn=state.mana;
state.hp = clamp(state.hp + 40, 0, 100);
state.mana = clamp(state.mana + 40, 0, 100);
updateUI();
log(`🛏 休憩:HP ${bhp}→${state.hp} / 魔力 ${bmn}→${state.mana}`);
}
function attack(){ const dmg = (14 + Math.floor(state.level/2)) + Math.floor(Math.random()*8); damageEnemy(dmg, "通常攻撃"); updateUI(); }
function castSpell(){
if(state.mana < 18){ log("💤 魔力が足りない"); return; }
state.mana -= 18;
const dmg = 24 + Math.floor(state.level*1.2) + Math.floor(Math.random()*10);
damageEnemy(dmg, "魔法");
updateUI();
}
/* ===== モーダル ===== */
function openModal(title, body, buttons){
document.getElementById("modalTitle").textContent = title;
document.getElementById("modalBody").textContent = body;
const area = document.getElementById("modalBtns");
area.innerHTML = "";
buttons.forEach(b=>{
const div = document.createElement("div");
div.className = "btn " + (b.type || "");
div.textContent = b.label;
bindPress(div, ()=>{ try{ b.onClick(); }catch(e){} });
area.appendChild(div);
});
document.getElementById("modalBack").style.display = "flex";
}
function closeModal(){ document.getElementById("modalBack").style.display = "none"; unlockAudio(); }
document.getElementById("modalBack").addEventListener("click", (e)=>{ if(e.target && e.target.id === "modalBack") closeModal(); });
/* ===== 会話/クエスト/ショップ ===== */
function talk(){
const npcName = (state.field==="town") ? "Guide"
: (state.field==="castle") ? "Castle Knight"
: (state.field==="cave") ? "Miner"
: "Ruins Sage";
openModal(`💬 ${npcName}`, `${npcName}:\nここは ${FIELD_JP[state.field]}。\n準備ができたら戦うか、別の場所へ行け。`, [
{ label:"通常攻撃", type:"primary", onClick:()=>{ closeModal(); attack(); } },
{ label:"魔法", type:"primary", onClick:()=>{ closeModal(); castSpell(); } },
{ label:"閉じる", onClick:()=> closeModal() }
]);
}
function quest(){
openModal("📜 クエスト", "今は簡易クエスト(討伐でEXPとGを稼げ)。\n次段階で固定シナリオを増やせる。", [
{ label:"閉じる", onClick:()=> closeModal() }
]);
}
function shop(){
openModal("🛒 ショップ", "(クライアントのみ簡易)\n街でゴールドを稼いで強化できる拡張に対応。", [
{ label:"閉じる", onClick:()=> closeModal() }
]);
}
/* ===== セーブ/ロード ===== */
function saveGame(){
const data = { ...state, audioUnlocked: state.audioUnlocked };
localStorage.setItem("elder_social_vr_save", JSON.stringify(data));
log("💾 セーブ完了");
}
function loadGame(){
const raw = localStorage.getItem("elder_social_vr_save");
if(!raw){ log("📂 セーブデータがない"); return; }
try{
const data = JSON.parse(raw);
Object.assign(state, data || {});
setField(state.field || "town");
updateUI();
log("📂 ロード完了");
}catch(e){
log("❌ ロード失敗:データ破損");
}
}
/* ===== VR Enter/Exit ===== */
function enterVR(){
const scene = document.getElementById("scene");
state.inVR = true;
document.body.classList.add("vr");
document.getElementById("vrUI").setAttribute("visible","true");
document.getElementById("cam").setAttribute("position","0 1.72 0.05");
document.getElementById("heroHead").setAttribute("material","opacity:0.0; transparent:true; color:#f4d7bd");
try{ scene.enterVR(); log("🕶️ VRに入った"); }catch(e){ log("⚠️ WebXRに入れない(疑似VRで続行)"); }
}
function exitApp(){
const scene = document.getElementById("scene");
state.inVR = false;
document.body.classList.remove("vr");
document.getElementById("vrUI").setAttribute("visible","false");
document.getElementById("cam").setAttribute("position","0 1.75 3.4");
document.getElementById("heroHead").setAttribute("material","opacity:1.0; transparent:false; color:#f4d7bd");
try{ scene.exitVR(); }catch(e){}
setHUDVisible(hudVisible);
log("⏏ Exit");
}
function fieldTown(){ setField("town"); }
function fieldCastle(){ setField("castle"); }
function fieldCave(){ setField("cave"); }
function fieldRuins(){ setField("ruins"); }
/* ===== 操作 ===== */
const rig = document.getElementById("playerRig");
const hero = document.getElementById("hero");
const cam = document.getElementById("cam");
const keys = { w:false,a:false,s:false,d:false, shift:false, space:false };
let vy = 0, grounded = true;
function getYaw(){
const rot = cam.getAttribute("rotation");
return (rot && typeof rot.y === "number") ? rot.y : 0;
}
function tickMovement(dt){
const speedBase = keys.shift ? 7.2 : 4.4;
const step = (speedBase * dt) / 1000;
let moveX = 0, moveZ = 0;
if(keys.w) moveZ -= 1;
if(keys.s) moveZ += 1;
if(keys.a) moveX -= 1;
if(keys.d) moveX += 1;
const len = Math.hypot(moveX, moveZ);
if(len > 0){ moveX/=len; moveZ/=len; }
const yaw = (getYaw() * Math.PI) / 180;
const cos = Math.cos(yaw), sin = Math.sin(yaw);
const dx = (moveX * cos - moveZ * sin) * step;
const dz = (moveX * sin + moveZ * cos) * step;
const pos = rig.getAttribute("position");
let nx = pos.x + dx, nz = pos.z + dz;
const r = Math.hypot(nx, nz);
const limit = 44;
if(r > limit){ const k = limit / r; nx *= k; nz *= k; }
if(keys.space && grounded){ vy = 5.2; grounded = false; }
if(!grounded){ vy -= 12.0 * (dt/1000); }
let ny = pos.y + vy * (dt/1000);
/* ★地面は固定じゃない。今いる場所の地面へクランプ(埋まりゼロ) */
const gy = groundYAt(nx, nz);
if(ny <= gy){ ny = gy; vy = 0; grounded = true; }
rig.setAttribute("position", { x:nx, y:ny, z:nz });
if(len > 0){ hero.setAttribute("rotation", { x:0, y:getYaw(), z:0 }); }
// 敵の追従(★yも地面追従)
const enemy = document.getElementById("enemy");
const epos = enemy.getAttribute("position");
const dist = Math.hypot((epos.x - nx), (epos.z - nz));
if(dist > 18){
const ez = nz - 2.5;
enemy.setAttribute("position", { x:nx, y:enemyYAt(nx, ez), z:ez });
}
const floating = document.getElementById("floating");
if(floating){
const t = performance.now() / 1000;
floating.setAttribute("rotation", { x:0, y:(t*18)%360, z:0 });
floating.setAttribute("position", { x:0, y:2.2 + Math.sin(t*1.4)*0.12, z:-10 });
}
}
function hookThumbstick(){
const RH = document.getElementById("rightHand");
const LH = document.getElementById("leftHand");
const onMove = (e)=>{
if(!e || !e.detail) return;
const { x, y } = e.detail;
keys.w = y < -0.2; keys.s = y > 0.2; keys.a = x < -0.2; keys.d = x > 0.2;
};
RH.addEventListener("thumbstickmoved", onMove);
LH.addEventListener("thumbstickmoved", onMove);
}
window.addEventListener("keydown", (e)=>{
if(e.repeat) return;
if(e.code==="KeyW") keys.w = true;
if(e.code==="KeyA") keys.a = true;
if(e.code==="KeyS") keys.s = true;
if(e.code==="KeyD") keys.d = true;
if(e.code==="ShiftLeft" || e.code==="ShiftRight") keys.shift = true;
if(e.code==="Space") keys.space = true;
if(e.code==="KeyJ"){ unlockAudio(); attack(); }
if(e.code==="KeyK"){ unlockAudio(); castSpell(); }
if(e.code==="Escape"){ if(!state.inVR) toggleHUD(); }
});
window.addEventListener("keyup", (e)=>{
if(e.code==="KeyW") keys.w = false;
if(e.code==="KeyA") keys.a = false;
if(e.code==="KeyS") keys.s = false;
if(e.code==="KeyD") keys.d = false;
if(e.code==="ShiftLeft" || e.code==="ShiftRight") keys.shift = false;
if(e.code==="Space") keys.space = false;
});
/* ===== DOMボタン配線 ===== */
function wireButtons(){
bindPress(document.getElementById("btnTown"), ()=> setField("town"));
bindPress(document.getElementById("btnCastle"), ()=> setField("castle"));
bindPress(document.getElementById("btnCave"), ()=> setField("cave"));
bindPress(document.getElementById("btnRuins"), ()=> setField("ruins"));
bindPress(document.getElementById("btnEnterVR"), ()=> enterVR());
bindPress(document.getElementById("btnExit"), ()=> exitApp());
bindPress(document.getElementById("btnWave"), ()=> wave());
bindPress(document.getElementById("btnCheer"), ()=> cheer());
bindPress(document.getElementById("btnTalk"), ()=> talk());
bindPress(document.getElementById("btnQuest"), ()=> quest());
bindPress(document.getElementById("btnShop"), ()=> shop());
bindPress(document.getElementById("btnRest"), ()=> rest());
bindPress(document.getElementById("btnSave"), ()=> saveGame());
bindPress(document.getElementById("btnLoad"), ()=> loadGame());
bindPress(document.getElementById("btnHideUI"), ()=> setHUDVisible(false));
bindPress(document.getElementById("floatingShowUI"), ()=> setHUDVisible(true));
bindPress(document.getElementById("panel"), ()=>{
try{ cam.components["look-controls"].pointerLockEnabled = true; }catch(e){}
});
}
/* ===== 初期化 ===== */
(function init(){
wireButtons();
hookThumbstick();
const hidden = localStorage.getItem("elder_ui_hidden") === "1";
setHUDVisible(!hidden);
/* ★まず“基準0.55で置いた物”を全てスナップ(埋まり解消) */
snapAll();
/* ★プレイヤーは「その場の地面」に強制一致(埋まりゼロ) */
const p0 = rig.getAttribute("position");
if(p0 && typeof p0.x==="number" && typeof p0.z==="number"){
rig.setAttribute("position", { x:p0.x, y:groundYAt(p0.x, p0.z), z:p0.z });
}
updateUI();
setBGMByField();
spawnEnemy();
log("起動。島の段差に合わせて player/NPC/建物を自動補正(埋まりゼロ)。UIは右上で閉じられる。ESCでも切替。");
let last = performance.now();
function loop(now){
const dt = now - last;
last = now;
tickMovement(dt);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
const scene = document.getElementById("scene");
scene.addEventListener("enter-vr", ()=>{
state.inVR = true;
document.body.classList.add("vr");
document.getElementById("vrUI").setAttribute("visible","true");
document.getElementById("cam").setAttribute("position","0 1.72 0.05");
document.getElementById("heroHead").setAttribute("material","opacity:0.0; transparent:true; color:#f4d7bd");
log("🕶️ WebXR: enter-vr");
});
scene.addEventListener("exit-vr", ()=>{
state.inVR = false;
document.body.classList.remove("vr");
document.getElementById("vrUI").setAttribute("visible","false");
document.getElementById("cam").setAttribute("position","0 1.75 3.4");
document.getElementById("heroHead").setAttribute("material","opacity:1.0; transparent:false; color:#f4d7bd");
setHUDVisible(hudVisible);
log("⏏ WebXR: exit-vr");
});
})();
</script>
</body>
</html>
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>GameWorks — ゲーム作品投稿</title>
<meta name="description" content="ゲーム作品を投稿・管理できるローカル保存型ポートフォリオサイト" />
<style>
:root{
--bg:#0b0d12; --panel:#111520; --panel2:#0f1320; --card:#0f1524;
--text:#e9edf7; --muted:#aab3c7; --line:#1f2a44;
--accent:#7c5cff; --accent2:#22c55e; --warn:#f59e0b; --danger:#ef4444;
--shadow: 0 12px 30px rgba(0,0,0,.35);
--r: 18px;
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--sans: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji","Segoe UI Emoji";
}
*{box-sizing:border-box}
html,body{height:100%}
body{
margin:0;
background:
radial-gradient(900px 600px at 20% -10%, rgba(124,92,255,.25), transparent 60%),
radial-gradient(900px 600px at 85% 0%, rgba(34,197,94,.18), transparent 60%),
var(--bg);
color:var(--text);
font-family:var(--sans);
letter-spacing:.2px;
}
a{color:inherit}
.app{
min-height:100%;
display:grid;
grid-template-columns: 360px 1fr;
gap:16px;
padding:16px;
max-width:1400px;
margin:0 auto;
}
@media (max-width: 980px){
.app{grid-template-columns: 1fr; }
}
header{
grid-column: 1 / -1;
display:flex;
align-items:center;
justify-content:space-between;
gap:12px;
padding:14px 16px;
border:1px solid var(--line);
border-radius: var(--r);
background: linear-gradient(180deg, rgba(17,21,32,.9), rgba(15,19,32,.75));
box-shadow: var(--shadow);
position:sticky;
top:16px;
z-index:10;
backdrop-filter: blur(10px);
}
.brand{
display:flex; align-items:center; gap:12px;
}
.logo{
width:38px; height:38px; border-radius:14px;
background: conic-gradient(from 180deg, var(--accent), #3b82f6, var(--accent2), var(--accent));
box-shadow: 0 10px 20px rgba(124,92,255,.25);
}
.brand h1{font-size:16px; margin:0}
.brand p{margin:2px 0 0 0; color:var(--muted); font-size:12px}
.headerActions{display:flex; align-items:center; gap:10px; flex-wrap:wrap; justify-content:flex-end}
.chip{
display:inline-flex; align-items:center; gap:8px;
padding:8px 10px;
border:1px solid var(--line);
border-radius: 999px;
color:var(--muted);
background: rgba(10,12,18,.35);
font-size:12px;
user-select:none;
}
.btn{
appearance:none; border:1px solid var(--line);
background: rgba(15,19,32,.85);
color:var(--text);
padding:10px 12px;
border-radius: 12px;
cursor:pointer;
font-weight:600;
transition: transform .08s ease, border-color .2s ease, background .2s ease, opacity .2s ease;
}
.btn:hover{border-color: rgba(124,92,255,.55)}
.btn:active{transform: translateY(1px)}
.btn.primary{
border-color: rgba(124,92,255,.6);
background: linear-gradient(180deg, rgba(124,92,255,.35), rgba(124,92,255,.15));
}
.btn.good{
border-color: rgba(34,197,94,.55);
background: linear-gradient(180deg, rgba(34,197,94,.25), rgba(34,197,94,.10));
}
.btn.danger{
border-color: rgba(239,68,68,.6);
background: linear-gradient(180deg, rgba(239,68,68,.25), rgba(239,68,68,.10));
}
.btn.ghost{background: transparent}
.btn.small{padding:8px 10px; border-radius: 10px; font-size:12px}
.btn:disabled{opacity:.55; cursor:not-allowed}
.panel{
border:1px solid var(--line);
border-radius: var(--r);
background: linear-gradient(180deg, rgba(17,21,32,.92), rgba(15,19,32,.78));
box-shadow: var(--shadow);
overflow:hidden;
}
.panelHeader{
padding:14px 16px;
border-bottom:1px solid var(--line);
display:flex; align-items:center; justify-content:space-between; gap:10px;
}
.panelHeader h2{font-size:14px; margin:0}
.panelHeader .hint{font-size:12px; color:var(--muted)}
.panelBody{padding:14px 16px}
.field{display:flex; flex-direction:column; gap:6px; margin-bottom:12px}
.field label{font-size:12px; color:var(--muted)}
.row{display:grid; grid-template-columns: 1fr 1fr; gap:10px}
@media (max-width: 980px){ .row{grid-template-columns: 1fr} }
input, select, textarea{
width:100%;
padding:10px 12px;
border-radius: 12px;
border:1px solid var(--line);
background: rgba(10,12,18,.35);
color:var(--text);
outline:none;
}
textarea{min-height:110px; resize:vertical; line-height:1.5}
input:focus, select:focus, textarea:focus{border-color: rgba(124,92,255,.55)}
.help{font-size:11px; color:var(--muted); line-height:1.5}
.divider{height:1px; background: var(--line); margin:14px 0}
.toolbar{
display:flex; gap:10px; align-items:center; flex-wrap:wrap;
padding:12px 16px;
border-bottom:1px solid var(--line);
background: rgba(10,12,18,.18);
}
.toolbar input, .toolbar select{
padding:10px 12px; border-radius: 999px;
min-width: 200px;
}
.toolbar .grow{flex:1}
.stats{
display:flex; gap:8px; flex-wrap:wrap;
padding:0 16px 14px 16px;
color:var(--muted);
font-size:12px;
}
.grid{
padding:16px;
display:grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap:14px;
}
@media (max-width: 1200px){ .grid{grid-template-columns: repeat(2, minmax(0, 1fr));} }
@media (max-width: 680px){ .grid{grid-template-columns: 1fr;} }
.card{
border:1px solid var(--line);
border-radius: 18px;
overflow:hidden;
background: linear-gradient(180deg, rgba(15,21,36,.95), rgba(12,16,28,.88));
box-shadow: 0 10px 25px rgba(0,0,0,.22);
display:flex; flex-direction:column;
min-height: 260px;
}
.thumb{
height:160px;
background: radial-gradient(1200px 260px at 10% 0%, rgba(124,92,255,.18), transparent 60%),
radial-gradient(1200px 260px at 80% 0%, rgba(34,197,94,.12), transparent 60%),
rgba(10,12,18,.25);
border-bottom:1px solid var(--line);
display:flex; align-items:center; justify-content:center;
position:relative;
overflow:hidden;
}
.thumb img{
width:100%; height:100%;
object-fit:cover;
display:block;
filter:saturate(1.02) contrast(1.02);
}
.badgeRow{
position:absolute; left:10px; top:10px;
display:flex; gap:8px; flex-wrap:wrap;
}
.badge{
font-size:11px; color:var(--text);
padding:6px 9px;
border-radius: 999px;
border:1px solid rgba(255,255,255,.12);
background: rgba(0,0,0,.35);
backdrop-filter: blur(8px);
}
.badge.good{border-color: rgba(34,197,94,.35)}
.badge.warn{border-color: rgba(245,158,11,.35)}
.badge.muted{color:var(--muted)}
.cardBody{padding:12px 12px 10px 12px; display:flex; flex-direction:column; gap:8px; flex:1}
.titleRow{display:flex; align-items:flex-start; justify-content:space-between; gap:10px}
.card h3{margin:0; font-size:15px; line-height:1.25}
.tagline{margin:0; color:var(--muted); font-size:12px; line-height:1.45}
.meta{
display:flex; gap:8px; flex-wrap:wrap;
color:var(--muted); font-size:12px;
}
.pill{
border:1px solid var(--line);
background: rgba(10,12,18,.25);
padding:6px 9px; border-radius:999px;
font-size:12px;
}
.cardFooter{
padding:10px 12px 12px 12px;
display:flex; align-items:center; justify-content:space-between; gap:10px;
border-top:1px solid var(--line);
background: rgba(10,12,18,.18);
}
.actions{display:flex; gap:8px; flex-wrap:wrap}
.muted{color:var(--muted)}
.mono{font-family:var(--mono)}
.empty{
padding:28px 16px 40px 16px;
text-align:center;
color:var(--muted);
}
dialog{
border:none;
border-radius: 18px;
padding:0;
width:min(920px, calc(100vw - 24px));
background: rgba(15,19,32,.96);
color:var(--text);
box-shadow: 0 30px 80px rgba(0,0,0,.55);
}
dialog::backdrop{background: rgba(0,0,0,.6)}
.modalHeader{
padding:14px 16px;
border-bottom:1px solid var(--line);
display:flex; align-items:center; justify-content:space-between; gap:10px;
}
.modalHeader h3{margin:0; font-size:14px}
.modalBody{padding:14px 16px}
.modalGrid{
display:grid; grid-template-columns: 1.2fr .8fr; gap:14px;
}
@media (max-width: 900px){ .modalGrid{grid-template-columns: 1fr} }
.gallery{
display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap:10px;
}
@media (max-width: 680px){ .gallery{grid-template-columns: repeat(2, minmax(0, 1fr));} }
.gimg{
border:1px solid var(--line);
border-radius: 14px;
overflow:hidden;
background: rgba(10,12,18,.25);
aspect-ratio: 16 / 10;
display:flex; align-items:center; justify-content:center;
}
.gimg img{width:100%; height:100%; object-fit:cover}
.kvs{display:grid; grid-template-columns: 110px 1fr; gap:8px; align-items:start; font-size:12px; color:var(--muted)}
.kvs b{color:var(--text); font-weight:700}
.note{
padding:10px 12px;
border:1px solid var(--line);
border-radius: 14px;
background: rgba(10,12,18,.25);
color:var(--muted);
font-size:12px;
line-height:1.55;
}
.toast{
position: fixed;
left:50%;
bottom:16px;
transform: translateX(-50%);
background: rgba(15,19,32,.95);
border: 1px solid var(--line);
border-radius: 999px;
padding: 10px 14px;
color: var(--text);
box-shadow: var(--shadow);
opacity:0;
pointer-events:none;
transition: opacity .2s ease, transform .2s ease;
display:flex; gap:8px; align-items:center;
z-index:100;
max-width: calc(100vw - 24px);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.toast.show{opacity:1; transform: translateX(-50%) translateY(-2px)}
.smallLink{
color: var(--muted);
font-size:12px;
text-decoration:none;
border-bottom:1px dashed rgba(170,179,199,.35);
}
.smallLink:hover{color: var(--text); border-bottom-color: rgba(233,237,247,.55)}
.dangerText{color: #ffb4b4}
.goodText{color: #b5ffd1}
.warnText{color: #ffe2b0}
</style>
</head>
<body>
<div class="app">
<header>
<div class="brand">
<div class="logo" aria-hidden="true"></div>
<div>
<h1>GameWorks <span class="muted">— ゲーム作品投稿</span></h1>
<p>サーバー不要 / ブラウザに保存 / 作品ポートフォリオを即作る</p>
</div>
</div>
<div class="headerActions">
<span class="chip" id="chipCount">作品: 0</span>
<button class="btn small" id="btnSeed" type="button">サンプル追加</button>
<button class="btn small" id="btnExport" type="button">エクスポート</button>
<button class="btn small" id="btnImport" type="button">インポート</button>
<button class="btn small danger" id="btnWipe" type="button">全削除</button>
</div>
</header>
<!-- Left: 投稿フォーム -->
<section class="panel" aria-label="投稿フォーム">
<div class="panelHeader">
<div>
<h2 id="formTitle">新規投稿</h2>
<div class="hint" id="formHint">作品情報を入力して保存</div>
</div>
<button class="btn small ghost" id="btnResetForm" type="button">リセット</button>
</div>
<div class="panelBody">
<div class="field">
<label for="author">投稿者名(任意)</label>
<input id="author" type="text" maxlength="40" placeholder="例:Yuhei" />
<div class="help">同じブラウザ内だけの表示です。</div>
</div>
<div class="field">
<label for="title">作品タイトル *</label>
<input id="title" type="text" maxlength="60" placeholder="例:Elder Chronicle VR" required />
</div>
<div class="field">
<label for="tagline">ひとこと(キャッチコピー)</label>
<input id="tagline" type="text" maxlength="80" placeholder="例:探索と戦闘が気持ちいいVRアクションRPG" />
</div>
<div class="row">
<div class="field">
<label for="engine">使用エンジン</label>
<select id="engine">
<option value="">未設定</option>
<option>Unity</option>
<option>Unreal Engine</option>
<option>Godot</option>
<option>RPGツクール</option>
<option>自作</option>
<option>その他</option>
</select>
</div>
<div class="field">
<label for="status">状態</label>
<select id="status">
<option>開発中</option>
<option>体験版あり</option>
<option>公開中</option>
<option>凍結</option>
</select>
</div>
</div>
<div class="row">
<div class="field">
<label for="platform">対応プラットフォーム</label>
<input id="platform" type="text" maxlength="60" placeholder="例:PC / Web / Quest / Android" />
</div>
<div class="field">
<label for="genre">ジャンル</label>
<input id="genre" type="text" maxlength="60" placeholder="例:アクション / VR / サバイバル" />
</div>
</div>
<div class="field">
<label for="tags">タグ(カンマ区切り)</label>
<input id="tags" type="text" maxlength="120" placeholder="例:VR, 探索, ダンジョン, ボス戦" />
</div>
<div class="field">
<label for="desc">説明 *</label>
<textarea id="desc" maxlength="2000" placeholder="作品の魅力、遊び方、特徴、今後の予定など"></textarea>
<div class="help">最大2000文字。長い場合は要点→詳細の順で書くと強い。</div>
</div>
<div class="row">
<div class="field">
<label for="linkPlay">プレイURL / 公開ページ</label>
<input id="linkPlay" type="url" placeholder="https://..." />
</div>
<div class="field">
<label for="linkRepo">GitHub / リポジトリ</label>
<input id="linkRepo" type="url" placeholder="https://github.com/..." />
</div>
</div>
<div class="row">
<div class="field">
<label for="linkVideo">動画URL(YouTubeなど)</label>
<input id="linkVideo" type="url" placeholder="https://www.youtube.com/watch?v=..." />
</div>
<div class="field">
<label for="rating">自己評価(1〜5)</label>
<select id="rating">
<option value="0">未設定</option>
<option value="1">★1</option>
<option value="2">★2</option>
<option value="3">★3</option>
<option value="4">★4</option>
<option value="5">★5</option>
</select>
</div>
</div>
<div class="field">
<label for="shots">スクリーンショット(複数可 / 自動でデータ化して保存)</label>
<input id="shots" type="file" accept="image/*" multiple />
<div class="help">
画像はブラウザ内に保存されます(容量が大きいと重くなります)。<br/>
目安:1枚 500KB〜1MB程度に圧縮すると快適。
</div>
</div>
<div class="divider"></div>
<div style="display:flex; gap:10px; flex-wrap:wrap; align-items:center">
<button class="btn primary" id="btnSave" type="button">保存</button>
<button class="btn" id="btnDraft" type="button">下書き保存</button>
<span class="help" id="saveHelp">* 必須:タイトル / 説明</span>
</div>
<div class="divider"></div>
<div class="note">
<b>保存方式:</b>このページは <span class="mono">localStorage</span> に保存します。<br/>
つまり「あなたのブラウザ内だけ」に残ります。公開サイトで運用したいなら、次はサーバー保存(PHP/DB)に切り替える。
</div>
</div>
</section>
<!-- Right: 一覧 -->
<main class="panel" aria-label="作品一覧">
<div class="toolbar">
<input class="grow" id="q" type="search" placeholder="検索:タイトル/説明/タグ/エンジン/ジャンル..." />
<select id="filterStatus" title="状態">
<option value="">状態:すべて</option>
<option>開発中</option>
<option>体験版あり</option>
<option>公開中</option>
<option>凍結</option>
</select>
<select id="sort" title="並び替え">
<option value="new">新しい順</option>
<option value="old">古い順</option>
<option value="likes">いいね順</option>
<option value="rating">評価順</option>
<option value="title">タイトル順</option>
</select>
<button class="btn small good" id="btnNew" type="button">+ 新規投稿</button>
</div>
<div class="stats" id="stats"></div>
<div id="list" class="grid" aria-live="polite"></div>
<div id="empty" class="empty" hidden>
まだ作品がありません。左のフォームから投稿して、あなたの作品集を完成させよう。
</div>
</main>
</div>
<!-- 詳細モーダル -->
<dialog id="modal">
<div class="modalHeader">
<h3 id="mTitle">詳細</h3>
<div style="display:flex; gap:8px; align-items:center">
<button class="btn small" id="mEdit" type="button">編集</button>
<button class="btn small" id="mLike" type="button">いいね</button>
<button class="btn small danger" id="mDelete" type="button">削除</button>
<button class="btn small ghost" id="mClose" type="button">閉じる</button>
</div>
</div>
<div class="modalBody">
<div class="modalGrid">
<div>
<div class="note" id="mTagline"></div>
<div class="divider"></div>
<div class="gallery" id="mGallery"></div>
<div class="divider"></div>
<div class="note" id="mDesc"></div>
<div class="divider"></div>
<div class="panel" style="background: rgba(10,12,18,.12); box-shadow:none">
<div class="panelHeader" style="border-bottom:1px solid var(--line)">
<div>
<h2 style="margin:0;font-size:14px">コメント</h2>
<div class="hint">ローカル保存(このブラウザだけ)</div>
</div>
</div>
<div class="panelBody">
<div class="field">
<label for="cName">名前(任意)</label>
<input id="cName" type="text" maxlength="40" placeholder="例:Anonymous" />
</div>
<div class="field">
<label for="cText">コメント</label>
<textarea id="cText" maxlength="400" placeholder="感想 / フィードバック"></textarea>
<div class="help">最大400文字</div>
</div>
<div style="display:flex; gap:10px; align-items:center; flex-wrap:wrap">
<button class="btn good" id="cAdd" type="button">コメント追加</button>
<span class="help" id="cHint"></span>
</div>
<div class="divider"></div>
<div id="cList" style="display:flex;flex-direction:column;gap:10px"></div>
</div>
</div>
</div>
<div>
<div class="note">
<div class="kvs" id="mMeta"></div>
</div>
<div class="divider"></div>
<div class="note" id="mLinks"></div>
<div class="divider"></div>
<div class="note" id="mSystem"></div>
</div>
</div>
</div>
</dialog>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<input id="importFile" type="file" accept="application/json" hidden />
<script>
"use strict";
// ====== Storage Keys ======
const KEY = "gameworks_posts_v1";
const KEY_DRAFT = "gameworks_draft_v1";
// ====== DOM ======
const el = (id) => document.getElementById(id);
const chipCount = el("chipCount");
const listEl = el("list");
const emptyEl = el("empty");
const statsEl = el("stats");
const qEl = el("q");
const filterStatusEl = el("filterStatus");
const sortEl = el("sort");
const formTitleEl = el("formTitle");
const formHintEl = el("formHint");
// form fields
const authorEl = el("author");
const titleEl = el("title");
const taglineEl = el("tagline");
const engineEl = el("engine");
const statusEl = el("status");
const platformEl = el("platform");
const genreEl = el("genre");
const tagsEl = el("tags");
const descEl = el("desc");
const linkPlayEl = el("linkPlay");
const linkRepoEl = el("linkRepo");
const linkVideoEl = el("linkVideo");
const ratingEl = el("rating");
const shotsEl = el("shots");
const btnSave = el("btnSave");
const btnDraft = el("btnDraft");
const btnResetForm = el("btnResetForm");
const btnSeed = el("btnSeed");
const btnExport = el("btnExport");
const btnImport = el("btnImport");
const btnWipe = el("btnWipe");
const btnNew = el("btnNew");
const toastEl = el("toast");
const importFileEl = el("importFile");
// modal
const modal = el("modal");
const mTitle = el("mTitle");
const mTagline = el("mTagline");
const mGallery = el("mGallery");
const mDesc = el("mDesc");
const mMeta = el("mMeta");
const mLinks = el("mLinks");
const mSystem = el("mSystem");
const mEdit = el("mEdit");
const mLike = el("mLike");
const mDelete = el("mDelete");
const mClose = el("mClose");
// comments
const cName = el("cName");
const cText = el("cText");
const cAdd = el("cAdd");
const cHint = el("cHint");
const cList = el("cList");
// ====== State ======
let posts = loadPosts();
let editingId = null;
let pendingImages = []; // base64 array for current form
let currentModalId = null;
// ====== Utils ======
const nowISO = () => new Date().toISOString();
const uid = () => "p_" + Math.random().toString(16).slice(2) + Date.now().toString(16);
function toast(msg){
toastEl.textContent = msg;
toastEl.classList.add("show");
clearTimeout(toastEl._t);
toastEl._t = setTimeout(()=> toastEl.classList.remove("show"), 1700);
}
function safeText(str){
// basic escape for text rendering into HTML
return String(str ?? "")
.replaceAll("&","&")
.replaceAll("<","<")
.replaceAll(">",">")
.replaceAll('"',""")
.replaceAll("'","'");
}
function normalizeTags(input){
return String(input ?? "")
.split(",")
.map(s => s.trim())
.filter(Boolean)
.slice(0, 20);
}
function stars(n){
const v = Number(n || 0);
if(!v) return "未設定";
return "★".repeat(v) + "☆".repeat(5 - v);
}
function fmtDate(iso){
try{
const d = new Date(iso);
const y = d.getFullYear();
const m = String(d.getMonth()+1).padStart(2,"0");
const dd = String(d.getDate()).padStart(2,"0");
const hh = String(d.getHours()).padStart(2,"0");
const mm = String(d.getMinutes()).padStart(2,"0");
return `${y}/${m}/${dd} ${hh}:${mm}`;
}catch{
return String(iso);
}
}
function loadPosts(){
try{
const raw = localStorage.getItem(KEY);
const arr = raw ? JSON.parse(raw) : [];
return Array.isArray(arr) ? arr : [];
}catch{
return [];
}
}
function savePosts(){
localStorage.setItem(KEY, JSON.stringify(posts));
chipCount.textContent = `作品: ${posts.length}`;
}
function loadDraft(){
try{
const raw = localStorage.getItem(KEY_DRAFT);
return raw ? JSON.parse(raw) : null;
}catch{
return null;
}
}
function saveDraft(draft){
localStorage.setItem(KEY_DRAFT, JSON.stringify(draft));
}
function clearDraft(){
localStorage.removeItem(KEY_DRAFT);
}
function bytesApprox(){
// rough localStorage usage for our key
const raw = localStorage.getItem(KEY) || "";
return raw.length;
}
function buildStats(filteredCount){
const likeSum = posts.reduce((a,p)=> a + (p.likes||0), 0);
const bytes = bytesApprox();
const mb = (bytes / (1024*1024)).toFixed(2);
statsEl.innerHTML = `
<span class="chip">表示: <b>${filteredCount}</b></span>
<span class="chip">総いいね: <b>${likeSum}</b></span>
<span class="chip">保存サイズ目安: <b>${mb} MB</b></span>
`;
}
// ====== Image handling (compress to JPEG) ======
async function fileToDataUrlCompressed(file, maxW=1280, quality=0.82){
const img = await new Promise((res, rej)=>{
const i = new Image();
i.onload = ()=> res(i);
i.onerror = rej;
i.src = URL.createObjectURL(file);
});
const scale = Math.min(1, maxW / img.width);
const w = Math.round(img.width * scale);
const h = Math.round(img.height * scale);
const canvas = document.createElement("canvas");
canvas.width = w; canvas.height = h;
const ctx = canvas.getContext("2d");
ctx.drawImage(img, 0, 0, w, h);
URL.revokeObjectURL(img.src);
return canvas.toDataURL("image/jpeg", quality);
}
shotsEl.addEventListener("change", async () => {
const files = Array.from(shotsEl.files || []);
if(!files.length) return;
toast("画像を処理中…");
const max = 9;
const slice = files.slice(0, max);
const out = [];
for(const f of slice){
if(!f.type.startsWith("image/")) continue;
try{
const d = await fileToDataUrlCompressed(f);
out.push(d);
}catch{}
}
pendingImages = out;
toast(`スクショ ${pendingImages.length}枚 準備OK`);
});
// ====== Render ======
function getFiltered(){
const q = (qEl.value || "").trim().toLowerCase();
const st = filterStatusEl.value || "";
let arr = [...posts];
if(q){
arr = arr.filter(p=>{
const blob = [
p.title, p.tagline, p.desc, p.engine, p.platform, p.genre,
...(p.tags||[]),
].join(" ").toLowerCase();
return blob.includes(q);
});
}
if(st){
arr = arr.filter(p => (p.status || "") === st);
}
// sort
const s = sortEl.value;
arr.sort((a,b)=>{
if(s === "new") return (b.createdAt||"").localeCompare(a.createdAt||"");
if(s === "old") return (a.createdAt||"").localeCompare(b.createdAt||"");
if(s === "likes") return (b.likes||0) - (a.likes||0);
if(s === "rating") return (Number(b.rating||0) - Number(a.rating||0)) || (b.likes||0)-(a.likes||0);
if(s === "title") return (a.title||"").localeCompare(b.title||"", "ja");
return 0;
});
return arr;
}
function statusBadgeClass(status){
if(status === "公開中") return "good";
if(status === "体験版あり") return "warn";
return "";
}
function render(){
savePosts(); // also updates chipCount
const arr = getFiltered();
buildStats(arr.length);
listEl.innerHTML = "";
emptyEl.hidden = arr.length !== 0;
for(const p of arr){
const cover = (p.images && p.images[0]) ? `<img alt="cover" src="${p.images[0]}">` : "";
const tags = (p.tags||[]).slice(0,3).map(t=>`<span class="pill">#${safeText(t)}</span>`).join("");
const engine = p.engine ? `<span class="pill">${safeText(p.engine)}</span>` : "";
const genre = p.genre ? `<span class="pill">${safeText(p.genre)}</span>` : "";
const rating = Number(p.rating||0) ? `<span class="pill">${safeText(stars(p.rating))}</span>` : "";
const card = document.createElement("article");
card.className = "card";
card.innerHTML = `
<div class="thumb">
${cover || `<div class="muted">No Image</div>`}
<div class="badgeRow">
<span class="badge ${statusBadgeClass(p.status)}">${safeText(p.status || "未設定")}</span>
${p.platform ? `<span class="badge muted">${safeText(p.platform)}</span>` : ""}
</div>
</div>
<div class="cardBody">
<div class="titleRow">
<div style="min-width:0">
<h3 title="${safeText(p.title)}">${safeText(p.title)}</h3>
<p class="tagline">${safeText(p.tagline || "—")}</p>
</div>
<div class="muted" style="font-size:12px; white-space:nowrap">
❤ ${p.likes||0}
</div>
</div>
<div class="meta">
${engine}
${genre}
${rating}
${tags}
</div>
<div class="muted" style="font-size:12px; line-height:1.55; display:-webkit-box; -webkit-line-clamp:3; -webkit-box-orient:vertical; overflow:hidden">
${safeText(p.desc || "")}
</div>
</div>
<div class="cardFooter">
<div class="muted" style="font-size:12px">更新: ${safeText(fmtDate(p.updatedAt || p.createdAt))}</div>
<div class="actions">
<button class="btn small" data-act="open" data-id="${p.id}">詳細</button>
<button class="btn small good" data-act="like" data-id="${p.id}">いいね</button>
</div>
</div>
`;
listEl.appendChild(card);
}
}
listEl.addEventListener("click", (e)=>{
const btn = e.target.closest("button");
if(!btn) return;
const id = btn.getAttribute("data-id");
const act = btn.getAttribute("data-act");
if(!id || !act) return;
if(act === "open") openModal(id);
if(act === "like") { likePost(id); render(); }
});
// ====== CRUD ======
function validateForm(){
const title = titleEl.value.trim();
const desc = descEl.value.trim();
if(!title || !desc){
toast("タイトルと説明は必須");
return false;
}
return true;
}
function readFormAsPost(){
const tags = normalizeTags(tagsEl.value);
return {
id: editingId || uid(),
author: authorEl.value.trim(),
title: titleEl.value.trim(),
tagline: taglineEl.value.trim(),
engine: engineEl.value,
status: statusEl.value,
platform: platformEl.value.trim(),
genre: genreEl.value.trim(),
tags,
desc: descEl.value.trim(),
linkPlay: linkPlayEl.value.trim(),
linkRepo: linkRepoEl.value.trim(),
linkVideo: linkVideoEl.value.trim(),
rating: Number(ratingEl.value || 0),
images: (pendingImages && pendingImages.length) ? pendingImages : [],
likes: 0,
comments: [],
createdAt: nowISO(),
updatedAt: nowISO()
};
}
function resetForm(keepDraft=false){
editingId = null;
pendingImages = [];
if(!keepDraft){
authorEl.value = "";
titleEl.value = "";
taglineEl.value = "";
engineEl.value = "";
statusEl.value = "開発中";
platformEl.value = "";
genreEl.value = "";
tagsEl.value = "";
descEl.value = "";
linkPlayEl.value = "";
linkRepoEl.value = "";
linkVideoEl.value = "";
ratingEl.value = "0";
shotsEl.value = "";
}
formTitleEl.textContent = "新規投稿";
formHintEl.textContent = "作品情報を入力して保存";
btnSave.textContent = "保存";
}
function setFormFromPost(p){
editingId = p.id;
pendingImages = Array.isArray(p.images) ? [...p.images] : [];
authorEl.value = p.author || "";
titleEl.value = p.title || "";
taglineEl.value = p.tagline || "";
engineEl.value = p.engine || "";
statusEl.value = p.status || "開発中";
platformEl.value = p.platform || "";
genreEl.value = p.genre || "";
tagsEl.value = (p.tags || []).join(", ");
descEl.value = p.desc || "";
linkPlayEl.value = p.linkPlay || "";
linkRepoEl.value = p.linkRepo || "";
linkVideoEl.value = p.linkVideo || "";
ratingEl.value = String(p.rating || 0);
shotsEl.value = "";
formTitleEl.textContent = "編集";
formHintEl.textContent = "内容を更新して保存";
btnSave.textContent = "更新";
toast("編集モード");
window.scrollTo({top:0, behavior:"smooth"});
}
function upsertPost(p){
const idx = posts.findIndex(x=>x.id===p.id);
if(idx >= 0){
const prev = posts[idx];
posts[idx] = {
...prev,
...p,
likes: prev.likes || 0,
comments: Array.isArray(prev.comments) ? prev.comments : [],
createdAt: prev.createdAt || nowISO(),
updatedAt: nowISO()
};
}else{
posts.unshift(p);
}
savePosts();
}
btnSave.addEventListener("click", ()=>{
if(!validateForm()) return;
const p = readFormAsPost();
// if editing, keep previous likes/comments/createdAt
if(editingId){
const prev = posts.find(x=>x.id===editingId);
if(prev){
p.likes = prev.likes || 0;
p.comments = Array.isArray(prev.comments) ? prev.comments : [];
p.createdAt = prev.createdAt || nowISO();
p.updatedAt = nowISO();
// if user did not reselect images, keep old images
if(!pendingImages.length && Array.isArray(prev.images)) p.images = prev.images;
}
}
upsertPost(p);
clearDraft();
resetForm();
render();
toast(editingId ? "更新した" : "保存した");
});
btnDraft.addEventListener("click", ()=>{
const draft = {
author: authorEl.value,
title: titleEl.value,
tagline: taglineEl.value,
engine: engineEl.value,
status: statusEl.value,
platform: platformEl.value,
genre: genreEl.value,
tags: tagsEl.value,
desc: descEl.value,
linkPlay: linkPlayEl.value,
linkRepo: linkRepoEl.value,
linkVideo: linkVideoEl.value,
rating: ratingEl.value,
images: pendingImages
};
saveDraft(draft);
toast("下書きを保存した");
});
btnResetForm.addEventListener("click", ()=>{
resetForm();
toast("フォームをリセット");
});
btnNew.addEventListener("click", ()=>{
resetForm();
toast("新規投稿モード");
window.scrollTo({top:0, behavior:"smooth"});
});
// ====== Like / Delete ======
function likePost(id){
const p = posts.find(x=>x.id===id);
if(!p) return;
p.likes = (p.likes||0) + 1;
p.updatedAt = nowISO();
savePosts();
toast("いいね +1");
if(currentModalId === id) refreshModal();
}
function deletePost(id){
const idx = posts.findIndex(x=>x.id===id);
if(idx < 0) return;
posts.splice(idx, 1);
savePosts();
toast("削除した");
}
// ====== Modal ======
function openModal(id){
const p = posts.find(x=>x.id===id);
if(!p) return;
currentModalId = id;
// count view? (optional)
modal.showModal();
refreshModal();
}
function refreshModal(){
const p = posts.find(x=>x.id===currentModalId);
if(!p) return;
mTitle.textContent = p.title || "詳細";
mTagline.innerHTML = `<b>${safeText(p.tagline || "—")}</b><br><span class="muted">❤ ${p.likes||0} / 評価: ${safeText(stars(p.rating))}</span>`;
// gallery
mGallery.innerHTML = "";
const imgs = Array.isArray(p.images) ? p.images : [];
if(imgs.length){
for(const src of imgs){
const div = document.createElement("div");
div.className = "gimg";
div.innerHTML = `<img alt="screenshot" src="${src}">`;
mGallery.appendChild(div);
}
}else{
mGallery.innerHTML = `<div class="note">スクショなし</div>`;
}
mDesc.innerHTML = safeText(p.desc || "").replaceAll("\n","<br>");
// meta
const t = (p.tags||[]).map(x=>`#${x}`).join(" ");
mMeta.innerHTML = `
<b>投稿者</b><div>${safeText(p.author || "—")}</div>
<b>状態</b><div>${safeText(p.status || "—")}</div>
<b>エンジン</b><div>${safeText(p.engine || "—")}</div>
<b>プラットフォーム</b><div>${safeText(p.platform || "—")}</div>
<b>ジャンル</b><div>${safeText(p.genre || "—")}</div>
<b>タグ</b><div>${safeText(t || "—")}</div>
<b>作成</b><div>${safeText(fmtDate(p.createdAt))}</div>
<b>更新</b><div>${safeText(fmtDate(p.updatedAt || p.createdAt))}</div>
`;
// links
const links = [];
if(p.linkPlay) links.push(`<a class="smallLink" href="${safeText(p.linkPlay)}" target="_blank" rel="noopener">▶ 公開/プレイページ</a>`);
if(p.linkRepo) links.push(`<a class="smallLink" href="${safeText(p.linkRepo)}" target="_blank" rel="noopener">⌂ リポジトリ</a>`);
if(p.linkVideo) links.push(`<a class="smallLink" href="${safeText(p.linkVideo)}" target="_blank" rel="noopener">🎬 動画</a>`);
mLinks.innerHTML = links.length ? links.join("<br>") : `<span class="muted">リンクなし</span>`;
mSystem.innerHTML = `
<b>操作</b><br>
・編集で左フォームに読み込み<br>
・いいねはローカルカウント<br>
・削除は取り消し不可
`;
// buttons
mLike.textContent = `いいね (${p.likes||0})`;
renderComments(p);
}
mClose.addEventListener("click", ()=> modal.close());
modal.addEventListener("click", (e)=>{
const rect = modal.getBoundingClientRect();
const inDialog = (
rect.top <= e.clientY && e.clientY <= rect.top + rect.height &&
rect.left <= e.clientX && e.clientX <= rect.left + rect.width
);
if(!inDialog) modal.close();
});
mLike.addEventListener("click", ()=>{
if(!currentModalId) return;
likePost(currentModalId);
render();
});
mDelete.addEventListener("click", ()=>{
if(!currentModalId) return;
const p = posts.find(x=>x.id===currentModalId);
if(!p) return;
const ok = confirm(`「${p.title}」を削除します。よろしいですか?`);
if(!ok) return;
deletePost(currentModalId);
modal.close();
currentModalId = null;
render();
});
mEdit.addEventListener("click", ()=>{
const p = posts.find(x=>x.id===currentModalId);
if(!p) return;
setFormFromPost(p);
modal.close();
});
// ====== Comments ======
function renderComments(p){
const arr = Array.isArray(p.comments) ? p.comments : [];
cHint.textContent = `コメント数: ${arr.length}`;
cList.innerHTML = "";
if(!arr.length){
cList.innerHTML = `<div class="note">まだコメントはありません。</div>`;
return;
}
for(const c of arr.slice().reverse()){
const box = document.createElement("div");
box.className = "note";
box.innerHTML = `
<b>${safeText(c.name || "Anonymous")}</b>
<span class="muted"> / ${safeText(fmtDate(c.at))}</span><br>
${safeText(c.text || "").replaceAll("\n","<br>")}
`;
cList.appendChild(box);
}
}
cAdd.addEventListener("click", ()=>{
const p = posts.find(x=>x.id===currentModalId);
if(!p) return;
const text = (cText.value || "").trim();
if(!text){
toast("コメントを入力");
return;
}
const name = (cName.value || "").trim();
p.comments = Array.isArray(p.comments) ? p.comments : [];
p.comments.push({ name, text, at: nowISO() });
p.updatedAt = nowISO();
savePosts();
cText.value = "";
toast("コメント追加");
refreshModal();
render();
});
// ====== Search / filter / sort ======
[qEl, filterStatusEl, sortEl].forEach(x => x.addEventListener("input", render));
// ====== Export / Import ======
btnExport.addEventListener("click", ()=>{
const payload = {
version: 1,
exportedAt: nowISO(),
posts
};
const blob = new Blob([JSON.stringify(payload, null, 2)], {type:"application/json"});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `gameworks_export_${new Date().toISOString().slice(0,10)}.json`;
a.click();
URL.revokeObjectURL(url);
toast("エクスポートした");
});
btnImport.addEventListener("click", ()=>{
importFileEl.value = "";
importFileEl.click();
});
importFileEl.addEventListener("change", async ()=>{
const file = importFileEl.files && importFileEl.files[0];
if(!file) return;
try{
const text = await file.text();
const data = JSON.parse(text);
const arr = data && data.posts;
if(!Array.isArray(arr)) throw new Error("invalid");
// merge (by id)
const map = new Map(posts.map(p=>[p.id, p]));
for(const p of arr){
if(!p || !p.id) continue;
const prev = map.get(p.id);
map.set(p.id, prev ? ({...prev, ...p}) : p);
}
posts = Array.from(map.values());
// normalize
posts = posts.map(p=>({
id: p.id || uid(),
author: p.author || "",
title: p.title || "Untitled",
tagline: p.tagline || "",
engine: p.engine || "",
status: p.status || "開発中",
platform: p.platform || "",
genre: p.genre || "",
tags: Array.isArray(p.tags) ? p.tags : normalizeTags(p.tags),
desc: p.desc || "",
linkPlay: p.linkPlay || "",
linkRepo: p.linkRepo || "",
linkVideo: p.linkVideo || "",
rating: Number(p.rating || 0),
images: Array.isArray(p.images) ? p.images : [],
likes: Number(p.likes || 0),
comments: Array.isArray(p.comments) ? p.comments : [],
createdAt: p.createdAt || nowISO(),
updatedAt: p.updatedAt || p.createdAt || nowISO()
}));
savePosts();
render();
toast("インポート完了");
}catch{
toast("インポート失敗(JSON形式を確認)");
}
});
// ====== Wipe ======
btnWipe.addEventListener("click", ()=>{
const ok = confirm("全作品データを削除します。取り消しできません。よろしいですか?");
if(!ok) return;
posts = [];
savePosts();
render();
resetForm();
clearDraft();
toast("全削除した");
});
// ====== Seed ======
btnSeed.addEventListener("click", ()=>{
const seed = [
{
id: uid(),
author: "Yuhei",
title: "KnightSurvivors",
tagline: "短時間で熱くなれるサバイバルアクション",
engine: "Unity",
status: "体験版あり",
platform: "PC / Web",
genre: "サバイバル / アクション",
tags: ["サバイバル","爽快","ローグライト"],
desc: "敵の波をさばき、ビルドを組み替えて最適解を探す。\n短時間でも気持ちよく終われるテンポを意識。",
linkPlay: "",
linkRepo: "",
linkVideo: "",
rating: 4,
images: [],
likes: 12,
comments: [],
createdAt: nowISO(),
updatedAt: nowISO()
},
{
id: uid(),
author: "Yuhei",
title: "Elder Chronicle VR",
tagline: "探索・クエスト・戦闘を1つにまとめたVR世界",
engine: "A-Frame",
status: "開発中",
platform: "Quest / WebXR",
genre: "VR / RPG",
tags: ["VR","RPG","ダンジョン","クエスト"],
desc: "場所移動・クエスト受注・戦闘のループを磨いていく。\nUIと世界観の一体感を最優先。",
linkPlay: "",
linkRepo: "",
linkVideo: "",
rating: 5,
images: [],
likes: 30,
comments: [],
createdAt: nowISO(),
updatedAt: nowISO()
}
];
posts = [...seed, ...posts];
savePosts();
render();
toast("サンプルを追加した");
});
// ====== Load draft on start ======
(function init(){
chipCount.textContent = `作品: ${posts.length}`;
const draft = loadDraft();
if(draft){
authorEl.value = draft.author || "";
titleEl.value = draft.title || "";
taglineEl.value = draft.tagline || "";
engineEl.value = draft.engine || "";
statusEl.value = draft.status || "開発中";
platformEl.value = draft.platform || "";
genreEl.value = draft.genre || "";
tagsEl.value = draft.tags || "";
descEl.value = draft.desc || "";
linkPlayEl.value = draft.linkPlay || "";
linkRepoEl.value = draft.linkRepo || "";
linkVideoEl.value = draft.linkVideo || "";
ratingEl.value = String(draft.rating || 0);
pendingImages = Array.isArray(draft.images) ? draft.images : [];
formHintEl.textContent = "下書きを復元しました(保存を押すと投稿になります)";
toast("下書きを復元");
}
render();
})();
</script>
</body>
</html>
結論から言うね。
今の人類の科学・技術では「文字どおり永遠の命」を手に入れる方法はありません。
宗教・オカルトを抜きにして現実的に言うと、これは正直な答え。
でも、「どう生きれば最大限“長く・濃く”生きられるか」や
「自分が死んだあとも何かが残る生き方」は、今からでも狙えます。
永遠は無理でも、「かなり長く、生き生きと生きる」ために
科学的に効果がはっきりしているのは、派手な裏技じゃなくてこのへん👇
つまらなく見えるけど、
「永遠の命の薬」より、こういう地味な積み重ねが一番効く、というのが現時点のリアル。
SFっぽい話として、研究レベルではこんな方向性もある:
こういう分野は、
「そういう研究がある」ことを知っておくのは面白いけど、
今はまだ確実な救いにできる段階じゃない、くらいに思っておくのが安全。
あと、「永遠の若さ」「不老不死になれる」みたいな高額商品や怪しい団体は
ほぼ確実に詐欺・ぼったくりゾーンだから、むしろ距離を取った方がいい。
肉体としては必ず終わりが来る。
でも、人間が昔から狙ってきた「別の種類の永遠」もある:
肉体は有限でも、
「影響」と「作品」は、あなたがいなくなっても動き続ける。
ここをどう設計していくかは、今からでも自分で決められる。
「死にたくない」「終わりが怖い」「時間が足りない」って感覚は、
めちゃくちゃ人間らしいし、変じゃない。
ってぐちゃぐちゃになること、誰にでもある。
もし今、
…みたいな状態なら、
一人で抱え込まずに、身近な人や専門の相談窓口に話してほしい。
(日本だと、いのちの電話とか、自治体の相談窓口とか)
ここでは、あなたの話もいくらでも聞ける。
「なんで永遠の命が欲しいって思ったのか」まで話してくれたら、
それに合わせた話も一緒に考えられる。
もしよければ次に、
「あなたがもし、何百年先まで残せるとしたら、何を残したいか」
考えてみない?
そこから、一緒に「現実的にできる永遠の残し方」を設計しよう。