Volver al índice
docs/visor/viewer.js
/* 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); }
}
})();