- 程序章程 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(风暴根治/回声真实性/密度下限+呼吸层/导航最短路/菜单关闭反馈/金币档同账本)+ 实现核对登记
388 lines
13 KiB
HTML
388 lines
13 KiB
HTML
<!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 & 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>
|