cómo recuerda la isla

9 de septiembre de 2026 meta devlogsveltekit

Los pájaros y peces de este sitio se supone que se sienten vivos, moviéndose por el cielo y la franja de la orilla en la cabecera y el pie de página. Lo que no pensé del todo hasta que un lector (yo, probando mi propio sitio como una persona normal por una vez) lo señaló es qué pasa cuando pulsas recargar. Cada vez, toda la escena se reiniciaba. Pájaros distintos, posiciones iniciales distintas, como si el cielo se borrara y repoblara desde cero en el momento en que dejabas de mirar. No se sentía vivo, se sentía regenerado aleatoriamente, que es una cosa completamente distinta.

la petición, con mis propias palabras

Me escribí una nota en su momento que decía, más o menos, "aleatorio si la visita es de menos de 5min... si no, seguir dónde se quedó, y mantener si cambia de página." Hacer clic por el sitio no debería tocar la escena en absoluto, ya que los componentes se montan una vez desde el layout raíz y se quedan montados todo el tiempo que navegas del lado del cliente. Esa parte ya venía gratis, SvelteKit no vuelve a montar un componente solo porque la URL cambió debajo de él. El hueco real era el otro caso: una recarga forzada, o abrir una pestaña nueva. Ese es el momento en que el componente se vuelve a montar desde la nada, y hasta ese punto, "desde la nada" significaba un sorteo aleatorio completamente nuevo cada vez.

la primera versión no estaba mal, estaba congelada

Mi primer intento de arreglar esto estaba casi bien y completamente roto de una forma que no noté durante un día. Guardé la escena dibujada en localStorage, qué sprites se habían elegido y en qué punto de su ciclo de vuelo estaba cada uno, y la restauraba en el siguiente montaje en vez de volver a sortear:

// first attempt: save the phase, restore the exact same phase
function restoreScene(saved: ScenePhases): ScenePhases {
	return saved;
}

Esa parte funcionaba, en el sentido de que los datos guardados volvían intactos. Lo que olvidé fue el tiempo. Guardé la fase exacta en la que estaba cada pájaro cuando la página se descargaba, y en la siguiente carga simplemente... los ponía de vuelta exactamente ahí. Congelados. Si dejabas la pestaña en el dock durante dos minutos y volvías, los pájaros no se habían movido ni un centímetro, porque nada tenía en cuenta los dos minutos que de verdad habían pasado en el mundo real.

El arreglo real necesitaba avanzar cada fase guardada según cuánto tiempo había estado fuera realmente el visitante, envuelto correctamente alrededor de la duración de ciclo propia de cada pájaro, para que un pájaro que vuela de borde a borde cada 75 segundos y ha estado "fuera" 200 segundos vuelva a mitad de su tercera vuelta, no atascado en el segundo doce de la vuelta uno para siempre:

export function advancePhases(
	phases: ScenePhases,
	cycles: Record<string, number>,
	elapsedMs: number
): ScenePhases {
	const elapsedS = Math.max(0, elapsedMs) / 1000;
	const advanced: ScenePhases = {};
	for (const [key, value] of Object.entries(phases)) {
		const cycle = cycles[key];
		if (!cycle || cycle <= 0 || !Number.isFinite(value)) {
			advanced[key] = value;
			continue;
		}
		advanced[key] = (((value + elapsedS) % cycle) + cycle) % cycle;
	}
	return advanced;
}

El doble módulo al final ((((value + elapsedS) % cycle) + cycle) % cycle) está ahí porque el % de JavaScript puede devolver un resultado negativo si el lado izquierdo es negativo, y quería que esta función fuera segura de llamar también con un tiempo transcurrido raro o negativo (desfase de reloj, una marca de tiempo guardada del futuro, lo que sea) sin producir una fase fuera de su propio rango válido. Cinturón y tirantes, pero baratos.

la caducidad también tiene un techo

Nada de esto debería durar para siempre. Una escena de hace seis horas no es "la misma visita continuando," es una visita nueva que resulta que comparte navegador. Así que la escena guardada lleva su propia comprobación de antigüedad y caduca a los cinco minutos:

export const SCENE_MAX_AGE_MS = 5 * 60 * 1000;

Pasado eso, o si no se guardó nada, o si los datos guardados no se pueden parsear, simplemente sortea una escena fresca como siempre hizo antes de que nada de esto existiera. Me aseguré de que cada lectura aquí también esté envuelta en try/catch, Safari en navegación privada lanza un error con solo tocar localStorage, y un visitante con el almacenamiento desactivado no debería obtener una página rota, debería simplemente obtener el decorado normal de sorteo fresco sin memoria asociada. Perder la persistencia en silencio está bien. Lanzar un error donde se suponía que debía haber un pájaro no lo está.

la arruga: qué cuenta realmente como "recarga"

Aquí está la parte que más me costó hacer bien, después de que ya pensaba que había terminado. Un visitante que recarga la página a propósito, pulsa el botón real de recargar porque quiere ver la página fresca, espera que una recarga baraje las cosas, igual que lo haría una primera visita. Pero mi lógica de restauración no podía distinguir "pulsaste recargar" de "hiciste clic en un enlace" desde dentro del componente, porque para cuando se ejecuta, todo lo que sabe es "ocurrió un montaje." Ambos casos simplemente parecen un montaje.

El navegador en realidad te dice la diferencia, a través de una API que no sabía que existía hasta que la busqué: performance.getEntriesByType('navigation') devuelve una entrada cuyo type es literalmente 'reload' para una recarga deliberada, frente a 'navigate' para básicamente todo lo demás:

export function safeNavigationType(): NavigationType | undefined {
	try {
		if (typeof performance === 'undefined' || typeof performance.getEntriesByType !== 'function') {
			return undefined;
		}
		const [entry] = performance.getEntriesByType('navigation') as PerformanceNavigationTiming[];
		return entry?.type;
	} catch {
		return undefined;
	}
}

Todas estas matemáticas, el sorteo, el guardado, la restauración, el avance de fase, viven en un módulo de TypeScript plano completamente separado del componente Svelte que de verdad renderiza los pájaros, específicamente para poder escribir pruebas unitarias reales contra él sin necesitar un navegador ni un runtime de Svelte levantado en absoluto. Dado un objeto de almacenamiento falso en memoria y una marca de tiempo fija en vez del Date.now() real, puedo verificar exactamente a qué restaura un guardado de hace cinco minutos, a qué cae uno de hace seis minutos, y qué hace un guardado corrupto o parcialmente escrito, todo sin tocar nunca una página real. Esa separación es lo que me dio la confianza suficiente para lanzar el arreglo del tiempo transcurrido en primer lugar, podía ver los números de fase caer exactamente donde decían las matemáticas que debían, en vez de calcular a ojo si un pájaro "se veía más o menos bien" tras esperar unos minutos en una pestaña de navegador real.

Y luego la decisión real, que se lee casi aburridamente simple una vez que las dos piezas anteriores existen para respaldarla:

const saved = navigationType === 'reload' ? null : loadScene<unknown>(storage, SKY_COLONY_STORAGE_KEY, now);

Una recarga real se salta la escena guardada por completo y sortea una fresca, a propósito, cada vez. Cualquier otra cosa, botón de atrás, una pestaña nueva, simplemente navegar por ahí, restaura y avanza con normalidad. Es una distinción pequeña y me tomó más tiempo rastrearla que la propia lógica de persistencia, pero es la diferencia entre que la escena se comporte como un visitante real espera y técnicamente-correcto-pero-molesto.

0 comentarios

Inicia sesión para comentar.

Iniciar sesión

¿No tienes cuenta?