Files
SoundGame/web/index.html
T
xsl d870a75a26 feat(new8/batch1): 两款新 RPG《不眠频率》radio +《回声地窟》dungeon 全套(多agent流水线:策划→资产→开发→测试→玩家R1→修复→R2)
- 程序章程 research/new8/00-program.md(8款概念/回合协议/验收门/进度账)
- 设计:docs/design/15-radio.md+voice-ids(132句,6状态9抉择4结局,电话滤波资产侧带通)/16-dungeon.md+voice-ids(125句,脉冲回声/7层seed生成/三档战斗/元进度遗产)
- 资产:TTS 970文件(GPU flock串行)+ Mureka BGM 8首 $0.24(台账 mureka_log.md)+ 程序合成 sfx(含预pan侧墙/开口、phone_ring铃声、amb_cave呼吸层、turn_tick)+ 终检 check_all_assets 8游戏全绿
- radio:game.js+playtest 221断言(4结局矩阵/pendingChoice盲玩公平/RING两级看门狗/菜单循环/存档schema)
- dungeon:game.js+playtest 1610断言(seed全网格回声一致性/学期零死/格挡80%/死亡归因真话/元进度/菜单冻结/上滑5段状态链)
- 引擎修复:audio.js _playVoice 加载竞态补杀 token(R1真机71分钟语音风暴根因)+ 同id 3s窗口≥5次熔断保险丝 + gesture.js 长按boost事件门(+66行,9套件回归全绿)
- 大厅:新增 radio/dungeon 卡片 + GAMES 数组
- harness:sessions/radio.mjs+dungeon.mjs(主线+压力双剧本)+ transcribe 注册 + voice_text.json 刷新(8游戏704句)+ 13号文档 §20 登记
- R1 存档:4人设评审×2游戏 + 真机试玩报告×2(radio 5/5愿意玩;dungeon 0/4→确诊风暴)+ synthesis×2 + 转写8份
- R2 修复:radio F1-F5(看门狗补挂/rep_low改词重录/真铃声/ASK续玩语义/链末跳过保护)+ dungeon D0-1..D1-6(风暴根治/回声真实性/密度下限+呼吸层/导航最短路/菜单关闭反馈/金币档同账本)+ 实现核对登记
2026-09-02 18:52:37 +08:00

388 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="radio/" data-game="radio">
<h2>不眠频率 <span class="badge-new">新作</span></h2>
<span class="slug">RADIO · MIDNIGHT HOTLINE</span>
<p>深夜电台热线 RPG。一晚五个来电,你接话的方式,改变五个陌生人的夜晚。</p>
<p class="progress"></p>
</a>
<a class="card" href="dungeon/" data-game="dungeon">
<h2>回声地窟 <span class="badge-new">新作</span></h2>
<span class="slug">DUNGEON · ECHO DELVE</span>
<p>听声辨位 Roguelike。单击发出声波脉冲,用回声在黑暗中走出一条生路。</p>
<p class="progress"></p>
</a>
<a class="card" href="rpg/" data-game="rpg">
<h2>剑与声</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 = ['radio', 'dungeon', '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>