1336 lines
47 KiB
JavaScript
1336 lines
47 KiB
JavaScript
(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();
|
||
})();
|