Postmorten de Lifeguard, minijuego creado en vacaciones
Esta semana he estado de vacaciones y se me ha ocurrido un reto, hacer una pequeña jam creando un videojuego. La idea con esto era estar entretenido, crear algo y no limitarme a consumir contenido, y aprender algo de desarrollo de videojuegos.
Tampoco iba a estar 24/7 programando, al final cayeron un par de horas cada día como mucho, unas 9 horas en total.
He decidido autoimponerme algunas normas para limitar un poco el scope y no agobiarme.
Normas:
- Hacerlo solo durante la semana de vacaciones
- Que el juego tenga jugabilidad (obvio) y sea completo (sonidos, pantalla de inicio, etc)
- Temática que recuerde al verano
- Paleta con muy pocos colores, mi idea es usar 4 (negro, blanco, azul y rojo)
- Usar un lenguaje web para que se pueda jugar online en mi web
El juego lo he decido llamar Lifeguard y la idea es manejar a un socorrista que tiene que ayudar a unos bañistas en una playa.

Creando la estructura base
Para el juego he decidido usar Pixi, una librería WebGL para manejar de forma cómoda el canvas HTML y que da algunas abstracciones útiles como Sprites, Texturas, Assets, e incluso game loop.
El proyecto lo he montado usando Vite y Typescript. Tras instalar todas las dependencias y configurar Vite pasé a pintar algo en pantalla. Creé una textura de un cuadrado roja para probar que el juego permite cargar texturas y la pinté en pantalla.
Mi idea era montar las mecánicas el juego para probar la jugabilidad antes de pasar al arte (texturas, sonidos, etc).

Luego pasé a crear la clase NPC, con otra textura. Hice una clase Entity de la que heredan NPC y Player. Aclarar que no tengo experiencia creando videojuegos y que en general la arquitectura del software no se me da bien (sigo aprendiendo).
import { Assets, Sprite } from "pixi.js";
import { Position } from "./interfaces/position";
export class Entity extends Sprite {
constructor(private texturePath: string) {
super();
this.loadSprite();
this.setPosition(0, 0);
}
loadSprite = async () => {
const texture = await Assets.load(this.texturePath);
this.texture = texture;
this.anchor.set(0.5);
};
setPosition = (x: Position["x"], y: Position["y"]) => {
this.x = x;
this.y = y;
};
}
import { Entity } from "./entity";
export class Npc extends Entity {
constructor() {
super("./assets/npc.png");
}
}
import { Entity } from "./entity";
export class Player extends Entity {
constructor() {
super("./assets/player.png");
}
}

También hice el patrón singleton para poder tener acceso a la instancia que contiene le juego de forma global y así no ando pasándola por todos lados.
import { Canvas } from "./canvas";
import { Npc } from "./npc";
import { Player } from "./player";
export class Game {
static readonly instance: Game;
private canvas: Canvas;
private player: Player;
private npc: Npc;
constructor() {}
start = () => {
this.canvas = new Canvas();
this.player = new Player();
this.npc = new Npc();
this.canvas.app.stage.addChild(this.player);
this.canvas.app.stage.addChild(this.npc);
this.player.setPosition(200, 200);
this.npc.setPosition(240, 240);
};
}
La clase Canvas simplemente instancia una Application de Pixi y añade el canvas al HTML.
Primeras mecánicas
La mecánica principal del juego es la del jugador resolviendo una serie de "tareas" de los NPC, es decir, los NPC tienen una barrita que va subiendo y el jugador tiene que llegar a tiempo para bajarla.
import { Graphics, Rectangle, Ticker } from "pixi.js";
import { Entity } from "./entity";
import { Game } from "./game";
import { linearMap } from "../utils/numbers";
export class Npc extends Entity {
private taskTime: number = 0;
private taskRange = { min: 5000, max: 10000 };
private taskBarMaxWidth = 100;
private taskBar: Graphics;
constructor() {
super("./assets/npc.png");
}
setNewTask = () => {
this.taskTime = Math.random() * (this.taskRange.max - this.taskRange.min) + this.taskRange.min;
this.createTaskBar();
}
update = (delta: Ticker) => {
if (this.taskTime <= 0) {
this.setNewTask();
}
this.taskTime = Math.max(this.taskTime - delta.deltaMS, 0);
this.renderTask();
};
renderTask = () => {
this.taskBar?.clear();
this.createTaskBar();
this.addChild(this.taskBar);
};
createTaskBar = () => {
const barWidth = linearMap(this.taskTime, 0, this.taskRange.max, 0, this.taskBarMaxWidth);
this.taskBar = new Graphics().rect(0 - this.width / 2, -40, barWidth, 10).fill(0xff00ff);
}
}
De primeras lo que hice fue que los NPC tuvieran un campo taskTime el tiempo de la tarea y con ese campo hago un linearMap para mapearlo al ancho de una barra que se dibuja encima del NPC. La función de update es la que se ejecuta en el game loop del juego y es la que se encarga de bajar el tiempo de la tarea.
Creando el movimiento del personaje
Para el movimiento del personaje simplemente añadí un keylistener en el player para moverlo dependiendo de la tecla pulsada.
Un problema que me encontré es, que al implementar el sistema para reaccionar cuando el usuario pulsa una tecla, tiene un delay al dejarla presionada. Yo lo había implementado de primeras con el evento keydown de Javascript, pero se ve que lo mejor es usar el propio game loop para actualizar al jugador.
La estrategia consiste en tener un objeto keyState con las teclas que se están pulsado con el estado true de tal forma que solo se ponen a false en el keyup, haciendo que si mantienes pulsada una tecla no haya delay porque se mantiene a true desde que la pulsas por primera vez.
En la clase Game:
setEvents = () => {
window.addEventListener(
"keydown",
(event: KeyboardEvent) => {
const key = event.key.toLowerCase();
this.keyState[key] = true;
},
true
);
window.addEventListener(
"keyup",
(event: KeyboardEvent) => {
const key = event.key.toLowerCase();
this.keyState[key] = false;
},
true
);
};
En la clase Player:
onKeyDown = (keyState: { [key: string]: boolean }) => {
let velocity = this.velocity;
const isWPressed = Boolean(keyState["w"]);
const isAPressed = Boolean(keyState["a"]);
const isSPressed = Boolean(keyState["s"]);
const isDPressed = Boolean(keyState["d"]);
if (Number(isWPressed) + Number(isAPressed) + Number(isSPressed) + Number(isDPressed) >= 2) {
velocity = velocity * 0.75;
}
if (isWPressed) {
this.y -= velocity;
}
if (isAPressed) {
this.x -= velocity;
}
if (isSPressed) {
this.y += velocity;
}
if (isDPressed) {
this.x += velocity;
}
}
Un detalle interesante es que detecto si se pulsan más de dos teclas para bajar un pelín la velocidad del jugador. Esto lo hago porque en los con este sistema de movimiento en x e y, al avanzar en diagonal se hace más rápido que en una dirección, por eso lo limito un poco, para intentar que la velocidad siempre similar.
La interacción del jugador con los NPC
A la clase Entity le añadí un método para saber si está cerca de otra:
isNearEntity = (entity: Entity, distance: number) => {
const xDistance = Math.abs(this.x - entity.x);
const yDistance = Math.abs(this.y - entity.y);
if (xDistance < distance && yDistance < distance) {
return true;
}
return false;
}
Simplemente detecto si el jugador está cerca de algún NPC para reducir su taskTime (hacer la barra más pequeña otra vez).
playerNpcInteraction = (delta: Ticker) => {
if (this.player.isNearEntity(this.npc, 40)) {
const isActionKeyDown = Boolean(this.keyState["e"]);
if (isActionKeyDown) {
this.npc.reduceTask(delta, this.playerTaskActionFactor);
}
}
}
En este punto me dí cuenta de que el rendimiento era una mierda porque en cada tick del loop se estaba creando un sprite nuevo (el de la barra de la tarea de los NPC). Refactorizé el código para que el sprite se cree una sola vez y en el game loop simplemente acutalice su ancho.
El arte del juego
Como he dicho al principio el arte del juego es muy sencillo, 4 colores. La idea con esto era no volverme loco y hacer algo acotado. Creé un spritesheet con todas las texturas del juego, cada textura de 16 x 16.

También aproveché a meter todos los frames de la animación de correr del jugador y los sprites para la pantalla de arranque del juego.
Para cargar las texturas hice una clase también usando el patrón singleton. para cargar el spritesheet una sola vez en todo el juego y simplemente indicar qué parte de la textura cargar:
import { Texture, Assets as PixiAssets, Rectangle, TextureSource, TextureStyle } from "pixi.js";
export class Assets {
static instance: Assets;
private spriteSheet!: TextureSource;
private spriteSize: number = 16;
constructor() {}
load = async () => {
TextureStyle.defaultOptions.scaleMode = "nearest";
return new Promise(async (resolve) => {
this.spriteSheet = await this.loadSpriteSheet();
resolve(true);
});
};
loadSpriteSheet = async (): Promise<TextureSource> => {
return new Promise(async (resolve) => {
resolve(PixiAssets.load("/assets/sheet.png"));
});
};
getTexture = (x: number = 0, y: number = 0, x2: number = 0, y2: number = 0) => {
return new Texture({
source: this.spriteSheet,
frame: new Rectangle(x, y, x2 || this.spriteSize, y2 || this.spriteSize),
});
};
}
También metí una forma de iterar sobre los frames de una animación para hacer la animación de correr del personaje:
changeFrame(textures: Texture[]) {
this.currentFrame = this.currentFrame + 1;
if (this.currentFrame >= textures.length) {
this.currentFrame = 0;
}
this.texture = textures[this.currentFrame];
}
Esta no es la forma correcta de hacerlo. En PIXI existe una forma de cargar animaciones usando un json para indicar cada uno de los frames del spritesheet. Yo tiré por esta forma de hacerlo por simplicidad, pero no es lo mejor.
Para los sonidos tiré de esta herramienta chiptune para la creación de efectos de sonido. Un detalle es que creé dos sonidos para los pasos del personaje. Normalmente la gente utiliza solo uno, pero queda demasiado robótico. Además hice que de forma aleatoria se usara un sonido o otro, para darle algo de naturalidad.
Conclusiones y reflexiones finales
Hacer uno videojuego es más complicado de lo que pueda parecer. Yo ya había intentado hace años hacer videojuegos, y pensaba que en una semana daría para mucho más.
Hacer videojuegos es una experiencia muy completa porque tocas muchos palos: algoritmia, arte, diseño de videojuegos, qa, ajuste de las mecánicas, sonidos, performance, etc. Yo recomiendo a todo el mundo que pruebe a hacer uno, es muy disfrutable.
Por último unas reflexiones:
- ¡El terminado el proyecto! No es divertido, pero eso es otro tema.
- Hacer las mecánicas antes que el arte es buena idea, aunque también mola ir viendo cómo va quedando. Yo creo que lo mejor es ir intercalando la programación con el arte.
- A los videojuegos les afecta mucho la performance. Mi minijuego se laguea mucho y eso que es lo más sencillo del mundo. Hay que tener cuidado con lo que se ejecuta dentro de los gameloops.
- Tenía que haber pensado en otra mecánica para el minijuego, la que escogí de las tareas da para muy poco entretenimiento.
- Cada mecánica y algoritmo del juego puede provocar muchos bugs. Yo me pase tiempo arreglando cosas y eso que la mecánica es muy muy simple.
- Las jams ayudan mucho a terminar los juegos. Yo fue lo que prioricé, al menos terminarlo, aunque el gameplay sea una mierda, así no es el enésimo proyecto que se queda a medias.
Si te ha gustado el artículo y no te quieres perder lo nuevo me puedes seguir:
- Con tu lector RSS
- En mi canal de Telegram
- En Mastodon y en Bluesky
-
Y por email. Te envío un correo cada vez que publique un artículo en el blog.
También me puedes escribir directamente a mi correo. Intento responder a todos los mensajes.
hola [arroba] diegologs.com