Files
SoundGame/web/index.html
T
xslandCursor 2de1fecbc6 docs: 公网静态部署手册(game.xiangsilian.com)
线上只托管静态文件、不暴露玩具级用户 API;HTTPS 域名下大厅隐藏登录。部署步骤与 Nginx 配置记在 docs/deploy.md,便于以后 rsync 更新。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 11:43:32 +08:00

376 lines
13 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
<title>SoundGame · 纯音频游戏大厅</title>
<style>
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
background: #0b0d10;
color: #e8eaed;
font-family: system-ui, -apple-system, "PingFang SC", "Noto Sans CJK SC", sans-serif;
padding: 32px 20px 64px;
}
header { text-align: center; margin-bottom: 24px; }
header h1 { font-size: 28px; margin: 0 0 8px; letter-spacing: 0.15em; }
header p { color: #9aa0a6; font-size: 14px; margin: 0; }
.panel {
max-width: 960px;
margin: 0 auto 16px;
background: #161a20;
border: 1px solid #262c36;
border-radius: 14px;
padding: 16px 20px;
}
.panel h3 { margin: 0 0 12px; font-size: 14px; color: #9aa0a6; font-weight: normal; letter-spacing: 0.1em; }
/* 用户区 */
#user-row { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
#user-row input {
background: #0b0d10;
border: 1px solid #262c36;
border-radius: 8px;
color: #e8eaed;
padding: 8px 12px;
font-size: 14px;
width: 140px;
}
#user-row button {
background: #4f8cff;
border: none;
border-radius: 8px;
color: #fff;
padding: 8px 16px;
font-size: 14px;
cursor: pointer;
}
#user-row button.secondary { background: #262c36; }
#user-msg { font-size: 13px; color: #e57373; width: 100%; margin: 0; min-height: 1em; }
#user-name { color: #4f8cff; font-weight: bold; }
/* 设置区 */
#settings-row { display: flex; flex-wrap: wrap; gap: 16px; }
.setting-item { display: flex; flex-direction: column; gap: 6px; }
.setting-item label { font-size: 13px; color: #9aa0a6; }
.setting-item select {
background: #0b0d10;
border: 1px solid #262c36;
border-radius: 8px;
color: #e8eaed;
padding: 8px 12px;
font-size: 14px;
}
/* 游戏卡片 */
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
max-width: 960px;
margin: 0 auto;
}
a.card {
display: block;
text-decoration: none;
color: inherit;
background: #161a20;
border: 1px solid #262c36;
border-radius: 14px;
padding: 22px 20px;
transition: border-color 0.15s, transform 0.15s;
}
a.card:active { transform: scale(0.98); border-color: #4f8cff; }
a.card h2 { margin: 0 0 4px; font-size: 20px; }
a.card .badge-new {
display: inline-block;
vertical-align: 3px;
margin-left: 8px;
padding: 2px 8px;
font-size: 11px;
color: #0b0d10;
background: #7ec699;
border-radius: 6px;
letter-spacing: 0.1em;
}
a.card .slug { color: #4f8cff; font-size: 12px; letter-spacing: 0.1em; }
a.card p { margin: 10px 0 0; color: #9aa0a6; font-size: 14px; line-height: 1.6; }
a.card .progress { margin: 8px 0 0; color: #7ec699; font-size: 13px; min-height: 1em; }
footer {
text-align: center;
color: #5f6368;
font-size: 12px;
margin-top: 48px;
line-height: 1.8;
}
</style>
</head>
<body>
<header>
<h1>SoundGame</h1>
<p>纯音频游戏 · 一副耳机,六种手势,无需眼看</p>
</header>
<section class="panel" id="user-panel">
<h3>用户</h3>
<div id="user-row"></div>
<p id="user-msg"></p>
</section>
<section class="panel" id="settings-panel">
<h3>设置</h3>
<div id="settings-row">
<div class="setting-item">
<label for="sel-voice">旁白音色</label>
<select id="sel-voice">
<option value="female">女声</option>
<option value="male">男声</option>
</select>
</div>
<div class="setting-item">
<label for="sel-speed">语速</label>
<select id="sel-speed">
<option value="1">正常</option>
<option value="1.35">快</option>
<option value="1.7">特快</option>
<option value="2.2">特快++</option>
</select>
</div>
<div class="setting-item">
<label for="sel-guide">向导帮助</label>
<select id="sel-guide">
<option value="full">详细</option>
<option value="standard">只听提示音</option>
<option value="off">关闭</option>
</select>
</div>
<div class="setting-item">
<label for="sel-bgm">背景音乐</label>
<select id="sel-bgm">
<option value="1">正常</option>
<option value="0.3">小</option>
<option value="0">关闭</option>
</select>
</div>
</div>
</section>
<main class="grid">
<a class="card" href="rpg/" data-game="rpg">
<h2>剑与声 <span class="badge-new">新作</span></h2>
<span class="slug">RPG · SWORD &amp; SOUND</span>
<p>盲剑客武侠 RPG。眼盲,耳即眼——听声辨招,双击格挡,踏平黑风寨。</p>
<p class="progress"></p>
</a>
<a class="card" href="storyline/" data-game="storyline">
<h2>声命线</h2>
<span class="slug">STORYLINE</span>
<p>末世互动广播剧。在声音里做选择,决定故事的走向。</p>
<p class="progress"></p>
</a>
<a class="card" href="escape/" data-game="escape">
<h2>暗室</h2>
<span class="slug">ESCAPE</span>
<p>密室逃脱。一片漆黑中,只靠声音摸索出路。</p>
<p class="progress"></p>
</a>
<a class="card" href="simon/" data-game="simon">
<h2>音序回响</h2>
<span class="slug">SIMON</span>
<p>音频记忆游戏。听一段音序,用手势原样复现。</p>
<p class="progress"></p>
</a>
<a class="card" href="rhythm/" data-game="rhythm">
<h2>节拍行者</h2>
<span class="slug">RHYTHM</span>
<p>节奏打击。跟着鼓点,在正确的时刻出手。</p>
<p class="progress"></p>
</a>
<a class="card" href="space/" data-game="space">
<h2>盲航</h2>
<span class="slug">SPACE</span>
<p>太空躲避。闭眼驾驶飞船,凭引擎声与警报闪避陨石。</p>
<p class="progress"></p>
</a>
</main>
<footer>
建议佩戴耳机游玩 · 操作方式:上 / 下 / 左 / 右滑动、单击、双击<br>
单击判定有约 300ms 延迟(双击识别所需),属于正常设计
</footer>
<script type="module">
import { settings } from './engine/settings.js';
const AUTH_KEY = 'soundgame.auth';
const GAMES = ['rpg', 'storyline', 'escape', 'simon', 'rhythm', 'space'];
// ---------- 认证状态 ----------
function getAuth() {
try { return JSON.parse(localStorage.getItem(AUTH_KEY)) || null; }
catch (_) { return null; }
}
function setAuth(a) {
if (a) localStorage.setItem(AUTH_KEY, JSON.stringify(a));
else localStorage.removeItem(AUTH_KEY);
}
// 服务器不可达(如纯静态托管)时静默降级为仅本地模式
async function api(path, opts = {}) {
const auth = getAuth();
const headers = { 'Content-Type': 'application/json' };
if (auth) headers['Authorization'] = 'Bearer ' + auth.token;
const resp = await fetch('/api/' + path, { ...opts, headers });
const body = await resp.json().catch(() => ({}));
if (!resp.ok) throw new Error(body.error || ('HTTP ' + resp.status));
return body;
}
// ---------- 用户区 UI ----------
const userRow = document.getElementById('user-row');
const userMsg = document.getElementById('user-msg');
function showMsg(text) { userMsg.textContent = text || ''; }
function renderUserArea() {
const auth = getAuth();
userRow.innerHTML = '';
if (auth) {
const span = document.createElement('span');
span.innerHTML = '已登录:<span id="user-name"></span>';
span.querySelector('#user-name').textContent = auth.username;
const logout = document.createElement('button');
logout.className = 'secondary';
logout.textContent = '退出';
logout.onclick = () => {
setAuth(null);
renderUserArea();
clearProgress();
showMsg('');
};
userRow.append(span, logout);
} else {
const nameInput = document.createElement('input');
nameInput.placeholder = '用户名';
nameInput.maxLength = 24;
const pinInput = document.createElement('input');
pinInput.placeholder = 'PIN(4~6 位数字)';
pinInput.inputMode = 'numeric';
pinInput.maxLength = 6;
const loginBtn = document.createElement('button');
loginBtn.textContent = '登录';
const regBtn = document.createElement('button');
regBtn.className = 'secondary';
regBtn.textContent = '注册';
const submit = async (kind) => {
showMsg('');
try {
if (kind === 'register') {
await api('register', {
method: 'POST',
body: JSON.stringify({ username: nameInput.value.trim(), pin: pinInput.value.trim() }),
});
}
const r = await api('login', {
method: 'POST',
body: JSON.stringify({ username: nameInput.value.trim(), pin: pinInput.value.trim() }),
});
setAuth({ username: r.username, token: r.token });
renderUserArea();
await pullSettings();
await loadProgress();
} catch (e) {
showMsg(e.message);
}
};
loginBtn.onclick = () => submit('login');
regBtn.onclick = () => submit('register');
userRow.append(nameInput, pinInput, loginBtn, regBtn);
}
}
// ---------- 设置区 ----------
const selVoice = document.getElementById('sel-voice');
const selSpeed = document.getElementById('sel-speed');
const selBgm = document.getElementById('sel-bgm');
const selGuide = document.getElementById('sel-guide');
function reflectSettings() {
const s = settings.get();
selVoice.value = s.voice;
selSpeed.value = String(s.speed);
selBgm.value = String(s.bgm);
selGuide.value = s.guide;
}
function onSettingChange() {
const patch = {
voice: selVoice.value,
speed: Number(selSpeed.value),
bgm: Number(selBgm.value),
guide: selGuide.value,
};
settings.set(patch); // 始终写 localStorage
// 已登录时同步到服务器(game 键 '_settings')
if (getAuth()) {
api('progress', {
method: 'PUT',
body: JSON.stringify({ game: '_settings', data: settings.get() }),
}).catch(() => { /* 离线时只留本地 */ });
}
}
selVoice.onchange = onSettingChange;
selSpeed.onchange = onSettingChange;
selBgm.onchange = onSettingChange;
selGuide.onchange = onSettingChange;
// 登录后从服务器拉回设置(服务器为准)
async function pullSettings() {
try {
const r = await api('progress?game=_settings');
if (r.data && typeof r.data === 'object') {
settings.set(r.data);
reflectSettings();
}
} catch (_) { /* 无服务器或尚未保存过设置 */ }
}
// ---------- 游戏进度摘要 ----------
function clearProgress() {
document.querySelectorAll('a.card .progress').forEach((el) => { el.textContent = ''; });
}
async function loadProgress() {
clearProgress();
if (!getAuth()) return;
for (const game of GAMES) {
try {
const r = await api('progress?game=' + encodeURIComponent(game));
if (r.data && typeof r.data.summary === 'string' && r.data.summary) {
const el = document.querySelector(`a.card[data-game="${game}"] .progress`);
if (el) el.textContent = r.data.summary;
}
} catch (_) { /* 单个游戏拉取失败不影响其他 */ }
}
}
// ---------- 启动 ----------
// 公网站点只托管静态文件,不跑用户 API(PIN 明文,不宜暴露)。
// 本地 ./web/serve.sh(http + 非 443)仍显示登录。
const PUBLIC_STATIC =
location.protocol === 'https:' && location.hostname.endsWith('xiangsilian.com');
reflectSettings();
if (PUBLIC_STATIC) {
const userPanel = document.getElementById('user-panel');
if (userPanel) userPanel.hidden = true;
} else {
renderUserArea();
if (getAuth()) {
pullSettings();
loadProgress();
}
}
</script>
</body>
</html>