
Collectathon
Exploras mundos amplios en busca de muchísimos objetos, y reunirlos no es opcional: lo que recoges abre niveles nuevos y te acerca al final.
También se llama: recolectatlón, collect-a-thon, plataformas de recolección
Esta es una ficha básica: tiene la definición y la clasificación. Las fichas completas suman historia, juegos y una demo.
Demo propia
Controles. Flechas o WASD para moverte (respecto de la cámara), espacio para saltar y arrastrar con el ratón para girar la cámara. En móvil, los botones en pantalla y arrastrar con el dedo.
Abrir en pantalla completaUn pequeño mundo en 3D lleno de gemas. Juntar las suficientes abre la puerta que lleva a la estrella: lo que recoges no es un adorno, es lo que te deja avanzar. Está hecha con threeplay, un motor 3D en la línea de Kaplay.
Hecha con threeplay, en unas 155 líneas de JavaScript.
Cómo está hecha
Configuración y controles
Se crea el juego a 640×360 y se definen botones virtuales; los botones en pantalla del móvil pulsan esos mismos botones. La gravedad, la velocidad y la fuerza del salto son los números que más cambian la sensación, y la constante REQUIRED decide cuánto hay que explorar antes de avanzar.
const k = threeplay({ width: 640, height: 360, letterbox: true, background: "#8ecae6", buttons: { left: { keyboard: ["left", "a"] }, right: { keyboard: ["right", "d"] }, up: { keyboard: ["up", "w"] }, down: { keyboard: ["down", "s"] }, jump: { keyboard: ["space"] }, }, }); touchControls(k, [ { button: "left", label: "◀" }, { button: "up", label: "▲" }, { button: "down", label: "▼" }, { button: "right", label: "▶" }, { button: "jump", label: "⤒", side: "right" }, ]); setGravity(30); const SPEED = 6; const JUMP = 11; const REQUIRED = 18; // gems needed to open the gateEl mundo
El mundo es una lista de cajas con posición y tamaño. Cada una lleva area() y body({ isStatic: true }) para que el personaje pueda pararse encima. Un muro corta el mapa y deja un hueco que tapa la puerta.
// [x, y, z, width, height, depth]: y is the center of the box. const PLATFORMS = [ [10, 0.75, 2, 3, 1.5, 3], [13, 1.75, -2, 3, 0.5, 3], [10, 2.75, -6, 3, 0.5, 3], [-7, 0.5, 4, 3, 1, 3], [-10, 1.5, 1, 3, 1, 3], [-13, 2.5, -2, 4, 1, 4], // Behind the gate [0, 1, -16, 3, 0.5, 3], [3, 2.2, -18.5, 2.5, 0.5, 2.5], [0, 3.4, -21, 2.5, 0.5, 2.5], ]; function buildWorld() { add([box(44, 1, 44), pos(0, -0.5, 0), color("#90be6d"), area(), body({ isStatic: true })]); for (const [x, y, z, w, h, d] of PLATFORMS) { add([box(w, h, d), pos(x, y, z), color("#bc6c25"), area(), body({ isStatic: true })]); } // A wall across the map, with a gap closed by the gate. for (const x of [-11.5, 11.5]) { add([box(20, 4, 1), pos(x, 2, -12), color("#6c757d"), area(), body({ isStatic: true })]); } }Gemas en líneas y anillos
Las gemas se colocan en filas, anillos y sobre cada plataforma. Esa disposición es diseño: una fila de gemas indica el camino sin palabras y una gema sobre una plataforma alta invita a intentar el salto. Son octaedros de three.js que giran con un componente propio, spin, que solo cambia rot.y cada fotograma.
// Gems follow lines and rings: trails tell players where to go without words. function gemSpots() { const spots = []; for (let z = 6; z >= -6; z -= 2) spots.push(vec3(0, 0.8, z)); // a trail north for (let i = 0; i < 8; i++) { const a = (i / 8) * Math.PI * 2; spots.push(vec3(6 + Math.cos(a) * 3, 0.8, 8 + Math.sin(a) * 3)); // a ring } for (const [x, y, z, , h] of PLATFORMS) spots.push(vec3(x, y + h / 2 + 0.8, z)); // one per platform return spots; } function spin(speed) { return { id: "spin", require: ["rotate"], update() { this.rot.y += speed * dt(); }, }; } function addGem(p) { add([mesh(new THREE.OctahedronGeometry(0.3)), pos(p), rotate(), spin(120), color("#4cc9f0"), area(), "gem"]); } // The big prize: a five-pointed star extruded from a 2D outline. function addStar(p) { const outline = new THREE.Shape(); for (let i = 0; i < 10; i++) { const r = i % 2 === 0 ? 0.7 : 0.3; const a = (i / 10) * Math.PI * 2 + Math.PI / 2; outline[i === 0 ? "moveTo" : "lineTo"](Math.cos(a) * r, Math.sin(a) * r); } const geometry = new THREE.ExtrudeGeometry(outline, { depth: 0.2, bevelEnabled: false }); geometry.center(); add([mesh(geometry), pos(p), rotate(), spin(90), color("gold"), area(), "star"]); }El personaje y la cámara
El jugador es una cápsula con character(), un controlador que se desliza contra las paredes y sube escalones pequeños. inputDir() convierte las teclas en una dirección relativa a la cámara, que es como se controlan casi todos los juegos 3D en tercera persona: «adelante» es hacia donde mira la cámara. thirdPerson() deja girar la cámara arrastrando.
const player = add([capsule(0.4, 1.6), pos(0, 1, 9), rotate(), color("#f77f00"), area(), character(), "player"]); player.add([box(0.3, 0.2, 0.3), pos(0, 0.4, -0.4), color("#003049")]); // shows where it faces thirdPerson(player, { distance: 8, height: 1.2 }); onUpdate(() => { const dir = inputDir(); // relative to the camera, on the ground plane player.move(dir.clone().multiplyScalar(SPEED)); if (dir.lengthSq() > 0) player.lookAt(player.worldPos().add(dir)); if (player.pos.y < -10) player.teleport(vec3(0, 1, 9)); // fell off the world }); onButtonPress("jump", () => { if (player.isGrounded()) player.jump(JUMP); });La puerta que se abre con gemas
Es el corazón del género. La puerta es un cuerpo cinemático, así que bloquea el paso pero se puede mover desde el código. Un cartel flotante cuenta las gemas que faltan y, al llegar a REQUIRED, un tween la hunde en el suelo.
// Progress is gated by what you collect: the core loop of the genre. const gate = add([box(3, 4, 0.6), pos(0, 2, -12), color("#e63946"), area(), body({ kinematic: true })]); const sign = gate.add([text("", { size: 18, font: FONT }), pos(0, 2.6, 0), anchor("center")]); let gateOpen = false; function openGate() { gateOpen = true; sign.hidden = true; beep({ freq: 220, slideTo: 660, duration: 0.5, type: "triangle" }); tween(gate.pos.y, -2.2, 1.5, (y) => (gate.pos.y = y), easings.easeInOutQuad); }El marcador
Los textos con fixed() van en la pantalla, no en el mundo. El marcador muestra cuántas gemas llevas sobre las necesarias, que es la información que guía todo lo que haces.
let gems = 0; const hud = add([text("", { size: 18, font: FONT }), pos(14, 10), fixed()]); function updateHud() { hud.text = `◆ ${gems} / ${REQUIRED}`; sign.text = gateOpen ? "" : `◆ ${Math.max(0, REQUIRED - gems)}`; } updateHud(); add([ text(t({ es: "Arrastra para girar la cámara", en: "Drag to turn the camera" }), { size: 12, font: FONT }), pos(626, 10), anchor("topright"), fixed(), ]);Recoger
Las gemas tienen area() sin body(), así que son sensores que no frenan al personaje. onCollide("gem") las destruye, suma una y sube el tono del pitido para que juntar varias seguidas suene a recompensa. La estrella termina la partida.
player.onCollide("gem", (gem) => { destroy(gem); gems++; beep({ freq: 880 + (gems % 5) * 110, duration: 0.08 }); updateHud(); if (gems >= REQUIRED && !gateOpen) openGate(); }); player.onCollide("star", (star) => { destroy(star); beep({ freq: 523, slideTo: 1046, duration: 0.6, type: "triangle" }); wait(0.6, () => go("end", gems)); });Escenas
Tres escenas (title, game y end) y go() para pasar de una a otra. La pantalla de título reutiliza el mundo con una cámara que orbita sola.
scene("title", () => { add([text("Collectathon", { size: 40, font: FONT }), pos(320, 140), anchor("center"), fixed()]); add([ text(t({ es: "Junta gemas para abrir la puerta y alcanza la estrella", en: "Collect gems to open the gate and reach the star" }), { size: 16, font: FONT }), pos(320, 190), anchor("center"), fixed(), ]); add([text(t({ es: "Espacio para empezar", en: "Space to start" }), { size: 16, font: FONT }), pos(320, 240), anchor("center"), fixed()]); orbitCamera({ distance: 26, autoRotate: 10, interactive: false }); buildWorld(); onButtonPress("jump", () => go("game")); }); scene("end", (gems) => { add([text(t({ es: "¡Estrella conseguida!", en: "Star collected!" }), { size: 36, font: FONT }), pos(320, 140), anchor("center"), fixed()]); add([ text(t({ es: `Gemas: ${gems}. En el juego real, la estrella abriría un mundo nuevo.`, en: `Gems: ${gems}. In a real game, the star would open a new world.` }), { size: 15, font: FONT }), pos(320, 190), anchor("center"), fixed(), ]); add([text(t({ es: "Espacio para volver a jugar", en: "Space to play again" }), { size: 16, font: FONT }), pos(320, 240), anchor("center"), fixed()]); onButtonPress("jump", () => go("game")); }); go("title");
Qué simplificamos
- Un solo mundo pequeño con un único candado (la puerta) y una estrella; un collectathon real encadena muchos mundos que se abren con lo recogido.
- El personaje es una cápsula y no hay animaciones; el sonido son pitidos sintetizados.
- No hay coleccionables de distintos tipos ni secretos escondidos.
Para modificarla
- Agrega una segunda puerta que pida todas las gemas y esconda una gema detrás de un muro.
- Haz que las gemas giren hacia el jugador cuando esté cerca y vuelen hacia él.
- Agrega un doble salto que solo se desbloquea al recoger un objeto especial.
Código completo
import threeplay from "../../vendor/threeplay/threeplay.mjs";
import { FONT, t, touchControls } from "../common.js";
// #region setup
const k = threeplay({
width: 640,
height: 360,
letterbox: true,
background: "#8ecae6",
buttons: {
left: { keyboard: ["left", "a"] },
right: { keyboard: ["right", "d"] },
up: { keyboard: ["up", "w"] },
down: { keyboard: ["down", "s"] },
jump: { keyboard: ["space"] },
},
});
touchControls(k, [
{ button: "left", label: "◀" },
{ button: "up", label: "▲" },
{ button: "down", label: "▼" },
{ button: "right", label: "▶" },
{ button: "jump", label: "⤒", side: "right" },
]);
setGravity(30);
const SPEED = 6;
const JUMP = 11;
const REQUIRED = 18; // gems needed to open the gate
// #endregion
// #region world
// [x, y, z, width, height, depth]: y is the center of the box.
const PLATFORMS = [
[10, 0.75, 2, 3, 1.5, 3],
[13, 1.75, -2, 3, 0.5, 3],
[10, 2.75, -6, 3, 0.5, 3],
[-7, 0.5, 4, 3, 1, 3],
[-10, 1.5, 1, 3, 1, 3],
[-13, 2.5, -2, 4, 1, 4],
// Behind the gate
[0, 1, -16, 3, 0.5, 3],
[3, 2.2, -18.5, 2.5, 0.5, 2.5],
[0, 3.4, -21, 2.5, 0.5, 2.5],
];
function buildWorld() {
add([box(44, 1, 44), pos(0, -0.5, 0), color("#90be6d"), area(), body({ isStatic: true })]);
for (const [x, y, z, w, h, d] of PLATFORMS) {
add([box(w, h, d), pos(x, y, z), color("#bc6c25"), area(), body({ isStatic: true })]);
}
// A wall across the map, with a gap closed by the gate.
for (const x of [-11.5, 11.5]) {
add([box(20, 4, 1), pos(x, 2, -12), color("#6c757d"), area(), body({ isStatic: true })]);
}
}
// #endregion
// #region collectibles
// Gems follow lines and rings: trails tell players where to go without words.
function gemSpots() {
const spots = [];
for (let z = 6; z >= -6; z -= 2) spots.push(vec3(0, 0.8, z)); // a trail north
for (let i = 0; i < 8; i++) {
const a = (i / 8) * Math.PI * 2;
spots.push(vec3(6 + Math.cos(a) * 3, 0.8, 8 + Math.sin(a) * 3)); // a ring
}
for (const [x, y, z, , h] of PLATFORMS) spots.push(vec3(x, y + h / 2 + 0.8, z)); // one per platform
return spots;
}
function spin(speed) {
return {
id: "spin",
require: ["rotate"],
update() {
this.rot.y += speed * dt();
},
};
}
function addGem(p) {
add([mesh(new THREE.OctahedronGeometry(0.3)), pos(p), rotate(), spin(120), color("#4cc9f0"), area(), "gem"]);
}
// The big prize: a five-pointed star extruded from a 2D outline.
function addStar(p) {
const outline = new THREE.Shape();
for (let i = 0; i < 10; i++) {
const r = i % 2 === 0 ? 0.7 : 0.3;
const a = (i / 10) * Math.PI * 2 + Math.PI / 2;
outline[i === 0 ? "moveTo" : "lineTo"](Math.cos(a) * r, Math.sin(a) * r);
}
const geometry = new THREE.ExtrudeGeometry(outline, { depth: 0.2, bevelEnabled: false });
geometry.center();
add([mesh(geometry), pos(p), rotate(), spin(90), color("gold"), area(), "star"]);
}
// #endregion
scene("game", () => {
buildWorld();
for (const p of gemSpots()) addGem(p);
addStar(vec3(0, 4.6, -21));
// #region player
const player = add([capsule(0.4, 1.6), pos(0, 1, 9), rotate(), color("#f77f00"), area(), character(), "player"]);
player.add([box(0.3, 0.2, 0.3), pos(0, 0.4, -0.4), color("#003049")]); // shows where it faces
thirdPerson(player, { distance: 8, height: 1.2 });
onUpdate(() => {
const dir = inputDir(); // relative to the camera, on the ground plane
player.move(dir.clone().multiplyScalar(SPEED));
if (dir.lengthSq() > 0) player.lookAt(player.worldPos().add(dir));
if (player.pos.y < -10) player.teleport(vec3(0, 1, 9)); // fell off the world
});
onButtonPress("jump", () => {
if (player.isGrounded()) player.jump(JUMP);
});
// #endregion
// #region gate
// Progress is gated by what you collect: the core loop of the genre.
const gate = add([box(3, 4, 0.6), pos(0, 2, -12), color("#e63946"), area(), body({ kinematic: true })]);
const sign = gate.add([text("", { size: 18, font: FONT }), pos(0, 2.6, 0), anchor("center")]);
let gateOpen = false;
function openGate() {
gateOpen = true;
sign.hidden = true;
beep({ freq: 220, slideTo: 660, duration: 0.5, type: "triangle" });
tween(gate.pos.y, -2.2, 1.5, (y) => (gate.pos.y = y), easings.easeInOutQuad);
}
// #endregion
// #region hud
let gems = 0;
const hud = add([text("", { size: 18, font: FONT }), pos(14, 10), fixed()]);
function updateHud() {
hud.text = `◆ ${gems} / ${REQUIRED}`;
sign.text = gateOpen ? "" : `◆ ${Math.max(0, REQUIRED - gems)}`;
}
updateHud();
add([
text(t({ es: "Arrastra para girar la cámara", en: "Drag to turn the camera" }), { size: 12, font: FONT }),
pos(626, 10),
anchor("topright"),
fixed(),
]);
// #endregion
// #region collect
player.onCollide("gem", (gem) => {
destroy(gem);
gems++;
beep({ freq: 880 + (gems % 5) * 110, duration: 0.08 });
updateHud();
if (gems >= REQUIRED && !gateOpen) openGate();
});
player.onCollide("star", (star) => {
destroy(star);
beep({ freq: 523, slideTo: 1046, duration: 0.6, type: "triangle" });
wait(0.6, () => go("end", gems));
});
// #endregion
});
// #region scenes
scene("title", () => {
add([text("Collectathon", { size: 40, font: FONT }), pos(320, 140), anchor("center"), fixed()]);
add([
text(t({ es: "Junta gemas para abrir la puerta y alcanza la estrella", en: "Collect gems to open the gate and reach the star" }), { size: 16, font: FONT }),
pos(320, 190),
anchor("center"),
fixed(),
]);
add([text(t({ es: "Espacio para empezar", en: "Space to start" }), { size: 16, font: FONT }), pos(320, 240), anchor("center"), fixed()]);
orbitCamera({ distance: 26, autoRotate: 10, interactive: false });
buildWorld();
onButtonPress("jump", () => go("game"));
});
scene("end", (gems) => {
add([text(t({ es: "¡Estrella conseguida!", en: "Star collected!" }), { size: 36, font: FONT }), pos(320, 140), anchor("center"), fixed()]);
add([
text(t({ es: `Gemas: ${gems}. En el juego real, la estrella abriría un mundo nuevo.`, en: `Gems: ${gems}. In a real game, the star would open a new world.` }), { size: 15, font: FONT }),
pos(320, 190),
anchor("center"),
fixed(),
]);
add([text(t({ es: "Espacio para volver a jugar", en: "Space to play again" }), { size: 16, font: FONT }), pos(320, 240), anchor("center"), fixed()]);
onButtonPress("jump", () => go("game"));
});
go("title");
// #endregionHistoria
- 1996OrigenSuper Mario 64
Super Mario 64 hizo que cada nivel tuviera objetivos y que las estrellas conseguidas abrieran nuevos mundos; Banjo-Kazooie, Spyro the Dragon y Donkey Kong 64 copiaron el formato y así se formó el género.
Platformer (Wikipedia)
Juegos de referencia
1996
Super Mario 64
Llevó el plataformas a mundos 3D abiertos con control analógico y cámara libre, y lo cambió de objetivo, de llegar al final a explorar.