Caso de estudio: Circuito Luz Verde
Recorrido comentado del juego de referencia de Green Games: la idea, el manifiesto, la escena, el script por partes con fragmentos reales, el guion del bot y qué prueba cada cosa.
Corresponde aFormato 1, API 1 Para quien escribe código
En esta página
- La idea en una frase
- El manifiesto
- La escena
- Dificultades en la escena
- Etiquetas e ids
- Puntos de control
- El script, por partes
- Ajustes por dificultad
- Estado de la partida y estado de cada jugador
- El inicio: estado, menú y eventos
- Monedas: azar con semilla y toque por etiqueta
- Peligros, trampolín y puntos de control
- La barredora: una pieza movida cada cuadro
- El corredor de la luz roja
- El ascensor: una acción del jugador
- El final
- Los cuatro adornos
- El guion del bot
- Qué prueba cada cosa
- Sigue con
Circuito Luz Verde es el juego de referencia de Green Games: una carrera de obstáculos contra el reloj, hecha para usar todos los servicios de la API en poco código. Esta página lo lee por dentro. Puedes jugarlo en https://play.green.games.
| Archivo | Tamaño |
|---|---|
juego.json | El manifiesto, con 32 textos por idioma |
escenas/principal.json | 109 líneas: 79 piezas, 5 apariciones, 7 zonas, 4 luces |
scripts/principal.ts | 340 líneas |
pruebas/bot.json | 34 pasos |
El .ggx resultante | 9,0 KB |
La idea en una frase#
Corre hasta la meta lo más rápido que puedas; ganas al llegar, pierdes si se acaba el tiempo; el puntaje es tu tiempo, menos un segundo por cada moneda.
Todo el diseño sale de ahí: una tabla de tiempos (gana el menor), un límite por dificultad y monedas que tientan a desviarse.
El manifiesto#
Lo que declara y por qué (games/circuito-luz-verde/juego.json):
"jugadores": {
"min": 1,
"max": 4
},
"controles": {
"teclado": true,
"tactil": true,
"mando": true,
"acciones": {
"saltar": {
"es": "Saltar",
"en": "Jump"
},
"accion": {
"es": "Llamar el ascensor",
"en": "Call the lift"
}
}
},jugadores.max: 4permite jugar con hasta tres bots. El script está escrito para una lista de jugadores, no para uno.- Dos acciones.
saltaryaccion. El texto deacciondice lo que hace en este juego, "Llamar el ascensor", y eso es lo que el jugador lee en la tabla de controles.accion2no se declara, así que no tiene botón táctil ni aparece en la ayuda.
"puntajes": [
{
"id": "tiempo",
"nombre": {
"es": "Mejores tiempos",
"en": "Best times"
},
"orden": "menor",
"formato": "tiempo"
}
],- Una tabla, de tiempos.
"orden": "menor"porque gana el tiempo más bajo, y"formato": "tiempo"para que se muestre como 1:23.45. - Tres dificultades (
facil,normal,dificil) connormalcomo inicial. - Todos los textos están en
textos, en español y en inglés, con variables:"monedas": "{n} de {total}","sinTiempo": "Se acabó el tiempo. Llegaste al control {n} de {total}.".
La escena#
Un solo archivo, escenas/principal.json. El recorrido avanza por +Z, de z = 0 a z = 131, en siete tramos:
| Tramo | z | Qué hay | Qué enseña |
|---|---|---|---|
| Salida | 0 a 5 | Plataforma, semáforo de tres luces y una barrera | Piezas con id que el script enciende y mueve |
| Losas | 9 a 22 | Cuatro losas sobre el vacío | Piezas distintas por dificultad |
| Plataforma móvil | 32 a 38 | Una plataforma que va y viene | movimiento declarado en la escena |
| Trampolín | 43 | Un disco neón que lanza al nivel de arriba | Etiqueta más jugador.impulsar |
| Barredora | 63 | Un plato con un brazo que gira | Una pieza movida por el script cada cuadro, con datos |
| Corredor | 74 a 106 | Un pasillo con semáforo y tres parapetos | Zona, rayo y etiquetas que cambian |
| Ascensor y meta | 115 a 131 | Cajas sueltas, un ascensor y el arco de meta | Piezas dinámicas, acción del jugador, zona de meta |
Dificultades en la escena#
Cada losa existe dos veces en el mismo lugar: ancha para facil y normal, angosta para dificil. Y en facil hay, además, puentes de madera entre losa y losa.
{ "id": "losa-1", "forma": "caja", "posicion": [0, -0.5, 9], "tamano": [3.4, 1, 3], "material": { "color": "verde", "patron": "rejilla" }, "dificultades": ["facil", "normal"] },
{ "id": "losa-1-d", "forma": "caja", "posicion": [0, -0.5, 9], "tamano": [2.2, 1, 2.2], "material": { "color": "verde", "patron": "rejilla" }, "dificultades": ["dificil"] }, { "forma": "caja", "posicion": [0, -0.35, 6.75], "tamano": [1.6, 0.5, 1.7], "material": "madera", "dificultades": ["facil"], "etiquetas": ["puente"] },La sala carga solo las piezas de la dificultad elegida: el script no tiene que ocultar ni crear nada.
Etiquetas e ids#
- Con
id: lo que el script o el bot nombran uno por uno.barrera,brazo,ascensor,semaforo-corredor,luz-control-1. - Con etiqueta: lo que se atiende en grupo.
peligro(el brazo),trampolin,banda(las dos franjas de luz del corredor), ycontrolen las cuatro zonas de control. - Sin nada: postes, decorado y los bloques que flotan a los lados, todos con
"fisica": "ninguna".
El brazo de la barredora lleva en datos lo que el script necesita para moverlo, en vez de tener esos números escritos en el código:
{ "id": "brazo", "forma": "caja", "posicion": [2.4, 4.75, 63.5], "tamano": [4, 0.7, 0.5], "material": { "color": "coral", "acabado": "neon" }, "etiquetas": ["peligro"], "datos": { "centro": [0, 4.75, 63.5], "radio": 2.4 } },Puntos de control#
Cada control son tres cosas con nombres que calzan: una aparición control-1, una zona zona-control-1 con la etiqueta control, y una esfera luz-control-1 que el script enciende. Las monedas no están en la escena: las crea el script.
El script, por partes#
scripts/principal.ts tiene 340 líneas. Los fragmentos que siguen están copiados del archivo; // ... marca lo recortado.
Ajustes por dificultad#
// --- Ajustes por dificultad ----------------------------------------------------------------------------------------
// juego.nivelDificultad es el indice de la dificultad elegida en juego.json: 0 facil, 1 normal, 2 dificil.
const AJUSTES = [
{ limite: 300, giro: 50, verde: 3.2, ambar: 0.9, roja: 1.5, monedas: 24 },
{ limite: 240, giro: 72, verde: 2.6, ambar: 0.8, roja: 1.8, monedas: 20 },
{ limite: 180, giro: 95, verde: 2.0, ambar: 0.6, roja: 2.2, monedas: 16 }
][Math.max(0, juego.nivelDificultad)];Una fila por dificultad y todo lo que cambia en un solo lugar: el límite de tiempo, la velocidad del brazo, cuánto dura cada luz del corredor y cuántas monedas hay. La escena cambia la forma del nivel; esta tabla, sus tiempos.
Estado de la partida y estado de cada jugador#
let fase: 'menu' | 'cuenta' | 'carrera' | 'fin' = 'menu';
let libre = false;
let totalMonedas = 0;
let crono: Cronometro;
let luz: 'verde' | 'ambar' | 'roja' = 'verde';
let rojaDesde = 0;
let musica = 0;
let ascensorOcupado = false;
let juez: Actor;
/** Estado de cada jugador. Vive en jugador.datos para que cada uno tenga el suyo. */
interface Estado { control: number; monedas: number; llego: boolean; cubierto: boolean }
const estado = (j: Jugador) => j.datos as unknown as Estado;Lo que es de todos (la fase, la luz del corredor, si el ascensor está ocupado) son variables del archivo. Lo que es de cada uno (su control, sus monedas, si ya llegó) vive en jugador.datos. La función estado solo le pone tipo. Con esa separación, el mismo script sirve con uno o con cuatro jugadores.
fase es una máquina de estados mínima: casi todos los eventos empiezan preguntando en qué fase está la partida.
El inicio: estado, menú y eventos#
Todo se registra dentro de juego.alIniciar. Primero, el estado de cada jugador:
jugadores.alEntrar((j) => {
j.datos = { control: 0, monedas: 0, llego: false, cubierto: false };
j.congelado = true; // nadie se mueve hasta la luz verde
pintarHud(j);
});Después, un menú que ofrece dos modos y recuerda el mejor tiempo guardado con progreso:
// El menu de inicio se abre para todos; solo cuenta lo que elige una persona.
const humano = jugadores.humanos()[0];
const mejor = humano ? progreso.leer<number | null>(humano, `mejor-${juego.dificultad}`, null) : null;
interfaz.menu({
id: 'inicio',
titulo: interfaz.t('menuTitulo'),
texto: mejor === null ? interfaz.t('menuTexto') : interfaz.t('menuTextoMejor', { tiempo: mejor.toFixed(2) }),
opciones: [
{ id: 'carrera', texto: interfaz.t('menuCarrera') },
{ id: 'paseo', texto: interfaz.t('menuPaseo') }
]
});
interfaz.alElegir((menu, opcion, jugador) => {
if (menu !== 'inicio' || jugador.esBot || fase !== 'menu') return;
libre = opcion === 'paseo';
interfaz.cerrarMenu('inicio');
cuentaRegresiva();
});Tres detalles: un menú no pausa la partida, por eso los jugadores están congelados; la elección de un bot de relleno se ignora (jugador.esBot); y la clave de progreso lleva la dificultad, así cada una tiene su mejor tiempo.
Monedas: azar con semilla y toque por etiqueta#
Las monedas se crean al iniciar. Hay 31 lugares posibles y cada partida usa algunos:
function sembrarMonedas(): void {
// azar sale de la semilla de la partida: con la misma semilla, las monedas quedan en los mismos lugares.
const elegidos = azar.mezclar(LUGARES).slice(0, AJUSTES.monedas);
totalMonedas = elegidos.length;
for (const posicion of elegidos) {
const moneda = mundo.crear({
forma: 'cilindro',
posicion,
rotacion: [90, 0, 0],
tamano: [0.9, 0.16, 0.9],
material: { color: 'ambar', acabado: 'brillante' },
solida: false,
etiquetas: ['moneda']
});
moneda.girar([0, 160, 0]);
}
}mundo.crear recibe el mismo formato que una pieza de la escena. Como el azar sale de azar, dos partidas con la misma semilla tienen las mismas monedas: por eso el bot puede probar el juego y el resultado se repite.
Un solo evento atiende a todas:
mundo.alTocar('#moneda', (moneda, j) => {
interfaz.flotante(interfaz.t('menosUno'), moneda.posicion, { tipo: 'exito', para: j });
moneda.eliminar();
estado(j).monedas++;
j.puntos++;
sonido.tocar('moneda', { para: j, tono: 1 + estado(j).monedas * 0.02 });
pintarHud(j);
});El sonido sube un poco de tono con cada moneda, y todo lleva para: j: solo lo ve y lo oye quien la recogió. interfaz.flotante es el "-1 s" que sube desde la moneda.
Peligros, trampolín y puntos de control#
mundo.alTocar('#peligro', (_pieza, j) => {
if (fase !== 'carrera' || estado(j).llego) return;
sonido.tocar('golpe', { para: j });
interfaz.mensaje(interfaz.t('golpe'), { tipo: 'peligro', para: j });
j.reaparecer();
});
mundo.alTocar('#trampolin', (_pieza, j) => {
j.impulsar([0, 21, 5]);
sonido.tocar('poder', { para: j });
});
mundo.alEntrar('#control', (zona, j) => {
const n = CONTROLES.indexOf(zona.id.replace('zona-', '')) + 1;
if (n <= estado(j).control) return;
estado(j).control = n;
j.aparicion = CONTROLES[n - 1];
pintarHud(j);
if (j.esBot) return;
mundo.pieza(`luz-control-${n}`).material = { color: 'verde', acabado: 'neon' };
sonido.tocar('control', { para: j });
interfaz.mensaje(interfaz.t('controlAlcanzado', { n }), { tipo: 'exito', duracion: 1.6, para: j });
});j.reaparecer()devuelve al jugador a su aparición actual.j.aparicion = ...la cambia: eso es un punto de control.- El evento de control aprovecha los nombres de la escena: de la zona
zona-control-2sale la aparicióncontrol-2y la luzluz-control-2. if (n <= estado(j).control) return;evita que volver atrás "desactive" un control.
La barredora: una pieza movida cada cuadro#
juego.cadaCuadro(() => {
// La barredora: un brazo que da vueltas alrededor del eje. Se mueve a mano para mostrar posicion y rotacion.
const brazo = mundo.pieza('brazo');
const centro = brazo.datos.centro as Vec3;
const radio = brazo.datos.radio as number;
const grados = (reloj.ahora * AJUSTES.giro) % 360;
const a = (grados * Math.PI) / 180;
brazo.posicion = [centro[0] + Math.cos(a) * radio, centro[1], centro[2] - Math.sin(a) * radio];
brazo.rotacion = [0, grados, 0];El ángulo sale de reloj.ahora, el tiempo de partida, no de un contador propio: se detiene con la pausa y es igual en cada repetición. La posición se asigna como un vector nuevo. El centro y el radio vienen de datos de la escena.
Como el ángulo depende solo del tiempo, el bot puede esperar a que el brazo esté en un ángulo seguro (giroY), como se ve más abajo.
El corredor de la luz roja#
El tramo más elaborado. Un ciclo de temporizadores cambia la luz:
function cicloDelCorredor(nueva: 'verde' | 'ambar' | 'roja'): void {
if (fase === 'fin') return;
luz = nueva;
const semaforo = mundo.pieza('semaforo-corredor');
// ...
const color = nueva === 'verde' ? 'verde' : nueva === 'ambar' ? 'ambar' : 'coral';
for (const banda of mundo.piezas('banda')) banda.material = { color, acabado: 'neon' };
// Las etiquetas le sirven al bot de prueba para saber cuando partir (ver pruebas/bot.json).
semaforo.desetiquetar('recien-verde');
if (nueva === 'verde') {
semaforo.etiquetar('recien-verde');
reloj.despues(0.3, () => semaforo.desetiquetar('recien-verde'));
}
if (nueva === 'roja') rojaDesde = reloj.ahora;
// ...
const duracion = nueva === 'verde' ? AJUSTES.verde : nueva === 'ambar' ? AJUSTES.ambar : AJUSTES.roja;
reloj.despues(duracion, () => cicloDelCorredor(nueva === 'verde' ? 'ambar' : nueva === 'ambar' ? 'roja' : 'verde'));
}La función se vuelve a llamar a sí misma con reloj.despues: no hay setTimeout. Las bandas del piso cambian de color para que la luz se vea aunque el jugador no mire el semáforo. Y la etiqueta recien-verde, que dura 0,3 segundos, existe para el bot.
Cada cuadro se revisa quién se mueve con luz roja:
if (fase !== 'carrera') return;
for (const j of mundo.zona('zona-corredor').jugadores()) {
const cubierto = !loVeElSemaforo(j);
if (cubierto !== estado(j).cubierto) {
estado(j).cubierto = cubierto;
interfaz.hud('cubierto', cubierto ? { texto: interfaz.t('aCubierto'), lugar: 'abajo-centro' } : null, j);
}
if (luz !== 'roja' || reloj.ahora - rojaDesde < 0.35 || cubierto) continue;
const [vx, , vz] = j.velocidad;
if (Math.hypot(vx, vz) > 1.2) {
sonido.tocar('alerta', { para: j });
interfaz.mensaje(interfaz.t('pillado'), { tipo: 'peligro', para: j });
j.reaparecer();
}
}
});Tres decisiones de justicia: solo se revisa a quien está dentro de la zona del corredor; hay 0,35 segundos de gracia desde que la luz se pone roja; y se tolera una velocidad de hasta 1,2 (el avatar frenando).
Los parapetos sirven de escondite gracias a un rayo:
/** Un rayo desde el semaforo hasta el pecho del jugador: si choca antes con un parapeto, el jugador esta a cubierto. */
function loVeElSemaforo(j: Jugador): boolean {
const ojo: Vec3 = [0, 7.9, 105.9];
const p = j.posicion;
const hacia: Vec3 = [p[0] - ojo[0], p[1] + 1 - ojo[1], p[2] - ojo[2]];
const distancia = Math.hypot(hacia[0], hacia[1], hacia[2]);
const impacto = mundo.rayo(ojo, hacia, distancia);
return impacto === null || impacto.distancia > distancia - 0.6;
}mundo.rayo choca solo con piezas sólidas e ignora a los jugadores: si el rayo llega hasta el jugador sin chocar antes, el semáforo lo ve.
El ascensor: una acción del jugador#
jugadores.alAccion('accion', (j) => {
if (ascensorOcupado || !mundo.zona('zona-ascensor').jugadores().includes(j)) return;
subirAscensor();
});alAccion se dispara una vez por pulsación, esté donde esté el jugador. El script decide si cuenta: solo si está dentro de la zona del ascensor y el ascensor está libre. subirAscensor lo mueve con pieza.moverA y, al llegar, programa la bajada con alLlegar y reloj.despues. Las plataformas que se mueven llevan a quien está parado encima.
El final#
function llegar(j: Jugador): void {
const e = estado(j);
if (e.llego || fase !== 'carrera') return;
e.llego = true;
j.congelado = true;
if (j.esBot) {
interfaz.mensaje(interfaz.t('botLlego', { nombre: j.nombre }));
return;
}
fase = 'fin';
crono.detener();
sonido.detener(musica);
sonido.tocar('victoria');
const tiempo = crono.valor;
const final = Math.max(0, Math.round((tiempo - e.monedas) * 100) / 100);
// ...
const mejor = progreso.leer<number | null>(j, `mejor-${juego.dificultad}`, null);
if (mejor === null || final < mejor) progreso.guardar(j, `mejor-${juego.dificultad}`, final);
reloj.despues(1.2, () => {
juego.terminar({ resultado: 'victoria', puntaje: final, tabla: 'tiempo', de: j, detalle: interfaz.t('finDetalle', { tiempo: tiempo.toFixed(2), monedas: e.monedas }) });
});
}- Si llega un bot de relleno, se anuncia y la partida sigue: la partida es de la persona.
- El puntaje es el tiempo menos un segundo por moneda, redondeado a centésimas.
juego.terminarcontabla: 'tiempo'yde: jregistra el puntaje.detallees la línea que el panel de fin muestra bajo el resultado.juego.terminarse llama 1,2 segundos después de llegar, conreloj.despues.
Si el tiempo se acaba, otra función termina con derrota, sin tabla:
function sinTiempo(): void {
if (fase !== 'carrera') return;
fase = 'fin';
sonido.detener(musica);
sonido.tocar('derrota');
const humano = jugadores.humanos()[0];
juego.terminar({ resultado: 'derrota', detalle: interfaz.t('sinTiempo', { n: humano ? estado(humano).control : 0, total: CONTROLES.length }) });
}Los dos caminos terminan en juego.terminar. Es la regla 8 de los scripts: toda partida debe terminar.
Los cuatro adornos#
Cuatro adornos del script usan lo que se agregó después de la primera versión del contrato:
| Qué | Dónde | Para qué |
|---|---|---|
actores.crear y juez.seguirRuta | Líneas 45 y 203 | Un juez que espera junto a la salida y se pasea cuando empieza la carrera. |
camara.toma | Línea 186 | Durante la cuenta regresiva, la cámara mira el semáforo desde un costado y vuelve sola. |
interfaz.rotulo | Líneas 48, 265 y 270 | Un rótulo sobre el ascensor, que mientras sube muestra una barra de avance. |
interfaz.flotante | Línea 76 | El "-1 s" que sale de cada moneda. |
Ninguno cambia las reglas del juego: sin ellos la carrera es la misma.
El guion del bot#
pruebas/bot.json juega la carrera entera en 34 pasos. Empieza eligiendo el modo en el menú y esperando a que la barrera se levante:
{
"$schema": "../../../packages/sdk/esquemas/bot.schema.json",
"formato": 1,
"tiempoMaximo": 170,
"esperado": { "resultado": "victoria" },
"pasos": [
{ "elegir": "inicio:carrera" },
{ "esperarPieza": "barrera", "cerca": [0, 3.4, 5], "radio": 0.3 },
{ "ir": [0, 0, 9] },
{ "ir": [1.5, 0, 13.5] },
{ "ir": [-1.5, 0, 18] },
{ "ir": [0, 0, 22.5] },
{ "ir": "aparicion:control-1", "control": true },- Un
irpor losa. Los cuatro puntos son los centros de las cuatro losas. El bot va en línea recta y salta solo ante el borde. control: trueen cada punto de control: si el juego devuelve al bot (lo barrió el brazo, se movió con luz roja), retoma desde ahí y no desde el principio.tiempoMaximoes 170 y el límite de la dificultad más corta es 180: la prueba exige ganar con margen.
El resto del guion repite una idea: esperar a que el mundo esté en el estado correcto y después caminar.
{ "ir": [0, 0, 41.9] },
{ "ir": [0, 4, 52], "saltar": "no" }, { "esperarPieza": "brazo", "giroY": [115, 150] },
{ "ir": [3, 4, 61.5] },
{ "ir": [3, 4, 65.5] },
{ "ir": [0, 4, 70.5] }, { "ir": [0, 4.4, 125] },
{ "pulsar": "accion" },
{ "esperarPieza": "ascensor", "cerca": [0, 10.2, 125], "radio": 0.1 },
{ "ir": "zona:meta" }- En el trampolín,
"saltar": "no": el impulso lo da el juego. - En la barredora espera un ángulo del brazo y rodea el plato por un costado en tres pasos.
- En el ascensor se para encima, pulsa la acción, espera a que la pieza llegue arriba y camina a la meta.
El corredor se cruza en tres carreras, cada una después de esperar la etiqueta recien-verde. Esos pasos están comentados en Pruebas con el bot.
Qué prueba cada cosa#
| Qué se comprueba | Con qué |
|---|---|
| Los archivos son JSON válido y cumplen los esquemas | greengames validar |
| Cada pieza, zona, aparición, tabla y clave de texto que el script nombra existe | greengames validar |
| El script no usa nada que no exista en la sala, y sus tipos calzan | greengames validar |
| Las dos listas de textos tienen las mismas claves y variables | greengames validar |
| La partida se puede ganar en las tres dificultades | greengames probar, con el guion |
Las losas angostas de dificil se pueden saltar; el corredor se puede cruzar | El guion, que pasa por ahí en cada dificultad |
| Ningún evento lanza errores y no se excede ningún límite | greengames probar |
| Con la misma semilla, la partida se repite igual | greengames probar, que repite la primera corrida |
| Las monedas cambian con la semilla | Las dos semillas dan puntajes distintos |
Salida real de greengames probar sobre una copia del juego:
OK facil semilla 1: victoria, puntaje 26.67, 44.9 s de partida en 325 ms; script 0.018 ms/cuadro (max 0.84), 99 piezas
OK facil semilla 2: victoria, puntaje 27.67, 44.9 s de partida en 285 ms; script 0.019 ms/cuadro (max 0.82), 99 piezas
OK normal semilla 1: victoria, puntaje 30.83, 47.0 s de partida en 273 ms; script 0.016 ms/cuadro (max 0.81), 90 piezas
OK normal semilla 2: victoria, puntaje 31.83, 47.0 s de partida en 334 ms; script 0.017 ms/cuadro (max 1.02), 90 piezas
OK dificil semilla 1: victoria, puntaje 32.78, 45.0 s de partida en 279 ms; script 0.017 ms/cuadro (max 0.77), 86 piezas
OK dificil semilla 2: victoria, puntaje 33.78, 45.0 s de partida en 319 ms; script 0.017 ms/cuadro (max 1.37), 86 piezas
circuito-luz-verde: prueba aprobada (6 de 6 corridas, .ggx de 9.0 KB)Qué se lee ahí:
- En
facil, la partida dura 44,9 segundos con las dos semillas y el puntaje es 26,67 con una y 27,67 con la otra: el mismo recorrido y un segundo de diferencia, que es una moneda. - Hay más piezas en
facil(99) que endificil(86): los puentes y las monedas extra. - El script gasta menos de 0,02 ms por cuadro en promedio, muy por debajo del milisegundo que recomienda la lista de calidad.
Lo que el guion no prueba: el modo paseo, la derrota por tiempo y la partida con bots de relleno. Para la derrota haría falta un segundo guion que espere perder.
Sigue con#
- Scripts: las reglas que este script sigue.
- Referencia de la API: cada función usada aquí, con su firma.
- Tu primer juego en 15 minutos: hacer uno propio, sin código.