Agrega sonido y música a tu juego de Flutter

1. Antes de comenzar

Los juegos son experiencias audiovisuales. Flutter es una herramienta excelente para crear imágenes atractivas y una IU sólida que te acerca mucho más al aspecto visual. El ingrediente que falta es el audio. En este codelab, aprenderás a usar el complemento flutter_soloud para incorporar sonido y música de baja latencia a tu proyecto. Comienzas con un andamiaje básico de modo que puedas pasar directamente a las partes interesantes.

Una ilustración dibujada a mano de auriculares.

Por supuesto, puedes usar lo que aprendas aquí para agregar audio a tus apps, no solo a los juegos. Sin embargo, si bien casi todos los juegos requieren sonido y música, la mayoría de las apps no, por lo que este codelab se enfoca en los juegos.

Requisitos previos

  • Conocimientos básicos sobre Flutter
  • Conocimientos para ejecutar y depurar apps de Flutter

Qué aprenderá

  • Cómo reproducir sonidos de un solo intento
  • Cómo reproducir y personalizar bucles de música sin espacios
  • Cómo atenuar y aumentar el volumen de los sonidos
  • Cómo aplicar efectos ambientales a los sonidos
  • Cómo abordar las excepciones
  • Cómo encapsular todas estas funciones en un solo controlador de audio

Requisitos

  • El SDK de Flutter
  • El editor de código que prefieras

2. Configurar

  1. Descarga los siguientes archivos. Si tienes una conexión lenta, no te preocupes. Necesitarás los archivos reales más adelante, así que puedes permitir que se descarguen mientras trabajas.
  1. Crea un proyecto de Flutter con el nombre que elijas.
  1. Crea un archivo lib/audio/audio_controller.dart en el proyecto.
  2. En el archivo, ingresa el siguiente código:

lib/audio/audio_controller.dart

import 'dart:async';

import 'package:logging/logging.dart';

class AudioController {
  static final Logger _log = Logger('AudioController');

  Future<void> initialize() async {
    // TODO
  }

  void dispose() {
    // TODO
  }

  Future<void> playSound(String assetKey) async {
    _log.warning('Not implemented yet.');
  }

  Future<void> startMusic() async {
    _log.warning('Not implemented yet.');
  }

  void fadeOutMusic() {
    _log.warning('Not implemented yet.');
  }

  void applyFilter() {
    // TODO
  }

  void removeFilter() {
    // TODO
  }
}

Como puedes ver, este es solo un esqueleto para la funcionalidad futura. Lo implementaremos todo durante este codelab.

  1. A continuación, abre el archivo lib/main.dart y reemplaza su contenido con el siguiente código:

lib/main.dart

import 'dart:developer' as dev;

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:logging/logging.dart';

import 'audio/audio_controller.dart';

void main() async {
  // The `flutter_soloud` package logs everything
  // (from severe warnings to fine debug messages)
  // using the standard `package:logging`.
  // You can listen to the logs as shown below.
  Logger.root.level = kDebugMode ? Level.FINE : Level.INFO;
  Logger.root.onRecord.listen((record) {
    dev.log(
      record.message,
      time: record.time,
      level: record.level.value,
      name: record.loggerName,
      zone: record.zone,
      error: record.error,
      stackTrace: record.stackTrace,
    );
  });

  WidgetsFlutterBinding.ensureInitialized();

  final audioController = AudioController();
  await audioController.initialize();

  runApp(
    MyApp(audioController: audioController),
  );
}

class MyApp extends StatelessWidget {
  const MyApp({required this.audioController, super.key});

  final AudioController audioController;

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter SoLoud Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.brown),
        useMaterial3: true,
      ),
      home: MyHomePage(audioController: audioController),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key, required this.audioController});

  final AudioController audioController;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  static const _gap = SizedBox(height: 16);

  bool filterApplied = false;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Flutter SoLoud Demo')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            OutlinedButton(
              onPressed: () {
                widget.audioController.playSound('assets/sounds/pew1.mp3');
              },
              child: const Text('Play Sound'),
            ),
            _gap,
            OutlinedButton(
              onPressed: () {
                widget.audioController.startMusic();
              },
              child: const Text('Start Music'),
            ),
            _gap,
            OutlinedButton(
              onPressed: () {
                widget.audioController.fadeOutMusic();
              },
              child: const Text('Fade Out Music'),
            ),
            _gap,
            Row(
              mainAxisSize: MainAxisSize.min,
              children: [
                const Text('Apply Filter'),
                Checkbox(
                  value: filterApplied,
                  onChanged: (value) {
                    setState(() {
                      filterApplied = value!;
                    });
                    if (filterApplied) {
                      widget.audioController.applyFilter();
                    } else {
                      widget.audioController.removeFilter();
                    }
                  },
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}
  1. Después de descargar los archivos de audio, crea un directorio en la raíz de tu proyecto llamado assets.
  2. En el directorio assets, crea dos subdirectorios, uno llamado music y otro llamado sounds.
  3. Mueve los archivos descargados a tu proyecto para que el archivo de la canción esté en el archivo assets/music/looped-song.ogg y los sonidos de los bancos estén en los siguientes archivos:
  • assets/sounds/pew1.mp3
  • assets/sounds/pew2.mp3
  • assets/sounds/pew3.mp3

La estructura de tu proyecto debería verse de la siguiente manera:

Una vista de árbol del proyecto, con carpetas como `android`, `ios`, archivos como `README.md` y `analysis_options.yaml`. Entre ellos, podemos ver el directorio `assets` con los subdirectorios `music` y `Sounds`, el directorio `lib` con `main.dart` y un subdirectorio `audio` con `audio_controller.dart` y el archivo `pubspec.yaml`.  Las flechas apuntan a los nuevos directorios y a los archivos que tocaste hasta el momento.

Ahora que los archivos están allí, debes informarles a Flutter sobre ellos.

  1. Abre el archivo pubspec.yaml y, luego, reemplaza la sección flutter: en la parte inferior del archivo por lo siguiente:

pubspec.yaml

...

flutter:
  uses-material-design: true

  assets:
    - assets/music/
    - assets/sounds/
  1. Agrega una dependencia en el paquete flutter_soloud y en el paquete logging.

pubspec.yaml

...

dependencies:
  flutter:
    sdk: flutter

  flutter_soloud: ^2.0.0
  logging: ^1.2.0

...
  1. Ejecutar el proyecto Aún no funciona nada porque agregarás la funcionalidad en las siguientes secciones.

10f0f751c9c47038.png

/flutter_soloud/src/filters/filters.cpp:21:24: warning: implicit conversion loses integer precision: 'decltype(__x.base() - __y.base())' (aka 'long') to 'int' [-Wshorten-64-to-32];

Estos provienen de la biblioteca C++ subyacente de SoLoud. No afectan la funcionalidad y se pueden ignorar de forma segura.

3. Inicializa y cierra

Para reproducir audio, usa el complemento flutter_soloud. Este complemento se basa en el proyecto SoLoud, un motor de audio C++ para juegos que usa, entre otros, Nintendo SNES Classic.

8c23849b6d0d09a.png

Para inicializar el motor de audio SoLoud, sigue estos pasos:

  1. En el archivo audio_controller.dart, importa el paquete flutter_soloud y agrega un campo _soloud privado a la clase.

lib/audio/audio_controller.dart

import 'dart:ui';

import 'package:flutter_soloud/flutter_soloud.dart';  //  Add this...
import 'package:logging/logging.dart';

class AudioController {
  static final Logger _log = Logger('AudioController');

  SoLoud? _soloud;                                    //  ... and this.

  Future<void> initialize() async {
    // TODO
  }

  ...

El controlador de audio administra el motor de SoLoud subyacente a través de este campo y desviará todas las llamadas hacia él.

  1. En el método initialize(), ingresa el siguiente código:

lib/audio/audio_controller.dart

...

  Future<void> initialize() async {
    _soloud = SoLoud.instance;
    await _soloud!.init();
  }

...

Esto propaga el campo _soloud y espera la inicialización. Ten en cuenta lo siguiente:

  • SoLoud proporciona un campo instance singleton. No hay forma de crear instancias de varias instancias de SoLoud. Esto no es algo que permite el motor de C++, por lo que el complemento de Dart tampoco lo permite.
  • La inicialización del complemento es asíncrona y no finaliza hasta que se muestra el método init().
  • Para abreviar este ejemplo, no capturas errores en un bloque try/catch. En el código de producción, debes hacerlo e informar cualquier error al usuario.
  1. En el método dispose(), ingresa el siguiente código:

lib/audio/audio_controller.dart

...

  void dispose() {
    _soloud?.deinit();
  }

...

Se recomienda cerrar SoLoud al salir de la app, aunque todo debería funcionar bien incluso si no lo haces.

  1. Ten en cuenta que el método AudioController.initialize() ya se llama desde la función main(). Esto significa que el reinicio en caliente del proyecto inicializa SoLoud en segundo plano, pero no te hará ningún bien antes de reproducir algunos sonidos.

4. Reproducir sonidos únicos

Carga un recurso y reprodúcelo

Ahora que sabes que SoLoud se inicializa al inicio, puedes pedirle que reproduzca sonidos.

SoLoud diferencia entre una fuente de audio, que son los datos y metadatos que se usan para describir un sonido, y sus "instancias de sonido", que son los sonidos que realmente se reprodujeron. Un ejemplo de una fuente de audio puede ser un archivo MP3 cargado en la memoria, listo para reproducirse y representado por una instancia de la clase AudioSource. Cada vez que reproduces esta fuente de audio, SoLoud crea una "instancia de sonido" que se representa con el tipo SoundHandle.

Para obtener una instancia de AudioSource, cárgala. Por ejemplo, si tienes un archivo MP3 en tus recursos, puedes cargarlo para obtener un AudioSource. Luego, le dices a SoLoud que reproduzca este AudioSource. Puedes reproducirlo muchas veces, incluso al mismo tiempo.

Cuando termines de usar una fuente de audio, elimínala con el método SoLoud.disposeSource().

Para cargar un elemento y reproducirlo, sigue estos pasos:

  1. En el método playSound() de la clase AudioController, ingresa el siguiente código:

lib/audio/audio_controller.dart

  ...

  Future<void> playSound(String assetKey) async {
    final source = await _soloud!.loadAsset(assetKey);
    await _soloud!.play(source);
  }

  ...
  1. Guarda el archivo, realiza la recarga en caliente y, luego, selecciona Reproducir sonido. Deberías escuchar un sonido de pew tonto. Ten en cuenta lo siguiente:
  • El argumento assetKey proporcionado es similar a assets/sounds/pew1.mp3, la misma cadena que le darías a cualquier otra API de Flutter que cargue elementos, como el widget Image.asset().
  • La instancia de SoLoud proporciona un método loadAsset() que carga de forma asíncrona un archivo de audio desde los recursos del proyecto de Flutter y muestra una instancia de la clase AudioSource. Existen métodos equivalentes para cargar un archivo desde el sistema de archivos (el método loadFile()) y para cargarlo a través de la red desde una URL (el método loadUrl()).
  • Luego, la instancia de AudioSource recién adquirida se pasa al método play() de SoLoud. Este método muestra una instancia del tipo SoundHandle que representa el sonido que se acaba de reproducir. Este controlador, a su vez, puede pasarse a otros métodos de SoLoud para realizar acciones como pausar, detener o modificar el volumen del sonido.
  • Si bien play() es un método asíncrono, la reproducción se inicia de forma prácticamente instantánea. El paquete flutter_soloud usa la interfaz de función externa (FFI) de Dart para llamar al código C de forma directa y síncrona. No se encuentran en ninguna parte los mensajes habituales entre el código Dart y el código de la plataforma que son característicos de la mayoría de los complementos de Flutter. El único motivo por el que algunos métodos son asíncronos es que parte del código del complemento se ejecuta en su propio aislamiento y la comunicación entre los elementos aislados de Dart es asíncrona.
  • Solo debes afirmar que el campo _soloud no es nulo con _soloud!. Esto es, de nuevo, por brevedad. El código de producción debe abordar correctamente la situación en la que el desarrollador intenta reproducir un sonido antes de que el controlador de audio haya tenido la oportunidad de inicializarse por completo.

Cómo controlar excepciones

Es posible que hayas notado que, una vez más, estás ignorando posibles excepciones. Para fines de aprendizaje, corrijamos esto en este método en particular. (Por cuestiones de brevedad, el codelab vuelve a ignorar excepciones después de esta sección).

  • Para controlar las excepciones en este caso, une las dos líneas del método playSound() en un bloque try/catch y solo captura instancias de SoLoudException.

lib/audio/audio_controller.dart

  ...

  Future<void> playSound(String assetKey) async {
    try {
      final source = await _soloud!.loadAsset(assetKey);
      await _soloud!.play(source);
    } on SoLoudException catch (e) {
      _log.severe("Cannot play sound '$assetKey'. Ignoring.", e);
    }
  }

  ...

SoLoud arroja varias excepciones, como las excepciones SoLoudNotInitializedException o SoLoudTemporaryFolderFailedException. Los documentos de API de cada método enumeran los tipos de excepciones que se podrían generar.

SoLoud también proporciona una clase superior a todas sus excepciones, la excepción SoLoudException, para que puedas detectar todos los errores relacionados con la funcionalidad del motor de audio. Esto es especialmente útil en los casos en que no es fundamental reproducir audio. Por ejemplo, cuando no quieres que se bloquee la sesión de juego del jugador solo porque no se pudo cargar uno de los sonidos de "pew-pew".

Como es de esperar, el método loadAsset() también puede arrojar un error FlutterError si proporcionas una clave de activo que no existe. Por lo general, debes abordar el intento de cargar recursos que no están empaquetados con el juego, por lo que se trata de un error.

Reproducir diferentes sonidos

Es posible que hayas notado que solo reproduces el archivo pew1.mp3, pero hay otras dos versiones del sonido en el directorio de recursos. Suele sonar más natural cuando los juegos tienen varias versiones del mismo sonido y las reproducen de forma aleatoria o rotativa. Esto evita, por ejemplo, que los pasos y los disparos suenen demasiado uniformes y, por lo tanto, falsos.

  • Como ejercicio opcional, modifica el código para que se reproduzca un sonido de iglesia diferente cada vez que se presione el botón.

Una ilustración de

5. Reproducir bucles de música

Cómo administrar sonidos de ejecución prolongada

Algunos audios están diseñados para reproducirse durante períodos prolongados. La música es el ejemplo obvio, pero muchos juegos también juegan a la atmósfera, como el viento aullando por pasillos, los cantos distantes de los monjes, el crujido de metales centenarios o la tos distante de los pacientes.

Son fuentes de audio con tiempos de reproducción que se pueden medir en minutos. Debes hacer un seguimiento de ellos para poder detenerlos o detenerlos cuando sea necesario. A menudo, también se respaldan con archivos grandes y pueden consumir mucha memoria, por lo que otra razón para hacerles un seguimiento es que puedas descartar la instancia de AudioSource cuando ya no sea necesaria.

Por ese motivo, introducirás un nuevo campo privado en AudioController. Es un identificador para la canción que se está reproduciendo, si corresponde. Agrega la siguiente línea:

lib/audio/audio_controller.dart

...

class AudioController {
  static final Logger _log = Logger('AudioController');

  SoLoud? _soloud;

  SoundHandle? _musicHandle;    // ← Add this.

  ...

Cómo iniciar música

En esencia, reproducir música no es diferente a reproducir un sonido de un solo intento. Primero, debes cargar el archivo assets/music/looped-song.ogg como una instancia de la clase AudioSource y, luego, usar el método play() de SoLoud para reproducirlo.

Sin embargo, esta vez, tomarás el controlador de sonido que muestra el método play() para manipular el audio mientras se reproduce.

  • Si lo deseas, implementa el método AudioController.startMusic() por tu cuenta. No te preocupes si no proporcionas algunos detalles correctamente. Lo importante es que la música comience cuando selecciones Iniciar música.

A continuación, se muestra una implementación de referencia:

lib/audio/audio_controller.dart

...

  Future<void> startMusic() async {
    if (_musicHandle != null) {
      if (_soloud!.getIsValidVoiceHandle(_musicHandle!)) {
        _log.info('Music is already playing. Stopping first.');
        await _soloud!.stop(_musicHandle!);
      }
    }
    final musicSource = await _soloud!
        .loadAsset('assets/music/looped-song.ogg', mode: LoadMode.disk);
    _musicHandle = await _soloud!.play(musicSource);
  }

...

Ten en cuenta que cargarás el archivo de música en modo de disco (la enumeración LoadMode.disk). Esto simplemente significa que el archivo solo se carga en fragmentos según sea necesario. Para un audio de ejecución más prolongada, por lo general, es mejor cargarlo en modo de disco. En el caso de efectos de sonido cortos, tiene más sentido cargarlos y descomprimirlos en la memoria (la enumeración LoadMode.memory predeterminada).

Sin embargo, tienes algunos problemas. En primer lugar, la música es demasiado fuerte y domina los sonidos. En la mayoría de los juegos, la música se encuentra en segundo plano la mayor parte del tiempo, lo que da protagonismo al audio más informativo, como el habla y los efectos de sonido. Esto se puede solucionar fácilmente con el parámetro de volumen del método de reproducción. Por ejemplo, puedes probar _soloud!.play(musicSource, volume: 0.6) para reproducir la canción con un volumen del 60%. Como alternativa, puedes establecer el volumen en cualquier momento con algo como _soloud!.setVolume(_musicHandle, 0.6).

El segundo problema es que la canción se detiene abruptamente. Esto se debe a que se trata de una canción que se supone que se reproduce en bucle y que el punto de partida del bucle no es el principio del archivo de audio.

88d2c57fffdfe996.png

Esta es una opción popular para la música de videojuegos porque significa que la canción comienza con una introducción natural y, luego, se reproduce el tiempo que sea necesario sin un punto evidente en el bucle. Cuando el juego necesita hacer la transición de la canción que se está reproduciendo, simplemente la atenúa.

Por suerte, SoLoud ofrece formas de reproducir audio en bucle. El método play() toma un valor booleano para el parámetro looping y también el valor del punto inicial del bucle como el parámetro loopingStartAt. El código resultante se ve así:

lib/audio/audio_controller.dart

...

_musicHandle = await _soloud!.play(
  musicSource,
  volume: 0.6,
  looping: true,
  //  The exact timestamp of the start of the loop.
  loopingStartAt: const Duration(seconds: 25, milliseconds: 43),
);

...

Si no configuras el parámetro loopingStartAt, el valor predeterminado es Duration.zero (en otras palabras, el inicio del archivo de audio). Si tienes una pista de música que es un bucle perfecto sin ninguna introducción, esta es la opción que quieres.

  • Para asegurarte de que la fuente de audio se elimine correctamente una vez que termine de reproducirse, escucha la transmisión allInstancesFinished que proporciona cada fuente de audio. Con las llamadas de registro agregadas, el método startMusic() se verá de la siguiente manera:

lib/audio/audio_controller.dart

...

  Future<void> startMusic() async {
    if (_musicHandle != null) {
      if (_soloud!.getIsValidVoiceHandle(_musicHandle!)) {
        _log.info('Music is already playing. Stopping first.');
        await _soloud!.stop(_musicHandle!);
      }
    }
    _log.info('Loading music');
    final musicSource = await _soloud!
        .loadAsset('assets/music/looped-song.ogg', mode: LoadMode.disk);
    musicSource.allInstancesFinished.first.then((_) {
      _soloud!.disposeSource(musicSource);
      _log.info('Music source disposed');
      _musicHandle = null;
    });

    _log.info('Playing music');
    _musicHandle = await _soloud!.play(
      musicSource,
      volume: 0.6,
      looping: true,
      loopingStartAt: const Duration(seconds: 25, milliseconds: 43),
    );
  }

...

Atenuar sonido

Tu próximo problema es que la música nunca termina. Implementemos un fundido.

Una forma de implementar la atenuación sería tener algún tipo de función a la que se le llame varias veces por segundo, como Ticker o Timer.periodic, y bajar el volumen de la música en pequeños decrementos. Esto funcionaría, pero requiere mucho trabajo.

Por suerte, SoLoud proporciona métodos convenientes de "activar y olvidar" que hacen esto por ti. Aquí te mostramos cómo puedes hacer que la música se atenúe en el transcurso de cinco segundos y luego detener la instancia de sonido para que no consuma recursos de CPU innecesariamente. Reemplaza el método fadeOutMusic() por este código:

lib/audio/audio_controller.dart

...

  void fadeOutMusic() {
    if (_musicHandle == null) {
      _log.info('Nothing to fade out');
      return;
    }
    const length = Duration(seconds: 5);
    _soloud!.fadeVolume(_musicHandle!, 0, length);
    _soloud!.scheduleStop(_musicHandle!, length);
  }

...

6. Aplica efectos

Una gran ventaja de tener un motor de audio adecuado a tu disposición es que puedes realizar el procesamiento de audio, como enrutar algunos sonidos a través de una reverberación, un ecualizador o un filtro de paso bajo.

En los juegos, esto se puede usar para la diferenciación auditiva de las ubicaciones. Por ejemplo, un aplauso suena diferente en un bosque que en un búnker de hormigón. Mientras un bosque ayuda a disipar y absorber el sonido, las paredes desnudas de un búnker reflejan las ondas de sonido, lo que genera reverberación. Del mismo modo, las voces de las personas suenan diferentes cuando se escuchan a través de una pared. Las frecuencias más altas de esos sonidos se atenúan más fácilmente a medida que viajan a través del medio sólido, lo que da como resultado un efecto de filtro de paso bajo.

Ilustración de dos personas hablando en una habitación. Las ondas de sonido no solo van de una persona a la otra directamente, sino que también rebotan en las paredes y el techo.

SoLoud ofrece varios efectos de audio diferentes, que se pueden aplicar al audio.

  • Para que suene como si el reproductor estuviera en una sala grande, como una catedral o una cueva, usa el campo SoLoud.filters:

lib/audio/audio_controller.dart

...

  void applyFilter() {
    _soloud!.filters.freeverbFilter.activate();
    _soloud!.filters.freeverbFilter.wet.value = 0.2;
    _soloud!.filters.freeverbFilter.roomSize.value = 0.9;
  }

  void removeFilter() {
    _soloud!.filters.freeverbFilter.deactivate();
  }

...

El campo SoLoud.filters te da acceso a todos los tipos de filtro y sus parámetros. Cada parámetro también tiene funciones integradas, como la atenuación gradual y la oscilación.

Nota: _soloud!.filters expone filtros globales. Si quieres aplicar filtros a una sola fuente, usa el equivalente AudioSource.filters, que actúa de la misma manera.

Con el código anterior, haz lo siguiente:

  • Habilita el filtro freeverb de forma global.
  • Establece el parámetro Wet en 0.2, lo que significa que el audio resultante será un 80% original y un 20% de la salida del efecto de reverberación. Si estableces este parámetro en 1.0, sería como escuchar solo las ondas de sonido que regresan a ti desde las paredes distantes de la habitación y ninguna parte del audio original.
  • Establece el parámetro Room Size en 0.9. Puedes ajustar este parámetro a tu gusto o incluso cambiarlo de forma dinámica. 1.0 es una gran caverna, mientras que 0.0 es un baño.
  • Si quieres, cambia el código y aplica uno de los siguientes filtros o una combinación de ellos:
  • biquadFilter (se puede usar como filtro de paso bajo)
  • pitchShiftFilter
  • equalizerFilter
  • echoFilter
  • lofiFilter
  • flangerFilter
  • bassboostFilter
  • waveShaperFilter
  • robotizeFilter

7. Felicitaciones

Implementaste un controlador de audio que reproduce sonidos, repite música y aplica efectos.

Más información

  • Aprovecha al máximo el control de audio con funciones como la precarga de sonidos en el inicio, la reproducción de canciones en una secuencia o la aplicación gradual de un filtro a lo largo del tiempo.
  • Lee la documentación del paquete de flutter_soloud.
  • Lee la página principal de la biblioteca C++ subyacente.
  • Obtén más información sobre la FFI de Dart, la tecnología que se usa para interactuar con la biblioteca de C++.
  • Mira la charla de Guy Somberg sobre la programación de audio de juegos para inspirarte. (También hay una más larga). Cuando Guy habla de "middleware", se refiere a bibliotecas como SoLoud y FMOD. El resto del código suele ser específico de cada juego.
  • Compila tu juego y publícalo.

Una ilustración de auriculares