Files
SoundGame/web/index.html
T
SoundGame Dev 79dffac01e guide层v1: 全局向导交互层落地(状态签名earcon/turn cue/静默看门狗/纠错) + 6游戏接线 + 8句向导语音 + 测试全绿
- engine/guide.js 运行时合成8个签名earcon(方法论L2硬参数),off档零定时器,插播queue不顶对白
- settings: guide三档(full/standard/off默认full) + 语速2.2视障档;audio: isVoicePlaying()/setRateBoostEnabled() 清两个已知问题
- 6游戏按schemes§2接线: storyline看门狗+无效手势纠错、simon免扣重听看门狗、escape turn cue+提前卡关提示、rhythm gd_answer/gd_miss+结算看门狗+快进豁免、space首撞指令提示+结算看门狗、rpg格挡纠错+60s催促+状态签名
- 大厅设置面板+向导文档(14-guide-layer.md)+00-global-ux §6;R5台词表补4条一致性
- 试玩转写含向导事件;6 playtest(guide=off断言零改动)+guide.test(34断言)+资产终检全绿
2026-08-30 22:46:05 +08:00

366 lines
12 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 (_) { /* 单个游戏拉取失败不影响其他 */ }
}
}
// ---------- 启动 ----------
reflectSettings();
renderUserArea();
if (getAuth()) {
pullSettings();
loadProgress();
}
</script>
</body>
</html>