/* Estado del visor: imágenes, movimiento y sensibilidad comparten selección. */ const catalog = { idle: ['Acecho', 'La mirada espera; el cuerpo busca otra postura.'], chase: ['Persecución', 'Zancadas largas, peso bajo y manos que buscan apoyo.'], chase4: ['En cuatro apoyos', 'Las manos alcanzan el suelo antes de que avance el resto del cuerpo.'], drag: ['Arrastre', 'Los brazos tiran del cuerpo; las piernas lo siguen.'], crawl: ['Paso estrecho', 'Se pliega para atravesar los huecos.'], fold: ['Plegarse', 'El cuerpo desciende antes de entrar y se incorpora al recuperar espacio.'], turn: ['Girar', 'El torso y la mirada acompañan el cambio de dirección.'], climb: ['Trepar', 'Manos y pies alternan sus apoyos sobre la pared.'], lore: ['Encuentro', 'La espalda se retuerce. Todavía queda algo del huésped.'], attack: ['Ataque', 'Un golpe breve y una vuelta al apoyo.'] }; const safe = document.querySelector('#safe'); const motion = document.querySelector('#motion'); const voice = document.querySelector('#voice'); /* Velocidad del visor: se conserva al cambiar de movimiento o altura. */ const playbackSpeed = document.querySelector('#playback-speed'); function applyPlaybackSpeed() { const requested = Number(playbackSpeed.value); const speed = [0.5, 1, 1.5, 2, 3].includes(requested) ? requested : 1; motion.defaultPlaybackRate = speed; motion.playbackRate = speed; } playbackSpeed.addEventListener('change', applyPlaybackSpeed); motion.addEventListener('loadedmetadata', applyPlaybackSpeed); /* Catálogo de efectos: los pendientes no crean reproductores ni cargan archivos. */ const soundCatalog = { step: 'Pasos', attack: 'Ataque', climb: 'Trepar', chase: 'Persecución' }; for (const [id, title] of Object.entries(soundCatalog)) { const item = document.createElement('article'); item.dataset.sound = id; const heading = document.createElement('h3'); heading.textContent = title; const status = document.createElement('p'); status.className = 'sound-status'; status.textContent = 'No disponible'; item.append(heading, status); document.querySelector('#sounds').append(item); } function stopHorrorAudio(except = null) { for (const player of document.querySelectorAll('audio')) { if (player === except) continue; player.pause(); player.currentTime = 0; } } for (const player of document.querySelectorAll('audio')) { player.addEventListener('play', () => stopHorrorAudio(safe.checked ? null : player)); } let phase = 4; let mode = 'idle'; let passageHeight = 1; for (let height = 1; height <= 4; height++) { const button = document.createElement('button'); button.type = 'button'; button.textContent = '1 × ' + height; button.dataset.height = height; button.onclick = () => { passageHeight = height; updateMotion(true); }; document.querySelector('#clearance').append(button); } for (let i = 0; i < 5; i++) { const button = document.createElement('button'); button.type = 'button'; button.textContent = 'Fase ' + i; button.onclick = () => { phase = i; updateImages(); }; document.querySelector('#stages').append(button); } for (const [id, [title]] of Object.entries(catalog)) { const button = document.createElement('button'); button.type = 'button'; button.textContent = title; button.dataset.mode = id; button.onclick = () => { mode = id; updateMotion(true); }; document.querySelector('#modes').append(button); } /* La voz siempre empieza mediante un gesto; el modo sensible la detiene. */ function updateImages() { document.querySelector('#hero').src = 'media/parasito-' + (safe.checked ? 'sensible' : 'modelos') + '.png'; document.querySelector('#stage-image').src = 'media/parasito-' + (safe.checked ? 'sensible' : 'fase-' + phase) + '.png'; document.querySelector('#face').src = 'media/parasito-' + (safe.checked ? 'sensible' : 'rostro') + '.png'; document.querySelector('#phase-name').textContent = safe.checked ? 'Modo sensible' : 'Fase ' + phase; for (const [i, button] of [...document.querySelector('#stages').children].entries()) { button.setAttribute('aria-pressed', String(i === phase)); button.disabled = safe.checked; } } let playbackRevision = 0; function updateMotion(play = false) { const revision = ++playbackRevision; const lateralSupport = !safe.checked && mode === 'crawl' && passageHeight === 3; const wallClimb = !safe.checked && mode === 'climb'; let selected = safe.checked ? 'friendly' : mode === 'crawl' && passageHeight > 1 ? 'crawl' + passageHeight : mode; if (lateralSupport) selected = 'crawl3-paredes'; if (wallClimb) selected = 'climb-pared'; motion.pause(); motion.src = 'media/parasito-' + selected + '.mp4'; motion.load(); applyPlaybackSpeed(); motion.poster = 'media/parasito-' + (wallClimb ? 'climb-pared' : lateralSupport ? 'crawl3-paredes' : safe.checked ? 'sensible' : 'modelos') + '.png'; document.querySelector('#motion-description').textContent = safe.checked ? 'Un huésped tranquilo.' : lateralSupport ? 'Se sostiene en las paredes laterales mientras avanza.' : catalog[mode][1]; document.querySelector('#voice-panel').hidden = safe.checked; document.querySelector('#sounds-panel').hidden = safe.checked; document.querySelector('#clearance-panel').hidden = safe.checked || mode !== 'crawl'; for (const button of document.querySelector('#clearance').children) button.setAttribute('aria-pressed', String(Number(button.dataset.height) === passageHeight)); for (const button of document.querySelector('#modes').children) { button.setAttribute('aria-pressed', String(button.dataset.mode === mode)); button.disabled = safe.checked; } if (safe.checked) { stopHorrorAudio(); for (const media of document.querySelectorAll('#voice-panel audio, #voice-panel video')) { media.pause(); media.currentTime = 0; } } if (play) { motion.muted = true; motion.play().catch(() => { if (revision === playbackRevision) document.querySelector('#motion-play').hidden = false; }); } } const playButton = document.querySelector('#motion-play'); playButton.onclick = () => { motion.muted = true; motion.play().catch(() => {}); }; motion.addEventListener('playing', () => { playButton.hidden = true; }); motion.addEventListener('pause', () => { playButton.hidden = false; }); motion.addEventListener('emptied', () => { playButton.hidden = false; }); safe.onchange = () => { updateImages(); updateMotion(true); }; updateImages(); updateMotion(); /* Herramientas del sitio: navegar por el mismo visor y leer su código público. */ (async function exposeSiteTools() { const context = document.modelContext || navigator.modelContext; if (typeof context?.registerTool !== 'function') return; const schema = (properties, required = []) => ({type: 'object', properties, required, additionalProperties: false}); const state = () => JSON.stringify({phase, motion: mode, sensitivity: safe.checked, image: document.querySelector('#stage-image').src, video: motion.currentSrc || motion.src, visualGallery: new URL('visual/', location.href).href, codeIndex: new URL('auditoria/', location.href).href}); const tools = [ {name: 'raps_gallery_state', description: 'Leer la fase, animación y enlaces visibles de la galería de Raps.', inputSchema: schema({}), execute: async () => state()}, {name: 'raps_show_phase', description: 'Mostrar una fase de transformación del huésped en la página actual.', inputSchema: schema({phase: {type: 'integer', minimum: 0, maximum: 4}}, ['phase']), execute: async (input) => { if (!Number.isInteger(input.phase) || input.phase < 0 || input.phase > 4) throw new Error('Fase inválida.'); phase = input.phase; updateImages(); return state(); }}, {name: 'raps_show_animation', description: 'Seleccionar una animación del huésped para revisarla en el visor.', inputSchema: schema({motion: {type: 'string', enum: Object.keys(catalog)}}, ['motion']), execute: async (input) => { if (!Object.hasOwn(catalog, input.motion)) throw new Error('Animación inválida.'); mode = input.motion; updateMotion(); motion.scrollIntoView({block: 'center'}); return state(); }}, {name: 'raps_sensitivity', description: 'Activar o desactivar la variante visual amistosa del visor.', inputSchema: schema({enabled: {type: 'boolean'}}, ['enabled']), execute: async (input) => { if (typeof input.enabled !== 'boolean') throw new Error('Preferencia inválida.'); safe.checked = input.enabled; updateImages(); updateMotion(); return state(); }} ]; if (location.protocol === 'https:' || location.protocol === 'http:') { let manifest; const getManifest = async () => { if (!manifest) { const response = await fetch('auditoria/manifest.json', {signal: AbortSignal.timeout(15000)}); if (!response.ok) throw new Error('No se pudo leer el índice.'); manifest = await response.json(); } return manifest; }; tools.push( {name: 'raps_list_project_files', description: 'Enumerar los archivos públicos de Raps y sus hashes para una auditoría.', inputSchema: schema({}), execute: async () => JSON.stringify(await getManifest())}, {name: 'raps_read_project_file', description: 'Leer un archivo de código o documentación del índice público de Raps.', inputSchema: schema({path: {type: 'string'}}, ['path']), execute: async (input) => { if (!(await getManifest()).some(file => file.path === input.path)) throw new Error('Archivo fuera del índice público.'); const filename = input.path.replaceAll('/', '--') + '.txt'; const response = await fetch('auditoria/codigo/' + encodeURIComponent(filename), {signal: AbortSignal.timeout(15000)}); if (!response.ok) throw new Error('No se pudo leer el archivo.'); return response.text(); }} ); } for (const tool of tools) { try { await context.registerTool(tool); } catch (error) { console.debug('No se pudo registrar la herramienta del visor:', tool.name); } } })();