Files

1336 lines
47 KiB
JavaScript
Raw Permalink 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.
(function () {
const body = document.body;
const roomId = body.dataset.roomId;
const playerId = body.dataset.playerId;
const playerName = body.dataset.playerName;
const isSpectator = body.dataset.spectator === '1';
const sceneHero = document.getElementById('scene-hero');
const sceneTitle = document.getElementById('scene-title');
const sceneDescription = document.getElementById('scene-description');
const heroStatusList = document.getElementById('hero-status-list');
const storyLog = document.getElementById('story-log');
const chatLog = document.getElementById('chat-log');
const playersList = document.getElementById('players-list');
const statusPanel = document.getElementById('status-panel');
const cluesList = document.getElementById('clues-list');
const cluesToggleBtn = document.getElementById('clues-toggle-btn');
const inventoryList = document.getElementById('inventory-list');
const allStatusList = document.getElementById('all-status-list');
const allInventoryList = document.getElementById('all-inventory-list');
const pendingActions = document.getElementById('pending-actions');
const privateList = document.getElementById('private-list');
const choiceChips = document.getElementById('choice-chips');
const cluesTitle = document.getElementById('clues-title');
const inventoryTitle = document.getElementById('inventory-title');
const choicesTitle = document.getElementById('choices-title');
const endingOverlay = document.getElementById('ending-overlay');
const endingTitle = document.getElementById('ending-title');
const endingSummary = document.getElementById('ending-summary');
const endingSummaryLink = document.getElementById('ending-summary-link');
const readyBtn = document.getElementById('ready-btn');
const startBtn = document.getElementById('start-btn');
const deleteRoomBtn = document.getElementById('delete-room-btn');
const publicBtn = document.getElementById('public-btn');
const inviteBtn = document.getElementById('invite-btn');
const inviteModal = document.getElementById('invite-modal');
const inviteCloseBtn = document.getElementById('invite-close-btn');
const inviteJoinLink = document.getElementById('invite-join-link');
const inviteSpectateLink = document.getElementById('invite-spectate-link');
const inviteJoinQr = document.getElementById('invite-join-qr');
const inviteSpectateQr = document.getElementById('invite-spectate-qr');
const inviteCopyJoinBtn = document.getElementById('invite-copy-join-btn');
const inviteCopySpectateBtn = document.getElementById('invite-copy-spectate-btn');
const inviteOpenJoinBtn = document.getElementById('invite-open-join-btn');
const inviteOpenSpectateBtn = document.getElementById('invite-open-spectate-btn');
const actionForm = document.getElementById('action-form');
const actionInput = document.getElementById('action-input');
const actionSubmitBtn = actionForm.querySelector('button[type="submit"]');
const chatForm = document.getElementById('chat-form');
const chatInput = document.getElementById('chat-input');
const chatSubmitBtn = chatForm.querySelector('button[type="submit"]');
const localState = {
snapshot: null,
privateMessages: [],
autoResolveTimer: null,
actionLocked: false,
lastSubmittedRound: null,
invitePayload: null,
inviteLoading: false,
forcedEnding: null,
cluesCollapsed: true,
summaryUrl: `/room/${encodeURIComponent(roomId)}/summary`,
};
const PHASE_LABELS = {
lobby: '大厅阶段',
running: '进行中',
ended: '已结束',
};
const DEFAULT_SCENE_LABELS = {
lobby: '大厅',
dining_room: '餐厅',
corridor: '走廊',
guest_room: '客房',
basement: '地下室',
opening: '开场',
};
const ENDING_LABELS = {
escaped: '成功逃离',
sacrifice: '找到真相但有人牺牲',
trapped: '被旅店困住',
pact: '与隐藏势力合作',
custom_end: '本局结局',
};
const BADGE_LABELS = {
detective: '侦探徽章',
negotiator: '交涉者徽章',
infiltrator: '潜行者徽章',
};
const ITEM_LABELS = {
bloodied_key: '染血钥匙',
torn_manifest: '残破船务清单',
silver_bell: '银铃',
maid_diary: '女仆日记',
cellar_sigil: '地下室印记',
missing_guest_note: '失踪者便条',
};
const NPC_NAME_LABELS = {
innkeeper: '旅店老板',
maid: '女仆',
silent_traveler: '沉默旅客',
};
let socket = null;
if (!isSpectator && playerId) {
localStorage.setItem(`echo-player-${roomId}`, playerId);
localStorage.setItem('echo-last-name', playerName);
}
const localizeStoryText = (text) => {
let output = String(text || '');
output = output.replace(/\bsilent_traveler\b/gi, NPC_NAME_LABELS.silent_traveler);
output = output.replace(/\bsilent traveler\b/gi, NPC_NAME_LABELS.silent_traveler);
output = output.replace(/\binnkeeper\b/gi, NPC_NAME_LABELS.innkeeper);
output = output.replace(/\bmaid\b/gi, NPC_NAME_LABELS.maid);
return output;
};
const getLabel = (value, mapping) => {
if (!value) return '-';
return mapping[value] || value;
};
const getItemLabel = (value) => {
if (!value) return '-';
return ITEM_LABELS[value] || value;
};
const createMsg = (kind, text) => {
const div = document.createElement('div');
div.className = `msg ${kind}`;
div.textContent = localizeStoryText(text);
return div;
};
const appendSystem = (text) => {
if (!text) return;
chatLog.appendChild(createMsg('system', text));
chatLog.scrollTop = chatLog.scrollHeight;
};
const fallbackCopyByExecCommand = (text) => {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.setAttribute('readonly', 'readonly');
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
textarea.style.left = '-9999px';
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
let ok = false;
try {
ok = document.execCommand('copy');
} catch {
ok = false;
}
document.body.removeChild(textarea);
return ok;
};
const copyInviteText = async (text) => {
if (!text) return false;
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
// continue fallback
}
}
if (fallbackCopyByExecCommand(text)) {
return true;
}
return false;
};
const qrUrl = (text) => {
return `https://api.qrserver.com/v1/create-qr-code/?size=240x240&margin=10&data=${encodeURIComponent(text || '')}`;
};
const setInviteLoading = (loading) => {
localState.inviteLoading = !!loading;
if (!inviteBtn) return;
inviteBtn.disabled = !!loading;
inviteBtn.textContent = loading ? '生成中...' : '邀请链接';
};
const closeInviteModal = () => {
if (!inviteModal) return;
inviteModal.classList.add('hidden');
inviteModal.setAttribute('aria-hidden', 'true');
};
const openInviteModal = () => {
if (!inviteModal) return;
inviteModal.classList.remove('hidden');
inviteModal.setAttribute('aria-hidden', 'false');
};
const renderInvitePanel = (payload) => {
localState.invitePayload = payload || null;
const joinLink = String(payload?.join_link || '');
const spectateLink = String(payload?.spectate_link || '');
if (inviteJoinLink) inviteJoinLink.textContent = joinLink || '暂无';
if (inviteSpectateLink) inviteSpectateLink.textContent = spectateLink || '暂无';
if (inviteOpenJoinBtn) inviteOpenJoinBtn.href = joinLink || '#';
if (inviteOpenSpectateBtn) inviteOpenSpectateBtn.href = spectateLink || '#';
if (inviteOpenJoinBtn) inviteOpenJoinBtn.classList.toggle('disabled', !joinLink);
if (inviteOpenSpectateBtn) inviteOpenSpectateBtn.classList.toggle('disabled', !spectateLink);
if (inviteCopyJoinBtn) inviteCopyJoinBtn.disabled = !joinLink;
if (inviteCopySpectateBtn) inviteCopySpectateBtn.disabled = !spectateLink;
if (inviteJoinQr) {
inviteJoinQr.src = joinLink ? qrUrl(joinLink) : '';
inviteJoinQr.style.visibility = joinLink ? 'visible' : 'hidden';
}
if (inviteSpectateQr) {
inviteSpectateQr.src = spectateLink ? qrUrl(spectateLink) : '';
inviteSpectateQr.style.visibility = spectateLink ? 'visible' : 'hidden';
}
};
const currentSchema = () => {
const snapshot = localState.snapshot || {};
const cardSchema = snapshot?.game_card?.state_schema || {};
return {
labels: cardSchema.labels || {},
metrics: Array.isArray(cardSchema.metrics) ? cardSchema.metrics : [],
sceneLabels: cardSchema.scene_labels || {},
dataScope: cardSchema.data_scope || {},
visibility: cardSchema.visibility || {},
ui: cardSchema.ui || {},
};
};
const normalizeScope = (schema) => {
const raw = schema?.dataScope || {};
const out = {
metrics: String(raw.metrics || 'shared').toLowerCase(),
inventory: String(raw.inventory || 'per_player').toLowerCase(),
clues: String(raw.clues || 'shared').toLowerCase(),
};
if (!['shared', 'per_player'].includes(out.metrics)) out.metrics = 'shared';
if (!['shared', 'per_player'].includes(out.inventory)) out.inventory = 'per_player';
if (!['shared', 'per_player'].includes(out.clues)) out.clues = 'shared';
return out;
};
const normalizeVisibility = (schema) => {
const raw = schema?.visibility || {};
return {
showAllPlayerMetrics: raw.show_all_player_metrics !== false,
showAllPlayerInventory: raw.show_all_player_inventory !== false,
};
};
const metricValue = (key, gameState, forPlayerId = null) => {
if (typeof gameState?.[key] === 'number') return gameState[key];
const scope = normalizeScope(currentSchema());
if (scope.metrics === 'per_player') {
const playerValues = gameState?.global_flags?.metric_values_by_player || {};
const targetValues = playerValues[forPlayerId || playerId] || {};
const v = targetValues[key];
if (typeof v === 'number') return v;
}
const sharedValues = gameState?.global_flags?.metric_values || {};
const sharedVal = sharedValues[key];
return typeof sharedVal === 'number' ? sharedVal : null;
};
const statusTrackConfigs = (schema, gameState) => {
const ui = schema.ui || {};
const metrics = schema.metrics || [];
const metricMap = {};
metrics.forEach((metric) => {
const key = String(metric.key || '').trim();
if (!key) return;
metricMap[key] = metric;
});
const out = [];
const uiTracks = Array.isArray(ui.status_tracks) ? ui.status_tracks : [];
uiTracks.forEach((track) => {
if (!track || typeof track !== 'object') return;
const key = String(track.key || '').trim();
if (!key) return;
const metric = metricMap[key] || {};
const value = metricValue(key, gameState, playerId);
if (value === null) return;
const min = Number(track.min ?? metric.min ?? 0) || 0;
const max = Number(track.max ?? metric.max ?? 10) || 10;
const label = String(track.label || metric.label || key);
const color = /^#[0-9a-fA-F]{6}$/.test(String(track.color || metric.color || ''))
? (track.color || metric.color)
: '#ffb500';
out.push({ key, value, min, max: Math.max(min, max), label, color });
});
if (out.length) return out;
const uiStatus = ui.hero_status && typeof ui.hero_status === 'object' ? ui.hero_status : {};
const uiKey = String(uiStatus.key || '').trim();
if (uiKey) {
const v = metricValue(uiKey, gameState, playerId);
if (v !== null) {
return [{
key: uiKey,
value: v,
min: Number(uiStatus.min || 0) || 0,
max: Number(uiStatus.max || 10) || 10,
label: String(uiStatus.label || uiKey),
color: /^#[0-9a-fA-F]{6}$/.test(String(uiStatus.color || '')) ? uiStatus.color : '#ffb500',
}];
}
}
for (const metric of metrics) {
const key = String(metric.key || '').trim();
if (!key) continue;
const v = metricValue(key, gameState, playerId);
if (v === null) continue;
return [{
key,
value: v,
min: Number(metric.min || 0) || 0,
max: Number(metric.max || 10) || 10,
label: String(metric.label || key),
color: /^#[0-9a-fA-F]{6}$/.test(String(metric.color || '')) ? metric.color : '#ffb500',
}];
}
return [{
key: 'danger_level',
value: Number(gameState?.danger_level || 0),
min: 0,
max: 10,
label: '危险值',
color: '#ffb500',
}];
};
const getMyPending = (snapshot) => {
const rows = snapshot?.pending_actions || [];
return rows.some((row) => row.player_id === playerId);
};
const roundControl = (snapshot) => {
const flags = snapshot?.game_state?.global_flags || {};
const requireAll = flags?.runtime_options?.require_all_players_response === true;
const timeoutSeconds = Number(flags?.runtime_options?.response_timeout_seconds || 45);
const expectedIds = Array.isArray(flags?.round_expected_player_ids) ? flags.round_expected_player_ids : [];
const deadlineText = String(flags?.round_deadline_at || '').trim();
const deadlineMs = deadlineText ? Date.parse(deadlineText) : NaN;
const deadlineValid = Number.isFinite(deadlineMs);
const nowMs = Date.now();
const timedOut = requireAll && deadlineValid && nowMs >= deadlineMs;
const remainingMs = requireAll && deadlineValid ? Math.max(0, deadlineMs - nowMs) : 0;
return {
requireAll,
timeoutSeconds: Math.max(5, Math.min(300, Number.isFinite(timeoutSeconds) ? Math.round(timeoutSeconds) : 45)),
deadlineText,
deadlineValid,
timedOut,
remainingMs,
expectedCount: expectedIds.length,
submittedCount: (snapshot?.pending_actions || []).length,
};
};
const renderSectionLabels = () => {
const schema = currentSchema();
const labels = schema.labels;
const scope = normalizeScope(schema);
cluesTitle.textContent = labels.clues || '线索墙';
inventoryTitle.textContent = scope.inventory === 'shared' ? `共享${labels.inventory || '背包'}` : (labels.inventory || '我的背包');
choicesTitle.textContent = labels.choices || 'AI 快速行动';
};
const applyCardTheme = () => {
const snapshot = localState.snapshot;
if (!snapshot) return;
const card = snapshot.game_card || {};
const schema = currentSchema();
const ui = schema.ui || {};
const bg = String(ui.room_background_url || card.cover_url || '/static/img/cards/fogharbor.svg');
body.style.setProperty('--room-bg-image', `url("${bg}")`);
sceneHero.style.setProperty('--scene-bg-image', `url("${bg}")`);
};
const renderSceneBoard = () => {
const snapshot = localState.snapshot;
if (!snapshot) return;
const gameState = snapshot.game_state || {};
const card = snapshot.game_card || {};
const schema = currentSchema();
const sceneLabels = { ...DEFAULT_SCENE_LABELS, ...schema.sceneLabels };
const sceneLabel = getLabel(gameState.scene_id, sceneLabels);
const sceneCaption = schema.labels.scene || '场景';
sceneTitle.textContent = `${card.title || '未知剧本'} · ${sceneCaption}${sceneLabel !== '-' ? `:${sceneLabel}` : ''}`;
sceneDescription.textContent = localizeStoryText(gameState.scene_description || '等待剧情描述...');
const tracks = statusTrackConfigs(schema, gameState);
heroStatusList.textContent = '';
tracks.forEach((track, index) => {
const row = document.createElement('div');
row.className = 'hero-status-track';
const head = document.createElement('div');
head.className = 'hero-status-head';
const label = document.createElement('span');
label.textContent = track.label;
const value = document.createElement('span');
value.textContent = `${track.value} / ${track.max}`;
head.appendChild(label);
head.appendChild(value);
const bg = document.createElement('div');
bg.className = 'hero-status-bar-bg';
const bar = document.createElement('div');
bar.className = 'hero-status-bar';
const span = Math.max(1, track.max - track.min);
const percent = Math.max(0, Math.min(100, Math.round(((track.value - track.min) / span) * 100)));
bar.style.width = `${percent}%`;
bar.style.background = track.color;
bg.appendChild(bar);
row.appendChild(head);
row.appendChild(bg);
heroStatusList.appendChild(row);
if (index === 0) {
body.style.setProperty('--hero-status-color', track.color);
}
});
};
const renderLogs = () => {
storyLog.textContent = '';
chatLog.textContent = '';
if (!localState.snapshot) return;
const messages = localState.snapshot.messages || [];
let hasStory = false;
messages.forEach((row) => {
if (row.visibility === 'private' && row.player_id !== playerId) {
return;
}
const kind = row.kind || 'system';
const text = row.content || '';
const isStoryKind = ['ai', 'npc', 'action'].includes(kind) || (kind === 'system' && text.includes('触发:'));
if (isStoryKind) {
storyLog.appendChild(createMsg(kind, text));
hasStory = true;
} else {
chatLog.appendChild(createMsg(kind, text));
}
});
if (!hasStory) {
const sceneText = localState.snapshot?.game_state?.scene_description || '';
if (sceneText) {
storyLog.appendChild(createMsg('ai', sceneText));
}
}
storyLog.scrollTop = storyLog.scrollHeight;
chatLog.scrollTop = chatLog.scrollHeight;
};
const renderPlayers = () => {
playersList.textContent = '';
const players = localState.snapshot?.players || [];
const playerStates = localState.snapshot?.game_state?.global_flags?.player_states || {};
const me = players.find((p) => p.player_id === playerId);
const canManage = Boolean(me?.is_host && localState.snapshot?.status === 'lobby');
players.forEach((p) => {
const li = document.createElement('li');
li.className = `player-row ${p.is_host ? 'player-host ' : ''}${!p.is_online ? 'player-offline' : ''}`.trim();
const profile = document.createElement('div');
profile.className = 'player-profile';
if (p.avatar_url) {
const img = document.createElement('img');
img.src = p.avatar_url;
img.alt = p.name;
profile.appendChild(img);
} else {
const dot = document.createElement('span');
dot.className = 'player-dot';
dot.style.background = p.avatar_color || '#0070f2';
profile.appendChild(dot);
}
if (p.username) {
const nameLink = document.createElement('a');
nameLink.className = 'player-name-link';
nameLink.href = `/users/${encodeURIComponent(p.username)}`;
nameLink.textContent = p.name;
profile.appendChild(nameLink);
} else {
const name = document.createElement('span');
name.textContent = p.name;
profile.appendChild(name);
}
li.appendChild(profile);
if (p.is_host) {
const hostBadge = document.createElement('span');
hostBadge.className = 'badge';
hostBadge.textContent = '房主';
li.appendChild(hostBadge);
}
const activeBadgeId = String(p.active_badge || '').trim();
const activeBadgeName = String(p.active_badge_name || '').trim() || BADGE_LABELS[activeBadgeId] || '';
if (activeBadgeName) {
const badge = document.createElement('span');
badge.className = 'badge role';
badge.textContent = activeBadgeName;
li.appendChild(badge);
}
if (p.is_ready) {
const badge = document.createElement('span');
badge.className = 'badge ready';
badge.textContent = '准备';
li.appendChild(badge);
}
if (!p.is_online) {
const badge = document.createElement('span');
badge.className = 'badge danger';
badge.textContent = '离线';
li.appendChild(badge);
}
const ps = playerStates && typeof playerStates === 'object' ? playerStates[p.player_id] : null;
if (ps && typeof ps === 'object') {
const status = String(ps.status || '').trim().toLowerCase();
if (status === 'dead' || status === 'failed') {
const badge = document.createElement('span');
badge.className = 'badge danger';
badge.textContent = '阵亡';
li.appendChild(badge);
} else if (status === 'down') {
const badge = document.createElement('span');
badge.className = 'badge';
badge.textContent = '倒地';
li.appendChild(badge);
} else if (status === 'revived') {
const badge = document.createElement('span');
badge.className = 'badge ready';
badge.textContent = '复活';
li.appendChild(badge);
}
}
if (canManage && !p.is_host) {
const ops = document.createElement('div');
ops.className = 'player-ops';
const transferBtn = document.createElement('button');
transferBtn.type = 'button';
transferBtn.className = 'ghost-btn slim';
transferBtn.textContent = '转交房主';
transferBtn.addEventListener('click', () => {
if (!confirm(`确认将房主转交给 ${p.name} 吗?`)) return;
emitSafe('transfer_host', { room_id: roomId, player_id: playerId, target_player_id: p.player_id });
});
const kickBtn = document.createElement('button');
kickBtn.type = 'button';
kickBtn.className = 'ghost-btn slim';
kickBtn.textContent = '踢出';
kickBtn.addEventListener('click', () => {
if (!confirm(`确认踢出 ${p.name} 吗?`)) return;
emitSafe('kick_player', { room_id: roomId, player_id: playerId, target_player_id: p.player_id });
});
ops.appendChild(transferBtn);
ops.appendChild(kickBtn);
li.appendChild(ops);
}
playersList.appendChild(li);
});
};
const renderStatus = () => {
statusPanel.textContent = '';
const gameState = localState.snapshot?.game_state || {};
const schema = currentSchema();
const ending = getLabel(gameState.ending_state, ENDING_LABELS);
const sceneLabels = { ...DEFAULT_SCENE_LABELS, ...schema.sceneLabels };
const rows = [
`阶段:${getLabel(gameState.game_phase, PHASE_LABELS)}`,
`场景:${getLabel(gameState.scene_id, sceneLabels)}`,
`公开:${localState.snapshot?.is_public ? '是' : '否'}`,
`回合:${gameState.round_no ?? 0}`,
`结局:${ending === '-' ? '未达成' : ending}`,
];
const control = roundControl(localState.snapshot || {});
if (control.requireAll) {
rows.push(`回合制:全员提交(超时 ${control.timeoutSeconds}s)`);
if (control.deadlineValid) {
rows.push(`本轮剩余:${Math.ceil(control.remainingMs / 1000)}s${control.timedOut ? '(已超时)' : ''}`);
}
} else {
rows.push('回合制:任意玩家提交后可快速结算');
}
const metrics = schema.metrics;
const scope = normalizeScope(schema);
if (metrics.length > 0) {
metrics.forEach((metric) => {
const key = String(metric.key || '').trim();
if (!key) return;
const val = metricValue(key, gameState, playerId);
const max = Number(metric.max || 0);
const label = String(metric.label || key);
if (val !== null) {
rows.push(`${label}:${max > 0 ? `${val} / ${max}` : val}`);
}
});
if (scope.metrics === 'per_player') {
rows.push('状态模式:按玩家独立');
}
} else {
rows.push(`章节:第 ${gameState.chapter ?? '-'} 章`);
}
rows.forEach((text) => {
const div = document.createElement('div');
div.textContent = text;
statusPanel.appendChild(div);
});
};
const renderClues = () => {
cluesList.textContent = '';
const schema = currentSchema();
const scope = normalizeScope(schema);
let clues = localState.snapshot?.game_state?.discovered_clues || [];
if (scope.clues === 'per_player') {
const byPlayer = localState.snapshot?.game_state?.global_flags?.clues_by_player || {};
clues = byPlayer[playerId] || [];
}
if (!clues.length) {
const li = document.createElement('li');
li.textContent = '暂无线索';
cluesList.appendChild(li);
syncCluesCollapse();
return;
}
clues.forEach((item) => {
const li = document.createElement('li');
li.textContent = getItemLabel(item);
cluesList.appendChild(li);
});
syncCluesCollapse();
};
const syncCluesCollapse = () => {
if (!cluesList || !cluesToggleBtn) return;
const clueCount = cluesList.children.length;
const canCollapse = clueCount > 8;
cluesToggleBtn.classList.toggle('hidden', !canCollapse);
cluesList.classList.toggle('is-collapsed', canCollapse && localState.cluesCollapsed);
cluesToggleBtn.textContent = localState.cluesCollapsed ? '展开线索' : '收起线索';
};
const renderInventory = () => {
inventoryList.textContent = '';
const scope = normalizeScope(currentSchema());
const gameState = localState.snapshot?.game_state || {};
if (scope.inventory === 'shared') {
const shared = gameState?.global_flags?.shared_inventory || [];
if (!shared.length) {
const li = document.createElement('li');
li.textContent = '共享背包为空';
inventoryList.appendChild(li);
return;
}
shared.forEach((item) => {
const li = document.createElement('li');
li.textContent = getItemLabel(item);
inventoryList.appendChild(li);
});
return;
}
const allInventory = localState.snapshot?.game_state?.inventory_by_player || {};
const inventory = allInventory[playerId] || [];
if (!inventory.length) {
const li = document.createElement('li');
li.textContent = '背包为空';
inventoryList.appendChild(li);
return;
}
inventory.forEach((item) => {
const li = document.createElement('li');
li.textContent = getItemLabel(item);
inventoryList.appendChild(li);
});
};
const renderAllStatuses = () => {
allStatusList.textContent = '';
const snapshot = localState.snapshot || {};
const schema = currentSchema();
const visibility = normalizeVisibility(schema);
if (!visibility.showAllPlayerMetrics) {
const li = document.createElement('li');
li.textContent = '此游戏卡未开启全员状态展示';
allStatusList.appendChild(li);
return;
}
const metrics = schema.metrics || [];
const players = snapshot.players || [];
if (!players.length || !metrics.length) {
const li = document.createElement('li');
li.textContent = '暂无状态信息';
allStatusList.appendChild(li);
return;
}
players.forEach((player) => {
const parts = [];
metrics.forEach((metric) => {
const key = String(metric.key || '').trim();
if (!key) return;
const val = metricValue(key, snapshot.game_state || {}, player.player_id);
if (val === null) return;
const label = String(metric.label || key);
const max = Number(metric.max || 0);
parts.push(`${label}${max > 0 ? ` ${val}/${max}` : ` ${val}`}`);
});
const li = document.createElement('li');
li.textContent = `${player.name}:${parts.length ? parts.join(' · ') : '暂无'}`;
allStatusList.appendChild(li);
});
};
const renderAllInventory = () => {
allInventoryList.textContent = '';
const snapshot = localState.snapshot || {};
const schema = currentSchema();
const visibility = normalizeVisibility(schema);
if (!visibility.showAllPlayerInventory) {
const li = document.createElement('li');
li.textContent = '此游戏卡未开启全员背包展示';
allInventoryList.appendChild(li);
return;
}
const scope = normalizeScope(schema);
if (scope.inventory === 'shared') {
const shared = snapshot?.game_state?.global_flags?.shared_inventory || [];
const li = document.createElement('li');
li.textContent = `共享背包:${shared.length ? shared.map(getItemLabel).join('、') : '为空'}`;
allInventoryList.appendChild(li);
return;
}
const players = snapshot.players || [];
const allInventory = snapshot?.game_state?.inventory_by_player || {};
if (!players.length) {
const li = document.createElement('li');
li.textContent = '暂无背包信息';
allInventoryList.appendChild(li);
return;
}
players.forEach((player) => {
const items = allInventory[player.player_id] || [];
const li = document.createElement('li');
li.textContent = `${player.name}:${items.length ? items.map(getItemLabel).join('、') : '空'}`;
allInventoryList.appendChild(li);
});
};
const renderPending = () => {
pendingActions.textContent = '';
const rows = localState.snapshot?.pending_actions || [];
const resolvingRound = Boolean(localState.snapshot?.is_resolving_round);
if (resolvingRound && !rows.length) {
const li = document.createElement('li');
li.textContent = '本轮正在结算中...';
pendingActions.appendChild(li);
return;
}
if (!rows.length) {
const li = document.createElement('li');
li.textContent = '暂无待处理行动';
pendingActions.appendChild(li);
return;
}
rows.forEach((row) => {
const li = document.createElement('li');
li.textContent = `${row.player_name}: ${row.content}`;
pendingActions.appendChild(li);
});
};
const trySubmitAction = (content) => {
const text = String(content || '').trim();
if (!text) return false;
const snapshot = localState.snapshot || {};
const running = snapshot.status === 'running' && snapshot.game_state?.game_phase === 'running';
if (!running) {
appendSystem('请等待房主开始游戏。');
return false;
}
const control = roundControl(snapshot);
if (control.requireAll && control.timedOut) {
appendSystem('本轮已超时,无法再提交。请等待结算。');
return false;
}
if (localState.actionLocked || getMyPending(snapshot)) {
appendSystem('你已提交本轮行动,请等待本轮AI结算。');
return false;
}
if (emitSafe('submit_action', { room_id: roomId, player_id: playerId, content: text })) {
localState.actionLocked = true;
localState.lastSubmittedRound = Number(snapshot.game_state?.round_no || 0) + 1;
renderAll();
scheduleResolveFallback();
return true;
}
return false;
};
const renderQuickChoices = () => {
choiceChips.textContent = '';
const rows = localState.snapshot?.game_state?.pending_choices || [];
if (!rows.length) {
const hint = document.createElement('div');
hint.className = 'quick-hint';
hint.textContent = '等待 AI 生成下一批可执行行动...';
choiceChips.appendChild(hint);
return;
}
rows.forEach((row) => {
const chip = document.createElement('button');
chip.type = 'button';
chip.className = 'choice-chip';
chip.textContent = row;
chip.addEventListener('click', () => {
trySubmitAction(row);
});
choiceChips.appendChild(chip);
});
};
const renderPrivate = () => {
privateList.textContent = '';
if (!localState.privateMessages.length) {
const li = document.createElement('li');
li.textContent = '暂无私密信息';
privateList.appendChild(li);
return;
}
localState.privateMessages.slice(-20).forEach((text) => {
const li = document.createElement('li');
li.textContent = text;
privateList.appendChild(li);
});
};
const syncActionLock = () => {
const snapshot = localState.snapshot || {};
const ended = snapshot.game_state?.game_phase === 'ended' || snapshot.status === 'ended';
const running = snapshot.status === 'running' && snapshot.game_state?.game_phase === 'running';
const resolvingRound = Boolean(snapshot.is_resolving_round);
const myPending = getMyPending(snapshot);
const control = roundControl(snapshot);
const timedOutNoSubmit = control.requireAll && control.timedOut && !myPending;
const blocked = isSpectator || ended || !running || resolvingRound || myPending || localState.actionLocked || timedOutNoSubmit;
actionInput.disabled = blocked;
if (actionSubmitBtn) actionSubmitBtn.disabled = blocked;
const chatBlocked = ended || isSpectator;
chatInput.disabled = chatBlocked;
if (chatSubmitBtn) chatSubmitBtn.disabled = chatBlocked;
const quickButtons = choiceChips.querySelectorAll('button');
quickButtons.forEach((btn) => {
btn.disabled = blocked;
});
if (ended) {
actionInput.placeholder = '本局已结束';
chatInput.placeholder = '本局已结束';
} else if (isSpectator) {
actionInput.placeholder = '观战模式不可提交行动';
chatInput.placeholder = '观战模式不可发送讨论';
} else if (!running) {
actionInput.placeholder = '请等待房主开始游戏';
chatInput.placeholder = '例如:我问老板,你搜柜台';
} else if (myPending || localState.actionLocked) {
actionInput.placeholder = '你已提交本轮行动,等待AI结算...';
chatInput.placeholder = '讨论可继续发送';
} else if (timedOutNoSubmit) {
actionInput.placeholder = '本轮已超时,等待系统结算...';
chatInput.placeholder = '讨论可继续发送';
} else {
actionInput.placeholder = '例如:检查登记台抽屉里的账本';
chatInput.placeholder = '例如:我问老板,你搜柜台';
}
};
const renderEnding = () => {
const snapshot = localState.snapshot;
if (!snapshot) return;
const endedBySnapshot = snapshot.game_state?.game_phase === 'ended' || snapshot.status === 'ended';
const forced = localState.forcedEnding || {};
const endedByForced = Boolean(forced.ending_state || forced.ending_summary);
const ended = endedBySnapshot || endedByForced;
chatInput.disabled = ended || isSpectator;
if (chatSubmitBtn) chatSubmitBtn.disabled = ended || isSpectator;
if (!ended) {
endingOverlay.classList.add('hidden');
return;
}
const endingState = snapshot.game_state?.ending_state || forced.ending_state;
endingTitle.textContent = `游戏结束:${getLabel(endingState, ENDING_LABELS)}`;
const fromRound = (snapshot.game_state?.action_history || []).slice(-1)[0] || {};
const endingText = forced.ending_summary
|| snapshot.game_state?.global_flags?.ending_summary
|| fromRound.narration
|| snapshot.game_state?.scene_description
|| '本局已结束。';
endingSummary.textContent = localizeStoryText(endingText);
if (endingSummaryLink) {
endingSummaryLink.href = localState.summaryUrl || `/room/${encodeURIComponent(roomId)}/summary`;
}
endingOverlay.classList.remove('hidden');
};
const syncButtons = () => {
const snapshot = localState.snapshot;
if (!snapshot) return;
const me = (snapshot.players || []).find((p) => p.player_id === playerId);
const running = snapshot.status === 'running' && snapshot.game_state?.game_phase === 'running';
const ended = snapshot.status === 'ended' || snapshot.game_state?.game_phase === 'ended';
const isPublicRoom = Boolean(snapshot.is_public);
readyBtn.textContent = me?.is_ready ? '取消准备' : '准备';
readyBtn.disabled = isSpectator || running || ended;
startBtn.textContent = running ? '游戏进行中' : ended ? '本局已结束' : '房主开始';
startBtn.disabled = isSpectator || !me?.is_host || running || ended;
deleteRoomBtn.disabled = isSpectator || !me?.is_host;
if (publicBtn) {
publicBtn.textContent = isPublicRoom ? '取消公开' : '公开房间';
publicBtn.disabled = isSpectator || !me?.is_host;
}
};
const renderAll = () => {
renderSectionLabels();
applyCardTheme();
renderSceneBoard();
renderLogs();
renderPlayers();
renderStatus();
renderClues();
renderInventory();
renderAllStatuses();
renderAllInventory();
renderPending();
renderQuickChoices();
renderPrivate();
syncButtons();
syncActionLock();
renderEnding();
};
const loadSnapshotOnce = async () => {
try {
const res = await fetch(`/api/rooms/${roomId}/snapshot`);
const data = await res.json();
if (data.ok) {
localState.snapshot = data.snapshot;
renderAll();
}
} catch (error) {
appendSystem(`加载快照失败: ${error}`);
}
};
const emitSafe = (event, payload) => {
if (!socket || !socket.connected) {
appendSystem('连接未建立,操作未发送。');
return false;
}
if (localState.snapshot?.game_state?.game_phase === 'ended' && event !== 'request_snapshot') {
appendSystem('本局已结束,请返回大厅开始新游戏。');
return false;
}
socket.emit(event, payload);
return true;
};
const scheduleResolveFallback = () => {
if (localState.autoResolveTimer) {
clearTimeout(localState.autoResolveTimer);
}
const snapshot = localState.snapshot || {};
const control = roundControl(snapshot);
const delayMs = control.requireAll && control.deadlineValid
? Math.max(300, control.remainingMs + 150)
: 2200;
localState.autoResolveTimer = window.setTimeout(() => {
const snapshot = localState.snapshot || {};
const pendingCount = (snapshot?.pending_actions || []).length;
const running = snapshot?.game_state?.game_phase === 'running';
const resolvingRound = Boolean(snapshot?.is_resolving_round);
const controlNow = roundControl(snapshot);
if (!running || resolvingRound) return;
if (!controlNow.requireAll && pendingCount < 1) return;
if (controlNow.requireAll && !controlNow.timedOut && controlNow.submittedCount < Math.max(1, controlNow.expectedCount)) {
return;
}
emitSafe('resolve_round', { room_id: roomId, player_id: playerId });
}, delayMs);
};
const bindPreventSubmit = (form) => {
if (!form) return;
form.addEventListener('submit', (event) => {
event.preventDefault();
});
};
bindPreventSubmit(actionForm);
bindPreventSubmit(chatForm);
if (cluesToggleBtn) {
cluesToggleBtn.addEventListener('click', () => {
localState.cluesCollapsed = !localState.cluesCollapsed;
syncCluesCollapse();
});
}
readyBtn.addEventListener('click', () => {
if (isSpectator) {
appendSystem('观战模式不可操作。');
return;
}
const me = (localState.snapshot?.players || []).find((p) => p.player_id === playerId);
const nextReady = !(me?.is_ready);
emitSafe('toggle_ready', { room_id: roomId, player_id: playerId, ready: nextReady });
});
startBtn.addEventListener('click', () => {
if (isSpectator) {
appendSystem('观战模式不可操作。');
return;
}
emitSafe('start_game', { room_id: roomId, player_id: playerId });
});
deleteRoomBtn.addEventListener('click', async () => {
if (isSpectator) {
appendSystem('观战模式不可操作。');
return;
}
if (!confirm('确认删除这个房间吗?删除后该局记录会被清空。')) {
return;
}
try {
const res = await fetch(`/api/rooms/${roomId}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ player_id: playerId }),
});
const data = await res.json();
if (!data.ok) {
appendSystem(data.error || '删除失败。');
return;
}
window.location.href = '/';
} catch (error) {
appendSystem(`删除失败: ${error}`);
}
});
if (publicBtn) {
publicBtn.addEventListener('click', async () => {
if (isSpectator) {
appendSystem('观战模式不可操作。');
return;
}
const snapshot = localState.snapshot || {};
const me = (snapshot.players || []).find((p) => p.player_id === playerId);
if (!me?.is_host) {
appendSystem('只有房主可以设置房间公开状态。');
return;
}
const nextPublic = !Boolean(snapshot.is_public);
try {
const res = await fetch(`/api/rooms/${roomId}/visibility`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ player_id: playerId, is_public: nextPublic }),
});
const data = await res.json();
if (!data.ok) {
appendSystem(`设置失败:${data.error || '未知错误'}`);
return;
}
localState.snapshot = data.snapshot || localState.snapshot;
appendSystem(nextPublic ? '房间已公开,已出现在联机广场。' : '房间已取消公开。');
renderAll();
} catch (error) {
appendSystem(`网络错误: ${error}`);
}
});
}
actionForm.addEventListener('submit', (event) => {
event.preventDefault();
if (isSpectator) {
appendSystem('观战模式不可提交行动。');
return;
}
const content = actionInput.value.trim();
if (!content) return;
if (trySubmitAction(content)) {
actionInput.value = '';
}
});
chatForm.addEventListener('submit', (event) => {
event.preventDefault();
if (isSpectator) {
appendSystem('观战模式不可发送讨论。');
return;
}
const content = chatInput.value.trim();
if (!content) return;
if (emitSafe('send_chat', { room_id: roomId, player_id: playerId, content })) {
chatInput.value = '';
}
});
if (inviteBtn) {
inviteBtn.addEventListener('click', async () => {
if (localState.inviteLoading) return;
try {
setInviteLoading(true);
const resp = await fetch(`/api/rooms/${roomId}/invite`);
const data = await resp.json();
if (!data.ok) {
appendSystem(data.error || '邀请链接生成失败。');
return;
}
renderInvitePanel(data);
openInviteModal();
const copied = await copyInviteText(data.join_link || '');
appendSystem(copied ? '邀请面板已打开,加入链接已自动复制。' : '邀请面板已打开,请点击按钮复制链接。');
} catch (error) {
appendSystem(`邀请面板打开失败: ${error}`);
} finally {
setInviteLoading(false);
}
});
}
if (inviteCloseBtn) {
inviteCloseBtn.addEventListener('click', closeInviteModal);
}
if (inviteModal) {
inviteModal.addEventListener('click', (event) => {
if (event.target === inviteModal) {
closeInviteModal();
}
});
}
if (inviteCopyJoinBtn) {
inviteCopyJoinBtn.addEventListener('click', async () => {
const ok = await copyInviteText(localState.invitePayload?.join_link || '');
appendSystem(ok ? '加入链接已复制。' : '复制失败:当前浏览器限制复制权限。');
});
}
if (inviteCopySpectateBtn) {
inviteCopySpectateBtn.addEventListener('click', async () => {
const ok = await copyInviteText(localState.invitePayload?.spectate_link || '');
appendSystem(ok ? '观战链接已复制。' : '复制失败:当前浏览器限制复制权限。');
});
}
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && inviteModal && !inviteModal.classList.contains('hidden')) {
closeInviteModal();
}
});
if (typeof window.io !== 'function') {
appendSystem('Socket.IO 客户端加载失败,请刷新页面。');
loadSnapshotOnce();
return;
}
socket = window.io();
socket.on('connect', () => {
socket.emit('join_room_socket', { room_id: roomId, player_id: playerId, spectator: isSpectator });
});
socket.on('connect_error', (error) => {
appendSystem(`连接失败: ${error?.message || '未知错误'}`);
});
socket.on('room_snapshot', (snapshot) => {
localState.snapshot = snapshot;
if (snapshot?.status === 'ended' || snapshot?.game_state?.game_phase === 'ended') {
localState.summaryUrl = `/room/${encodeURIComponent(roomId)}/summary`;
}
const endedNow = snapshot?.status === 'ended' || snapshot?.game_state?.game_phase === 'ended';
if (!endedNow) {
localState.forcedEnding = null;
}
const running = snapshot?.status === 'running' && snapshot?.game_state?.game_phase === 'running';
const myPending = getMyPending(snapshot);
const roundNo = Number(snapshot?.game_state?.round_no || 0);
if (!running) {
localState.actionLocked = false;
localState.lastSubmittedRound = null;
} else if (!myPending && localState.lastSubmittedRound !== null && roundNo >= localState.lastSubmittedRound) {
localState.actionLocked = false;
localState.lastSubmittedRound = null;
}
renderAll();
if (running && ((snapshot?.pending_actions || []).length > 0 || roundControl(snapshot).requireAll)) {
scheduleResolveFallback();
}
});
socket.on('system_message', (payload) => {
appendSystem(payload?.content);
});
socket.on('pending_action_update', (payload) => {
if (payload?.should_auto_resolve) {
if (localState.autoResolveTimer) {
clearTimeout(localState.autoResolveTimer);
localState.autoResolveTimer = null;
}
return;
}
scheduleResolveFallback();
});
socket.on('round_resolved', (payload) => {
appendSystem('本轮行动已处理,故事继续推进。');
if (payload?.scene_transition) {
const t = payload.scene_transition;
storyLog.appendChild(createMsg('system', `场景推进:${t.from} -> ${t.to}`));
}
if (payload?.ending_summary) {
storyLog.appendChild(createMsg('ai', `结局:${payload.ending_summary}`));
}
if (payload?.ending_state || payload?.ending_summary) {
localState.forcedEnding = {
ending_state: payload?.ending_state || null,
ending_summary: payload?.ending_summary || '',
};
}
if (localState.autoResolveTimer) {
clearTimeout(localState.autoResolveTimer);
localState.autoResolveTimer = null;
}
localState.actionLocked = false;
localState.lastSubmittedRound = null;
renderAll();
});
socket.on('game_ended', (payload) => {
localState.forcedEnding = {
ending_state: payload?.ending_state || null,
ending_summary: payload?.ending_summary || '',
};
localState.summaryUrl = String(payload?.summary_url || '').trim() || `/room/${encodeURIComponent(roomId)}/summary`;
appendSystem('本局已结束。');
renderAll();
});
socket.on('private_updates', (payload) => {
(payload?.messages || []).forEach((msg) => {
localState.privateMessages.push(msg);
});
renderPrivate();
});
socket.on('error_message', (payload) => {
const errorText = String(payload?.error || '未知错误');
if (
errorText.includes('你不在该房间中')
|| errorText.includes('目标玩家不存在')
|| errorText.includes('玩家信息不存在')
) {
appendSystem('你已离开该房间,即将返回大厅。');
window.setTimeout(() => {
window.location.href = '/';
}, 900);
return;
}
if (!errorText.includes('已提交本轮行动') && !errorText.includes('正在结算')) {
localState.actionLocked = false;
localState.lastSubmittedRound = null;
}
appendSystem(`提示:${errorText}`);
renderAll();
});
loadSnapshotOnce();
})();