Haciendo un juego “escape room” solo con CSS, sin JS

A la hora de animar elementos, siempre es mejor hacerlo desde css. En los navegadores, el motor de render que interpreta el css es independiente y mas “barato” en terminos de procesamiento que el interprete de js.

Además, bajo ciertas circunstancias, podremos hacer uso de la GPU del dispositivo (animando solo las propiedades transform y opacity, que se ejecutan después del pintado de la pagina) y ahorrar aun más capacidad de proceso.
Por tanto, siempre será mejor animar con css y usar js solo para asignar las clases que arrancan las animaciones, en vez de usar cosas como las funciones de animación de Jquery (.anim(), .fadeIn() , etc.)

Como demo de lo que se puede conseguir en términos de UX/UI usando únicamente CSS (cero JS) vamos a hacer un juego tipo “escape room”. La “magia” del tema esta en el selector ~ , que significa “hermano menor”, es decir, aquellos elementos que están después, y al mismo nivel, que un elemento determinado, sin ese selector seria imposible nada de lo que vamos a ver.
Y checkbox’s a los que podemos cambiar el estado desde un label (o varios) en cualquier punto del árbol DOM. Con esto, podemos hacer pseudocondicionales y ejecutar “acciones” (cambio de propiedades css) en función de ellos.

Esto es muy práctico para hacer menús acordeón y desplegables que no necesitan JS para funcionar perfectamente, y funcionan desde el primer pintado de la pagina, sin necesidad de que cargue y arranque ningún script, evitando los típicos bloqueos de menús que los ves pero tardan en funcionar en conexiones malas.

El juego

 

Será un “escape room” 3D en el que podremos mirar a distintos sitios e interactuar con los objetos, tendrá el siguiente mecanismo de juego:

  • Cogemos el martillo
  • Con el martillo rompemos el cuadro
  • Al apagar la luz, en el hueco del cuadro veremos la combinación de la caja fuerte
  • Si introducimos una contraseña erronea en la caja fuerte, perdemos la partida, al introducir la correcta, nos da una llave
  • Usamos la llave en la puerta y ganamos el juego

 

El argumento no es como para ganar ningún oscar pero nos sirve para mostrar las posibilidades de css y aprender algunos trucos. El juego será en 3D como hemos dicho, podemos pulsar sobre las paredes para cambiar el punto de vista y hacer zoom sobre los objetos interactivos pulsando sobre ellos.

Layout

El entorno de la habitación vamos a hacerlo en 3D, la contendremos en un div .screen que hará de visor, dentro pondremos un div .room que alojará todos los elementos de la habitación, paredes, objetos, etc.
Para hacer el responsive lo resolvemos con una técnica que uso mucho para escalar textos y otros elementos en proporción directa al tamaño de la pantalla, usando em’s vinculados a un tamaño matriz vw.
Si usáramos pixels para situar y escalar los elementos, solo podríamos ajustar el juego al tamaño de la pantalla usando un transform: scale manejado por JS, de esta forma podemos escalar la ventana de juego a cualquier tamaño cambiando su font-size, bien sea relativo con vw o absoluto en pixels.
Siguiendo esta tecnica de establecer un font-size raiz y usar em’s para escalar sus hijos, aplicamos un font-size al .room de 0.18vw (0.18% del ancho de la ventana).
Esa medida nos permite usar 500em (un numero redondo y bonito) como ancho del .screen ocupando el 90% del viewport (500*0.18=90).

Para conseguir que se aplique una vista 3d tenemos que aplicar una perspectiva al .screen, haciendo:

 perspective-origin: center;
perspective: 370em;

 

Cuanto menor sea perspective, mas “angular” será la vista, cuanto mayor, mas “teleobjetivo”.
Con perspective-origin situamos al espectador en el espacio. En este caso lo centramos, pero podemos simular una vision cenital, desplazarlo a un lado, etc.

Tendremos 4 divs que haran las funciones de paredes y suelo, y los colocamos en su lugar usando transformaciones 3d. Hay que tener en cuenta que se aplican según el eje local de los objetos, por lo que el orden en que las aplicamos es muy importante. No acabas en el mismo sitio si te giras a la derecha y andas hacia atrás, que si andas hacia atrás y luego giras a la derecha. Asi, a la pared de la izquierda la giramos 90º en el eje y la desplazamos hacia atrás, y la de la derecha lo mismo pero girándola -90º, ademas de “subirlas” un poco (eje Y) para lograr la perspectiva que nos interesa.

.pared_left{
      transform: rotatey(90deg) translatez(-200em) translatey(-100em);
}
.pared_right {
    transform: rotatey(-90deg) translatez(-400em) translatey(-100em);
}

Además a la habitación, .room, tenemos que aplicar transform-style: preserve-3d; para que no “aplaste” las transformaciones y mantenga el aspecto tridimensional. La habitación mide 600em, y la ventana 500em, por lo que deberemos desplazarla 50em a la izquierda en el eje x para centrarla.
Vamos a hacer todo lo posible con css, por lo que haremos un uso intensivo de :before y :after para añadir elementos sin incorporar gráficos, en este caso los usamos para el ventanuco de la puerta y el agujero de la cerradura. Para el ventanuca nos basta un :before, para la cerradura usamos ademas un after para simular el agujero con un circulo y un cuadrado:

.cerradura:after,.cerradura:before {
	content: "";
	background: #000;
}
.cerradura:before {
	width: 6em;
	height: 20em;
	left: 12em;
	top: 7em;
}
.cerradura:after {
	width: 15em;
	height: 15em;
	border-radius: 15em;
	left: 8em;
	top: 5em
}

 

Para hacer los azulejos del suelo, usamos una imagen de 2×2 pixels que convertiremos en base64 para poder incluirla en el css usando cualquier conversor online. Podemos conseguir que los bordes se mantengan nitidos al escalar y así simular una textura de cualquier resolución:

image-rendering: crisp-edges;
image-rendering: pixelated;

Vistas

 

Toda la interacción la haremos con elementos <input>, que serán marcados al pulsar los <label> distribuidos por la habitación. Con ellos podremos alterar cualquier elemento usando nuestro gran amigo del alma, el selector siblings ( ~ ), del que hemos hablado al principio. Con el podremos alterar cualquier elemento de la habitacion haciendo:

#input:checked ~ room .elementoacambiar {propiedades...}

 

Recordemos que solo permite referenciar a sus hermanos “menores”, por lo que si queremos que un input dependa de otro, debemos de situarlo despues en el html, aunque no tienen porque ser consecutivos.

En el caso de los puntos de vista, como no podemos mirar dos puntos al mismo tiempo, usamos elementos radio de forma que al seleccionar uno se deseleccionan los demás. Como no podemos mover “la cámara” lo que haremos sera mover la habitación. Pondremos un <label> en cada pared vinculado a cada input-radio para poder mover el .room al marcarlos. Para identificarlos facilmente, cada label tendra el mismo nombre que su radio, pero usando clases en los label e id´s en los input. Asi, cuando esté seleccionado el input-radio de la izquierda, rotaremos la habitacion a la derecha para que el espectador mire a la pared correspondiente.

/*---- interacción ---*/
#right:checked ~.screen .room {
 transform: translatex(-50em) translatez(-00em) rotatey(45deg);
}
#left:checked ~.screen .room {
 transform: translatex(-50em) translatez(-00em) rotatey(-45deg) ;
}

 

Ojo, ponemos los labels al 100% de ancho y alto para que cubran toda la habitación, en chrome funcione perfecto, pero algún bug de firefox provoca que se solapen los divs con los label, aunque visualmente no lo hagan, y no se puedan pulsar. Para evitarlo, desactivamos la capacidad de los divs de ser visibles para el al ratón y se la activamos explicitamente a los label para que no la hereden.

.screen div{
 pointer-events:none;
}
label{
 pointer-events:all;
}

Si juntamos todo lo visto, nos queda algo así, posteriormente pondremos display:none a los <input> para esconderlos al usuario, de momento los dejamos visibles para facilitar la comprensión del sistema. Ya podemos mirar a los lados y al frente.

 

Los objetos

Vamos a empezar por el interruptor para apagar la luz.  Lo haremos con un simple label asociado a  un checkbox, como con las vistas, aunque no es necesario en este caso usar radios’s ya que su estado es independiente de que se pulse cualquier otra cosa.
Pondremos un input #luz antes de .room   y un interruptor .luz dentro de la .pared_front. Le damos estilo para darle forma de interruptor y situarlo en la pared.  Cuando pulsemos sobre el haremos dos cosas, pondremos un :before en .screen con un fondo negro semitransparente e invertiremos el degradado del botón para darle aspecto de pulsado.
Usaremos (de nuevo y como siempre en este tutorial) el selector :checked asociado a #luz y ~ para llegar hasta el elemento que queremos modificar. Como le hemos puesto ya width, height y otros parametros a los :before y :after solo tendremos que darle un content y un background al :after para conseguir el velado:

 

#luz:checked ~.screen .luz:before {
 background: linear-gradient(to bottom, #ffffff 40%, #cedbe5 60%);
}
#luz:checked ~.screen:after {
 content: "";
}

Ahora añadimos un cuadro que al pulsarlo se caerá. Usamos el procedimiento anterior, añadir un input checkbox antes de .room para controlar su estado y un label dentro de .pared_right para situarlo en la pared de la derecha

En realidad, el “objeto” será la tipica sombra que dejan en la pared los cuadros viejos, el cuadro lo pondremos con un :before y será lo que animemos  para hacerlo caer al suelo. Para ello añadimos  transition: all 1s ease-in-out  entre sus propiedades y una clase #cuadro:checked~.screen .cuadro:before  para tirarlo al suelo cuando se clicque:

#cuadro:checked ~.screen .cuadro:before {
 transform: rotatex(90deg) translatey(120em) translatez(-270em);
}

Tenemos un problema, cuando volvemos a clicar sobre la sombra de la pared el input#cuadro se deselecciona y el cuadro vuelve a su sitio, por lo que vamos a desactivar el label con pointer-events:none una vez esté pulsado de forma que no pueda volverse a pulsar, además, vamos a hacer que se lea un codigo al apagar la luz. Como ya hemos “gastado” el :before del .cuadro para hacer el marco, usaremos :after y solo lo mostraremos cuando la luz esté apagada #luz:checked ~ #cuadro:checked es una simulación de condicionales con css:

#cuadro:checked~.screen .cuadro:before {
 transform: rotatex(90deg) translatey(120em) translatez(-270em);
}
#cuadro:checked~.screen .cuadro {
 pointer-events: none;
}
#luz:checked ~ #cuadro:checked ~.screen .cuadro:after{
 content:"01101";
 font-size: 40px;
 display: flex;
 color: #fff;
 align-items: center;
 justify-content: center;
}

Ya hemos visto como se hacen las condicionales. Ahora vamos a añadir un objeto que podremos coger y usar, un martillo. Ademas de su input para comprobar el estado, y su label para ponerlo en la pared, tenemos que añadir un elemento  de interfaz para mostrar si lo hemos cogido, usaremos un input text (en seguida veremos porque un “text”) que pondremos dentro de .screen.

Inciso: ¿por que sacamos todos los input fuera de .screen menos este?
Primero: para cambiar el estado de cualquier elemento a partir de un input, es mucho mas comodo si tenemos un elemento padre que los contenga a todos y usar la formula input:cheked ~ .container .elemento que tener que tener una ruta especifica para cada uno a partir de siblings.
Ademas, necesitamos acceder a .screen en determinadas ocasiones (insertar el velo al apagar la luz, cuando se gana o se pierde, etc.) y no podriamos hacerlo si los input estuvieran dentro.

Volviendo al martillo. Usaremos como fondo un svg convertido a base64, que minimiza el peso del código y es escalable a cualquier tamaño.
El icono de “objeto cogido” lo sacamos visualmente fuera de .screen y lo hacemos visible (y pulsable) una vez esté #martillo seleccionado usando la clase .icon.
Por defecto le ponemos  transform: translatey(-7em) para sacarlo de pantalla y animar su aparición,además de eliminar el objeto del escenario con un display:none cuando se coja, algo así.

#hammer:checked ~.screen .icon.hammer{
 transform: translatey(0);
}
#hammer:checked ~.screen .room .hammer {
 display: none;
}

Ahora viene lo gordo. Queremos que al pulsar un elemento con el martillo “activado” ejecutemos determinada accion y que el cursor adopte la forma del objeto que estamos usando, un martillo o (pronto) una llave.
Pero si usamos un checkbox, cuando lo pulsemos para activarlo, permanecerá “encendido” hasta que volvieramos a pulsarlo, queremos que al hacer click sobre cualquier cosa “soltemos” el objeto, pero no podemos deseleccionar un input desde css.

¿Como podemos hacerlo? Con el selector :focus vinculado  a un input tipo text. Gracias a :focus podemos detectar que ha sido pulsado un elemento, cuando pulsemos en algun otro punto el foco cambiara y el elemento volverá a su estado inicial. Para empezar, hacemos que al pulsar sobre el martillo, el icono se convierta en un martillo para indicar que lo hemos “cogido” , lo hacemos, como hemos dicho, usando :focus, por eso hemos puesto el input de icono como “text”, porque retiene el foco.

Así convertimos el cursor en un martillo:

.icon.hammer:focus + .room *{
 cursor:url('data:image/png;[...]);
}

Ahora tenemos que hacer que el cuadro solo se caiga si hemos cogido el martillo primero. Pondremos el label.cuadro por defecto a pointer-events:none y lo activariamos  (pointer-events: all) solo cuando esté el foco activo en .icon.hammer con .icon.hammer:focus ~ .room .cuadro{pointer-events: all} pero aquí me encuentro con un problema. Aunque debería funcionar, y de hecho segun mis pruebas funciona en situaciones tipo  elemento:focus ~ elemento pero en este caso, parece que el hecho de estar los elementos anidados hace que se pierda el focus antes de detectar el checked y no funciona.
Lo resolvemos así (si alguien tiene una idea mejor estoy ansioso por oirla):

.icon.hammer:not(:focus) ~ .room .cuadro{
 pointer-events:none;
 animation: activa 1s 1;
}
@keyframes activa{
 from {pointer-events:all }
 to {pointer-events:none}
}

En vez de decirle “Cuando martillo este en focus, dejate clicar” le decimos “cuando NO este en focus, NO te dejes clicar”. Con esto lo que hacemos es provocar un cambio de estado cuando se pierda el foco en .icon.hammer que arranca la animación activa. Pointer-events no es animable en el sentido estricto, un paso gradual de un estado a otro, pero lo utilizamos para que permanezca en “all” durante un segundo después de perderse el foco en .icon.hammer y capture el click. Es una forma de aplicar un delay a la aplicación de una propiedad, luego también lo emplearemos para poner una cuenta atrás. Ya tenemos esto:

Ganar o perder

Vamos a añadir una caja fuerte en la pared de la izquierda, si se intenta abrir con una combinación incorrecta perderemos el juego.
El proceso es similar a cuando pusimos el cuadro, pondremos 5 input que serán las ruedas para introducir la combinación y otro para aplicarla. Ademas, haremos que la cámara se centre en ella cuando la pulsemos, por lo que añadimos un <radio> a los que ya habiamos fijado para las vistas y metemos todo en un contenedor <label> asociado a el. Los selectores de combinación se llamaran .pass_1-…-.pass_5 (según la norma que seguimos, estarán asociados a los input #pass_1-…-#pass_5) y podemos referirnos a ellos sin usar una clase nueva con el lector de atributos de css.

Con [class*=x] buscamos los elementos que contengan “x” en sus nombres de clases:

label[class*="pass"] {
 background: #ccc;
 width: 30em;
 height: 100%;
 margin: 2em;
 position: relative;
 width: 15em;
 height: 35em;
 border-radius: 20em;
 overflow: hidden;
 box-shadow: 0 0 30px #666 inset;
}

Usaremos los :before y :after para añadir el texto 0/1 y un circulo que indique cual está seleccionado, animado con:

#pass_1:checked~.screen .pass_1:before,
#pass_2:checked~.screen .pass_2:before,
#pass_3:checked~.screen .pass_3:before,
#pass_4:checked~.screen .pass_4:before,
#pass_5:checked~.screen .pass_5:before {
 top:21em;
}

Antes elegimos la combinación 01101, por lo que vamos a hacer que se acabe el juego al marcar otra.

La forma como mostramos el fin de partida es mostrar un mensaje eliminar la posibilidad de interaccion para que no se pueda seguir jugando.
Lo más sencillo es activar la perdida por defecto al pulsar .box_open, y desactivarla (borrando el contenido)  si la combinación era correcta, declarandolo después en el CSS.
Ojo al pointer-events:all que hace que la capa :before bloquee la interacción con lo que hay debajo y no permite seguir “jugando”

#box_open:checked~.screen:before {
 content: "¡ALARMA!";
 font-weight:bold;
 pointer-events:all;
 ...
#pass_1:checked + #pass_2:not(checked) +#pass_3:not(checked) +#pass_4:checked +#pass_5:not(:checked) ~ #box_open:checked ~ .screen:before {
 content:none;
}

Pongamos que ha puesto bien la contraseña, entonces abrimos la puerta y mostramos una llave para aplicarla a la puerta de la misma forma que hicimos con el martillo al cuadro. Para añadir la llave hacemos lo mismo que antes, checkbox, label y text y replicamos los pasos de mostrar su icono cuando haya sido recogida y eliminarla de la escena.
La llave la pondremos dentro de .box, originalmente tapada por .box_door, y se mostrará al abrir la puerta con:

#pass_1:checked + #pass_2:not(checked) +#pass_3:not(checked) +#pass_4:checked +#pass_5:not(:checked) ~ #box_open:checked ~ .screen .box_door{
 transform:translatex(-90%);
}

Repetimos lo que hemos hemos hecho con el selector :focus y el martillo para hacer que solo pueda pulsar el label de la cerradura si ha “encendido” primero el icono de la llave.  Cuando lo consiga, replicaremos lo que hicimos antes para perder la partida, cambiando el mensaje por uno de victoria.

Ojo: Tendremos que añadir !important al content de .screen:before cuando gana, recordemos que lo estamos eliminando antes al poner la contraseña correcta, con mucha especificidad (seis id’s nada menos)

Para redondear el funcionamiento del juego añadimos una vista a la puerta, para dar a entender que hay que hacer “algo” con ella, y bloqueamos los label que están en un objeto con zoom (la puerta y la caja fuerte) a no ser que estemos en su vista, para no pulsar el botón de abrir la caja fuerte cuando intentamos ir a su vista, por ejemplo:

#left:not(:checked) ~.screen .box,
#center:not(:checked) ~.screen .door,
#door:not(:checked) ~.screen .door label,
#box:not(:checked) ~.screen .box label{
 pointer-events:none;
}

Apagamos los input para que no se vean y ya tenemos nuestro juego funcionando.

Leave a Reply

Your email address will not be published. Required fields are marked *