Nimbin[12]?Games / Entropie / entropie/src/main.c

Entropie git · main

Entropie festival game; first steps

sdl c game entropie cmake · first commit 2026-10-10 · last commit 2026-11-02 (18 hours ago) · synced 9 hours ago

C 88.5% Markdown 9.1% GLSL 2.3%
git clone https://git.christianimmanuel.de/games/Entropie.gitwget https://git.christianimmanuel.de/games/Entropie/archive/Entropie.tar.gz
entropie/src/main.c 24 KB · 516 lines raw
/*
 * Entropie - small first-person sandbox (SDL3 + OpenGL 3.3),
 * painted in the rotoscope style of "Waking Life".
 *
 * Layers:  core (platform, gl, input)  ->  render  ->  game  ->  main
 */

#include "core/audio.h"
#include "game/sound.h"
#include <math.h>
#include <stdio.h>
#include <string.h>
#include <SDL3/SDL.h>

#include "build_info.h" /* generated by the Makefile */
#include "core/input.h"
#include "core/platform.h"
#include "game/game.h"
#include "render/renderer.h"
#include "render/skybox.h"
#include "render/ui.h"
#include "game/save.h"

#define APP_NAME      "Entropie"
#define WINDOW_WIDTH  800
#define WINDOW_HEIGHT 800
#define UI_FONT       "fonts/GochiHand-Regular.ttf" /* SIL Open Font License, see fonts/OFL.txt */
#define UI_FONT_SIZE  27.0f
#define CONTROLS_HELP "I  keys      Tab  you"
#define NEEDS_LABEL   3
#define KEYS_LABEL    4   /* first UI label slot of the key list */
#define BUBBLE_LABEL  32  /* speech bubbles */
#define BUBBLE_MAX    8
#define CHOICE_LABEL  40  /* answers in a conversation */
#define HINT_LABEL    46  /* what to do now (someone follows you) */
#define STATS_LABEL   48  /* the stats panel */
#define STATS_MAX     12
#define DEV_LABEL     64  /* the dev menu (F1) */
#define MENU_LABEL    104 /* the menus (start, Esc, load, save as): up to 16 slots */
#define POCKET_LABEL  88  /* your pockets (B) */
#define CREW_LABEL    120 /* the crew overview (F2) */
#define CREW_LINES    20

static const char *const KEYS[] = { /* proportional font: no column padding */
    "W A S D:  walk,   mouse:  look around",
    "Shift:  jog (tap it again and again: sprint)",
    "Space:  jump (higher with a run-up)",
    "Left click:  take what's in front (heavy things: hold on / lift)",
    "Left click again:  drink / eat, or work with a tool",
    "Fireplace: click to put wood in / light it / take food off the grill",
    "Bag in hand: click on trash",
    "X:  talk to who's in front, else shout for someone;  1 - 5:  answer",
    "Hold left click:  keep going",
    "Right click:  put down / let go",
    "E / Q:  take / drop with right / left hand",
    "F (hold):  push / pull;  look left / right to turn it",
    "Ctrl (hold):  look around (holding on: without turning it)",
    "E / Q while holding on:  lift together / put down",
    "T:  toss what you carry  (hold T: wind up, throw harder overhand)",
    "P:  pee  (hold Shift: further;  aim with the mouse)",
    "Ladder in hand: click to slide it out, at a wall to lean it there",
    "Leaning ladder: click to climb;  E / Q or right click: take it down",
    "Axe or chainsaw at a tree stump: chop it out",
    "Z:  sleep / get up  (asleep, hold Space: faster)",
    "C:  sit down (by the fire) / get up",
    "G:  one more person helps you,   R:  call someone over",
    "H:  everybody stops helping",
    "Backspace:  call off showing someone how / where; at a meeting: skip it",
    "V:  first / third person",
    "I:  show / hide keys",
    "F1:  dev menu (change anybody's stats)",
    "F2:  the crew overview (Left / Right: who's who, now, the projects)",
    "Tab:  your stats (arrow keys: next page)",
    "K:  smoke;   B:  your pockets (Up / Down, Enter: take out or give);   J:  your notebook",
    "Esc:  menu (resume, save, load, keys, quit);  F5 quick save, F9 quick load;  M sound off / on",
};
#define KEY_COUNT ((int)(sizeof(KEYS) / sizeof(KEYS[0])))
#define MAX_FRAME_DT  0.1f /* avoids tunneling after stalls (window drag etc.) */

/* Your stats on a painted sheet of paper under the clock. */
static void drawStats(Ui *ui, const Game *game, int width, int height) {
    StatBar bars[STATS_MAX];
    char about[UI_TEXT_MAX], head[UI_TEXT_MAX];
    const char *title = "";
    int n = Game_stats(game, bars, STATS_MAX, &title, about, sizeof(about));
    snprintf(head, sizeof(head), "%s  -  %s   (%d/%d, arrow keys)", game->player->name, title, game->stats_page + 1, STATS_PAGES);
    const float row = 32.0f, pw = 380.0f, ph = 70.0f + row * (float)n + 46.0f;
    const float x = (float)width - pw - 18.0f, top = (float)height - 58.0f;
    Ui_drawPatch(ui, x, top - ph, pw, ph, 0.93f, 0.85f, 0.67f, 0.93f, 1.0f, width, height);
    Ui_drawTextLeft(ui, STATS_LABEL, head, x + 18.0f, top - 46.0f, width, height);

    const float bar_x = x + 130.0f, bar_w = pw - 150.0f;
    for (int i = 0; i < n && STATS_LABEL + 2 + i < UI_LABELS; i++) {
        float y = top - 60.0f - row * (float)(i + 1);
        Ui_drawTextLeft(ui, STATS_LABEL + 2 + i, bars[i].label, x + 22.0f, y, width, height);
        /* Good is green; a need goes over ochre to red as it grows */
        float v = clampf(bars[i].value, 0.0f, 1.0f);
        float bad = bars[i].need ? v : 1.0f - v;
        float r = 0.42f + 0.45f * bad, g = 0.62f - 0.30f * bad * bad, b = 0.30f - 0.08f * bad;
        Ui_drawPatch(ui, bar_x, y + 6.0f, bar_w, 16.0f, 0.86f, 0.80f, 0.66f, 1.0f, 0.6f, width, height);
        if (v > 0.02f)
            Ui_drawPatch(ui, bar_x + 1.0f, y + 7.0f, (bar_w - 2.0f) * v, 14.0f, r, g, b, 0.95f, 0.0f, width, height);
    }
    Ui_drawTextLeft(ui, STATS_LABEL + 1, about, x + 18.0f, top - ph + 14.0f, width, height);
}

/* The dev menu (F1) on a sheet of paper on the left. */
static void drawDev(Ui *ui, const Game *game, int width, int height) {
    char lines[DEV_LINES][96];
    int chosen = -1;
    int n = Game_devLines(game, lines, DEV_LINES, &chosen);
    if (n == 0) return;
    const float row = 30.0f, pw = 560.0f, ph = 30.0f + row * (float)n;
    const float x = 18.0f, top = (float)height * 0.5f + ph * 0.5f;
    Ui_drawPatch(ui, x, top - ph, pw, ph, 0.88f, 0.90f, 0.80f, 0.93f, 1.0f, width, height);
    for (int i = 0; i < n && DEV_LABEL + i < UI_LABELS; i++) {
        float y = top - 38.0f - row * (float)i;
        if (i == chosen) Ui_drawPatch(ui, x + 10.0f, y - 2.0f, pw - 20.0f, row - 2.0f, 0.95f, 0.78f, 0.45f, 0.8f, 0.0f, width, height);
        Ui_drawTextLeft(ui, DEV_LABEL + i, lines[i], x + 22.0f, y, width, height);
    }
}

/* The crew overview (F2): a big sheet in the middle. */
static void drawCrew(Ui *ui, const Game *game, int width, int height) {
    char lines[CREW_LINES][128];
    const char *title = "";
    int n = Game_crewLines(game, lines, CREW_LINES - 1, &title);
    const float row = 28.0f, pw = fminf(1240.0f, (float)width - 36.0f), ph = 60.0f + row * (float)n;
    const float x = ((float)width - pw) * 0.5f, top = (float)height * 0.5f + ph * 0.5f;
    Ui_drawPatch(ui, x, top - ph, pw, ph, 0.90f, 0.88f, 0.78f, 0.95f, 1.0f, width, height);
    Ui_drawTextLeft(ui, CREW_LABEL, title, x + 22.0f, top - 38.0f, width, height);
    for (int i = 0; i < n && CREW_LABEL + 1 + i < UI_LABELS; i++)
        Ui_drawTextLeft(ui, CREW_LABEL + 1 + i, lines[i], x + 22.0f, top - 72.0f - row * (float)i, width, height);
}

/* Your notebook (J): the same sheet (the crew overview's places: never both). */
static void drawNotes(Ui *ui, const Game *game, int width, int height) {
    char lines[CREW_LINES][128];
    const char *title = "";
    int n = Game_notesLines(game, lines, CREW_LINES - 1, &title);
    const float row = 28.0f, pw = fminf(1240.0f, (float)width - 36.0f), ph = 60.0f + row * (float)n;
    const float x = ((float)width - pw) * 0.5f, top = (float)height * 0.5f + ph * 0.5f;
    Ui_drawPatch(ui, x, top - ph, pw, ph, 0.93f, 0.91f, 0.82f, 0.95f, 1.0f, width, height);
    Ui_drawTextLeft(ui, CREW_LABEL, title, x + 22.0f, top - 38.0f, width, height);
    for (int i = 0; i < n && CREW_LABEL + 1 + i < UI_LABELS; i++)
        Ui_drawTextLeft(ui, CREW_LABEL + 1 + i, lines[i], x + 22.0f, top - 72.0f - row * (float)i, width, height);
}

/* Your pockets (B) on a sheet of paper on the right. */
static void drawPockets(Ui *ui, Game *game, int width, int height) {
    char lines[POCKET_LINES][96];
    int chosen = -1;
    int n = Game_pocketLines(game, lines, POCKET_LINES, &chosen);
    if (n == 0) return;
    const float row = 30.0f, pw = 520.0f, ph = 30.0f + row * (float)n;
    const float x = (float)width - pw - 18.0f, top = (float)height * 0.5f + ph * 0.5f;
    Ui_drawPatch(ui, x, top - ph, pw, ph, 0.90f, 0.86f, 0.74f, 0.93f, 1.0f, width, height);
    for (int i = 0; i < n && POCKET_LABEL + i < UI_LABELS; i++) {
        float y = top - 38.0f - row * (float)i;
        if (i == chosen) Ui_drawPatch(ui, x + 10.0f, y - 2.0f, pw - 20.0f, row - 2.0f, 0.95f, 0.78f, 0.45f, 0.8f, 0.0f, width, height);
        Ui_drawTextLeft(ui, POCKET_LABEL + i, lines[i], x + 22.0f, y, width, height);
    }
}

/* ---------- Menus: the start, Esc (the game waits meanwhile), loading, naming a save ---------- */

typedef enum { SCREEN_NONE, SCREEN_START, SCREEN_PAUSE, SCREEN_LOAD, SCREEN_NAME } Screen;
typedef enum { ACT_NONE, ACT_NEW, ACT_CONTINUE, ACT_LOAD_LIST, ACT_QUIT, ACT_RESUME, ACT_SAVE_AS, ACT_QUICK_SAVE,
               ACT_KEYS, ACT_LOAD, ACT_BACK, ACT_NAME, ACT_SAVE_NAMED } Action;
#define MENU_ITEMS     14
#define MENU_W         600.0f
#define MENU_BH        44.0f  /* button height */
#define MENU_GAP       10.0f
#define AUTOSAVE_EVERY 300.0f /* s of play between autosaves */
#define QUICK_SAVE     "quicksave"

typedef struct {
    Screen   screen, back;            /* back: where "Back" / Esc goes from the load list or the name */
    int      chosen, count;
    char     item[MENU_ITEMS][UI_TEXT_MAX];
    Action   act[MENU_ITEMS];
    int      arg[MENU_ITEMS];         /* ACT_LOAD: which save */
    const char *title;
    SaveInfo saves[SAVE_LIST_MAX];
    int      save_count;
    char     name[SAVE_NAME_MAX];     /* a save's name being typed */
    char     dir[512];                /* where the saves go */
} Menu;

static void menuAdd(Menu *m, Action act, int arg, const char *text) {
    if (m->count >= MENU_ITEMS) return;
    snprintf(m->item[m->count], UI_TEXT_MAX, "%s", text);
    m->act[m->count] = act;
    m->arg[m->count] = arg;
    m->count++;
}

/* Fills the menu for a screen. */
static void menuOpen(Menu *m, Screen screen) {
    m->screen = screen;
    m->count = 0;
    m->chosen = 0;
    char line[UI_TEXT_MAX];
    switch (screen) {
    case SCREEN_START: {
        m->title = APP_NAME " " APP_VERSION;
        m->save_count = Save_list(m->dir, m->saves, SAVE_LIST_MAX);
        int autosave = -1;
        for (int i = 0; i < m->save_count; i++) if (!strcmp(m->saves[i].name, SAVE_AUTO)) autosave = i;
        if (autosave >= 0) {
            snprintf(line, sizeof line, "Continue   %.40s", m->saves[autosave].label);
            menuAdd(m, ACT_CONTINUE, autosave, line);
        }
        menuAdd(m, ACT_NEW, 0, "New game");
        if (m->save_count > 0) menuAdd(m, ACT_LOAD_LIST, 0, "Load a save");
        menuAdd(m, ACT_QUIT, 0, "Quit");
        break;
    }
    case SCREEN_PAUSE:
        m->title = "Paused";
        menuAdd(m, ACT_RESUME, 0, "Resume");
        menuAdd(m, ACT_SAVE_AS, 0, "Save as ...");
        menuAdd(m, ACT_QUICK_SAVE, 0, "Quick save  (F5; F9 loads it)");
        menuAdd(m, ACT_LOAD_LIST, 0, "Load a save");
        menuAdd(m, ACT_KEYS, 0, "Keys (show / hide)");
        menuAdd(m, ACT_QUIT, 0, "Quit  (autosaves)");
        break;
    case SCREEN_LOAD:
        m->title = "Load a save";
        m->save_count = Save_list(m->dir, m->saves, SAVE_LIST_MAX);
        for (int i = 0; i < m->save_count && m->count < MENU_ITEMS - 1; i++) {
            snprintf(line, sizeof line, "%.20s   %.40s", m->saves[i].name, m->saves[i].label);
            menuAdd(m, ACT_LOAD, i, line);
        }
        menuAdd(m, ACT_BACK, 0, "Back");
        break;
    case SCREEN_NAME:
        m->title = "Save as (type a name, Enter)";
        snprintf(line, sizeof line, "%s_", m->name);
        menuAdd(m, ACT_NAME, 0, line);
        menuAdd(m, ACT_SAVE_NAMED, 0, "Save");
        menuAdd(m, ACT_BACK, 0, "Back");
        m->chosen = 1;
        break;
    case SCREEN_NONE:
        break;
    }
}

/* Button k's box (pixels, y from the bottom). */
static void menuButton(const Menu *m, int k, int width, int height, float *x, float *y) {
    float total = (float)m->count * (MENU_BH + MENU_GAP) - MENU_GAP;
    *x = ((float)width - MENU_W) * 0.5f + 20.0f;
    *y = (float)height * 0.5f + total * 0.5f - (float)(k + 1) * MENU_BH - (float)k * MENU_GAP - 20.0f;
}

/* The button under the pointer, or -1. */
static int menuHit(const Menu *m, const Platform *platform, const Input *in, int width, int height) {
    int ww, wh;
    Platform_windowSize(platform, &ww, &wh);
    if (ww <= 0 || wh <= 0) return -1;
    float mx = in->mouse_x * (float)width / (float)ww, my = (float)height - in->mouse_y * (float)height / (float)wh;
    for (int k = 0; k < m->count; k++) {
        float x, y;
        menuButton(m, k, width, height, &x, &y);
        if (mx >= x && mx <= x + MENU_W - 40.0f && my >= y && my <= y + MENU_BH) return k;
    }
    return -1;
}

static void drawMenu(Ui *ui, const Menu *m, const char *note, int width, int height) {
    float total = (float)m->count * (MENU_BH + MENU_GAP) - MENU_GAP + 40.0f + 50.0f + (note ? 36.0f : 0.0f);
    float px = ((float)width - MENU_W) * 0.5f, py = (float)height * 0.5f - total * 0.5f + 25.0f - (note ? 36.0f : 0.0f);
    Ui_drawPatch(ui, px, py - 20.0f, MENU_W, total, 0.93f, 0.85f, 0.67f, 0.95f, 1.0f, width, height);
    Ui_drawTextLeft(ui, MENU_LABEL, m->title, px + 24.0f, py + total - 70.0f, width, height);
    for (int k = 0; k < m->count; k++) {
        float x, y;
        menuButton(m, k, width, height, &x, &y);
        bool on = k == m->chosen;
        Ui_drawPatch(ui, x, y, MENU_W - 40.0f, MENU_BH, on ? 0.95f : 0.86f, on ? 0.72f : 0.80f, on ? 0.40f : 0.66f, 1.0f, 0.6f, width, height);
        Ui_drawTextLeft(ui, MENU_LABEL + 1 + k, m->item[k], x + 18.0f, y + 9.0f, width, height);
    }
    if (note) Ui_drawTextLeft(ui, MENU_LABEL + 1 + MENU_ITEMS, note, px + 24.0f, py - 6.0f, width, height);
}

/* A name typed in: letters, digits, - and _ (spaces become -). */
static void typeName(char *name, const Input *in) {
    size_t n = strlen(name);
    if (in->erase && n > 0) name[--n] = '\0';
    for (const char *c = in->text; *c && n + 1 < SAVE_NAME_MAX - 1; c++) {
        char ch = *c == ' ' ? '-' : *c;
        if ((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '-' || ch == '_') { name[n++] = ch; name[n] = '\0'; }
    }
}

int main(int argc, char **argv) {
    (void)argc;
    (void)argv;

    const char *title = APP_NAME " " APP_VERSION " (build " APP_BUILD ")";
    SDL_Log("%s", title);

    int exit_code = 1;
    Platform platform = { 0 };
    Renderer renderer = { 0 };
    Skybox   skybox   = { 0 };
    Ui       ui       = { 0 };
    Game     game     = { 0 };
    bool     game_ready = false;

    if (!Platform_init(&platform, title, WINDOW_WIDTH, WINDOW_HEIGHT)) goto cleanup;
    if (!Renderer_init(&renderer)) goto cleanup;
    if (!Skybox_init(&skybox)) goto cleanup;
    if (!Ui_init(&ui, UI_FONT, UI_FONT_SIZE)) goto cleanup;
    if (!(game_ready = Game_init(&game))) goto cleanup;
    Audio_init(); /* (no sound device: silent, that's all) */

    Platform_frameDelta(&platform); /* reset timer after loading */
    float anim_time = 0.0f;         /* real seconds, drives the painted animation */
    bool  look_settle = false;
    float menu_mouse_x = -1.0f, menu_mouse_y = -1.0f, autosave_t = 0.0f, note_time = 0.0f;
    char  note[UI_TEXT_MAX] = "";
    static Menu menu;
    if (!Platform_saveDir(menu.dir, sizeof menu.dir)) snprintf(menu.dir, sizeof menu.dir, "saves");
    menuOpen(&menu, SCREEN_START);  /* (the world waits behind it: a new game starts from here) */
    Platform_captureMouse(&platform, false);

    for (;;) {
        float dt = Platform_frameDelta(&platform);
        if (dt > MAX_FRAME_DT) dt = MAX_FRAME_DT;
        anim_time += dt;
        note_time = fmaxf(0.0f, note_time - dt);

        Input input;
        Input_poll(&input);
        bool playing = menu.screen == SCREEN_NONE || menu.screen == SCREEN_PAUSE || menu.back == SCREEN_PAUSE;
        if (input.quit) {
            char err[160];
            if (playing && menu.screen != SCREEN_START) Save_write(&game, menu.dir, SAVE_AUTO, err, sizeof err); /* (closing the window: autosaved) */
            break;
        }

        int width, height;
        Platform_drawableSize(&platform, &width, &height);

        Action act = ACT_NONE;
        int arg = 0;
        if (input.escape) { /* the menu, or back to the game (from a list: back a step) */
            if (menu.screen == SCREEN_NONE) menuOpen(&menu, SCREEN_PAUSE);
            else if (menu.screen == SCREEN_PAUSE) act = ACT_RESUME;
            else if (menu.screen == SCREEN_LOAD || menu.screen == SCREEN_NAME) act = ACT_BACK;
            Platform_captureMouse(&platform, menu.screen == SCREEN_NONE);
            look_settle = true;
        }
        if (menu.screen == SCREEN_NONE && (input.quick_save || input.quick_load)) {
            char err[160];
            if (input.quick_save) {
                bool ok = Save_write(&game, menu.dir, QUICK_SAVE, err, sizeof err);
                snprintf(note, sizeof note, ok ? "Quick saved" : "Not saved: %s", err);
            } else {
                bool ok = Save_read(&game, menu.dir, QUICK_SAVE, err, sizeof err);
                snprintf(note, sizeof note, ok ? "Quick save loaded" : "Not loaded: %s", err);
                look_settle = true;
            }
            note_time = 3.0f;
        }
        if (menu.screen != SCREEN_NONE && act == ACT_NONE) {
            int hit = menuHit(&menu, &platform, &input, width, height);
            if (hit >= 0 && (input.mouse_x != menu_mouse_x || input.mouse_y != menu_mouse_y)) menu.chosen = hit;
            menu_mouse_x = input.mouse_x;
            menu_mouse_y = input.mouse_y;
            if (input.menu_move) menu.chosen = (menu.chosen + input.menu_move + menu.count) % menu.count;
            if (menu.screen == SCREEN_NAME) { /* typing the name */
                typeName(menu.name, &input);
                snprintf(menu.item[0], UI_TEXT_MAX, "%s_", menu.name);
            }
            int pick = input.enter ? menu.chosen : (input.click && hit >= 0 ? hit : -1);
            if (menu.screen == SCREEN_NAME && input.enter) pick = 1; /* (Enter: save it) */
            if (pick >= 0 && pick < menu.count) { act = menu.act[pick]; arg = menu.arg[pick]; }
        }

        char err[160] = "";
        switch (act) {
        case ACT_NEW: case ACT_RESUME:
            menu.screen = SCREEN_NONE;
            menu.back = SCREEN_NONE;
            Platform_captureMouse(&platform, true);
            look_settle = true;
            break;
        case ACT_CONTINUE: case ACT_LOAD:
            if (Save_read(&game, menu.dir, menu.saves[arg].name, err, sizeof err)) {
                snprintf(note, sizeof note, "Loaded %s", menu.saves[arg].name);
                menu.screen = SCREEN_NONE;
                menu.back = SCREEN_NONE;
                Platform_captureMouse(&platform, true);
                look_settle = true;
                autosave_t = 0.0f;
            } else snprintf(note, sizeof note, "Not loaded: %.80s", err);
            note_time = 3.0f;
            break;
        case ACT_LOAD_LIST:
            menu.back = menu.screen;
            menuOpen(&menu, SCREEN_LOAD);
            break;
        case ACT_SAVE_AS:
            if (Save_blocked(&game)) { snprintf(note, sizeof note, "%s", Save_blocked(&game)); note_time = 3.0f; break; }
            menu.back = SCREEN_PAUSE;
            menu.name[0] = '\0';
            menuOpen(&menu, SCREEN_NAME);
            Platform_typing(&platform, true);
            break;
        case ACT_SAVE_NAMED:
            if (!menu.name[0]) break;
            if (Save_write(&game, menu.dir, menu.name, err, sizeof err)) snprintf(note, sizeof note, "Saved as %s", menu.name);
            else snprintf(note, sizeof note, "Not saved: %.80s", err);
            note_time = 3.0f;
            Platform_typing(&platform, false);
            menuOpen(&menu, SCREEN_PAUSE);
            menu.back = SCREEN_NONE;
            break;
        case ACT_QUICK_SAVE:
            if (Save_write(&game, menu.dir, QUICK_SAVE, err, sizeof err)) snprintf(note, sizeof note, "Quick saved");
            else snprintf(note, sizeof note, "Not saved: %.80s", err);
            note_time = 3.0f;
            break;
        case ACT_KEYS:
            game.show_keys = !game.show_keys;
            break;
        case ACT_BACK:
            if (menu.screen == SCREEN_NAME) Platform_typing(&platform, false);
            menuOpen(&menu, menu.back == SCREEN_NONE ? SCREEN_PAUSE : menu.back);
            menu.back = SCREEN_NONE;
            break;
        case ACT_QUIT:
            if (menu.screen == SCREEN_PAUSE) Save_write(&game, menu.dir, SAVE_AUTO, err, sizeof err);
            goto done;
        case ACT_NONE: case ACT_NAME:
            break;
        }

        if (menu.screen == SCREEN_NONE) {
            if (look_settle) { /* (the first mouse move after the menu is a jump: ignored) */
                input.look_yaw = input.look_pitch = 0.0f;
                look_settle = false;
            }
            Game_update(&game, &input, dt, (float)width / (float)height);
            if (input.mute) { /* M: sound off / on */
                Audio_mute(!Audio_muted());
                snprintf(note, sizeof note, Audio_muted() ? "Sound off (M)" : "Sound on");
                note_time = 2.0f;
            }
            if ((autosave_t += dt) >= AUTOSAVE_EVERY && !Save_blocked(&game)) { /* (every few minutes, quietly) */
                autosave_t = 0.0f;
                if (Save_write(&game, menu.dir, SAVE_AUTO, err, sizeof err)) { snprintf(note, sizeof note, "Autosaved"); note_time = 2.0f; }
            }
        }

        Sound_update(&game, dt, menu.screen != SCREEN_NONE);
        Renderer_beginFrame(&renderer, width, height, anim_time);
        Game_render(&game, &renderer);
        Skybox_draw(&skybox, &renderer, &game.camera, &game.daycycle.light);
        Game_renderAir(&game, &renderer); /* (rain and smoke against the sky too) */
        Renderer_endFrame(&renderer);

        char clock_text[UI_TEXT_MAX];
        DayCycle_format(&game.daycycle, clock_text, sizeof(clock_text));
        Ui_drawText(&ui, 0, clock_text, UI_TOP_RIGHT, width, height);
        Ui_setTime(&ui, anim_time);
        char needs_text[UI_TEXT_MAX];
        Game_needsText(&game, needs_text, sizeof(needs_text));
        if (game.show_stats) drawStats(&ui, &game, width, height);
        else if (needs_text[0]) Ui_drawTextLine(&ui, NEEDS_LABEL, needs_text, 1, UI_TOP_RIGHT, width, height);
        if (game.show_dev) drawDev(&ui, &game, width, height);
        if (game.show_crew) drawCrew(&ui, &game, width, height);
        else if (game.show_notes) drawNotes(&ui, &game, width, height);
        if (game.show_pockets) drawPockets(&ui, &game, width, height);
        if (game.show_keys)
            for (int k = 0; k < KEY_COUNT; k++) Ui_drawTextLine(&ui, KEYS_LABEL + k, KEYS[k], k, UI_TOP_LEFT, width, height);
        else
            Ui_drawText(&ui, 1, CONTROLS_HELP, UI_TOP_LEFT, width, height);
        const char *status = Game_status(&game);
        if (status) Ui_drawText(&ui, 2, status, UI_CENTER, width, height);

        /* Who says what, and what you can answer */
        Bubble bubbles[BUBBLE_MAX];
        int said = Game_bubbles(&game, bubbles, BUBBLE_MAX, width, height);
        for (int b = 0; b < said; b++)
            Ui_drawTextAt(&ui, BUBBLE_LABEL + b, bubbles[b].text, bubbles[b].x, bubbles[b].y, width, height);
        const char *choices[TALK_CHOICES];
        int answers = Game_choices(&game, choices);
        for (int c = 0; c < answers; c++) {
            char line[UI_TEXT_MAX];
            snprintf(line, sizeof(line), "%d   %s", c + 1, choices[c]);
            Ui_drawTextLine(&ui, CHOICE_LABEL + c, line, answers - 1 - c, UI_BOTTOM_LEFT, width, height);
        }
        const char *hint = Game_hint(&game);
        if (hint) Ui_drawTextLine(&ui, HINT_LABEL, hint, answers, UI_BOTTOM_LEFT, width, height);
        if (menu.screen != SCREEN_NONE) drawMenu(&ui, &menu, note_time > 0.0f ? note : NULL, width, height);
        else if (note_time > 0.0f) Ui_drawText(&ui, MENU_LABEL + 1 + MENU_ITEMS, note, UI_CENTER, width, height);

        Platform_swap(&platform);
    }
done:
    exit_code = 0;

cleanup:
    Audio_shutdown();
    if (game_ready) Game_shutdown(&game);
    if (platform.gl_context) {
        Ui_shutdown(&ui);
        Skybox_shutdown(&skybox);
        Renderer_shutdown(&renderer);
    }
    Platform_shutdown(&platform);
    return exit_code;
}