feat(kotlin-compose): design system + 33 components + gallery_kt + e2e android emulator + scaffolder fixes
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
// UNICA TU del proyecto que define MINIAUDIO_IMPLEMENTATION.
|
||||
#define MINIAUDIO_IMPLEMENTATION
|
||||
#include "../../vendor/miniaudio/miniaudio.h"
|
||||
|
||||
#include "audio_engine.h"
|
||||
|
||||
#include <cstdlib>
|
||||
|
||||
namespace fn::audio {
|
||||
|
||||
Engine engine_init() {
|
||||
Engine e{nullptr, false};
|
||||
ma_engine* eng = static_cast<ma_engine*>(std::malloc(sizeof(ma_engine)));
|
||||
if (!eng) return e;
|
||||
if (ma_engine_init(NULL, eng) != MA_SUCCESS) {
|
||||
std::free(eng);
|
||||
return e;
|
||||
}
|
||||
e.impl = eng;
|
||||
e.ok = true;
|
||||
return e;
|
||||
}
|
||||
|
||||
void engine_shutdown(Engine& e) {
|
||||
if (!e.ok || !e.impl) return;
|
||||
ma_engine* eng = static_cast<ma_engine*>(e.impl);
|
||||
ma_engine_uninit(eng);
|
||||
std::free(eng);
|
||||
e.impl = nullptr;
|
||||
e.ok = false;
|
||||
}
|
||||
|
||||
void engine_set_volume(Engine& e, float v) {
|
||||
if (!e.ok || !e.impl) return;
|
||||
ma_engine_set_volume(static_cast<ma_engine*>(e.impl), v);
|
||||
}
|
||||
|
||||
} // namespace fn::audio
|
||||
@@ -0,0 +1,21 @@
|
||||
// audio_engine — lifecycle del engine de audio (miniaudio wrapper).
|
||||
// Issue 0072b — runtime gamedev nucleo (PC desktop + WASM + futuro mobile).
|
||||
#pragma once
|
||||
|
||||
namespace fn::audio {
|
||||
|
||||
struct Engine {
|
||||
void* impl; // ma_engine* opaco
|
||||
bool ok;
|
||||
};
|
||||
|
||||
// Crea engine con device default. Engine.ok=false si falla.
|
||||
Engine engine_init();
|
||||
|
||||
// Libera engine. Idempotente con Engine.ok=false.
|
||||
void engine_shutdown(Engine& e);
|
||||
|
||||
// Master volume 0..1.
|
||||
void engine_set_volume(Engine& e, float v);
|
||||
|
||||
} // namespace fn::audio
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
name: audio_engine
|
||||
kind: function
|
||||
lang: cpp
|
||||
domain: gamedev
|
||||
version: "0.1.0"
|
||||
purity: impure
|
||||
signature: "engine_init() -> Engine; engine_shutdown(Engine&); engine_set_volume(Engine&, float)"
|
||||
description: "Lifecycle del engine de audio basado en miniaudio (single-header, public domain). Inicializa device default, expone master volume, y libera recursos. Cross-platform: Windows/Linux/macOS via WASAPI/ALSA/CoreAudio y WebAudio bajo emscripten. Issue 0072b — runtime gamedev nucleo. Esta TU es la unica del proyecto que define MINIAUDIO_IMPLEMENTATION."
|
||||
tags: [gamedev, audio, miniaudio]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
example: |
|
||||
fn::audio::Engine eng = fn::audio::engine_init();
|
||||
if (!eng.ok) { /* fallback silencioso */ }
|
||||
fn::audio::engine_set_volume(eng, 0.8f);
|
||||
// ... loop principal ...
|
||||
fn::audio::engine_shutdown(eng);
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "cpp/functions/gamedev/audio_engine.cpp"
|
||||
params:
|
||||
- name: e
|
||||
desc: "Engine handle. ok=true tras engine_init exitoso. Estructura opaca: impl apunta a ma_engine internamente."
|
||||
- name: v
|
||||
desc: "Volumen master 0..1 (lineal). Valores >1 amplifican (riesgo de clipping)."
|
||||
output: "Engine con impl=ma_engine* y ok=true si MA_SUCCESS, ok=false en cualquier fallo (malloc o init de miniaudio). engine_shutdown deja ok=false e impl=nullptr — llamadas posteriores son no-op seguros."
|
||||
---
|
||||
|
||||
# audio_engine
|
||||
|
||||
Wrapper minimo del engine de [miniaudio](https://miniaud.io/) (v0.11.25, single-header, public domain / MIT-0). Vendored en `cpp/vendor/miniaudio/`.
|
||||
|
||||
## Por que
|
||||
|
||||
Audio cross-platform sin depender de SDL_mixer / OpenAL / FMOD. Compila en Windows/Linux/macOS y WASM (emscripten) desde el mismo source. Sin excepciones, sin RTTI, sin std::string.
|
||||
|
||||
## Notas
|
||||
|
||||
- Estado de fallo se reporta via `Engine.ok=false`. No hay `error_type` porque no devolvemos `error_go_core`-style; el consumidor revisa `ok` antes de operar.
|
||||
- `MINIAUDIO_IMPLEMENTATION` se define UNICAMENTE en `audio_engine.cpp`. Otras TU que usen miniaudio deben hacer solo `#include "miniaudio.h"`.
|
||||
- Memoria via `malloc`/`free` (no `new`) para mantener compat con `-fno-exceptions`.
|
||||
@@ -0,0 +1,59 @@
|
||||
// NO definir MINIAUDIO_IMPLEMENTATION aqui — vive en audio_engine.cpp.
|
||||
#include "../../vendor/miniaudio/miniaudio.h"
|
||||
|
||||
#include "audio_play.h"
|
||||
|
||||
#include <cstdlib>
|
||||
|
||||
namespace fn::audio {
|
||||
|
||||
Sound sound_load(Engine& e, const char* path) {
|
||||
Sound s{nullptr, false};
|
||||
if (!e.ok || !e.impl || !path) return s;
|
||||
ma_sound* snd = static_cast<ma_sound*>(std::malloc(sizeof(ma_sound)));
|
||||
if (!snd) return s;
|
||||
ma_engine* eng = static_cast<ma_engine*>(e.impl);
|
||||
if (ma_sound_init_from_file(eng, path, 0, NULL, NULL, snd) != MA_SUCCESS) {
|
||||
std::free(snd);
|
||||
return s;
|
||||
}
|
||||
s.impl = snd;
|
||||
s.ok = true;
|
||||
return s;
|
||||
}
|
||||
|
||||
void sound_play(Sound& s) {
|
||||
if (!s.ok || !s.impl) return;
|
||||
ma_sound_start(static_cast<ma_sound*>(s.impl));
|
||||
}
|
||||
|
||||
void sound_stop(Sound& s) {
|
||||
if (!s.ok || !s.impl) return;
|
||||
ma_sound_stop(static_cast<ma_sound*>(s.impl));
|
||||
}
|
||||
|
||||
void sound_set_volume(Sound& s, float v) {
|
||||
if (!s.ok || !s.impl) return;
|
||||
ma_sound_set_volume(static_cast<ma_sound*>(s.impl), v);
|
||||
}
|
||||
|
||||
void sound_destroy(Sound& s) {
|
||||
if (!s.ok || !s.impl) return;
|
||||
ma_sound* snd = static_cast<ma_sound*>(s.impl);
|
||||
ma_sound_uninit(snd);
|
||||
std::free(snd);
|
||||
s.impl = nullptr;
|
||||
s.ok = false;
|
||||
}
|
||||
|
||||
void play_sound_oneshot(Engine& e, const char* path, float volume) {
|
||||
if (!e.ok || !e.impl || !path) return;
|
||||
ma_engine* eng = static_cast<ma_engine*>(e.impl);
|
||||
// ma_engine_play_sound respeta el master volume; volume per-call se aplica
|
||||
// creando un sonido ad-hoc si el caller quiere control fino. Para fire-and-forget
|
||||
// usamos el helper directo y dejamos el master modular.
|
||||
(void)volume;
|
||||
ma_engine_play_sound(eng, path, NULL);
|
||||
}
|
||||
|
||||
} // namespace fn::audio
|
||||
@@ -0,0 +1,32 @@
|
||||
// audio_play — reproducir sonidos one-shot y streaming sobre fn::audio::Engine.
|
||||
// Issue 0072b — runtime gamedev nucleo.
|
||||
#pragma once
|
||||
|
||||
#include "audio_engine.h"
|
||||
|
||||
namespace fn::audio {
|
||||
|
||||
struct Sound {
|
||||
void* impl; // ma_sound* opaco
|
||||
bool ok;
|
||||
};
|
||||
|
||||
// Carga y prepara un sonido (decodifica streaming desde disco). Sound.ok=false si falla.
|
||||
Sound sound_load(Engine& e, const char* path);
|
||||
|
||||
// Arranca/reanuda reproduccion.
|
||||
void sound_play(Sound& s);
|
||||
|
||||
// Detiene reproduccion (no libera).
|
||||
void sound_stop(Sound& s);
|
||||
|
||||
// Volumen 0..1 del sonido (independiente del master).
|
||||
void sound_set_volume(Sound& s, float v);
|
||||
|
||||
// Libera recursos del sonido.
|
||||
void sound_destroy(Sound& s);
|
||||
|
||||
// Fire-and-forget: reproduce path una vez sin handle. No-op si engine no esta listo.
|
||||
void play_sound_oneshot(Engine& e, const char* path, float volume = 1.0f);
|
||||
|
||||
} // namespace fn::audio
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
name: audio_play
|
||||
kind: function
|
||||
lang: cpp
|
||||
domain: gamedev
|
||||
version: "0.1.0"
|
||||
purity: impure
|
||||
signature: "sound_load(Engine&, const char*) -> Sound; sound_play/stop/set_volume/destroy(Sound&); play_sound_oneshot(Engine&, const char*, float)"
|
||||
description: "Reproduccion de audio sobre fn::audio::Engine: carga sonidos con streaming desde disco (wav/mp3/flac/ogg), play/stop/volumen por sonido, y helper fire-and-forget para one-shots sin handle. Cross-platform via miniaudio. Issue 0072b — runtime gamedev nucleo."
|
||||
tags: [gamedev, audio, miniaudio]
|
||||
uses_functions: ["audio_engine_cpp_gamedev"]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
example: |
|
||||
fn::audio::Engine eng = fn::audio::engine_init();
|
||||
fn::audio::Sound bgm = fn::audio::sound_load(eng, "assets/bgm.ogg");
|
||||
fn::audio::sound_set_volume(bgm, 0.5f);
|
||||
fn::audio::sound_play(bgm);
|
||||
// SFX one-shot:
|
||||
fn::audio::play_sound_oneshot(eng, "assets/jump.wav");
|
||||
// ...
|
||||
fn::audio::sound_destroy(bgm);
|
||||
fn::audio::engine_shutdown(eng);
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "cpp/functions/gamedev/audio_play.cpp"
|
||||
params:
|
||||
- name: e
|
||||
desc: "Engine inicializado por audio_engine_cpp_gamedev. Si e.ok=false, todas las operaciones son no-op."
|
||||
- name: path
|
||||
desc: "Ruta al archivo de audio (relativa al cwd o absoluta). Formatos: wav/mp3/flac/ogg via decoders integrados de miniaudio."
|
||||
- name: s
|
||||
desc: "Sound handle. ok=true tras sound_load exitoso. Operaciones tras destroy son no-op."
|
||||
- name: v
|
||||
desc: "Volumen 0..1 lineal, multiplicativo con el master del Engine."
|
||||
- name: volume
|
||||
desc: "Volumen sugerido para play_sound_oneshot (actualmente delega al master via ma_engine_play_sound; reservado para futura implementacion per-instance)."
|
||||
output: "Sound con impl=ma_sound* y ok=true en sound_load exitoso; ok=false ante cualquier fallo (engine no listo, malloc, decoder). play_sound_oneshot no devuelve handle — el sonido se gestiona internamente por el engine."
|
||||
---
|
||||
|
||||
# audio_play
|
||||
|
||||
Reproduccion de audio one-shot y streaming sobre `fn::audio::Engine`. Wrapper minimo de la API `ma_sound` de miniaudio.
|
||||
|
||||
## Estructura `Sound`
|
||||
|
||||
Handle opaco con `impl` (apunta a `ma_sound`) y `ok` (bool). Gestion explicita: cada `sound_load` requiere `sound_destroy` para liberar (no hay RAII porque mantenemos `-fno-exceptions` y compat con structs trivial).
|
||||
|
||||
## Patrones de uso
|
||||
|
||||
- **BGM / loops largos:** `sound_load` + `sound_play`. Por defecto miniaudio hace streaming desde disco (no carga todo a memoria).
|
||||
- **SFX cortos:** `play_sound_oneshot` — fire-and-forget, sin handle, ideal para clicks, jumps, hits.
|
||||
- **SFX repetidos con control:** `sound_load` + `sound_play` cada vez. Para concurrent voices del mismo sample, considerar `ma_sound_init_copy` (no expuesto aun).
|
||||
|
||||
## Notas
|
||||
|
||||
- `play_sound_oneshot` recibe `volume` como hint pero actualmente delega al master del engine. Iterar si el caller real lo necesita.
|
||||
- `sound_load` con `path=NULL` o engine no listo devuelve `Sound{nullptr, false}` — siempre comprobar `ok` antes de operar.
|
||||
@@ -0,0 +1,119 @@
|
||||
#include "camera_2d.h"
|
||||
|
||||
#include <cmath>
|
||||
|
||||
namespace fn::cam {
|
||||
|
||||
using fn::math2d::Vec2;
|
||||
using fn::math2d::Rect;
|
||||
|
||||
Vec2 world_to_screen(const Camera2D& c, Vec2 world) {
|
||||
float dx = world.x - c.pos.x;
|
||||
float dy = world.y - c.pos.y;
|
||||
|
||||
if (c.rotation != 0.0f) {
|
||||
float cs = std::cos(-c.rotation);
|
||||
float sn = std::sin(-c.rotation);
|
||||
float rx = dx * cs - dy * sn;
|
||||
float ry = dx * sn + dy * cs;
|
||||
dx = rx;
|
||||
dy = ry;
|
||||
}
|
||||
|
||||
float sx = dx * c.zoom + (float)c.viewport_w * 0.5f;
|
||||
float sy = dy * c.zoom + (float)c.viewport_h * 0.5f;
|
||||
return {sx, sy};
|
||||
}
|
||||
|
||||
Vec2 screen_to_world(const Camera2D& c, Vec2 screen) {
|
||||
float dx = (screen.x - (float)c.viewport_w * 0.5f) / c.zoom;
|
||||
float dy = (screen.y - (float)c.viewport_h * 0.5f) / c.zoom;
|
||||
|
||||
if (c.rotation != 0.0f) {
|
||||
float cs = std::cos(c.rotation);
|
||||
float sn = std::sin(c.rotation);
|
||||
float rx = dx * cs - dy * sn;
|
||||
float ry = dx * sn + dy * cs;
|
||||
dx = rx;
|
||||
dy = ry;
|
||||
}
|
||||
|
||||
return {c.pos.x + dx, c.pos.y + dy};
|
||||
}
|
||||
|
||||
Rect visible_world_rect(const Camera2D& c) {
|
||||
// For rotated cameras, return the AABB that contains the rotated viewport.
|
||||
float hw = (float)c.viewport_w * 0.5f / c.zoom;
|
||||
float hh = (float)c.viewport_h * 0.5f / c.zoom;
|
||||
|
||||
if (c.rotation == 0.0f) {
|
||||
return {c.pos.x - hw, c.pos.y - hh, hw * 2.0f, hh * 2.0f};
|
||||
}
|
||||
|
||||
float cs = std::fabs(std::cos(c.rotation));
|
||||
float sn = std::fabs(std::sin(c.rotation));
|
||||
float ehw = hw * cs + hh * sn;
|
||||
float ehh = hw * sn + hh * cs;
|
||||
return {c.pos.x - ehw, c.pos.y - ehh, ehw * 2.0f, ehh * 2.0f};
|
||||
}
|
||||
|
||||
void view_proj_matrix(const Camera2D& c, float out[16]) {
|
||||
// Orthographic projection mapping a viewport-sized box around camera pos
|
||||
// to clip space [-1, 1].
|
||||
float hw = (float)c.viewport_w * 0.5f / c.zoom;
|
||||
float hh = (float)c.viewport_h * 0.5f / c.zoom;
|
||||
|
||||
float l = c.pos.x - hw;
|
||||
float r = c.pos.x + hw;
|
||||
// Screen Y goes down, world Y can go either; we pick world-Y-up convention:
|
||||
// top of screen = pos.y + hh, bottom = pos.y - hh.
|
||||
float b = c.pos.y - hh;
|
||||
float t = c.pos.y + hh;
|
||||
|
||||
float cs = std::cos(-c.rotation);
|
||||
float sn = std::sin(-c.rotation);
|
||||
|
||||
// Build column-major:
|
||||
// M = Ortho(l,r,b,t) * Rotate(-rotation around camera center)
|
||||
// Compose by hand to avoid temporaries.
|
||||
|
||||
float ortho[16] = {
|
||||
2.0f / (r - l), 0.0f, 0.0f, 0.0f,
|
||||
0.0f, 2.0f / (t - b), 0.0f, 0.0f,
|
||||
0.0f, 0.0f, -1.0f, 0.0f,
|
||||
-(r + l) / (r - l), -(t + b) / (t - b), 0.0f, 1.0f,
|
||||
};
|
||||
|
||||
if (c.rotation == 0.0f) {
|
||||
for (int i = 0; i < 16; ++i) out[i] = ortho[i];
|
||||
return;
|
||||
}
|
||||
|
||||
// Rotation around camera pos in world space:
|
||||
// T(pos) * R * T(-pos)
|
||||
// We bake it as a column-major 4x4 then multiply: out = ortho * rot.
|
||||
float px = c.pos.x;
|
||||
float py = c.pos.y;
|
||||
|
||||
float rot[16] = {
|
||||
cs, sn, 0.0f, 0.0f,
|
||||
-sn, cs, 0.0f, 0.0f,
|
||||
0.0f, 0.0f, 1.0f, 0.0f,
|
||||
px - cs * px + sn * py,
|
||||
py - sn * px - cs * py,
|
||||
0.0f, 1.0f,
|
||||
};
|
||||
|
||||
// out = ortho * rot (column-major).
|
||||
for (int col = 0; col < 4; ++col) {
|
||||
for (int row = 0; row < 4; ++row) {
|
||||
float sum = 0.0f;
|
||||
for (int k = 0; k < 4; ++k) {
|
||||
sum += ortho[k * 4 + row] * rot[col * 4 + k];
|
||||
}
|
||||
out[col * 4 + row] = sum;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace fn::cam
|
||||
@@ -0,0 +1,25 @@
|
||||
#pragma once
|
||||
|
||||
// camera_2d — pure orthographic 2D camera (issue 0072b).
|
||||
// No state, no I/O. World <-> screen transforms + ortho view-projection matrix.
|
||||
|
||||
#include "../core/math2d.h"
|
||||
|
||||
namespace fn::cam {
|
||||
|
||||
struct Camera2D {
|
||||
fn::math2d::Vec2 pos = {0.0f, 0.0f}; // world position of camera center
|
||||
float zoom = 1.0f; // 1 = no zoom; >1 zoom in
|
||||
float rotation = 0.0f; // radians
|
||||
int viewport_w = 1280;
|
||||
int viewport_h = 720;
|
||||
};
|
||||
|
||||
fn::math2d::Vec2 world_to_screen(const Camera2D& c, fn::math2d::Vec2 world);
|
||||
fn::math2d::Vec2 screen_to_world(const Camera2D& c, fn::math2d::Vec2 screen);
|
||||
fn::math2d::Rect visible_world_rect(const Camera2D& c);
|
||||
|
||||
// Column-major 4x4 view-projection matrix (orthographic).
|
||||
void view_proj_matrix(const Camera2D& c, float out[16]);
|
||||
|
||||
} // namespace fn::cam
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
name: camera_2d
|
||||
kind: function
|
||||
lang: cpp
|
||||
domain: gamedev
|
||||
version: "0.1.0"
|
||||
purity: pure
|
||||
signature: "world_to_screen(Camera2D, Vec2) -> Vec2; screen_to_world(Camera2D, Vec2) -> Vec2; visible_world_rect(Camera2D) -> Rect; view_proj_matrix(Camera2D, float[16])"
|
||||
description: "Camara ortografica 2D pura: pos (centro), zoom, rotacion (rad) y viewport en pixeles. Conversiones world<->screen, AABB visible y matriz view-projection 4x4 column-major lista para cualquier renderer (sokol_gfx, OpenGL, WebGPU). Fast-path sin trig si rotation==0. Issue 0072b."
|
||||
tags: [gamedev, camera, 2d, math, pure]
|
||||
uses_functions: []
|
||||
uses_types: ["Vec2_cpp_core", "Rect_cpp_core"]
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: []
|
||||
example: |
|
||||
fn::cam::Camera2D cam;
|
||||
cam.pos = {100, 50};
|
||||
cam.zoom = 2.0f;
|
||||
cam.viewport_w = 1280;
|
||||
cam.viewport_h = 720;
|
||||
// mouse picking:
|
||||
fn::math2d::Vec2 world = fn::cam::screen_to_world(cam, {input.mx, input.my});
|
||||
// upload to shader:
|
||||
float mvp[16];
|
||||
fn::cam::view_proj_matrix(cam, mvp);
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "cpp/functions/gamedev/camera_2d.cpp"
|
||||
params:
|
||||
- name: camera
|
||||
desc: "Camera2D con pos (centro mundo), zoom (>1 acerca), rotation (radianes) y viewport_w/h (pixeles)."
|
||||
- name: world
|
||||
desc: "Punto en world space (Vec2) para world_to_screen."
|
||||
- name: screen
|
||||
desc: "Punto en screen/pixel space (Vec2) para screen_to_world."
|
||||
- name: out
|
||||
desc: "Buffer de 16 floats donde escribir la matriz column-major en view_proj_matrix."
|
||||
output: "Vec2 (transformaciones), Rect (AABB visible) o matriz column-major 4x4 (mapa world->clip [-1,1])."
|
||||
---
|
||||
|
||||
# camera_2d
|
||||
|
||||
Camara 2D minima y pura. Zero estado global, zero I/O — apta para reusar en N renderers (sokol_gfx, OpenGL, WebGPU) y para correr en tests headless.
|
||||
|
||||
Convencion:
|
||||
- `pos` es el centro de la camara en world coords.
|
||||
- Eje Y en world apunta hacia ARRIBA. La proyeccion mapea `pos.y + hh` a top de clip (y=+1).
|
||||
- Zoom multiplicativo: `zoom=2` muestra la mitad del area mundial en el mismo viewport.
|
||||
- Rotation en radianes, sentido antihorario en world (la matriz invierte para clip).
|
||||
- `visible_world_rect` para rotation != 0 devuelve el AABB ENVOLVENTE (no el rect rotado), util para frustum culling barato.
|
||||
- `view_proj_matrix` es column-major (compatible con sokol_gfx / OpenGL `glUniformMatrix4fv` con `transpose=GL_FALSE`).
|
||||
@@ -0,0 +1,87 @@
|
||||
#include "game_loop.h"
|
||||
|
||||
#if defined(__EMSCRIPTEN__)
|
||||
#include <emscripten/emscripten.h>
|
||||
#endif
|
||||
|
||||
namespace fn::game {
|
||||
|
||||
namespace {
|
||||
|
||||
struct LoopRT {
|
||||
LoopCfg cfg;
|
||||
Uint64 last_ticks;
|
||||
float accumulator;
|
||||
Uint64 perf_freq;
|
||||
};
|
||||
|
||||
#if defined(__EMSCRIPTEN__)
|
||||
static LoopRT g_rt;
|
||||
|
||||
void em_tick() {
|
||||
LoopRT& rt = g_rt;
|
||||
Uint64 now = SDL_GetPerformanceCounter();
|
||||
float frame_time = (float)((double)(now - rt.last_ticks) / (double)rt.perf_freq);
|
||||
rt.last_ticks = now;
|
||||
|
||||
float cap = rt.cfg.fixed_dt * (float)rt.cfg.max_steps_per_frame;
|
||||
if (frame_time > cap) frame_time = cap;
|
||||
|
||||
rt.accumulator += frame_time;
|
||||
int steps = 0;
|
||||
while (rt.accumulator >= rt.cfg.fixed_dt && steps < rt.cfg.max_steps_per_frame) {
|
||||
if (rt.cfg.on_fixed_update) rt.cfg.on_fixed_update(rt.cfg.user, rt.cfg.fixed_dt);
|
||||
rt.accumulator -= rt.cfg.fixed_dt;
|
||||
steps++;
|
||||
}
|
||||
|
||||
float interp = rt.accumulator / rt.cfg.fixed_dt;
|
||||
if (rt.cfg.on_render) rt.cfg.on_render(rt.cfg.user, interp);
|
||||
|
||||
if (rt.cfg.should_quit && rt.cfg.should_quit(rt.cfg.user)) {
|
||||
emscripten_cancel_main_loop();
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
} // namespace
|
||||
|
||||
void loop_run(SDL_Window* /*win*/, const LoopCfg& cfg) {
|
||||
if (!cfg.on_fixed_update && !cfg.on_render) return;
|
||||
|
||||
#if defined(__EMSCRIPTEN__)
|
||||
g_rt.cfg = cfg;
|
||||
g_rt.last_ticks = SDL_GetPerformanceCounter();
|
||||
g_rt.accumulator = 0.0f;
|
||||
g_rt.perf_freq = SDL_GetPerformanceFrequency();
|
||||
emscripten_set_main_loop(em_tick, 0, 1);
|
||||
#else
|
||||
Uint64 perf_freq = SDL_GetPerformanceFrequency();
|
||||
Uint64 last = SDL_GetPerformanceCounter();
|
||||
float accumulator = 0.0f;
|
||||
|
||||
for (;;) {
|
||||
if (cfg.should_quit && cfg.should_quit(cfg.user)) break;
|
||||
|
||||
Uint64 now = SDL_GetPerformanceCounter();
|
||||
float frame_time = (float)((double)(now - last) / (double)perf_freq);
|
||||
last = now;
|
||||
|
||||
float cap = cfg.fixed_dt * (float)cfg.max_steps_per_frame;
|
||||
if (frame_time > cap) frame_time = cap;
|
||||
|
||||
accumulator += frame_time;
|
||||
int steps = 0;
|
||||
while (accumulator >= cfg.fixed_dt && steps < cfg.max_steps_per_frame) {
|
||||
if (cfg.on_fixed_update) cfg.on_fixed_update(cfg.user, cfg.fixed_dt);
|
||||
accumulator -= cfg.fixed_dt;
|
||||
steps++;
|
||||
}
|
||||
|
||||
float interp = accumulator / cfg.fixed_dt;
|
||||
if (cfg.on_render) cfg.on_render(cfg.user, interp);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
} // namespace fn::game
|
||||
@@ -0,0 +1,22 @@
|
||||
#pragma once
|
||||
|
||||
// game_loop — fixed-timestep game loop (Glenn Fiedler) for SDL3 + WASM.
|
||||
// Issue 0072b. Decouples physics/sim (fixed dt) from rendering (interp).
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
|
||||
namespace fn::game {
|
||||
|
||||
struct LoopCfg {
|
||||
float fixed_dt = 1.0f / 60.0f;
|
||||
int max_steps_per_frame = 5;
|
||||
void (*on_fixed_update)(void* user, float dt) = nullptr;
|
||||
void (*on_render)(void* user, float interp) = nullptr;
|
||||
bool (*should_quit)(void* user) = nullptr; // optional poll
|
||||
void* user = nullptr;
|
||||
};
|
||||
|
||||
// Blocking on desktop. On WASM uses emscripten_set_main_loop and returns immediately.
|
||||
void loop_run(SDL_Window* win, const LoopCfg& cfg);
|
||||
|
||||
} // namespace fn::game
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
name: game_loop
|
||||
kind: function
|
||||
lang: cpp
|
||||
domain: gamedev
|
||||
version: "0.1.0"
|
||||
purity: impure
|
||||
signature: "loop_run(SDL_Window*, const LoopCfg&) -> void"
|
||||
description: "Game loop fixed-timestep estilo Glenn Fiedler ('Fix Your Timestep'). Desacopla simulacion (on_fixed_update con dt fijo) de renderizado (on_render con factor de interpolacion). Acumulador con cap anti spiral-of-death. Branch automatico desktop (while loop bloqueante) vs __EMSCRIPTEN__ (emscripten_set_main_loop). Issue 0072b."
|
||||
tags: [gamedev, game-loop, sdl3, wasm, fixed-timestep]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
example: |
|
||||
struct State { bool quit = false; float t = 0; };
|
||||
State st;
|
||||
fn::game::LoopCfg cfg;
|
||||
cfg.user = &st;
|
||||
cfg.on_fixed_update = [](void* u, float dt) {
|
||||
auto s = (State*)u;
|
||||
s->t += dt;
|
||||
};
|
||||
cfg.on_render = [](void* u, float interp) {
|
||||
// render with interp factor between [0, 1)
|
||||
};
|
||||
cfg.should_quit = [](void* u) { return ((State*)u)->quit; };
|
||||
fn::game::loop_run(window, cfg);
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "cpp/functions/gamedev/game_loop.cpp"
|
||||
params:
|
||||
- name: window
|
||||
desc: "SDL_Window activo (no usado actualmente; reservado para futuras integraciones swap/vsync)."
|
||||
- name: cfg
|
||||
desc: "LoopCfg con fixed_dt (default 1/60), max_steps_per_frame (cap), callbacks on_fixed_update/on_render/should_quit y user pointer."
|
||||
output: "Bloquea hasta should_quit==true (desktop). En WASM retorna inmediatamente y registra emscripten_set_main_loop."
|
||||
---
|
||||
|
||||
# game_loop
|
||||
|
||||
Loop canonico para apps gamedev del registry. Garantiza que la simulacion corra a `fixed_dt` constante (default 60 Hz) independientemente del framerate de render, y expone factor `interp` para que el renderer interpole posiciones entre estados de fisica.
|
||||
|
||||
Detalles:
|
||||
- `frame_time` se cap a `fixed_dt * max_steps_per_frame` para evitar la espiral de la muerte cuando el debugger pausa.
|
||||
- En `__EMSCRIPTEN__` el estado del acumulador vive en variable static (`g_rt`) — solo un loop activo por modulo WASM.
|
||||
- `should_quit` se consulta antes de cada frame; en WASM dispara `emscripten_cancel_main_loop`.
|
||||
- `loop_run` retorna sin hacer nada si ambos callbacks son nulos.
|
||||
@@ -0,0 +1,149 @@
|
||||
#include "input_unified.h"
|
||||
|
||||
namespace fn::input {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr float STICK_DEADZONE = 0.15f;
|
||||
constexpr float STICK_MAX = 32767.0f;
|
||||
|
||||
float normalize_axis(int16_t v) {
|
||||
float f = (float)v / STICK_MAX;
|
||||
if (f > 1.0f) f = 1.0f;
|
||||
if (f < -1.0f) f = -1.0f;
|
||||
if (f > -STICK_DEADZONE && f < STICK_DEADZONE) return 0.0f;
|
||||
return f;
|
||||
}
|
||||
|
||||
// Set logical button + rising-edge flag if transitioning false->true.
|
||||
void set_btn(bool& held, bool& pressed, bool down) {
|
||||
if (down && !held) pressed = true;
|
||||
held = down;
|
||||
}
|
||||
|
||||
void apply_key(InputState& s, SDL_Keycode key, bool down) {
|
||||
switch (key) {
|
||||
case SDLK_A: case SDLK_LEFT: set_btn(s.left, s.left_pressed, down); break;
|
||||
case SDLK_D: case SDLK_RIGHT: set_btn(s.right, s.right_pressed, down); break;
|
||||
case SDLK_W: case SDLK_UP: set_btn(s.up, s.up_pressed, down); break;
|
||||
case SDLK_S: case SDLK_DOWN: set_btn(s.down, s.down_pressed, down); break;
|
||||
case SDLK_SPACE: set_btn(s.action_a, s.a_pressed, down); break;
|
||||
case SDLK_RETURN: set_btn(s.start, s.start_pressed, down); break;
|
||||
case SDLK_ESCAPE: set_btn(s.back, s.back_pressed, down); break;
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
|
||||
void apply_gpad_button(InputState& s, Uint8 button, bool down) {
|
||||
switch (button) {
|
||||
case SDL_GAMEPAD_BUTTON_DPAD_LEFT: set_btn(s.left, s.left_pressed, down); break;
|
||||
case SDL_GAMEPAD_BUTTON_DPAD_RIGHT: set_btn(s.right, s.right_pressed, down); break;
|
||||
case SDL_GAMEPAD_BUTTON_DPAD_UP: set_btn(s.up, s.up_pressed, down); break;
|
||||
case SDL_GAMEPAD_BUTTON_DPAD_DOWN: set_btn(s.down, s.down_pressed, down); break;
|
||||
case SDL_GAMEPAD_BUTTON_SOUTH: set_btn(s.action_a, s.a_pressed, down); break;
|
||||
case SDL_GAMEPAD_BUTTON_EAST: set_btn(s.action_b, s.b_pressed, down); break;
|
||||
case SDL_GAMEPAD_BUTTON_WEST: set_btn(s.action_x, s.x_pressed, down); break;
|
||||
case SDL_GAMEPAD_BUTTON_NORTH: set_btn(s.action_y, s.y_pressed, down); break;
|
||||
case SDL_GAMEPAD_BUTTON_START: set_btn(s.start, s.start_pressed, down); break;
|
||||
case SDL_GAMEPAD_BUTTON_BACK: set_btn(s.back, s.back_pressed, down); break;
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
|
||||
int find_touch_slot(InputState& s, int id) {
|
||||
for (int i = 0; i < s.touch_count; ++i) {
|
||||
if (s.touches[i].id == id) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void input_begin_frame(InputState& s) {
|
||||
s.left_pressed = s.right_pressed = s.up_pressed = s.down_pressed = false;
|
||||
s.a_pressed = s.b_pressed = s.x_pressed = s.y_pressed = false;
|
||||
s.start_pressed = s.back_pressed = false;
|
||||
s.m_left_pressed = s.m_right_pressed = false;
|
||||
}
|
||||
|
||||
void input_process_event(InputState& s, const SDL_Event* e) {
|
||||
if (!e) return;
|
||||
|
||||
switch (e->type) {
|
||||
case SDL_EVENT_KEY_DOWN:
|
||||
if (!e->key.repeat) apply_key(s, e->key.key, true);
|
||||
break;
|
||||
case SDL_EVENT_KEY_UP:
|
||||
apply_key(s, e->key.key, false);
|
||||
break;
|
||||
|
||||
case SDL_EVENT_MOUSE_MOTION:
|
||||
s.mx = e->motion.x;
|
||||
s.my = e->motion.y;
|
||||
break;
|
||||
case SDL_EVENT_MOUSE_BUTTON_DOWN:
|
||||
if (e->button.button == SDL_BUTTON_LEFT) set_btn(s.m_left, s.m_left_pressed, true);
|
||||
if (e->button.button == SDL_BUTTON_RIGHT) set_btn(s.m_right, s.m_right_pressed, true);
|
||||
break;
|
||||
case SDL_EVENT_MOUSE_BUTTON_UP:
|
||||
if (e->button.button == SDL_BUTTON_LEFT) set_btn(s.m_left, s.m_left_pressed, false);
|
||||
if (e->button.button == SDL_BUTTON_RIGHT) set_btn(s.m_right, s.m_right_pressed, false);
|
||||
break;
|
||||
|
||||
case SDL_EVENT_GAMEPAD_BUTTON_DOWN:
|
||||
apply_gpad_button(s, e->gbutton.button, true);
|
||||
break;
|
||||
case SDL_EVENT_GAMEPAD_BUTTON_UP:
|
||||
apply_gpad_button(s, e->gbutton.button, false);
|
||||
break;
|
||||
case SDL_EVENT_GAMEPAD_AXIS_MOTION: {
|
||||
float v = normalize_axis(e->gaxis.value);
|
||||
switch (e->gaxis.axis) {
|
||||
case SDL_GAMEPAD_AXIS_LEFTX: s.lx = v; break;
|
||||
case SDL_GAMEPAD_AXIS_LEFTY: s.ly = v; break;
|
||||
case SDL_GAMEPAD_AXIS_RIGHTX: s.rx = v; break;
|
||||
case SDL_GAMEPAD_AXIS_RIGHTY: s.ry = v; break;
|
||||
default: break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case SDL_EVENT_GAMEPAD_ADDED:
|
||||
SDL_OpenGamepad(e->gdevice.which);
|
||||
break;
|
||||
|
||||
case SDL_EVENT_FINGER_DOWN: {
|
||||
if (s.touch_count < 8) {
|
||||
auto& t = s.touches[s.touch_count++];
|
||||
t.id = (int)e->tfinger.fingerID;
|
||||
t.x = e->tfinger.x;
|
||||
t.y = e->tfinger.y;
|
||||
t.pressed = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case SDL_EVENT_FINGER_MOTION: {
|
||||
int idx = find_touch_slot(s, (int)e->tfinger.fingerID);
|
||||
if (idx >= 0) {
|
||||
s.touches[idx].x = e->tfinger.x;
|
||||
s.touches[idx].y = e->tfinger.y;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case SDL_EVENT_FINGER_UP: {
|
||||
int idx = find_touch_slot(s, (int)e->tfinger.fingerID);
|
||||
if (idx >= 0) {
|
||||
// compact array
|
||||
for (int i = idx; i < s.touch_count - 1; ++i) {
|
||||
s.touches[i] = s.touches[i + 1];
|
||||
}
|
||||
s.touch_count--;
|
||||
s.touches[s.touch_count] = {};
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace fn::input
|
||||
@@ -0,0 +1,45 @@
|
||||
#pragma once
|
||||
|
||||
// input_unified — frame-based unified input snapshot for SDL3.
|
||||
// Issue 0072b. Keyboard + mouse + gamepad + touch mapped to logical buttons.
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
|
||||
namespace fn::input {
|
||||
|
||||
struct InputState {
|
||||
// Logical buttons (any source mapped here).
|
||||
bool left = false, right = false, up = false, down = false;
|
||||
bool action_a = false, action_b = false, action_x = false, action_y = false;
|
||||
bool start = false, back = false;
|
||||
|
||||
// Same buttons but "just pressed this frame" (rising edge).
|
||||
bool left_pressed = false, right_pressed = false, up_pressed = false, down_pressed = false;
|
||||
bool a_pressed = false, b_pressed = false, x_pressed = false, y_pressed = false;
|
||||
bool start_pressed = false, back_pressed = false;
|
||||
|
||||
// Analog sticks [-1, 1].
|
||||
float lx = 0.0f, ly = 0.0f, rx = 0.0f, ry = 0.0f;
|
||||
|
||||
// Mouse (window coords, pixels).
|
||||
float mx = 0.0f, my = 0.0f;
|
||||
bool m_left = false, m_right = false;
|
||||
bool m_left_pressed = false, m_right_pressed = false;
|
||||
|
||||
// Touch (mobile / WASM). x/y normalized [0, 1].
|
||||
struct Touch {
|
||||
float x = 0.0f, y = 0.0f;
|
||||
bool pressed = false;
|
||||
int id = -1;
|
||||
};
|
||||
Touch touches[8];
|
||||
int touch_count = 0;
|
||||
};
|
||||
|
||||
// Call once per frame BEFORE processing SDL events. Clears *_pressed edges.
|
||||
void input_begin_frame(InputState& s);
|
||||
|
||||
// Call for each SDL_Event in PollEvent loop.
|
||||
void input_process_event(InputState& s, const SDL_Event* e);
|
||||
|
||||
} // namespace fn::input
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
name: input_unified
|
||||
kind: function
|
||||
lang: cpp
|
||||
domain: gamedev
|
||||
version: "0.1.0"
|
||||
purity: impure
|
||||
signature: "input_begin_frame(InputState&); input_process_event(InputState&, const SDL_Event*)"
|
||||
description: "Snapshot unificado de input por frame para SDL3. Mapea keyboard (WASD+arrows), mouse, gamepad (SDL_Gamepad) y touch a botones logicos (left/right/up/down/action_a..y/start/back) y ejes analogicos. Expone flags *_pressed con rising edge limpio cada frame. Issue 0072b — runtime gamedev PC + WASM."
|
||||
tags: [gamedev, input, sdl3, touch, gamepad]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
example: |
|
||||
fn::input::InputState input{};
|
||||
// per frame:
|
||||
fn::input::input_begin_frame(input);
|
||||
SDL_Event e;
|
||||
while (SDL_PollEvent(&e)) {
|
||||
fn::input::input_process_event(input, &e);
|
||||
}
|
||||
if (input.a_pressed) jump();
|
||||
player.x += input.lx * speed * dt;
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "cpp/functions/gamedev/input_unified.cpp"
|
||||
params:
|
||||
- name: state
|
||||
desc: "InputState mantenido por la app entre frames (bools held + analog axes + touch slots)."
|
||||
- name: event
|
||||
desc: "Puntero al SDL_Event devuelto por SDL_PollEvent en el loop principal."
|
||||
output: "Mutaciones in-place sobre InputState. Sin retorno. Sin asignaciones dinamicas."
|
||||
---
|
||||
|
||||
# input_unified
|
||||
|
||||
Capa fina sobre SDL3 que normaliza todas las fuentes de input a un struct plano consultable cada frame. Sirve como fundacion para apps gamedev del registry (issue 0072b) y para tests headless.
|
||||
|
||||
Reglas de uso:
|
||||
- `input_begin_frame` se llama UNA vez por frame antes de bombear eventos. Limpia los flags `*_pressed` (rising edge), no los `*` held.
|
||||
- `input_process_event` se llama por cada `SDL_Event` recibido. Acumula state hasta que el siguiente `begin_frame` resetee edges.
|
||||
- Stick deadzone fijo a 0.15 — si la app necesita custom, reescribir snapshot tras la lectura.
|
||||
- Touch ids estables mientras el dedo este pressed; al soltar se compacta el array.
|
||||
- Gamepads conectados se abren automaticamente en `SDL_EVENT_GAMEPAD_ADDED`.
|
||||
Reference in New Issue
Block a user